agent build
This commit is contained in:
197
go-api/internal/gateway/gateway_test.go
Normal file
197
go-api/internal/gateway/gateway_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user