Files
krow_backend/go-api/internal/gateway/gateway_test.go
2026-08-28 12:21:44 +05:30

198 lines
6.2 KiB
Go

package gateway
import (
"context"
"errors"
"strings"
"testing"
"github.com/anthropics/anthropic-sdk-go"
"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 := NewAnthropic(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 != anthropic.OutputConfigEffortLow {
t.Errorf("fast effort = %q, want low", cfg.Fast.Effort)
}
if cfg.Balanced.Effort != anthropic.OutputConfigEffortHigh {
t.Errorf("balanced effort = %q, want high", cfg.Balanced.Effort)
}
if cfg.Deep.Effort != anthropic.OutputConfigEffortXhigh {
t.Errorf("deep effort = %q, want xhigh", cfg.Deep.Effort)
}
if cfg.MaxOutputTokens != 8000 {
t.Errorf("MaxOutputTokens = %d, want 8000", cfg.MaxOutputTokens)
}
}
func TestRoutingSelectsPerTier(t *testing.T) {
g := NewAnthropic(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())
}
}