fix(routes): preserve response bytes and close remaining test gaps

Replace the alias-echo rewrite on the Anthropic passthrough branch
with a byte-preserving field replacement. The prior version decoded
the response into a Go map and re-marshaled it. This silently
corrupted a JSON integer beyond float64's exact range. It also
rewrote 1.0 to 1, HTML-escaped angle brackets and ampersands, and
re-sorted every key. A tool_use block's "input" is arbitrary
model-generated JSON. A long numeric id inside tool arguments could
be silently corrupted on a route documented as passthrough.
rewriteModelField now walks the response with a JSON decoder and
copies every field but "model" as raw bytes.

Add a test with a 20-digit integer, a 1.0 float, and a literal "<"
character inside tool_use input. It asserts the response is
byte-identical to the source apart from the model field.

Add a negative test for the anthropic-beta header allowlist. It
asserts Authorization and Cookie never reach the upstream. It also
asserts the upstream sees exactly one x-api-key value: the gateway's
own key, never the client's. A single Get() call could miss a second,
leaked value, since Set() and Add() interact in a way a naive check
would not catch. The test checks the Values() length explicitly.

Add a test case for the native passthrough route's prompt-cache
billing. It fails if cache_creation_input_tokens or
cache_read_input_tokens drop out of the billed prompt count. This
closes a production billing change that previously carried no
coverage at all.

Decouple response delivery from usage extraction on the passthrough
branch. A response body read failure, or a malformed usage field, no
longer both discard the response and misreport the failure as an
upstream connection error. A read failure still returns an error, now
correctly classified as a translation failure. A malformed usage
field no longer blocks the response at all: the client still gets
it, and the existing zero-usage estimate takes over for accounting.

Return the adapter's own usage value on a chatCompletion error in the
translated path, instead of discarding it. This has no effect while
streaming stays rejected up front. It prevents the same critical bug
from resurfacing once streaming support lands.

Normalize tool_result content to a plain string before applying the
error prefix. The prior version only prefixed a Go string. Anthropic's
array content form, which Claude Code sends, lost the is_error signal
entirely. Absent content now becomes an empty string instead of the
JSON null OpenAI rejects.

Treat "stream": null the same as an absent key. The prior fix for a
non-boolean stream value also rejected null. An SDK that serializes an
unset optional field as null must still get an ordinary non-streaming
response.

Drop a yaegi-check assertion that could not fail. The fixture
configures no cache, so the header it checked was never set regardless
of what the code under test did. A mutation pass confirmed this: it
deleted the line the assertion was meant to guard, and make
yaegi-check still reported OK.

Document that this route now intercepts POST /v1/messages
unconditionally, even under passthroughUnknown, where it previously
fell through to the next handler. Add the note to both the new README
section and Known limitations. Correct the passthrough table's claim
about response bytes to describe the actual guarantee.
This commit is contained in:
2026-08-22 23:45:34 +01:00
parent 25e73cb585
commit 3e1f188426
7 changed files with 410 additions and 90 deletions
+137
View File
@@ -388,6 +388,39 @@ func TestHandleMessages_StreamingRejected(t *testing.T) {
}
}
// 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
@@ -542,3 +575,107 @@ func TestHandleMessages_TranslationFailureAfterBilledUsage_PreservesUsage(t *tes
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])
}
}