Files
traefik-llmgateway/routes_messages_test.go
T
lukaszraczylo c6e4450686 test(anthropic): pin tool translation and upstream error shapes
Cover four zero-coverage functions on the /v1/messages route Claude
Code drives on nearly every call: openAIToolsFromAnthropic,
openAIToolChoiceFromAnthropic, responseTranslationError.Error, and
writeAnthropicProviderUpstreamError.

Add table-driven tests for every tool_choice form (auto, any,
tool-by-name, unrecognized, absent, non-map, nil), a multi-tool
mapping test that checks field order, description omission, and
malformed-entry skipping, a full request-to-response round trip
through a forced tool call, and a test proving no-tools/no-tool-choice
omits both keys rather than sending an empty array.

Add direct tests for the Anthropic-shaped upstream error envelope
(status passthrough, error.code, decoded error.upstream) and for the
translation-failure error prefix.

Every test was confirmed to fail against a real mutation of the
target function before being kept; each mutation and its revert are
recorded in the test's own doc comment.
2026-08-23 00:50:26 +01:00

748 lines
34 KiB
Go

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
// admitRequest 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 admitRequest'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
// admitRequest 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_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"])
}
}