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
173 lines
6.1 KiB
Go
173 lines
6.1 KiB
Go
package config
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func modelCfg(env string, m ModelConfig) *Config {
|
|
c := &Config{AppEnv: env}
|
|
c.Model = m
|
|
return c
|
|
}
|
|
|
|
func TestValidateModelProvider(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
cfg *Config
|
|
wantErr bool
|
|
}{
|
|
{
|
|
"unset provider is openai, which is now the only implementation",
|
|
modelCfg("development", ModelConfig{}), false,
|
|
},
|
|
{"openai named explicitly", modelCfg("development", ModelConfig{Provider: "openai"}), false},
|
|
{
|
|
"anthropic is refused rather than ignored — it used to be correct",
|
|
modelCfg("development", ModelConfig{Provider: "anthropic"}), true,
|
|
},
|
|
{"a typo is caught once at startup, not once per run",
|
|
modelCfg("development", ModelConfig{Provider: "openal"}), true},
|
|
{
|
|
"a vendor name is not a provider: groq is reached through openai + a base URL",
|
|
modelCfg("development", ModelConfig{Provider: "groq"}), true,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := tc.cfg.validateModel()
|
|
if tc.wantErr != (err != nil) {
|
|
t.Fatalf("validateModel() = %v, wantErr = %v", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// THE STALE CONFIGURATION.
|
|
//
|
|
// This replaced a test called TestBaseURLWithoutOpenAIProviderIsRefused, which
|
|
// guarded the mirror image of the same mistake: while both providers existed, a
|
|
// base URL without MODEL_PROVIDER=openai meant a deployment that believed it had
|
|
// left Claude and had not. That failure is now impossible — there is nowhere
|
|
// else for a run to go — and the surviving one points the other way: a
|
|
// deployment that still names Anthropic, and must be told rather than silently
|
|
// re-pointed at a provider it never chose.
|
|
func TestTheRemovedProviderIsRefusedLoudly(t *testing.T) {
|
|
err := modelCfg("development", ModelConfig{Provider: "anthropic"}).validateModel()
|
|
if err == nil {
|
|
t.Fatal("MODEL_PROVIDER=anthropic was accepted; the stack would silently run on another vendor")
|
|
}
|
|
for _, want := range []string{"MODEL_PROVIDER=anthropic", "no longer supported", "MODEL_BASE_URL"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("the message does not mention %q:\n %v", want, err)
|
|
}
|
|
}
|
|
|
|
// The intended configuration is exactly what the message tells them to set.
|
|
if err := modelCfg("development", ModelConfig{
|
|
Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
|
|
}).validateModel(); err != nil {
|
|
t.Fatalf("the intended configuration was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A model id that outlived its provider.
|
|
//
|
|
// The expensive shape of this is not a typo, it is an UNCHANGED .env: the tier
|
|
// ids were claude-* for the whole life of the Anthropic path, and nothing about
|
|
// switching providers forces them to be revisited. Left unchecked the process
|
|
// starts clean and every single run fails at the gateway with a 400 — which is
|
|
// the incident that made the gateway start carrying upstream error text at all.
|
|
func TestClaudeModelIdsAreRefused(t *testing.T) {
|
|
base := ModelConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1",
|
|
Fast: "llama-3.1-8b-instant", Balanced: "llama-3.3-70b-versatile", Deep: "llama-3.3-70b-versatile"}
|
|
|
|
for _, tier := range []string{"MODEL_FAST", "MODEL_BALANCED", "MODEL_DEEP"} {
|
|
t.Run(tier, func(t *testing.T) {
|
|
m := base
|
|
switch tier {
|
|
case "MODEL_FAST":
|
|
m.Fast = "claude-opus-5"
|
|
case "MODEL_BALANCED":
|
|
m.Balanced = "claude-opus-5"
|
|
case "MODEL_DEEP":
|
|
m.Deep = "claude-3-5-sonnet-latest"
|
|
}
|
|
err := modelCfg("development", m).validateModel()
|
|
if err == nil {
|
|
t.Fatalf("%s kept a claude id and was accepted; every run on that tier would 400", tier)
|
|
}
|
|
// Naming the tier is the whole value: "a model is wrong" does not
|
|
// tell an operator which of three lines to edit.
|
|
if !strings.Contains(err.Error(), tier) {
|
|
t.Errorf("the message does not name the tier %q:\n %v", tier, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
if err := modelCfg("development", base).validateModel(); err != nil {
|
|
t.Fatalf("a fully-migrated configuration was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBaseURLMustBeAURL(t *testing.T) {
|
|
for _, raw := range []string{"api.groq.com", "ftp://x.test", "not a url", "://broken"} {
|
|
err := modelCfg("development", ModelConfig{Provider: "openai", BaseURL: raw}).validateModel()
|
|
if err == nil {
|
|
t.Errorf("MODEL_BASE_URL=%q was accepted", raw)
|
|
}
|
|
}
|
|
for _, raw := range []string{"http://localhost:11434/v1", "https://api.groq.com/openai/v1"} {
|
|
if err := modelCfg("development", ModelConfig{Provider: "openai", BaseURL: raw}).validateModel(); err != nil {
|
|
t.Errorf("MODEL_BASE_URL=%q was refused: %v", raw, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Production without a credential fails every run at the gateway, which is a
|
|
// misconfiguration wearing a runtime error's clothes. A local model is the one
|
|
// exception: it needs no key, and demanding one would make the zero-cost path
|
|
// impossible to configure.
|
|
func TestProductionCredentialRequirement(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
cfg *Config
|
|
wantErr bool
|
|
}{
|
|
{"production with no key", modelCfg("production", ModelConfig{}), true},
|
|
{"production with a key", modelCfg("production", ModelConfig{APIKey: "k"}), false},
|
|
{
|
|
"production against a local model needs no key",
|
|
modelCfg("production", ModelConfig{Provider: "openai", BaseURL: "http://localhost:11434/v1"}),
|
|
false,
|
|
},
|
|
{
|
|
"production against a hosted provider still does",
|
|
modelCfg("production", ModelConfig{Provider: "openai", BaseURL: "https://api.groq.com/openai/v1"}),
|
|
true,
|
|
},
|
|
{"development needs nothing", modelCfg("development", ModelConfig{}), false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
err := tc.cfg.validateModel()
|
|
if tc.wantErr != (err != nil) {
|
|
t.Fatalf("validateModel() = %v, wantErr = %v", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsLoopback(t *testing.T) {
|
|
for raw, want := range map[string]bool{
|
|
"http://localhost:11434/v1": true,
|
|
"http://127.0.0.1:11434/v1": true,
|
|
"https://api.groq.com/v1": false,
|
|
"": false,
|
|
// A remote host that merely mentions localhost in its path is not local.
|
|
"https://x.test/localhost/v1": false,
|
|
} {
|
|
if got := isLoopback(raw); got != want {
|
|
t.Errorf("isLoopback(%q) = %v, want %v", raw, got, want)
|
|
}
|
|
}
|
|
}
|