package traefikllmgateway import ( "bytes" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" ) // newMessagesRequest builds a POST /v1/messages request, JSON-encoding // body. authHeader/authValue let a test choose which credential header to // send; authHeader == "" sends no credential at all. func newMessagesRequest(t *testing.T, body map[string]any, authHeader, authValue string) *http.Request { t.Helper() b, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request body: %v", err) } req := httptest.NewRequest(http.MethodPost, messagesPath, bytes.NewReader(b)) if authHeader != "" { req.Header.Set(authHeader, authValue) } return req } // newMessagesTestGateway builds a *Gateway wired with an "anthropic" // provider pointed at anthropicURL (skipped when "") and an "openai" // provider pointed at openaiURL (skipped when ""), one permissive // "default" group, and one inline user "alice" — the shared fixture // every handleMessages test below starts from. func newMessagesTestGateway(t *testing.T, anthropicURL, openaiURL string) *Gateway { t.Helper() cfg := CreateConfig() cfg.Providers = map[string]*ProviderConfig{} if anthropicURL != "" { cfg.Providers["anthropic"] = &ProviderConfig{Type: providerTypeAnthropic, BaseURL: anthropicURL, APIKey: "sk-ant-up", Models: []string{"claude-test"}} // #nosec G101 -- test fixture literal, not a real credential } if openaiURL != "" { cfg.Providers["openai"] = &ProviderConfig{Type: providerTypeOpenAI, BaseURL: openaiURL, APIKey: "sk-oai-up", Models: []string{"gpt-test"}} } cfg.Groups = map[string]*GroupConfig{"default": {}} cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}} next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) h, err := New(context.Background(), next, cfg, "llmgw") if err != nil { t.Fatalf("New: %v", err) } gw, ok := h.(*Gateway) if !ok { t.Fatal("handler is not *Gateway") } return gw } // TestHandleMessages_AnthropicProvider_Passthrough proves a /v1/messages // request resolved to an anthropic-type provider is forwarded to that // provider's own Messages endpoint UNTRANSLATED (brief: "pass through, no // translation of the message body") and its response copied back to the // client with every field preserved except "model", which is rewritten to // the client's own requested alias (item 4 fix, 2026-08-22 review: a // routing rewrite, not message-body translation — every OTHER field, // including ones this translator does not itself know about, survives // unchanged), while still authenticating upstream, forwarding the // anthropic-beta header (item 5), billing prompt-cache tokens (item 6), // and accounting usage exactly like every other route. func TestHandleMessages_AnthropicProvider_Passthrough(t *testing.T) { const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"text","text":"hi there"}],"model":"claude-real","stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":6,"cache_creation_input_tokens":100,"cache_read_input_tokens":50}}` var gotBody map[string]any var gotAPIKey, gotVersion, gotPath, gotBeta string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path gotAPIKey = r.Header.Get("x-api-key") gotVersion = r.Header.Get("anthropic-version") gotBeta = r.Header.Get("anthropic-beta") _ = json.NewDecoder(r.Body).Decode(&gotBody) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, srv.URL, "") body := map[string]any{ "model": "claude-test", "max_tokens": 100, "messages": []any{map[string]any{"role": "user", "content": "hello"}}, } req := newMessagesRequest(t, body, "x-api-key", "sk-alice") req.Header.Set("anthropic-beta", "token-efficient-tools-2025-02-19") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode client response: %v", err) } if got["model"] != "claude-test" { t.Errorf(`response["model"] = %v, want "claude-test" (the client's requested alias echoed back, item 4 fix — not the upstream's real model id)`, got["model"]) } // Every other field must survive the passthrough unchanged, including // ones this gateway does not itself interpret (stop_sequence). if got["id"] != "msg_01ABC" || got["role"] != "assistant" || got["stop_reason"] != "end_turn" { t.Errorf("response id/role/stop_reason = %v/%v/%v, want the upstream's own values preserved", got["id"], got["role"], got["stop_reason"]) } if _, hasStopSeq := got["stop_sequence"]; !hasStopSeq { t.Error(`response missing "stop_sequence" — a field this gateway never reads must still survive the passthrough`) } content, _ := got["content"].([]any) if len(content) != 1 { t.Fatalf("response content = %v, want the upstream's one text block preserved", got["content"]) } if gotPath != anthropicMessagesPath { t.Errorf("upstream path = %q, want %q", gotPath, anthropicMessagesPath) } if gotAPIKey != "sk-ant-up" { // #nosec G101 -- test fixture literal, not a real credential t.Errorf("upstream x-api-key = %q, want sk-ant-up", gotAPIKey) } if gotVersion != anthropicAPIVersion { t.Errorf("upstream anthropic-version = %q, want %q", gotVersion, anthropicAPIVersion) } if gotBeta != "token-efficient-tools-2025-02-19" { t.Errorf("upstream anthropic-beta = %q, want the client's header forwarded (item 5 fix)", gotBeta) } if gotBody["model"] != "claude-test" { t.Errorf("upstream model = %v, want claude-test (resolved upstream id)", gotBody["model"]) } if _, hasAlias := gotBody[gatewayAliasKey]; hasAlias { t.Error("upstream request carries gatewayAliasKey; must never reach an upstream provider") } // 12 input + 100 cache-creation + 50 cache-read (item 6 fix): all // three fold into the billed prompt count, not just fresh input. tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) if !ok || tokIn != 162 { t.Errorf("user tokin/day counter = %d (ok=%v), want 162 (12 input + 100 cache-creation + 50 cache-read)", tokIn, ok) } tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) if !ok || tokOut != 6 { t.Errorf("user tokout/day counter = %d (ok=%v), want 6", tokOut, ok) } } // TestHandleMessages_OpenAIProvider_TranslatesBothDirections proves a // /v1/messages request resolved to an openai-type provider is translated // to OpenAI's chat-completion shape going out (Anthropic's top-level // "system" field folded into a role:"system" message) and the upstream's // OpenAI-shaped response translated back to Anthropic's Messages shape // coming back, with usage accounted from the translated numbers. func TestHandleMessages_OpenAIProvider_TranslatesBothDirections(t *testing.T) { const oaiResp = `{"id":"chatcmpl-99","object":"chat.completion","model":"gpt-real","choices":[{"index":0,"message":{"role":"assistant","content":"hi there"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":4}}` var gotReq map[string]any var gotPath string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotPath = r.URL.Path _ = json.NewDecoder(r.Body).Decode(&gotReq) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(oaiResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, "", srv.URL) body := map[string]any{ "model": "gpt-test", "max_tokens": 100, "system": "You are terse.", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, } req := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } if gotPath != "/v1/chat/completions" { t.Errorf("upstream path = %q, want /v1/chat/completions", gotPath) } msgs, _ := gotReq["messages"].([]any) if len(msgs) != 2 { t.Fatalf("upstream messages = %v, want 2 (system + user)", gotReq["messages"]) } first, _ := msgs[0].(map[string]any) if first["role"] != "system" || first["content"] != "You are terse." { t.Errorf("upstream messages[0] = %v, want the system message translated into OpenAI's shape", first) } if _, hasSystem := gotReq["system"]; hasSystem { t.Error("upstream request still carries Anthropic's own \"system\" field; want it folded into messages[]") } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode client response: %v", err) } if got["type"] != "message" || got["role"] != "assistant" { t.Errorf("response type/role = %v/%v, want message/assistant (Anthropic shape)", got["type"], got["role"]) } content, _ := got["content"].([]any) if len(content) != 1 { t.Fatalf("response content = %v, want one text block", got["content"]) } block, _ := content[0].(map[string]any) if block["type"] != "text" || block["text"] != "hi there" { t.Errorf("response content[0] = %v, want {type: text, text: \"hi there\"}", block) } if got["model"] != "gpt-test" { t.Errorf("response model = %v, want gpt-test (the client's own requested alias echoed back)", got["model"]) } if got["stop_reason"] != "end_turn" { t.Errorf("response stop_reason = %v, want end_turn", got["stop_reason"]) } usageMap, _ := got["usage"].(map[string]any) if usageMap["input_tokens"] != float64(8) || usageMap["output_tokens"] != float64(4) { t.Errorf("response usage = %v, want input_tokens=8 output_tokens=4", usageMap) } tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) if !ok || tokIn != 8 { t.Errorf("user tokin/day counter = %d (ok=%v), want 8", tokIn, ok) } tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) if !ok || tokOut != 4 { t.Errorf("user tokout/day counter = %d (ok=%v), want 4", tokOut, ok) } } // TestHandleMessages_Auth table-drives the three credential cases the // brief calls out explicitly: x-api-key authenticates, Authorization: // Bearer authenticates, and a bad key gets a 401 in ANTHROPIC error // shape ({"type":"error","error":{"type":...}}), not OpenAI's // ({"error":{...}}). func TestHandleMessages_Auth(t *testing.T) { const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() cases := []struct { name string authHeader string authValue string wantStatus int }{ {"x-api-key header authenticates", "x-api-key", "sk-alice", http.StatusOK}, {"Authorization Bearer header authenticates", "Authorization", "Bearer sk-alice", http.StatusOK}, {"bad key is rejected in Anthropic error shape", "x-api-key", "sk-not-a-real-key", http.StatusUnauthorized}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { gw := newMessagesTestGateway(t, srv.URL, "") body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} req := newMessagesRequest(t, body, tc.authHeader, tc.authValue) rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != tc.wantStatus { t.Fatalf("status = %d, want %d, body=%s", rec.Code, tc.wantStatus, rec.Body.String()) } if tc.wantStatus != http.StatusUnauthorized { return } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode error body: %v", err) } if got["type"] != "error" { t.Errorf(`response["type"] = %v, want "error" (Anthropic's envelope shape, not OpenAI's flat one)`, got["type"]) } errObj, _ := got["error"].(map[string]any) if errObj["type"] != "authentication_error" { t.Errorf("error.type = %v, want authentication_error", errObj["type"]) } if _, hasTopLevelCode := got["code"]; hasTopLevelCode { t.Error(`response carries a top-level "code" field — that is OpenAI's writeOAIError shape, not Anthropic's`) } }) } } // TestHandleMessages_StreamingRejected table-drives every "stream" shape // this route must reject cleanly, with a typed, Anthropic-shaped error, // before any upstream call is made or any rate-limit budget is spent — // never silently ignored, never answered non-streamed. Covers a real // boolean true and, per item 9's fix (2026-08-22 review), a non-bool // truthy value ("stream":"true", a string, and "stream":1, a number) that // a bare req["stream"].(bool) assertion would previously have silently // treated as false and forwarded upstream. func TestHandleMessages_StreamingRejected(t *testing.T) { cases := []struct { stream any name string }{ {name: "boolean true", stream: true}, {name: "string \"true\" (non-bool, item 9)", stream: "true"}, {name: "number 1 (non-bool, item 9)", stream: float64(1)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { called := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.WriteHeader(http.StatusOK) })) defer srv.Close() // requestsPerMinute: 1 so a second wrongly-counted request // would 429 — proving the rejection happens before // admitRequestForRoute ever runs (item 2 fix): both requests below // must succeed at the "not yet counted" stage. cfg := CreateConfig() cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: providerTypeAnthropic, BaseURL: srv.URL, APIKey: "sk-ant-up", Models: []string{"claude-test"}}} // #nosec G101 -- test fixture literal, not a real credential cfg.Groups = map[string]*GroupConfig{"default": {}} cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}}}} // #nosec G101 -- test fixture literal, not a real credential next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) h, err := New(context.Background(), next, cfg, "llmgw") if err != nil { t.Fatalf("New: %v", err) } gw, ok := h.(*Gateway) if !ok { t.Fatal("handler is not *Gateway") } body := map[string]any{"model": "claude-test", "max_tokens": 10, "stream": tc.stream, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} for attempt := 1; attempt <= 2; attempt++ { req := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) // 400, not 501 (item 2 fix): anthropic-sdk-python's retry // classifies purely on status code and retries anything // >= 500 — a 501 here turns every streaming call into a // three-request SDK retry storm against this route's own // rate limit. if rec.Code != http.StatusBadRequest { t.Fatalf("attempt %d: status = %d, want 400, body=%s", attempt, rec.Code, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode error body: %v", err) } if got["type"] != "error" { t.Errorf(`response["type"] = %v, want "error"`, got["type"]) } errObj, _ := got["error"].(map[string]any) if errObj["type"] != "invalid_request_error" { t.Errorf("error.type = %v, want invalid_request_error", errObj["type"]) } msg, _ := errObj["message"].(string) if !strings.Contains(msg, "stream") { t.Errorf("error message = %q, want it to mention streaming is not supported", msg) } } if called { t.Error("upstream was called; streaming must be rejected before any upstream request") } // item 2's actual fix: TWO streaming-rejected attempts against // a requestsPerMinute:1 user must BOTH succeed (as 400s, not // as a 429 on the second one) — proving admitRequestForRoute's // checkAndCount never ran for either. reqCount, ok := gw.limiter.getCounter("user", "alice", metricReq, windowMin, time.Now()) if !ok || reqCount != 0 { t.Errorf("user req:min counter = %d (ok=%v), want 0 — a streaming-rejected request must never consume rate-limit budget", reqCount, ok) } }) } } // TestHandleMessages_NullStream_IsNotStreaming is the regression for F7 // (2026-08-23 review): isStreamingRequested's item-9 fix ("anything not // the literal false is streaming") had the side effect of also // rejecting "stream": null, since a bare v != false comparison treats a // JSON null (decoded as a Go nil interface) as "present and not false". // An SDK that serializes an unset optional field as explicit null must // still get an ordinary, successful non-streaming response, not a false // -positive 400. func TestHandleMessages_NullStream_IsNotStreaming(t *testing.T) { const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` called := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, srv.URL, "") body := map[string]any{"model": "claude-test", "max_tokens": 10, "stream": nil, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} req := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } if !called { t.Error("upstream was never called; \"stream\": null must be treated as an ordinary non-streaming request") } } // TestHandleMessages_RateLimited_Returns429 proves this route enforces // the caller's group/user requestsPerMinute limit exactly like // routes_unified.go's runUnified: a second request past the limit is // refused before any upstream call, carries a Retry-After header, and // only the first, successful request's usage is accounted. func TestHandleMessages_RateLimited_Returns429(t *testing.T) { upstreamCalls := 0 const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamCalls++ w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() cfg := CreateConfig() cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: providerTypeAnthropic, BaseURL: srv.URL, APIKey: "sk-ant-up", Models: []string{"claude-test"}}} // #nosec G101 -- test fixture literal, not a real credential cfg.Groups = map[string]*GroupConfig{"default": {}} cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}}}} next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) h, err := New(context.Background(), next, cfg, "llmgw") if err != nil { t.Fatalf("New: %v", err) } gw, ok := h.(*Gateway) if !ok { t.Fatal("handler is not *Gateway") } body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} req1 := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec1 := httptest.NewRecorder() gw.ServeHTTP(rec1, req1) if rec1.Code != http.StatusOK { t.Fatalf("first request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String()) } req2 := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec2 := httptest.NewRecorder() gw.ServeHTTP(rec2, req2) if rec2.Code != http.StatusTooManyRequests { t.Fatalf("second request status = %d, want 429, body=%s", rec2.Code, rec2.Body.String()) } if rec2.Header().Get("Retry-After") == "" { t.Error("429 response missing Retry-After header") } if upstreamCalls != 1 { t.Errorf("upstream called %d times, want 1 (second request refused before any upstream call)", upstreamCalls) } // Envelope-shape assertion (item 8 fix, 2026-08-22 review): now that // admitRequestForRoute threads an envelopeWriter through // (routes_unified.go's writeLimitViolationEnvelope), a 429 on this // route must answer in Anthropic's shape like every other error this // route produces, not OpenAI's flat one. var got map[string]any if err := json.Unmarshal(rec2.Body.Bytes(), &got); err != nil { t.Fatalf("decode 429 body: %v", err) } if got["type"] != "error" { t.Errorf(`429 response["type"] = %v, want "error" (Anthropic's envelope shape, not OpenAI's flat one)`, got["type"]) } errObj, _ := got["error"].(map[string]any) if errObj["type"] != "rate_limit_error" { t.Errorf("429 error.type = %v, want rate_limit_error", errObj["type"]) } if _, hasTopLevelCode := got["code"]; hasTopLevelCode { t.Error(`429 response carries a top-level "code" field — that is OpenAI's writeOAIError shape, not Anthropic's`) } tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) if !ok || tokIn != 1 { t.Errorf("user tokin/day counter = %d (ok=%v), want 1 (only the first, successful request accounted)", tokIn, ok) } } // TestHandleMessages_RateLimited_RecordsRateLimitEvent proves a // /v1/messages 429 is recorded to GET /admin/api/events (F3 hook 1, v0.3 // dashboard task) exactly like every other metered route: handleMessages // switched from the route-blind admitRequest to admitRequestForRoute // (routes_unified.go) with routeMessages, so recordLimitEvent now runs // on this route's own rejection path too. Mirrors // admin_events_test.go's TestAdminEvents_RateLimitEvent, scoped to this // route's own Anthropic-shaped request/response. func TestHandleMessages_RateLimited_RecordsRateLimitEvent(t *testing.T) { const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() cfg := CreateConfig() cfg.Admin = &AdminConfig{Enabled: true} cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: providerTypeAnthropic, BaseURL: srv.URL, APIKey: "sk-ant-up", Models: []string{"claude-test"}}} // #nosec G101 -- test fixture literal, not a real credential cfg.Groups = map[string]*GroupConfig{"default": {}} cfg.Users = &UsersConfig{Inline: []*UserConfig{ {Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerMinute: 1}}, {Name: "admin1", Group: "default", APIKey: "sk-admin1", Admin: true}, }} next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) h, err := New(context.Background(), next, cfg, "llmgw") if err != nil { t.Fatalf("New: %v", err) } body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} rec1 := httptest.NewRecorder() h.ServeHTTP(rec1, newMessagesRequest(t, body, "x-api-key", "sk-alice")) if rec1.Code != http.StatusOK { t.Fatalf("1st call: status = %d, want 200, body=%s", rec1.Code, rec1.Body.String()) } rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, newMessagesRequest(t, body, "x-api-key", "sk-alice")) if rec2.Code != http.StatusTooManyRequests { t.Fatalf("2nd call: status = %d, want 429 (over requestsPerMinute:1), body=%s", rec2.Code, rec2.Body.String()) } got := getAdminEvents(t, h, "?limit=10") if len(got.Events) == 0 { t.Fatal("want at least one event") } ev := got.Events[0] // newest first if ev.Kind != eventKindRateLimit { t.Errorf("Kind = %q, want %q", ev.Kind, eventKindRateLimit) } if ev.Route != routeMessages { t.Errorf("Route = %q, want %q", ev.Route, routeMessages) } if ev.User != "alice" { t.Errorf("User = %q, want %q", ev.User, "alice") } if ev.Status != http.StatusTooManyRequests { t.Errorf("Status = %d, want 429", ev.Status) } } // TestHandleMessages_TranslationFailureAfterBilledUsage_PreservesUsage is // the regression for item 1 (CRITICAL, 2026-08-22 review): an // openai-type provider's response can succeed (200, real usage the // provider already charged for) yet still fail THIS route's own // translation into Anthropic's shape — here, a tool-call arguments // string truncated mid-JSON by the provider's own max_tokens cutoff // (finish_reason "length"). The previous version of callTranslatedMessages // returned usage{} on that path, discarding tokens the provider had // already billed — a client who could reliably induce a truncated // tool-call response got unlimited free tokens against any budget. This // proves the client gets a clean 502 (translation genuinely failed, no // valid Anthropic response could be produced) while the usage still // lands in the limiter's counters, and (item 12) that the log line // names the real cause — "response translation failed", never "upstream // connection error", which would misdirect an operator toward a network // problem that never happened. func TestHandleMessages_TranslationFailureAfterBilledUsage_PreservesUsage(t *testing.T) { // Exact verified repro from the review: usage prompt=5000/ // completion=900, finish_reason "length", and a tool_calls[].function. // arguments string truncated mid-object. const oaiResp = `{"id":"chatcmpl-trunc","object":"chat.completion","model":"gpt-real","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Lond"}}]},"finish_reason":"length"}],"usage":{"prompt_tokens":5000,"completion_tokens":900}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(oaiResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, "", srv.URL) body := map[string]any{ "model": "gpt-test", "max_tokens": 100, "messages": []any{map[string]any{"role": "user", "content": "hello"}}, } req := newMessagesRequest(t, body, "x-api-key", "sk-alice") var rec *httptest.ResponseRecorder logs := captureStderr(t, func() { rec = httptest.NewRecorder() gw.ServeHTTP(rec, req) }) if rec.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502, body=%s", rec.Code, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode error body: %v", err) } if got["type"] != "error" { t.Errorf(`response["type"] = %v, want "error" (Anthropic error shape)`, got["type"]) } // item 1: usage the provider already billed must still land in the // counters, even though the client got an error. tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) if !ok || tokIn != 5000 { t.Errorf("user tokin/day counter = %d (ok=%v), want 5000 (the upstream's already-billed usage, not discarded on translation failure)", tokIn, ok) } tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) if !ok || tokOut != 900 { t.Errorf("user tokout/day counter = %d (ok=%v), want 900", tokOut, ok) } // item 12: the log must name this a translation failure, never an // upstream connection error — the connection and the upstream call // both succeeded fine. if !strings.Contains(logs, "response translation failed") { t.Errorf("log output = %q, want it to contain \"response translation failed\"", logs) } if strings.Contains(logs, "upstream connection error") { t.Errorf("log output = %q, want it to NOT be classified as an upstream connection error", logs) } } // TestHandleMessages_AnthropicProvider_Passthrough_PreservesExactBytes is // the regression for F1 (BLOCKING, 2026-08-23 review): the previous // alias-echo fix decoded the upstream's 200 into map[string]any and // re-marshaled it, which silently corrupted data on a route documented // as passthrough — a JSON integer beyond float64's exact-integer range // (verified repro: 12345678901234567890 became 12345678901234567000), // a trailing ".0" on a float (1.0 became 1), and HTML-escaped "<" and // "&". A tool_use block's "input" is arbitrary model-generated JSON, so // a long numeric id inside tool arguments was silently corrupted on // every anthropic-type request through this route. This proves the // fixed version (rewriteModelField, routes_messages.go) reproduces the // upstream body byte-for-byte apart from the "model" field alone: same // field order, same big-integer digits, same "1.0", same literal "<". func TestHandleMessages_AnthropicProvider_Passthrough_PreservesExactBytes(t *testing.T) { const anthResp = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"calc","input":{"big_id":12345678901234567890,"score":1.0,"note":"1 < 2 & 3"}}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}` const wantBody = `{"id":"msg_01ABC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"calc","input":{"big_id":12345678901234567890,"score":1.0,"note":"1 < 2 & 3"}}],"model":"claude-test","stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}` srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, srv.URL, "") body := map[string]any{ "model": "claude-test", "max_tokens": 100, "messages": []any{map[string]any{"role": "user", "content": "hello"}}, } req := newMessagesRequest(t, body, "x-api-key", "sk-alice") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } if got := rec.Body.String(); got != wantBody { t.Errorf("body = %s\nwant = %s\n(must be byte-for-byte identical apart from the \"model\" field: no float64 rounding, no 1.0->1, no HTML-escaping, no key reordering)", got, wantBody) } } // TestHandleMessages_AnthropicProvider_Passthrough_HeaderAllowlist is the // regression for F3 (BLOCKING, 2026-08-23 review): negative assertions // that a blanket header-forward, or reordering injectAuth ahead of the // allowlist copy while adding x-api-key to it, would both defeat. // Authorization, x-api-key (the CLIENT's own value), and Cookie must // never reach the anthropic-type upstream, and the upstream must see // exactly ONE x-api-key value: the gateway's own configured key // ("sk-ant-up"), never the client's own ("sk-alice"). The exactly-ONE // check matters on its own: injectAuth's Set() call replaces any // existing x-api-key value, so a mutant that adds x-api-key to the // allowlist and reorders injectAuth BEFORE that copy would still leave // Get("x-api-key") returning "sk-ant-up" (Set ran first, Add appended // after) while a second, leaked "sk-alice" value silently rode along — // invisible to a bare .Get() check, which only ever returns the first // value. func TestHandleMessages_AnthropicProvider_Passthrough_HeaderAllowlist(t *testing.T) { const anthResp = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-real","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` var gotHeaders http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotHeaders = r.Header.Clone() w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(anthResp)) })) defer srv.Close() gw := newMessagesTestGateway(t, srv.URL, "") body := map[string]any{"model": "claude-test", "max_tokens": 10, "messages": []any{map[string]any{"role": "user", "content": "hi"}}} // Authenticate via Authorization: Bearer (presentedKey, auth.go, // gives it priority over x-api-key when both are present) and // ADDITIONALLY send an x-api-key header of the client's own — unused // for auth here, but still present on the request and must still // never reach the upstream — plus a Cookie header. None of the three // has any business reaching the upstream. req := newMessagesRequest(t, body, "Authorization", "Bearer sk-alice") req.Header.Set("x-api-key", "some-other-client-value") // #nosec G101 -- test fixture literal, not a real credential req.Header.Set("Cookie", "session=client-side-secret") rec := httptest.NewRecorder() gw.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } if got := gotHeaders.Get("Authorization"); got != "" { t.Errorf("upstream Authorization = %q, want empty (never forwarded)", got) } if got := gotHeaders.Get("Cookie"); got != "" { t.Errorf("upstream Cookie = %q, want empty (never forwarded)", got) } apiKeyValues := gotHeaders.Values("x-api-key") if len(apiKeyValues) != 1 { t.Fatalf("upstream x-api-key values = %v, want exactly 1 (the client's own key must never ride along as a second value)", apiKeyValues) } if apiKeyValues[0] != "sk-ant-up" { // #nosec G101 -- test fixture literal, not a real credential t.Errorf("upstream x-api-key = %q, want the gateway's own configured key sk-ant-up, never the client's sk-alice", apiKeyValues[0]) } } // TestResponseTranslationError_ErrorPrefixesTranslateResponse proves // (*responseTranslationError).Error prefixes the wrapped error's own // message with "translate response: " — the text a log line built from // this error carries, distinguishing a translation failure (the upstream // already answered and was billed) from a connectivity failure in the // same log stream. // // MUTATION PROVEN: changing the method body to `return e.err.Error()` // (dropping the prefix) made this test fail — confirmed via `go test -run // TestResponseTranslationError_ErrorPrefixesTranslateResponse`, then // reverted with `git checkout -- routes_messages.go`. func TestResponseTranslationError_ErrorPrefixesTranslateResponse(t *testing.T) { err := &responseTranslationError{err: errors.New("boom")} if got, want := err.Error(), "translate response: boom"; got != want { t.Errorf("Error() = %q, want %q", got, want) } } // TestWriteAnthropicProviderUpstreamError_EmbedsStatusTypeAndDecodedBody // proves writeAnthropicProviderUpstreamError writes perr's own status // through as the HTTP status (not a fixed one), wraps it in the Anthropic // {"type":"error","error":{...}} envelope with error.type "upstream_error" // and error.code as perr's status stringified, and embeds perr.body — // decoded as JSON when it parses as one — under error.upstream // (decodeUpstreamErrorBody, routes_unified.go, shared with this // function's OpenAI-shaped counterpart writeProviderUpstreamError). // // MUTATION PROVEN: replacing `w.WriteHeader(perr.status)` with a // hardcoded `w.WriteHeader(http.StatusInternalServerError)` made the // status assertion fail — confirmed, then reverted. Separately proven: // removing `"upstream": decodeUpstreamErrorBody(perr)` from the envelope // made the upstream-body assertion fail — confirmed, then reverted. func TestWriteAnthropicProviderUpstreamError_EmbedsStatusTypeAndDecodedBody(t *testing.T) { perr := &providerHTTPError{status: http.StatusServiceUnavailable, body: []byte(`{"error":{"message":"overloaded"}}`)} rec := httptest.NewRecorder() writeAnthropicProviderUpstreamError(rec, "anthropic", perr) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503 (perr's own status)", rec.Code) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("response is not valid JSON: %v, body=%s", err, rec.Body.String()) } if got["type"] != "error" { t.Errorf(`top-level "type" = %v, want "error"`, got["type"]) } errObj, _ := got["error"].(map[string]any) if errObj["type"] != "upstream_error" { t.Errorf(`error.type = %v, want "upstream_error"`, errObj["type"]) } if errObj["message"] != "anthropic upstream error" { t.Errorf(`error.message = %v, want "anthropic upstream error"`, errObj["message"]) } if errObj["code"] != "503" { t.Errorf(`error.code = %v, want "503"`, errObj["code"]) } upstream, _ := errObj["upstream"].(map[string]any) inner, _ := upstream["error"].(map[string]any) if inner["message"] != "overloaded" { t.Errorf(`error.upstream decoded body = %v, want the parsed provider body {"error":{"message":"overloaded"}}`, errObj["upstream"]) } }