Files
yetone 3aeb7d1bf8 gateway: a model a key's plan doesn't serve rests that model alone, not the key (#1235)
SenseNova answers a Token Plan key asked for deepseek-v4.1-flash with
403 "model is not available in the current token plan" while the same
key serves deepseek-v4-flash. That refusal was a plain failure: it
rested the whole key, its backoff counted per key and doubling up to
10 minutes, so an agent that kept asking for the model left every key
of the provider resting for every model (Sun1090: both keys "失败 · 9
分钟后恢复").

A 400/403/404/422 whose body says the model isn't served
(unservedWords), with no quota, credit, rate-limit or channel words,
now rests only that key's or account's model (modelRefused, keyed by
restID, which is now restKey/model for every candidate), its backoff
counted by the model and cleared when the model answers. The key's
other models stay first in order, and its row shows no rest.

TestModelNotInPlanRestsTheModelNotTheKey (two keys, four requests for
the refused model, SenseNova's error shape) fails without the change:
"key One rests for a model its plan lacks: {Why:other Status:403 ...
By:backoff Failures:3}" and "deepseek-v4.1-flash doesn't rest". With
it: go test -tags nogui ./... under a temp HOME passes; -race -count=5
over TestModelNotInPlan|TestSeveralKeys|TestFallback|TestRouting|
TestPool|TestGroup passes; go vet (darwin, GOOS=linux, GOOS=windows,
nogui) passes. Not tried against a real SenseNova key: the body's
"code" field in the fixture is a guess; the message is the reporter's.
2026-10-08 12:26:53 +08:00

478 lines
20 KiB
Go

package gateway
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/testenv"
)
// twoProviders: "plan" (the one agents pick) falls back to "spare".
func twoProviders(t *testing.T, plan, spare *fake) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
for _, x := range []struct {
p provider.Provider
f *fake
}{
{provider.Provider{ID: "plan", Name: "Plan", Key: "k", Models: []string{"m1"}, Fallback: []string{"spare/m2", "nowhere/x"}}, plan},
{provider.Provider{ID: "spare", Name: "Spare", Key: "k", Models: []string{"m2"}}, spare},
} {
up := httptest.NewServer(x.f)
t.Cleanup(up.Close)
x.p.Chat = up.URL + "/v1"
if err := provider.Save(x.p); err != nil {
t.Fatal(err)
}
}
}
const chatReq = `{"model":"plan/m1","messages":[{"role":"user","content":"hi"}]}`
func TestFallbackWhenOutOfQuota(t *testing.T) {
for _, tc := range []struct {
name string
code int
msg string
}{
{"rate limit", 429, "slow down"},
{"no balance", 402, "Insufficient Balance"},
{"overloaded", 529, "overloaded"},
{"quota as 403", 403, "您的套餐额度已用完"},
} {
t.Run(tc.name, func(t *testing.T) {
plan := &fake{t: t, ctype: "application/json", code: tc.code, reply: `{"error":{"message":"` + tc.msg + `"}}`}
spare := &fake{t: t, ctype: "application/json", reply: `{"id":"from-spare","choices":[]}`}
twoProviders(t, plan, spare)
code, body := post(t, "/v1/chat/completions", chatReq)
if code != 200 || !strings.Contains(body, "from-spare") || strings.Contains(body, tc.msg) {
t.Fatalf("%d %s", code, body)
}
if !bytes.Contains(spare.got, []byte(`"model":"m2"`)) {
t.Fatalf("spare got %s", spare.got)
}
})
}
}
func TestFallbackRestsTheFailedProvider(t *testing.T) {
plan := &fake{t: t, ctype: "application/json", code: 429, reply: `{"error":{"message":"rate limited"}}`}
spare := &fake{t: t, ctype: "application/json", reply: `{"id":"from-spare","choices":[]}`}
twoProviders(t, plan, spare)
s := New()
send := func() (int, string) {
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)))
return rec.Code, rec.Body.String()
}
send()
if c := s.Recent()[0]; c.Provider != "spare" || !strings.Contains(c.Fallback, "plan: ") {
t.Fatalf("recorded %+v", c)
}
plan.got = nil
if code, body := send(); code != 200 || !strings.Contains(body, "from-spare") {
t.Fatalf("%d %s", code, body)
}
if plan.got != nil {
t.Fatal("a resting provider was tried first")
}
}
func TestNoFallbackForOtherErrors(t *testing.T) {
plan := &fake{t: t, ctype: "application/json", code: 400, reply: `{"error":{"message":"messages: field required"}}`}
spare := &fake{t: t, ctype: "application/json", reply: `{"id":"from-spare","choices":[]}`}
twoProviders(t, plan, spare)
code, body := post(t, "/v1/chat/completions", chatReq)
if code != 400 || !strings.Contains(body, "field required") || spare.got != nil {
t.Fatalf("%d %s (spare got %s)", code, body, spare.got)
}
}
func TestLastFallbackErrorReachesTheAgent(t *testing.T) {
plan := &fake{t: t, ctype: "application/json", code: 429, reply: `{"error":{"message":"plan limit"}}`}
spare := &fake{t: t, ctype: "application/json", code: 503, reply: `{"error":{"message":"spare down"}}`}
twoProviders(t, plan, spare)
code, body := post(t, "/v1/chat/completions", chatReq)
if code != 503 || !strings.Contains(body, "spare down") {
t.Fatalf("%d %s", code, body)
}
}
// byKey answers per API key: a key named in limited is out of quota.
type byKey struct {
limited map[string]bool
seen []string
}
func (b *byKey) ServeHTTP(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
b.seen = append(b.seen, key)
w.Header().Set("Content-Type", "application/json")
if b.limited[key] {
w.WriteHeader(429)
io.WriteString(w, `{"error":{"message":"rate limited"}}`)
return
}
io.WriteString(w, `{"id":"from-`+key+`","choices":[]}`)
}
func TestSeveralKeysOnTakeOverFromEachOther(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
up := &byKey{limited: map[string]bool{"k-personal": true}}
srv := httptest.NewServer(up)
defer srv.Close()
p := provider.Provider{ID: "plan", Name: "Plan", Chat: srv.URL + "/v1", Models: []string{"m1"},
Key: "k-personal", KeyName: "Personal",
Keys: []provider.KeyAccount{{Name: "Idle", Key: "k-idle", Off: true}, {Name: "Team", Key: "k-team"}}}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
s := New()
send := func() string {
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(chatReq)))
return rec.Body.String()
}
if body := send(); !strings.Contains(body, "from-k-team") {
t.Fatalf("body %s", body)
}
if c := s.Recent()[0]; c.Provider != "plan" || !strings.Contains(c.Fallback, "plan (Personal): ") {
t.Fatalf("recorded %+v", c)
}
// the limited key rests; the one that's off is never tried
up.seen = nil
if body := send(); !strings.Contains(body, "from-k-team") || strings.Join(up.seen, ",") != "k-team" {
t.Fatalf("body %s, tried %v", body, up.seen)
}
}
// #1235: two SenseNova keys, whose plan doesn't have deepseek-v4.1-flash.
// An agent kept asking for it, every try refused with SenseNova's 403 "model
// is not available in the current token plan", and each refusal rested the
// whole key, longer each time, till both keys sat out for every model. Only
// the model rests: the keys serve deepseek-v4-flash first in their order,
// and no key's rest grows.
func TestModelNotInPlanRestsTheModelNotTheKey(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
restingUntil.Lock()
restingUntil.m, restingUntil.note = map[string]time.Time{}, map[string]Rest{}
restingUntil.Unlock()
routed.Lock()
routed.failures = map[string]int{}
routed.Unlock()
var seen []string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Model string `json:"model"`
}
json.NewDecoder(r.Body).Decode(&req)
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
seen = append(seen, key+":"+req.Model)
w.Header().Set("Content-Type", "application/json")
if req.Model == "deepseek-v4.1-flash" {
w.WriteHeader(403)
io.WriteString(w, `{"error": {"code": 7,"message": "model is not available in the current token plan"}}`)
return
}
io.WriteString(w, `{"id":"from-`+key+`","choices":[]}`)
}))
defer up.Close()
p := provider.Provider{ID: "sensenova", Name: "sensenova", Chat: up.URL + "/v1",
Models: []string{"deepseek-v4-flash", "deepseek-v4.1-flash"},
Key: "k-one", KeyName: "One", Keys: []provider.KeyAccount{{Name: "Two", Key: "k-two"}}}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
saved, err := provider.Find("sensenova")
if err != nil {
t.Fatal(err)
}
s := New()
send := func(model string) (int, string) {
rec := httptest.NewRecorder()
body := `{"model":"sensenova/` + model + `","messages":[{"role":"user","content":"hi"}]}`
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)))
return rec.Code, rec.Body.String()
}
for range 4 {
if code, body := send("deepseek-v4.1-flash"); code != 403 || !strings.Contains(body, "not available in the current token plan") {
t.Fatalf("v4.1: %d %s", code, body)
}
}
for _, k := range saved.KeyList() {
if r, ok := RestOf(saved.RestKey(k)); ok {
t.Errorf("key %s rests for a model its plan lacks: %+v", k.Name, r)
}
}
// the model itself rests on each key, as the failure it is
for _, k := range saved.KeyList() {
r, ok := RestOf(saved.RestKey(k) + "/deepseek-v4.1-flash")
if !ok {
t.Errorf("key %s: deepseek-v4.1-flash doesn't rest", k.Name)
} else if r.Why != failOther || r.Status != 403 {
t.Errorf("key %s: rest %+v", k.Name, r)
}
}
seen = nil
if code, body := send("deepseek-v4-flash"); code != 200 || !strings.Contains(body, "from-k-one") {
t.Fatalf("v4: %d %s", code, body)
}
if strings.Join(seen, ",") != "k-one:deepseek-v4-flash" {
t.Fatalf("v4 tried %v; want the first key, straight", seen)
}
}
// Two ChatGPT accounts ticked: the one Codex is signed in to is out of
// quota, so the request goes to the saved one, signed with its own tokens.
func TestSubscriptionAccountsTakeOver(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
claims := func(m map[string]any) string {
b, _ := json.Marshal(m)
return "h." + base64.RawURLEncoding.EncodeToString(b) + ".s"
}
auth := func(email, acct string) map[string]any {
return map[string]any{"auth_mode": "chatgpt", "tokens": map[string]any{
"id_token": claims(map[string]any{"email": email}),
"access_token": claims(map[string]any{"exp": time.Now().Add(time.Hour).Unix(), "who": acct}),
"refresh_token": "r-" + acct, "account_id": acct}}
}
os.MkdirAll(filepath.Join(home, ".codex"), 0o755)
os.WriteFile(filepath.Join(home, ".codex", "auth.json"), mustJSON(auth("me@example.com", "acct-1")), 0o600)
os.MkdirAll(filepath.Dir(provider.Path()), 0o755)
os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "logins.json"), mustJSON([]map[string]any{
{"agent": "codex", "user": "spare@example.com", "on": true, "seen": time.Now(), "auth": auth("spare@example.com", "acct-2")},
}), 0o600)
var tried []string
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.ReadAll(r.Body)
tried = append(tried, r.Header.Get("chatgpt-account-id"))
if r.Header.Get("chatgpt-account-id") == "acct-1" {
w.WriteHeader(429)
io.WriteString(w, `{"error":{"message":"You've hit your usage limit"}}`)
return
}
io.WriteString(w, sse(
`data: {"type":"response.created","response":{"id":"r1","model":"gpt-5.5"}}`,
`data: {"type":"response.output_text.delta","delta":"pong"}`,
`data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":7,"output_tokens":1}}}`))
}))
defer up.Close()
old := provider.CodexBase
provider.CodexBase = up.URL + "/backend-api/codex"
defer func() { provider.CodexBase = old }()
code, body := post(t, "/v1/responses", `{"model":"codex/gpt-5.5","input":"ping"}`)
if code != 200 || !strings.Contains(body, "pong") {
t.Fatalf("status %d: %s", code, body)
}
if strings.Join(tried, ",") != "acct-1,acct-2" {
t.Fatalf("tried %v", tried)
}
// the first sits out a minute; the next request goes straight on
tried = nil
post(t, "/v1/responses", `{"model":"codex/gpt-5.5","input":"ping"}`)
if strings.Join(tried, ",") != "acct-2" {
t.Fatalf("second request tried %v", tried)
}
}
// Two Claude Max accounts ticked (#177): the one Claude Code is signed in to
// is out of quota, and the request goes to the saved spare, streamed or not.
// With magpie wired into Claude Code, `claude auth status` tells of magpie's
// token and no email: the account is still named from ~/.claude.json, so it
// is the saved me@example.com, not a "Claude Max" served beside it.
func TestClaudeAccountsTakeOver(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("a shell script stands in for Claude Code")
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
far := time.Now().Add(24 * time.Hour).UnixMilli()
oauth := func(tok string) map[string]any {
return map[string]any{"claudeAiOauth": map[string]any{"accessToken": tok, "refreshToken": "r-" + tok, "expiresAt": far, "subscriptionType": "max"}}
}
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
os.WriteFile(filepath.Join(home, ".claude", ".credentials.json"), mustJSON(oauth("tok-primary")), 0o600)
os.WriteFile(filepath.Join(home, ".claude.json"), mustJSON(map[string]any{
"oauthAccount": map[string]any{"emailAddress": "me@example.com", "accountUuid": "u-me"}}), 0o600)
os.MkdirAll(filepath.Dir(provider.Path()), 0o755)
os.WriteFile(filepath.Join(filepath.Dir(provider.Path()), "logins.json"), mustJSON([]map[string]any{
{"agent": "claude", "user": "me@example.com", "plan": "max", "on": true, "seen": time.Now(), "auth": oauth("tok-me")},
{"agent": "claude", "user": "spare@example.com", "plan": "max", "on": true, "seen": time.Now(), "auth": oauth("tok-spare")},
}), 0o600)
dir := t.TempDir()
log := filepath.Join(dir, "log")
// out of quota, the CLI says so in a result and waits on its next input
script := `#!/bin/sh
if [ "$1" = auth ]; then echo '{"loggedIn":true,"authMethod":"oauth_token","apiProvider":"firstParty"}'; exit 0; fi
TOK=; [ -n "$CLAUDE_CONFIG_DIR" ] && TOK=$(sed -n 's/.*"accessToken": *"\([^"]*\)".*/\1/p' "$CLAUDE_CONFIG_DIR/.credentials.json")
echo "${TOK:-own}" >> ` + log + `
while read -r line; do
if [ -z "$TOK" ] || [ "$TOK" = tok-me ]; then
echo '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1790700000}}'
echo '{"type":"assistant","message":{"id":"x","model":"<synthetic>","role":"assistant","content":[{"type":"text","text":"You'"'"'ve hit your limit · resets 3am"}]},"error":"rate_limit"}'
echo '{"type":"result","subtype":"success","is_error":true,"result":"You'"'"'ve hit your limit · resets 3am"}'
else
echo '{"type":"stream_event","event":{"type":"message_start","message":{"id":"m","model":"claude-sonnet-5","usage":{"input_tokens":1}}}}'
echo '{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}}'
echo '{"type":"stream_event","event":{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}}'
echo '{"type":"stream_event","event":{"type":"message_stop"}}'
echo '{"type":"result","subtype":"success","is_error":false,"result":"pong"}'
fi
done
`
testenv.Program(t, filepath.Join(dir, "claude"), script)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
provider.ForgetAccounts()
t.Cleanup(provider.ForgetAccounts)
for _, stream := range []string{"true", "false"} {
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
os.Remove(log)
done := make(chan struct{})
var code int
var body string
go func() {
defer close(done)
code, body = post(t, "/v1/messages", `{"model":"claude/claude-sonnet-5","max_tokens":50,"stream":`+stream+`,"messages":[{"role":"user","content":"ping"}]}`)
}()
select {
case <-done:
case <-time.After(20 * time.Second):
t.Fatalf("stream=%s: no answer", stream)
}
b, _ := os.ReadFile(log)
if code != 200 || !strings.Contains(body, "pong") {
t.Fatalf("stream=%s: status %d: %s (runs %q)", stream, code, body, b)
}
// the signed-in account once, then the spare: never it again under
// its saved name
if runs := strings.Fields(string(b)); strings.Join(runs, ",") != "own,tok-spare" && strings.Join(runs, ",") != "tok-spare" {
t.Fatalf("stream=%s: runs %v", stream, runs)
}
}
for _, p := range provider.All() {
if p.Account != nil && p.Account.Agent == "claude" && p.Account.User != "me@example.com" {
t.Fatalf("Claude Code's own account named %q", p.Account.User)
}
}
}
// A relay that hands out one key for Anthropic and another for OpenAI:
// each key is used on its own endpoint only, and the one that suits the
// model goes first whatever the order.
func TestKeysMadeForOneProtocol(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
key := r.Header.Get("x-api-key")
if key == "" {
key = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
seen = append(seen, r.URL.Path+" "+key)
if (r.URL.Path == "/v1/messages") != (key == "k-ant") {
w.WriteHeader(401)
io.WriteString(w, `{"error":{"message":"this key is for another protocol"}}`)
return
}
if bytes.Contains(b, []byte(`"stream":true`)) && r.URL.Path == "/v1/messages" {
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, sse(
`event: message_start`+"\n"+`data: {"type":"message_start","message":{"id":"m1","type":"message","role":"assistant","model":"x","content":[],"usage":{"input_tokens":1,"output_tokens":0}}}`,
`event: content_block_start`+"\n"+`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
`event: content_block_delta`+"\n"+`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
`event: content_block_stop`+"\n"+`data: {"type":"content_block_stop","index":0}`,
`event: message_delta`+"\n"+`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}`,
`event: message_stop`+"\n"+`data: {"type":"message_stop"}`))
return
}
if bytes.Contains(b, []byte(`"stream":true`)) {
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, sse(
`data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}`,
`data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
`data: [DONE]`))
return
}
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/v1/messages" {
io.WriteString(w, `{"id":"m1","type":"message","role":"assistant","model":"x","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)
return
}
io.WriteString(w, `{"id":"c1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
p := provider.Provider{ID: "relay", Name: "Relay", Chat: srv.URL + "/v1", Anthropic: srv.URL,
Key: "k-oai", KeyProtocol: provider.Chat, Models: []string{"claude-opus-4-8", "gpt-5.5", "glm-5"},
Keys: []provider.KeyAccount{{Key: "k-ant", Protocol: provider.Anthropic}}}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
// the vendor lists glm-5 to the Anthropic key only
if err := catalog.SaveLive("relay", srv.URL, []catalog.Model{{ID: "gpt-5.5", Keys: []string{provider.KeyID("k-oai")}},
{ID: "glm-5", Keys: []string{provider.KeyID("k-ant")}}}); err != nil {
t.Fatal(err)
}
for _, x := range []struct{ path, body, want string }{
// Claude Code asking for Claude: the Anthropic key, relayed as-is
{"/v1/messages", `{"model":"relay/claude-opus-4-8","max_tokens":9,"messages":[{"role":"user","content":"hi"}]}`, "/v1/messages k-ant"},
// an OpenAI agent asking for Claude: translated, to the Anthropic key
{"/v1/chat/completions", `{"model":"relay/claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`, "/v1/messages k-ant"},
// Claude Code asking for GPT: translated, to the OpenAI key
{"/v1/messages", `{"model":"relay/gpt-5.5","max_tokens":9,"messages":[{"role":"user","content":"hi"}]}`, "/v1/chat/completions k-oai"},
// a model only the Anthropic key lists goes there, whatever the agent spoke
{"/v1/chat/completions", `{"model":"relay/glm-5","messages":[{"role":"user","content":"hi"}]}`, "/v1/messages k-ant"},
} {
seen = nil
code, body := post(t, x.path, x.body)
if code != 200 || len(seen) != 1 || seen[0] != x.want {
t.Fatalf("%s %s: %d %s, upstream saw %v", x.path, x.body, code, body, seen)
}
}
if got := modelFamily("openrouter/openai/o3-mini"); got != provider.Chat {
t.Errorf("o3: %q", got)
}
if got := modelFamily("deepseek-chat"); got != "" {
t.Errorf("deepseek: %q", got)
}
}