package gateway import ( "context" "errors" "strings" "testing" "github.com/krow/krow-backend/go-api/internal/config" ) func TestParseTier(t *testing.T) { cases := []struct { in string want Tier known bool }{ {"fast", TierFast, true}, {"balanced", TierBalanced, true}, {"deep", TierDeep, true}, {" DEEP ", TierDeep, true}, // Unset means the default, and is not a drift signal: most specs // simply do not declare a tier. {"", DefaultTier, true}, // A tier that is not in the vocabulary still runs, at the default, but // reports itself so a drifted definition stays visible. {"thorough", DefaultTier, false}, } for _, c := range cases { got, known := ParseTier(c.in) if got != c.want || known != c.known { t.Errorf("ParseTier(%q) = (%q, %v), want (%q, %v)", c.in, got, known, c.want, c.known) } } } func TestUsageTotalCountsCacheReads(t *testing.T) { // A cache read is cheaper than fresh input, not free. Excluding it would // make the budget drift further from the truth the longer a run went on. u := Usage{InputTokens: 100, OutputTokens: 50, CacheReadTokens: 900, CacheCreationTokens: 10} if got := u.Total(); got != 1060 { t.Errorf("Total() = %d, want 1060", got) } } func TestRequestValidate(t *testing.T) { if err := (Request{}).Validate(); err == nil { t.Error("a request with no messages should be refused") } blank := Request{Messages: []Message{{Role: RoleUser, Text: " "}}} if err := blank.Validate(); err == nil { t.Error("a whitespace-only message should be refused") } bad := Request{Messages: []Message{{Role: "system", Text: "hi"}}} err := bad.Validate() var gwErr *Error if !errors.As(err, &gwErr) || gwErr.Code != CodeInvalidRequest { t.Errorf("a bad role should give CodeInvalidRequest, got %v", err) } ok := Request{Messages: []Message{{Role: RoleUser, Text: "which shifts are uncovered?"}}} if err := ok.Validate(); err != nil { t.Errorf("a valid request was refused: %v", err) } } func TestCompleteWithoutCredentialsIsStructured(t *testing.T) { // The service boots without a key on purpose. The failure has to arrive as // something a run can terminate with, not as a panic or a bare string. g := NewOpenAI(Config{}) _, err := g.Complete(context.Background(), Request{ Messages: []Message{{Role: RoleUser, Text: "anything"}}, }) var gwErr *Error if !errors.As(err, &gwErr) { t.Fatalf("want a *gateway.Error, got %T: %v", err, err) } if gwErr.Code != CodeNotConfigured { t.Errorf("Code = %q, want %q", gwErr.Code, CodeNotConfigured) } if gwErr.Retryable() { t.Error("a missing key is not fixed by retrying") } } func TestRetryable(t *testing.T) { cases := map[*Error]bool{ {Code: CodeRateLimited}: true, {Code: CodeTimeout}: true, {Code: CodeUpstream, Status: 503}: true, {Code: CodeUpstream, Status: 400}: false, {Code: CodeUnauthorized, Status: 401}: false, {Code: CodeInvalidRequest}: false, // The one that matters: re-sending a request the model declined is how // a loop spends a whole budget on a single turn. {Code: CodeRefused, Category: "cyber"}: false, } for err, want := range cases { if got := err.Retryable(); got != want { t.Errorf("%s: Retryable() = %v, want %v", err.Code, got, want) } } } func TestFromConfigPinsEffortPerTier(t *testing.T) { cfg := FromConfig(config.ModelConfig{ APIKey: "test", Fast: "m-fast", Balanced: "m-balanced", Deep: "m-deep", MaxOutputTokens: 8000, }) if cfg.Fast.Effort != EffortLow { t.Errorf("fast effort = %q, want low", cfg.Fast.Effort) } if cfg.Balanced.Effort != EffortHigh { t.Errorf("balanced effort = %q, want high", cfg.Balanced.Effort) } if cfg.Deep.Effort != EffortXhigh { t.Errorf("deep effort = %q, want xhigh", cfg.Deep.Effort) } if cfg.MaxOutputTokens != 8000 { t.Errorf("MaxOutputTokens = %d, want 8000", cfg.MaxOutputTokens) } } // The neutral effort vocabulary still has to land on a vendor's own spelling, // and that mapping is the one thing FromConfig cannot assert now that its // result is provider-independent. Untested, a renamed constant would silently // route every tier to whatever the default arm returns. // // This asserts POSITIONS, not words. OpenAI's scale runs minimal/low/medium/ // high against our low/high/xhigh, so `high` here is their "medium" — matching // the spelling instead would collapse `fast` and `balanced` into neighbours. func TestEffortMapsOntoTheProviderScale(t *testing.T) { cases := map[Effort]string{ EffortLow: "low", EffortHigh: "medium", EffortXhigh: "high", } for neutral, want := range cases { if got := openAIEffort(neutral); got != want { t.Errorf("openAIEffort(%q) = %q, want %q", neutral, got, want) } } } func TestRoutingSelectsPerTier(t *testing.T) { g := NewOpenAI(Config{ Fast: Routing{Model: "m-fast"}, Balanced: Routing{Model: "m-balanced"}, Deep: Routing{Model: "m-deep"}, }) cases := map[Tier]string{ TierFast: "m-fast", TierBalanced: "m-balanced", TierDeep: "m-deep", // A zero value routes to balanced rather than to an empty model id. Tier(""): "m-balanced", } for tier, want := range cases { if got := g.routing(tier).Model; got != want { t.Errorf("routing(%q) = %q, want %q", tier, got, want) } } } /* ── Retrying what is worth retrying ────────────────────────────────────── */ func TestATransientOverloadIsWorthRetrying(t *testing.T) { // The classification this asserts existed from the start and had ZERO // callers, so a 529 killed runs that would have succeeded a moment later. // Found by a real overload during live testing. overloaded := &Error{Code: CodeUpstream, Message: "overloaded", Status: 529} if !overloaded.Retryable() { t.Error("a 529 overload should be retryable — it is the transient failure that actually happens") } for _, e := range []*Error{ {Code: CodeRateLimited, Status: 429}, {Code: CodeTimeout}, {Code: CodeUpstream, Status: 503}, } { if !e.Retryable() { t.Errorf("%s (status %d) should be retryable", e.Code, e.Status) } } // And the ones that will fail identically every time must not be. for _, e := range []*Error{ {Code: CodeInvalidRequest, Status: 400}, {Code: CodeUnauthorized, Status: 401}, {Code: CodeNotConfigured}, {Code: CodeRefused}, } { if e.Retryable() { t.Errorf("%s should NOT be retryable — the same call will fail the same way", e.Code) } } } func TestAnUpstreamErrorNamesItsStatus(t *testing.T) { // "the model call failed" cost an hour of debugging, because the trajectory // records the message and the message did not say it was a 529. A failure // an operator cannot classify is a failure they cannot act on. e := &Error{ Code: CodeUpstream, Message: "the model call failed (http 529)", Status: 529, } if !strings.Contains(e.Error(), "529") { t.Errorf("the rendered error hides its status: %s", e.Error()) } }