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) + } + } +}