The platform now runs on Groq by default, through the OpenAI-compatible chat-completions shape. That shape is not one vendor — Gemini, OpenRouter, Together, vLLM and a local Ollama serve it too — so moving again stays configuration rather than code. Two things in the deleted file were not Anthropic's and would have gone with it silently: withRetry / MaxAttempts / retryBackoff were defined in anthropic.go and CALLED BY openai.go. Deleting the file wholesale would have removed the retry policy of the provider that survived, and nothing in openai.go mentions it, so the loss would have been invisible until the next 429. The policy is a property of this platform's runs, not of a vendor's API; it now lives in retry.go where no provider can carry it off. StreamComplete had the same problem and moves to gateway.go, beside the Streamer interface whose comment already referenced it. Three stale-configuration failures are now refused at startup instead of being ignored. Each was verified firing through the real config.Load(): MODEL_PROVIDER=anthropic — named separately from every other wrong value because it used to be correct. Ignoring it gives a stack that believes it is on Claude while every run goes to Groq and is billed there. ANTHROPIC_API_KEY set while MODEL_API_KEY is empty. Ignoring a key an operator did set is the worst version of this: they fail every run on a missing credential they are looking straight at. A leftover claude-* model id, naming the tier that carries it. This is the check the previous commit's error-detail work was diagnosing: such an id is accepted by this process, rejected by the provider, and 400s on EVERY run. "A model is wrong" does not say which of three lines to edit. Defaults ship as a matched pair. defaultBaseURL and the three tier ids are one decision, not four: an id is only meaningful against the service that serves it, and a Groq id on an OpenAI base URL is the same failure from the other side. The tiers also stop being one model — a tier whose cost does not differ is a distinction that buys nothing. Verified end to end against a stub of the wire, driving the real wiring (config.Load in production mode, gateway.New, StreamComplete): streamed deltas, tool-call decoding, the loopback credential exemption, and usage totalling 150 rather than 190 — the cached-prefix subtraction still holds. gofmt clean, go vet clean, 14/14 non-DB packages pass. httpserver still needs a reachable database. NOT verified: the I7 planted-injection eval. Removing this path removed the only model whose refusal behaviour had been measured against it, so the new default is unproven there until `make eval-live` runs with a real key. The Groq model ids should also be confirmed against Groq's current lineup. Flagged in CLAUDE.md §12 and docs/handover.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
217 lines
6.9 KiB
Go
217 lines
6.9 KiB
Go
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())
|
|
}
|
|
}
|