From a20626f1eee946c0344d3692f7c3465619f1c410 Mon Sep 17 00:00:00 2001 From: sususu Date: Sun, 2 Aug 2026 11:01:17 +0800 Subject: [PATCH] fix(claude): send Anthropic header names with the real client's casing CPA negotiates ALPN http/1.1 with Anthropic, so header names are not lowercased by HPACK and reach the server verbatim. The casing is therefore part of the fingerprint, and six of the eighteen names CPA emits were wrong because Go canonicalises everything passed through Header.Set: anthropic-beta, anthropic-dangerous-direct-browser-access, anthropic-version, x-app, x-client-request-id and X-Stainless-OS. Writing the map keys directly is the only way to keep the original casing. This also fixes ordering at no extra cost. Go sorts header names bytewise when it serialises a request, and the captured order of the real client is exactly that same bytewise sort. Host, User-Agent and Content-Length stay misplaced because Go writes them ahead of the sorted block; a test records that gap and will fail if it ever closes. The pass runs at the send boundary rather than inside applyClaudeHeaders: the rewritten keys are unreachable through Header.Get, and doing it earlier hid these headers from the rest of the pipeline. The boundary is a single doClaudeUpstreamRequest helper pinned by a structural test, after a mutation check showed that dropping the call from the streaming path alone was otherwise undetectable. Expected order is pinned by serialising the request and reading the header lines back, not by inspecting the header map. --- .../executor/claude_executor_execute.go | 2 +- .../executor/claude_executor_request.go | 54 +++++ .../executor/claude_executor_stream.go | 2 +- .../executor/claude_executor_tokens.go | 2 +- .../claude_executor_wire_casing_test.go | 219 ++++++++++++++++++ 5 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 internal/runtime/executor/claude_executor_wire_casing_test.go diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index d83cb116..e5a478da 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -160,7 +160,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, err := httpClient.Do(httpReq) + httpResp, err := doClaudeUpstreamRequest(httpClient, httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 599b2a9d..a15e3204 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -646,6 +646,60 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, return nil } +// doClaudeUpstreamRequest is the single send boundary for every Claude upstream +// call. Folding the wire-casing pass in here makes it structurally impossible +// for one of the three request paths to drift away from the others, which is +// exactly how the streaming and non-streaming beta sets diverged before. +func doClaudeUpstreamRequest(client *http.Client, req *http.Request) (*http.Response, error) { + applyClaudeWireHeaderCasing(req) + return client.Do(req) +} + +// claudeWireHeaderCasing maps Go's canonical header name to the exact casing +// Claude Code 2.1.220 puts on the wire. Only the names that differ are listed; +// the other twelve already survive canonicalisation unchanged. +var claudeWireHeaderCasing = map[string]string{ + "X-Stainless-Os": "X-Stainless-OS", + "Anthropic-Beta": "anthropic-beta", + "Anthropic-Version": "anthropic-version", + "X-App": "x-app", + "X-Client-Request-Id": "x-client-request-id", + + "Anthropic-Dangerous-Direct-Browser-Access": "anthropic-dangerous-direct-browser-access", +} + +// applyClaudeWireHeaderCasing restores the header name casing of the real client. +// +// CPA negotiates ALPN http/1.1 with Anthropic, so header names reach the server +// verbatim rather than lowercased by HPACK, which makes casing observable. Go +// canonicalises every name passed through Header.Set, turning the client's +// anthropic-beta and x-app into Anthropic-Beta and X-App. Writing the map keys +// directly is the only way to keep the original casing. +// +// This also fixes ordering for free: Go sorts header names bytewise when it +// serialises them, and the real client's order is exactly that same bytewise +// sort, so correct casing reproduces the correct order. Host, User-Agent and +// Content-Length remain misplaced because Go writes them ahead of the sorted +// block; that needs transport-level surgery and is out of scope here. +// +// Call this immediately before handing the request to the client and nowhere +// else. The rewritten keys are unreachable through Header.Get, which +// canonicalises its argument, so running it any earlier would silently hide +// these headers from the rest of the pipeline. +func applyClaudeWireHeaderCasing(r *http.Request) { + if r == nil || r.Header == nil || !isAnthropicUpstreamURL(r.URL) { + return + } + for canonical, wire := range claudeWireHeaderCasing { + values, ok := r.Header[canonical] + if !ok { + continue + } + delete(r.Header, canonical) + r.Header[wire] = values + } +} + func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", "" diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index b86a02bb..67e91b65 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -154,7 +154,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) - httpResp, err := httpClient.Do(httpReq) + httpResp, err := doClaudeUpstreamRequest(httpClient, httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return nil, err diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index ae3512fe..d89f3ef3 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -221,7 +221,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy }) httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) - resp, err := httpClient.Do(httpReq) + resp, err := doClaudeUpstreamRequest(httpClient, httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return cliproxyexecutor.Response{}, err diff --git a/internal/runtime/executor/claude_executor_wire_casing_test.go b/internal/runtime/executor/claude_executor_wire_casing_test.go new file mode 100644 index 00000000..3416ed4c --- /dev/null +++ b/internal/runtime/executor/claude_executor_wire_casing_test.go @@ -0,0 +1,219 @@ +package executor + +import ( + "bufio" + "bytes" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" +) + +// claudeCode2_1_220WireHeaderOrder is the header name sequence captured from a +// real Claude Code 2.1.220 OAuth POST /v1/messages over HTTP/1.1, minus the four +// names the Node HTTP layer appends after the sorted block (Connection, Host, +// Accept-Encoding, Content-Length) and minus User-Agent. Go hardcodes Host, +// User-Agent and Content-Length ahead of the sorted block, so those four +// positions cannot be matched without replacing the request serialiser; the real +// client carries User-Agent inside the sorted block at index 3. +var claudeCode2_1_220WireHeaderOrder = []string{ + "Accept", + "Authorization", + "Content-Type", + "X-Claude-Code-Session-Id", + "X-Stainless-Arch", + "X-Stainless-Lang", + "X-Stainless-OS", + "X-Stainless-Package-Version", + "X-Stainless-Retry-Count", + "X-Stainless-Runtime", + "X-Stainless-Runtime-Version", + "X-Stainless-Timeout", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "x-app", + "x-client-request-id", +} + +func newClaudeWireProbeRequest(t *testing.T, rawURL string) *http.Request { + t.Helper() + auth := &cliproxyauth.Auth{ID: "wire", Metadata: map[string]any{"access_token": "sk-ant-oat01-wire"}} + req := httptest.NewRequest(http.MethodPost, rawURL, strings.NewReader("{}")) + req.Header = http.Header{} + body := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`) + if err := applyClaudeHeaders(req, auth, "sk-ant-oat01-wire", false, nil, body, nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders: %v", err) + } + // Mirror the production sequence: the casing pass runs at the send boundary, + // not inside applyClaudeHeaders, so Header.Get keeps working everywhere else. + applyClaudeWireHeaderCasing(req) + return req +} + +// The casing pass must stay at the send boundary. Running it inside +// applyClaudeHeaders would make these headers invisible to Header.Get for the +// rest of the pipeline, which is how the first attempt broke ten other tests. +func TestApplyClaudeHeaders_LeavesHeadersCanonicalForThePipeline(t *testing.T) { + auth := &cliproxyauth.Auth{ID: "wire", Metadata: map[string]any{"access_token": "sk-ant-oat01-wire"}} + req := httptest.NewRequest(http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", strings.NewReader("{}")) + req.Header = http.Header{} + body := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`) + if err := applyClaudeHeaders(req, auth, "sk-ant-oat01-wire", false, nil, body, nil, nil, false); err != nil { + t.Fatalf("applyClaudeHeaders: %v", err) + } + for canonical := range claudeWireHeaderCasing { + if req.Header.Get(canonical) == "" { + t.Fatalf("%s is unreadable through Header.Get right after applyClaudeHeaders", canonical) + } + } +} + +// serializedHeaderNames reads the names off the actual serialized request, which +// is the only representation the server ever sees. +func serializedHeaderNames(t *testing.T, req *http.Request) []string { + t.Helper() + var buf bytes.Buffer + if err := req.Write(&buf); err != nil { + t.Fatalf("write request: %v", err) + } + var names []string + scanner := bufio.NewScanner(&buf) + scanner.Scan() // request line + for scanner.Scan() { + line := scanner.Text() + if line == "" { + break + } + name, _, found := strings.Cut(line, ":") + if !found { + t.Fatalf("malformed header line %q", line) + } + names = append(names, name) + } + return names +} + +// The wire casing is a fingerprint in its own right: CPA negotiates ALPN +// http/1.1, so names are not lowercased by HPACK and reach Anthropic verbatim. +func TestApplyClaudeHeaders_WireCasingMatchesRealClient(t *testing.T) { + req := newClaudeWireProbeRequest(t, "https://api.anthropic.com/v1/messages?beta=true") + got := serializedHeaderNames(t, req) + + transportOwned := map[string]bool{ + "Host": true, "Content-Length": true, "Connection": true, "Accept-Encoding": true, + // Go writes User-Agent before the sorted block; the real client keeps it + // inside it. Tracked separately below. + "User-Agent": true, + } + var sdkNames []string + for _, name := range got { + if !transportOwned[name] { + sdkNames = append(sdkNames, name) + } + } + + want := claudeCode2_1_220WireHeaderOrder + if len(sdkNames) != len(want) { + t.Fatalf("header count = %d, want %d\n got %v", len(sdkNames), len(want), sdkNames) + } + for i := range want { + if sdkNames[i] != want[i] { + t.Fatalf("wire header %d = %q, want %q\n got %v\n want %v", i, sdkNames[i], want[i], sdkNames, want) + } + } +} + +// Documents the one ordering gap the casing fix cannot close. If Go ever stops +// hoisting User-Agent, or the serialiser is replaced, this test fails and the +// name can move back into claudeCode2_1_220WireHeaderOrder. +func TestApplyClaudeHeaders_UserAgentStillHoistedByGo(t *testing.T) { + req := newClaudeWireProbeRequest(t, "https://api.anthropic.com/v1/messages?beta=true") + names := serializedHeaderNames(t, req) + uaIndex, acceptIndex := -1, -1 + for i, name := range names { + switch name { + case "User-Agent": + uaIndex = i + case "Accept": + acceptIndex = i + } + } + if uaIndex == -1 || acceptIndex == -1 { + t.Fatalf("missing User-Agent or Accept: %v", names) + } + if uaIndex > acceptIndex { + t.Fatal("User-Agent now sorts with the block: fold it back into the expected wire order") + } + if got := req.Header.Get("User-Agent"); !strings.HasPrefix(got, "claude-cli/") { + t.Fatalf("User-Agent = %q, want the Claude Code identity", got) + } +} + +// Guards the property that makes the casing fix sufficient: the real client's +// order is a plain bytewise sort, which is also what Go emits. +func TestClaudeWireHeaderOrderIsBytewiseSorted(t *testing.T) { + sorted := append([]string(nil), claudeCode2_1_220WireHeaderOrder...) + sort.Strings(sorted) + for i := range sorted { + if sorted[i] != claudeCode2_1_220WireHeaderOrder[i] { + t.Fatalf("captured order is not a bytewise sort at %d: %q vs %q", i, claudeCode2_1_220WireHeaderOrder[i], sorted[i]) + } + } +} + +// Every fingerprint rule is keyed on the upstream host, never on the caller. +func TestApplyClaudeHeaders_WireCasingIsAnthropicOnly(t *testing.T) { + req := newClaudeWireProbeRequest(t, "https://api.moonshot.cn/v1/messages") + for _, name := range serializedHeaderNames(t, req) { + if name == "anthropic-beta" || name == "x-app" || name == "X-Stainless-OS" { + t.Fatalf("Anthropic wire casing leaked to a third-party gateway: %q", name) + } + } + if req.Header.Get("Anthropic-Version") == "" { + t.Fatal("third-party gateway lost its canonical headers") + } +} + +// The rewritten keys are unreachable through Header.Get, so the pass has to run +// after every other mutation. This pins that the values survived the rewrite. +func TestApplyClaudeHeaders_WireCasingPreservesValues(t *testing.T) { + req := newClaudeWireProbeRequest(t, "https://api.anthropic.com/v1/messages?beta=true") + for canonical, wire := range claudeWireHeaderCasing { + if _, stillCanonical := req.Header[canonical]; stillCanonical { + t.Fatalf("%s was not rewritten to %s", canonical, wire) + } + if len(req.Header[wire]) == 0 || req.Header[wire][0] == "" { + t.Fatalf("%s lost its value during the rewrite", wire) + } + } +} + +// The three Claude request paths must all leave through doClaudeUpstreamRequest. +// A direct client.Do would skip the wire-casing pass silently, and no behavioural +// test can catch that for a path it does not exercise, so the invariant is +// checked structurally. +func TestClaudeExecutorHasSingleUpstreamSendBoundary(t *testing.T) { + paths := []string{ + "claude_executor_execute.go", + "claude_executor_stream.go", + "claude_executor_tokens.go", + } + for _, name := range paths { + src, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + text := string(src) + if strings.Contains(text, "httpClient.Do(") { + t.Errorf("%s bypasses the send boundary with a direct httpClient.Do", name) + } + if !strings.Contains(text, "doClaudeUpstreamRequest(") { + t.Errorf("%s does not route through doClaudeUpstreamRequest", name) + } + } +} -- 2.51.2