198 lines
6.2 KiB
Go
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())
|
|
}
|
|
}
|