From f63a925d15f6e9f22f9e0cbe71dc6d30356c0a8a Mon Sep 17 00:00:00 2001 From: sususu Date: Sun, 2 Aug 2026 11:01:17 +0800 Subject: [PATCH] fix(claude): replay measured OAuth wire, Fast and diagnostic profiles Align the remaining measured OAuth wire profiles, including the ordered connection writer in internal/httpwire that reproduces the observed header sequence, and the refresh/profile response shapes in internal/auth/claude. Replay the measured Fast path and keep diagnostic continuity across cloaked and native requests. Preserve the native direct token-counting shape so a caller that reaches count_tokens itself is not reshaped into the cloaked form. Scope cloak dates to the credential's timezone rather than the host's, so currentDate matches what the real client would have sent for that account. --- config.example.yaml | 1 + internal/auth/claude/anthropic_auth.go | 75 +++-- internal/auth/claude/anthropic_auth_test.go | 151 ++++++++-- internal/auth/claude/oauth_response.go | 61 +++++ internal/auth/claude/oauth_response_test.go | 71 +++++ internal/auth/claude/utls_transport.go | 258 +++++++++--------- internal/auth/claude/utls_transport_test.go | 157 ++++++++++- .../config/claude_header_defaults_test.go | 4 + internal/config/config_normalization.go | 1 + internal/config/config_types.go | 1 + internal/httpwire/ordered_conn.go | 186 +++++++++++++ internal/httpwire/ordered_conn_test.go | 100 +++++++ .../runtime/executor/claude_executor_auth.go | 6 +- .../claude_executor_auth_race_test.go | 30 ++ .../claude_executor_beta_policy_test.go | 21 +- .../executor/claude_executor_cloaking.go | 55 +++- .../executor/claude_executor_diagnostics.go | 90 ++++++ .../claude_executor_diagnostics_test.go | 92 +++++++ .../executor/claude_executor_execute.go | 32 ++- .../executor/claude_executor_fast_fallback.go | 120 ++++++++ .../claude_executor_fast_fallback_test.go | 120 ++++++++ .../executor/claude_executor_request.go | 97 +++++-- .../executor/claude_executor_stream.go | 44 ++- .../runtime/executor/claude_executor_test.go | 136 ++++++++- .../executor/claude_executor_tokens.go | 40 ++- .../executor/helps/claude_diagnostics.go | 91 ++++++ .../executor/helps/claude_diagnostics_test.go | 38 +++ .../runtime/executor/helps/utls_client.go | 78 +++++- .../executor/helps/utls_client_test.go | 27 ++ 29 files changed, 1928 insertions(+), 255 deletions(-) create mode 100644 internal/auth/claude/oauth_response.go create mode 100644 internal/auth/claude/oauth_response_test.go create mode 100644 internal/httpwire/ordered_conn.go create mode 100644 internal/httpwire/ordered_conn_test.go create mode 100644 internal/runtime/executor/claude_executor_diagnostics.go create mode 100644 internal/runtime/executor/claude_executor_diagnostics_test.go create mode 100644 internal/runtime/executor/claude_executor_fast_fallback.go create mode 100644 internal/runtime/executor/claude_executor_fast_fallback_test.go create mode 100644 internal/runtime/executor/helps/claude_diagnostics.go create mode 100644 internal/runtime/executor/helps/claude_diagnostics_test.go diff --git a/config.example.yaml b/config.example.yaml index 7ca4f161..1a69a04d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -447,6 +447,7 @@ nonstream-keepalive-interval: 0 # os: "MacOS" # arch: "arm64" # timeout: "600" +# timezone: "Asia/Singapore" # fallback IANA timezone for cloaked currentDate; a credential JSON "timezone" takes priority # stabilize-device-profile: false # optional, default false; set true to enable per-auth/API-key fingerprint pinning # Default headers for Codex OAuth model requests. diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index 710c5ebb..35930164 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strings" @@ -22,11 +21,13 @@ import ( // OAuth configuration constants for Claude/Anthropic const ( - AuthURL = "https://claude.ai/oauth/authorize" - TokenURL = "https://api.anthropic.com/v1/oauth/token" - ProfileURL = "https://api.anthropic.com/api/oauth/profile" - ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - RedirectURI = "http://localhost:54545/callback" + AuthURL = "https://claude.ai/oauth/authorize" + TokenURL = "https://api.anthropic.com/v1/oauth/token" + RefreshTokenURL = "https://platform.claude.com/v1/oauth/token" + ProfileURL = "https://api.anthropic.com/api/oauth/profile" + ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + RedirectURI = "http://localhost:54545/callback" + ClaudeOAuthScope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" claudeRefreshMinBackoff = 5 * time.Second claudeRefreshMaxBackoff = 5 * time.Minute @@ -190,6 +191,18 @@ func NewClaudeAuthWithProxyURL(cfg *config.Config, proxyURL string) *ClaudeAuth } } +func applyClaudeOAuthAxiosHeaders(req *http.Request) { + if req == nil { + return + } + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "axios/1.15.2") + req.Header.Set("Accept-Encoding", "gzip, compress, deflate, br") + req.Header.Set("Connection", "close") + req.Close = true +} + // FetchOAuthProfile retrieves the account identity associated with an OAuth access token. func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) (*OAuthProfile, error) { if o == nil || o.httpClient == nil { @@ -203,8 +216,8 @@ func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) if errRequest != nil { return nil, fmt.Errorf("create Claude OAuth profile request: %w", errRequest) } + applyClaudeOAuthAxiosHeaders(req) req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("Accept", "application/json") req.Header.Set("Cache-Control", "no-cache") resp, errDo := o.httpClient.Do(req) @@ -216,7 +229,7 @@ func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) log.Errorf("failed to close Claude OAuth profile response body: %v", errClose) } }() - body, errRead := io.ReadAll(resp.Body) + body, errRead := readClaudeOAuthResponseBody(resp) if errRead != nil { return nil, fmt.Errorf("read Claude OAuth profile response: %w", errRead) } @@ -255,7 +268,7 @@ func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string "client_id": {ClientID}, "response_type": {"code"}, "redirect_uri": {RedirectURI}, - "scope": {"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"}, + "scope": {ClaudeOAuthScope}, "code_challenge": {pkceCodes.CodeChallenge}, "code_challenge_method": {"S256"}, "state": {state}, @@ -341,7 +354,7 @@ func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state stri } }() - body, err := io.ReadAll(resp.Body) + body, err := readClaudeOAuthResponseBody(resp) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } @@ -438,6 +451,7 @@ func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken "client_id": ClientID, "grant_type": "refresh_token", "refresh_token": refreshToken, + "scope": ClaudeOAuthScope, } jsonBody, err := json.Marshal(reqBody) @@ -445,13 +459,11 @@ func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken return nil, fmt.Errorf("failed to marshal request body: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", TokenURL, strings.NewReader(string(jsonBody))) + req, err := http.NewRequestWithContext(ctx, "POST", RefreshTokenURL, strings.NewReader(string(jsonBody))) if err != nil { return nil, fmt.Errorf("failed to create refresh request: %w", err) } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") + applyClaudeOAuthAxiosHeaders(req) resp, err := o.httpClient.Do(req) if err != nil { @@ -461,7 +473,7 @@ func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken _ = resp.Body.Close() }() - body, err := io.ReadAll(resp.Body) + body, err := readClaudeOAuthResponseBody(resp) if err != nil { return nil, fmt.Errorf("failed to read refresh response: %w", err) } @@ -487,18 +499,25 @@ func (o *ClaudeAuth) refreshTokensSingleFlight(ctx context.Context, refreshToken return nil, fmt.Errorf("failed to parse token response: %w", err) } - // Create token data clearClaudeRefreshBlockedUntil(refreshToken) - - return &ClaudeTokenData{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - Email: tokenResp.Account.EmailAddress, - AccountUUID: tokenResp.Account.UUID, - OrganizationUUID: tokenResp.Organization.UUID, - OrganizationName: tokenResp.Organization.Name, - Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), - }, nil + if strings.TrimSpace(tokenResp.RefreshToken) == "" { + tokenResp.RefreshToken = refreshToken + } + tokenData := &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339), + } + profile, errProfile := o.FetchOAuthProfile(ctx, tokenResp.AccessToken) + if errProfile != nil { + log.Warnf("fetch Claude OAuth profile after refresh: %v", errProfile) + return tokenData, nil + } + tokenData.Email = profile.Account.Email + tokenData.AccountUUID = profile.Account.UUID + tokenData.OrganizationUUID = profile.Organization.UUID + tokenData.OrganizationName = profile.Organization.Name + return tokenData, nil } // CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info. @@ -577,7 +596,9 @@ func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData * storage.AccessToken = tokenData.AccessToken storage.RefreshToken = tokenData.RefreshToken storage.LastRefresh = time.Now().Format(time.RFC3339) - storage.Email = tokenData.Email + if tokenData.Email != "" { + storage.Email = tokenData.Email + } if tokenData.AccountUUID != "" { storage.AccountUUID = tokenData.AccountUUID } diff --git a/internal/auth/claude/anthropic_auth_test.go b/internal/auth/claude/anthropic_auth_test.go index 3f66f338..7bc76d79 100644 --- a/internal/auth/claude/anthropic_auth_test.go +++ b/internal/auth/claude/anthropic_auth_test.go @@ -152,7 +152,8 @@ func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) { resetClaudeRefreshState() defer resetClaudeRefreshState() - var calls int32 + var tokenCalls int32 + var profileCalls int32 started := make(chan struct{}) release := make(chan struct{}) var once sync.Once @@ -160,22 +161,38 @@ func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) { auth := &ClaudeAuth{ httpClient: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - once.Do(func() { close(started) }) - <-release - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{ - "access_token":"new-access", - "refresh_token":"new-refresh", - "token_type":"Bearer", - "expires_in":3600, - "account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email_address":"shared@example.com"}, - "organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"} - }`)), - Header: make(http.Header), - Request: req, - }, nil + switch req.URL.String() { + case RefreshTokenURL: + atomic.AddInt32(&tokenCalls, 1) + once.Do(func() { close(started) }) + <-release + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "access_token":"new-access", + "refresh_token":"new-refresh", + "token_type":"Bearer", + "expires_in":3600, + "scope":"user:profile user:inference" + }`)), + Header: make(http.Header), + Request: req, + }, nil + case ProfileURL: + atomic.AddInt32(&profileCalls, 1) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"}, + "organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"} + }`)), + Header: make(http.Header), + Request: req, + }, nil + default: + t.Fatalf("unexpected OAuth request URL %s", req.URL) + return nil, nil + } }), }, } @@ -193,7 +210,7 @@ func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) { <-started time.Sleep(20 * time.Millisecond) - if got := atomic.LoadInt32(&calls); got != 1 { + if got := atomic.LoadInt32(&tokenCalls); got != 1 { t.Fatalf("expected concurrent refresh to share a single upstream call, got %d", got) } close(release) @@ -213,9 +230,84 @@ func TestRefreshTokens_DeduplicatesConcurrentRefresh(t *testing.T) { t.Fatalf("organization = %q/%q, want OAuth response organization", td.OrganizationUUID, td.OrganizationName) } } - if got := atomic.LoadInt32(&calls); got != 1 { + if got := atomic.LoadInt32(&tokenCalls); got != 1 { t.Fatalf("expected exactly 1 upstream refresh call, got %d", got) } + if got := atomic.LoadInt32(&profileCalls); got != 1 { + t.Fatalf("expected exactly 1 OAuth profile call, got %d", got) + } +} + +func TestRefreshTokensUsesNative220ControlPlaneShape(t *testing.T) { + resetClaudeRefreshState() + defer resetClaudeRefreshState() + + const refreshToken = "placeholder-refresh" + auth := &ClaudeAuth{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.String() { + case RefreshTokenURL: + if req.Method != http.MethodPost { + t.Fatalf("refresh method = %s, want POST", req.Method) + } + body, errRead := io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + wantBody := `{"client_id":"` + ClientID + `","grant_type":"refresh_token","refresh_token":"` + refreshToken + `","scope":"` + ClaudeOAuthScope + `"}` + if got := string(body); got != wantBody { + t.Fatalf("refresh body = %q, want %q", got, wantBody) + } + wantHeaders := map[string]string{ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "User-Agent": "axios/1.15.2", + "Accept-Encoding": "gzip, compress, deflate, br", + "Connection": "close", + } + for name, want := range wantHeaders { + if got := req.Header.Get(name); got != want { + t.Fatalf("%s = %q, want %q", name, got, want) + } + } + if !req.Close { + t.Fatal("refresh request Close = false, want true") + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"access_token":"new-access","expires_in":3600}`)), + Header: make(http.Header), + Request: req, + }, nil + case ProfileURL: + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{ + "account":{"uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","email":"shared@example.com"}, + "organization":{"uuid":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb","name":"Shared Org"} + }`)), + Header: make(http.Header), + Request: req, + }, nil + default: + t.Fatalf("unexpected OAuth request URL %s", req.URL) + return nil, nil + } + }), + }, + } + + tokenData, errRefresh := auth.RefreshTokens(t.Context(), refreshToken) + if errRefresh != nil { + t.Fatalf("RefreshTokens() error = %v", errRefresh) + } + if tokenData.RefreshToken != refreshToken { + t.Fatalf("refresh token fallback = %q, want original placeholder", tokenData.RefreshToken) + } + if tokenData.AccountUUID == "" || tokenData.Email == "" || tokenData.OrganizationUUID == "" { + t.Fatalf("profile identity was not populated: %#v", tokenData) + } } func TestFetchOAuthProfile(t *testing.T) { @@ -228,6 +320,22 @@ func TestFetchOAuthProfile(t *testing.T) { if got := req.Header.Get("Authorization"); got != "Bearer test-access" { t.Fatalf("Authorization = %q, want bearer token", got) } + wantHeaders := map[string]string{ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "User-Agent": "axios/1.15.2", + "Accept-Encoding": "gzip, compress, deflate, br", + "Connection": "close", + } + for name, want := range wantHeaders { + if got := req.Header.Get(name); got != want { + t.Fatalf("%s = %q, want %q", name, got, want) + } + } + if !req.Close { + t.Fatal("profile request Close = false, want true") + } return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{ @@ -255,6 +363,7 @@ func TestFetchOAuthProfile(t *testing.T) { func TestUpdateTokenStoragePreservesAccountWhenRefreshOmitsIt(t *testing.T) { storage := &ClaudeTokenStorage{ + Email: "user@example.com", AccountUUID: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", OrganizationUUID: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", OrganizationName: "Example Org", @@ -262,10 +371,12 @@ func TestUpdateTokenStoragePreservesAccountWhenRefreshOmitsIt(t *testing.T) { (&ClaudeAuth{}).UpdateTokenStorage(storage, &ClaudeTokenData{ AccessToken: "new-access", RefreshToken: "new-refresh", - Email: "user@example.com", Expire: "2099-01-01T00:00:00Z", }) + if storage.Email != "user@example.com" { + t.Fatalf("email = %q, want preserved", storage.Email) + } if storage.AccountUUID != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" { t.Fatalf("account UUID = %q, want preserved", storage.AccountUUID) } diff --git a/internal/auth/claude/oauth_response.go b/internal/auth/claude/oauth_response.go new file mode 100644 index 00000000..3d608284 --- /dev/null +++ b/internal/auth/claude/oauth_response.go @@ -0,0 +1,61 @@ +package claude + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/lzw" + "compress/zlib" + "fmt" + "io" + "net/http" + "strings" + + "github.com/andybalholm/brotli" +) + +func readClaudeOAuthResponseBody(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, fmt.Errorf("read Claude OAuth response: body is nil") + } + encoded, errRead := io.ReadAll(resp.Body) + if errRead != nil { + return nil, errRead + } + encoding := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Encoding"), ",")[0])) + if encoding == "" || encoding == "identity" { + return encoded, nil + } + + var reader io.ReadCloser + switch encoding { + case "gzip": + gzipReader, errGzip := gzip.NewReader(bytes.NewReader(encoded)) + if errGzip != nil { + return nil, fmt.Errorf("decode Claude OAuth gzip response: %w", errGzip) + } + reader = gzipReader + case "deflate": + zlibReader, errZlib := zlib.NewReader(bytes.NewReader(encoded)) + if errZlib == nil { + reader = zlibReader + } else { + reader = flate.NewReader(bytes.NewReader(encoded)) + } + case "br": + reader = io.NopCloser(brotli.NewReader(bytes.NewReader(encoded))) + case "compress": + reader = lzw.NewReader(bytes.NewReader(encoded), lzw.MSB, 8) + default: + return nil, fmt.Errorf("decode Claude OAuth response: unsupported content encoding %q", encoding) + } + decoded, errDecoded := io.ReadAll(reader) + if errDecoded != nil { + _ = reader.Close() + return nil, fmt.Errorf("decode Claude OAuth %s response: %w", encoding, errDecoded) + } + if errClose := reader.Close(); errClose != nil { + return nil, fmt.Errorf("close Claude OAuth %s decoder: %w", encoding, errClose) + } + return decoded, nil +} diff --git a/internal/auth/claude/oauth_response_test.go b/internal/auth/claude/oauth_response_test.go new file mode 100644 index 00000000..71c2d852 --- /dev/null +++ b/internal/auth/claude/oauth_response_test.go @@ -0,0 +1,71 @@ +package claude + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "testing" + + "github.com/andybalholm/brotli" +) + +func TestReadClaudeOAuthResponseBodyDecodesAdvertisedEncodings(t *testing.T) { + t.Parallel() + + const payload = `{"account":{"uuid":"test"}}` + tests := []struct { + name string + encoding string + encode func(testing.TB, []byte) []byte + }{ + { + name: "gzip", + encoding: "gzip", + encode: func(tb testing.TB, input []byte) []byte { + tb.Helper() + var output bytes.Buffer + writer := gzip.NewWriter(&output) + if _, errWrite := writer.Write(input); errWrite != nil { + tb.Fatal(errWrite) + } + if errClose := writer.Close(); errClose != nil { + tb.Fatal(errClose) + } + return output.Bytes() + }, + }, + { + name: "brotli", + encoding: "br", + encode: func(tb testing.TB, input []byte) []byte { + tb.Helper() + var output bytes.Buffer + writer := brotli.NewWriter(&output) + if _, errWrite := writer.Write(input); errWrite != nil { + tb.Fatal(errWrite) + } + if errClose := writer.Close(); errClose != nil { + tb.Fatal(errClose) + } + return output.Bytes() + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + resp := &http.Response{ + Header: http.Header{"Content-Encoding": []string{test.encoding}}, + Body: io.NopCloser(bytes.NewReader(test.encode(t, []byte(payload)))), + } + got, errRead := readClaudeOAuthResponseBody(resp) + if errRead != nil { + t.Fatal(errRead) + } + if string(got) != payload { + t.Fatalf("decoded body = %q, want %q", got, payload) + } + }) + } +} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 79543ee3..ec06952d 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -1,38 +1,112 @@ -// Package claude provides authentication functionality for Anthropic's Claude API. -// This file implements a custom HTTP transport using utls to bypass TLS fingerprinting. package claude import ( + "context" "fmt" + "net" "net/http" "strings" - "sync" "time" tls "github.com/refraction-networking/utls" + "github.com/router-for-me/CLIProxyAPI/v7/internal/httpwire" "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" - "golang.org/x/net/http2" "golang.org/x/net/proxy" ) type claudeRefreshHandshakeTimeoutContextKey struct{} -// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint -// to bypass Cloudflare's TLS fingerprinting on Anthropic domains. +var claudeOAuthRefreshHeaderOrder = []string{ + "Accept", + "Content-Type", + "User-Agent", + "Content-Length", + "Accept-Encoding", + "Host", + "Connection", +} + +var claudeOAuthProfileHeaderOrder = []string{ + "Accept", + "Content-Type", + "Authorization", + "Cache-Control", + "User-Agent", + "Accept-Encoding", + "Host", + "Connection", +} + +func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string { + if method == http.MethodGet && strings.HasPrefix(requestTarget, "/api/oauth/profile") { + return claudeOAuthProfileHeaderOrder + } + return claudeOAuthRefreshHeaderOrder +} + +// claudeOAuthTLSClientHelloSpec reproduces the compact Node/OpenSSL profile +// Claude Code 2.1.220 uses for Axios OAuth control-plane requests. Unlike the +// inference profile, it advertises no ALPN extension and therefore uses +// HTTP/1.1 without negotiating a protocol. +func claudeOAuthTLSClientHelloSpec() *tls.ClientHelloSpec { + return &tls.ClientHelloSpec{ + TLSVersMin: tls.VersionTLS12, + TLSVersMax: tls.VersionTLS13, + CompressionMethods: []uint8{0}, + CipherSuites: []uint16{ + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_AES_256_GCM_SHA384, + tls.TLS_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + }, + Extensions: []tls.TLSExtension{ + &tls.SNIExtension{}, + &tls.ExtendedMasterSecretExtension{}, + &tls.RenegotiationInfoExtension{Renegotiation: tls.RenegotiateOnceAsClient}, + &tls.SupportedCurvesExtension{Curves: []tls.CurveID{tls.X25519, tls.CurveP256, tls.CurveP384}}, + &tls.SupportedPointsExtension{SupportedPoints: []byte{0}}, + &tls.SessionTicketExtension{}, + &tls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []tls.SignatureScheme{ + tls.ECDSAWithP256AndSHA256, + tls.PSSWithSHA256, + tls.PKCS1WithSHA256, + tls.ECDSAWithP384AndSHA384, + tls.PSSWithSHA384, + tls.PKCS1WithSHA384, + tls.PSSWithSHA512, + tls.PKCS1WithSHA512, + tls.PKCS1WithSHA1, + }}, + &tls.KeyShareExtension{KeyShares: []tls.KeyShare{{Group: tls.X25519}}}, + &tls.PSKKeyExchangeModesExtension{Modes: []uint8{tls.PskModeDHE}}, + &tls.SupportedVersionsExtension{Versions: []uint16{tls.VersionTLS13, tls.VersionTLS12}}, + }, + } +} + +// utlsRoundTripper uses Claude Code's OAuth control-plane TLS and HTTP/1.1 +// profile while retaining net/http proxy, cancellation, response parsing and +// connection lifecycle semantics. type utlsRoundTripper struct { - // mu protects the connections map and pending map - mu sync.Mutex - // connections caches HTTP/2 client connections per host - connections map[string]*http2.ClientConn - // pending tracks hosts that are currently being connected to (prevents race condition) - pending map[string]*sync.Cond - // dialer is used to create network connections, supporting proxies - dialer proxy.Dialer + dialer proxy.Dialer + transport *http.Transport } -// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { var dialer proxy.Dialer = proxy.Direct if cfg != nil { @@ -44,137 +118,65 @@ func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { } } - return &utlsRoundTripper{ - connections: make(map[string]*http2.ClientConn), - pending: make(map[string]*sync.Cond), - dialer: dialer, + roundTripper := &utlsRoundTripper{dialer: dialer} + roundTripper.transport = &http.Transport{ + ForceAttemptHTTP2: false, + DialTLSContext: roundTripper.dialTLSContext, } + return roundTripper } -// getOrCreateConnection gets an existing connection or creates a new one. -// It uses a per-host locking mechanism to prevent multiple goroutines from -// creating connections to the same host simultaneously. -func (t *utlsRoundTripper) getOrCreateConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) { - t.mu.Lock() - - // Check if connection exists and is usable - if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { - t.mu.Unlock() - return h2Conn, nil +func (t *utlsRoundTripper) dialTLSContext(ctx context.Context, network, addr string) (net.Conn, error) { + var ( + conn net.Conn + err error + ) + if contextDialer, ok := t.dialer.(proxy.ContextDialer); ok { + conn, err = contextDialer.DialContext(ctx, network, addr) + } else { + conn, err = t.dialer.Dial(network, addr) } - - // Check if another goroutine is already creating a connection - if cond, ok := t.pending[host]; ok { - // Wait for the other goroutine to finish - cond.Wait() - // Check if connection is now available - if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() { - t.mu.Unlock() - return h2Conn, nil - } - // Connection still not available, we'll create one - } - - // Mark this host as pending - cond := sync.NewCond(&t.mu) - t.pending[host] = cond - t.mu.Unlock() - - // Create connection outside the lock - h2Conn, err := t.createConnection(host, addr, handshakeTimeout) - - t.mu.Lock() - defer t.mu.Unlock() - - // Remove pending marker and wake up waiting goroutines - delete(t.pending, host) - cond.Broadcast() - if err != nil { - return nil, err + return nil, fmt.Errorf("claude oauth tls: dial upstream: %w", err) } - // Store the new connection - t.connections[host] = h2Conn - return h2Conn, nil -} - -// createConnection creates a new HTTP/2 connection with Chrome TLS fingerprint. -// Chrome's TLS fingerprint is closer to Node.js/OpenSSL (which real Claude Code uses) -// than Firefox, reducing the mismatch between TLS layer and HTTP headers. -func (t *utlsRoundTripper) createConnection(host, addr string, handshakeTimeout time.Duration) (*http2.ClientConn, error) { - conn, errDial := t.dialer.Dial("tcp", addr) - if errDial != nil { - return nil, errDial + host, _, errSplit := net.SplitHostPort(addr) + if errSplit != nil { + if errClose := conn.Close(); errClose != nil { + log.Debugf("claude oauth tls: close failed connection: %v", errClose) + } + return nil, fmt.Errorf("claude oauth tls: split upstream address: %w", errSplit) } - - if handshakeTimeout > 0 { - if errSetDeadline := conn.SetDeadline(time.Now().Add(handshakeTimeout)); errSetDeadline != nil { - _ = conn.Close() - return nil, fmt.Errorf("failed to set TLS handshake deadline: %w", errSetDeadline) + tlsConn := tls.UClient(conn, &tls.Config{ServerName: host}, tls.HelloCustom) + if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil { + if errClose := tlsConn.Close(); errClose != nil { + log.Debugf("claude oauth tls: close connection after preset failure: %v", errClose) } + return nil, fmt.Errorf("claude oauth tls: apply ClientHello: %w", errPreset) } - - tlsConfig := &tls.Config{ServerName: host} - tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) - - if errHandshake := tlsConn.Handshake(); errHandshake != nil { - _ = conn.Close() - return nil, errHandshake + handshakeCtx := ctx + if handshakeTimeout, _ := ctx.Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration); handshakeTimeout > 0 { + var cancelHandshake context.CancelFunc + handshakeCtx, cancelHandshake = context.WithTimeout(ctx, handshakeTimeout) + defer cancelHandshake() } - if handshakeTimeout > 0 { - if errClearDeadline := conn.SetDeadline(time.Time{}); errClearDeadline != nil { - _ = conn.Close() - return nil, fmt.Errorf("failed to clear TLS handshake deadline: %w", errClearDeadline) + if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil { + if errClose := tlsConn.Close(); errClose != nil { + log.Debugf("claude oauth tls: close connection after handshake failure: %v", errClose) } + return nil, fmt.Errorf("claude oauth tls: handshake upstream: %w", errHandshake) } - - tr := &http2.Transport{} - h2Conn, errClientConn := tr.NewClientConn(tlsConn) - if errClientConn != nil { - _ = tlsConn.Close() - return nil, errClientConn - } - - return h2Conn, nil + return httpwire.NewOrderedRequestConn(tlsConn, claudeOAuthRequestHeaderOrder), nil } -// RoundTrip implements http.RoundTripper func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - host := req.URL.Host - addr := host - if !strings.Contains(addr, ":") { - addr += ":443" - } - - // Get hostname without port for TLS ServerName - hostname := req.URL.Hostname() - - handshakeTimeout, _ := req.Context().Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration) - h2Conn, err := t.getOrCreateConnection(hostname, addr, handshakeTimeout) - if err != nil { - return nil, err - } - - resp, err := h2Conn.RoundTrip(req) - if err != nil { - // Connection failed, remove it from cache - t.mu.Lock() - if cached, ok := t.connections[hostname]; ok && cached == h2Conn { - delete(t.connections, hostname) - } - t.mu.Unlock() - return nil, err - } + return t.transport.RoundTrip(req) +} - return resp, nil +func (t *utlsRoundTripper) CloseIdleConnections() { + t.transport.CloseIdleConnections() } -// NewAnthropicHttpClient creates an HTTP client that bypasses TLS fingerprinting -// for Anthropic domains by using utls with Chrome fingerprint. -// It accepts optional SDK configuration for proxy settings. func NewAnthropicHttpClient(cfg *config.SDKConfig) *http.Client { - return &http.Client{ - Transport: newUtlsRoundTripper(cfg), - } + return &http.Client{Transport: newUtlsRoundTripper(cfg)} } diff --git a/internal/auth/claude/utls_transport_test.go b/internal/auth/claude/utls_transport_test.go index d262f563..6e850dbf 100644 --- a/internal/auth/claude/utls_transport_test.go +++ b/internal/auth/claude/utls_transport_test.go @@ -1,10 +1,20 @@ package claude import ( + "context" + "crypto/md5" + "encoding/binary" + "encoding/hex" "errors" + "io" "net" + "reflect" + "strconv" + "strings" "testing" "time" + + tls "github.com/refraction-networking/utls" ) type claudeTestDialer struct { @@ -24,8 +34,9 @@ func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) { }() transport := &utlsRoundTripper{dialer: claudeTestDialer{conn: clientConn}} + ctx := context.WithValue(context.Background(), claudeRefreshHandshakeTimeoutContextKey{}, 20*time.Millisecond) startedAt := time.Now() - _, err := transport.createConnection("example.com", "unused", 20*time.Millisecond) + _, err := transport.dialTLSContext(ctx, "tcp", "example.com:443") if err == nil { t.Fatal("expected TLS handshake timeout") } @@ -37,3 +48,147 @@ func TestUtlsRoundTripperBoundsTLSHandshake(t *testing.T) { t.Fatalf("TLS handshake took %s, want less than one second", elapsed) } } + +func TestClaudeOAuthTLSClientHelloSpecMatchesNative220Capture(t *testing.T) { + t.Parallel() + + const wantJA3 = "771,4865-4866-4867-49195-49199-49196-49200-52393-52392-49161-49171-49162-49172-156-157-47-53,0-23-65281-10-11-35-13-51-45-43,29-23-24,0" + const wantJA3MD5 = "203503b7023848ab87b9836c336b8e81" + wantCipherSuites := []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53} + wantExtensions := []uint16{0, 23, 65281, 10, 11, 35, 13, 51, 45, 43} + + spec := claudeOAuthTLSClientHelloSpec() + if !reflect.DeepEqual(spec.CipherSuites, wantCipherSuites) { + t.Fatalf("cipher suites = %v, want %v", spec.CipherSuites, wantCipherSuites) + } + extensionTypes := claudeOAuthExtensionTypes(t, spec.Extensions) + if !reflect.DeepEqual(extensionTypes, wantExtensions) { + t.Fatalf("extension types = %v, want %v", extensionTypes, wantExtensions) + } + curves := spec.Extensions[3].(*tls.SupportedCurvesExtension).Curves + points := spec.Extensions[4].(*tls.SupportedPointsExtension).SupportedPoints + actualJA3 := "771," + joinClaudeOAuthUint16(spec.CipherSuites) + "," + joinClaudeOAuthUint16(extensionTypes) + "," + joinClaudeOAuthCurves(curves) + "," + joinClaudeOAuthUint8(points) + if actualJA3 != wantJA3 { + t.Fatalf("JA3 = %q, want %q", actualJA3, wantJA3) + } + if strings.Contains(actualJA3, "-16-") { + t.Fatal("OAuth JA3 unexpectedly contains ALPN extension 16") + } + hash := md5.Sum([]byte(actualJA3)) // #nosec G401 -- JA3 requires MD5. + if got := hex.EncodeToString(hash[:]); got != wantJA3MD5 { + t.Fatalf("JA3 MD5 = %s, want %s", got, wantJA3MD5) + } + + record := captureClaudeOAuthClientHello(t) + if got := len(record) - 9; got != 245 { + t.Fatalf("ClientHello length = %d, want 245", got) + } +} + +func TestClaudeOAuthRequestHeaderOrderMatchesNative220Capture(t *testing.T) { + t.Parallel() + + wantRefresh := []string{"Accept", "Content-Type", "User-Agent", "Content-Length", "Accept-Encoding", "Host", "Connection"} + wantProfile := []string{"Accept", "Content-Type", "Authorization", "Cache-Control", "User-Agent", "Accept-Encoding", "Host", "Connection"} + if got := claudeOAuthRequestHeaderOrder("POST", "/v1/oauth/token"); !reflect.DeepEqual(got, wantRefresh) { + t.Fatalf("refresh header order = %v, want %v", got, wantRefresh) + } + if got := claudeOAuthRequestHeaderOrder("GET", "/api/oauth/profile"); !reflect.DeepEqual(got, wantProfile) { + t.Fatalf("profile header order = %v, want %v", got, wantProfile) + } +} + +func claudeOAuthExtensionTypes(t *testing.T, extensions []tls.TLSExtension) []uint16 { + t.Helper() + result := make([]uint16, 0, len(extensions)) + for _, extension := range extensions { + switch extension.(type) { + case *tls.SNIExtension: + result = append(result, 0) + case *tls.ExtendedMasterSecretExtension: + result = append(result, 23) + case *tls.RenegotiationInfoExtension: + result = append(result, 65281) + case *tls.SupportedCurvesExtension: + result = append(result, 10) + case *tls.SupportedPointsExtension: + result = append(result, 11) + case *tls.SessionTicketExtension: + result = append(result, 35) + case *tls.SignatureAlgorithmsExtension: + result = append(result, 13) + case *tls.KeyShareExtension: + result = append(result, 51) + case *tls.PSKKeyExchangeModesExtension: + result = append(result, 45) + case *tls.SupportedVersionsExtension: + result = append(result, 43) + default: + t.Fatalf("unexpected OAuth TLS extension %T", extension) + } + } + return result +} + +func joinClaudeOAuthUint16(values []uint16) string { + parts := make([]string, len(values)) + for index, value := range values { + parts[index] = strconv.Itoa(int(value)) + } + return strings.Join(parts, "-") +} + +func joinClaudeOAuthCurves(values []tls.CurveID) string { + parts := make([]string, len(values)) + for index, value := range values { + parts[index] = strconv.Itoa(int(value)) + } + return strings.Join(parts, "-") +} + +func joinClaudeOAuthUint8(values []uint8) string { + parts := make([]string, len(values)) + for index, value := range values { + parts[index] = strconv.Itoa(int(value)) + } + return strings.Join(parts, "-") +} + +func captureClaudeOAuthClientHello(t *testing.T) []byte { + t.Helper() + clientConn, serverConn := net.Pipe() + t.Cleanup(func() { + if errClose := clientConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("close client connection: %v", errClose) + } + if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("close server connection: %v", errClose) + } + }) + tlsConn := tls.UClient(clientConn, &tls.Config{ServerName: "api.anthropic.com"}, tls.HelloCustom) + if errPreset := tlsConn.ApplyPreset(claudeOAuthTLSClientHelloSpec()); errPreset != nil { + t.Fatal(errPreset) + } + handshakeDone := make(chan error, 1) + go func() { handshakeDone <- tlsConn.Handshake() }() + if errDeadline := serverConn.SetReadDeadline(time.Now().Add(5 * time.Second)); errDeadline != nil { + t.Fatal(errDeadline) + } + header := make([]byte, 5) + if _, errRead := io.ReadFull(serverConn, header); errRead != nil { + t.Fatal(errRead) + } + payload := make([]byte, int(binary.BigEndian.Uint16(header[3:5]))) + if _, errRead := io.ReadFull(serverConn, payload); errRead != nil { + t.Fatal(errRead) + } + if errClose := serverConn.Close(); errClose != nil { + t.Fatal(errClose) + } + select { + case <-handshakeDone: + case <-time.After(5 * time.Second): + t.Fatal("OAuth uTLS handshake did not exit") + } + return append(header, payload...) +} diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go index 676f449a..a161a650 100644 --- a/internal/config/claude_header_defaults_test.go +++ b/internal/config/claude_header_defaults_test.go @@ -17,6 +17,7 @@ claude-header-defaults: os: " MacOS " arch: " arm64 " timeout: " 900 " + timezone: " Pacific/Honolulu " stabilize-device-profile: false `) if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { @@ -46,6 +47,9 @@ claude-header-defaults: if got := cfg.ClaudeHeaderDefaults.Timeout; got != "900" { t.Fatalf("Timeout = %q, want %q", got, "900") } + if got := cfg.ClaudeHeaderDefaults.Timezone; got != "Pacific/Honolulu" { + t.Fatalf("Timezone = %q, want %q", got, "Pacific/Honolulu") + } if cfg.ClaudeHeaderDefaults.StabilizeDeviceProfile == nil { t.Fatal("StabilizeDeviceProfile = nil, want non-nil") } diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go index 7f9af128..69485a60 100644 --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -54,6 +54,7 @@ func (cfg *Config) SanitizeClaudeHeaderDefaults() { cfg.ClaudeHeaderDefaults.OS = strings.TrimSpace(cfg.ClaudeHeaderDefaults.OS) cfg.ClaudeHeaderDefaults.Arch = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Arch) cfg.ClaudeHeaderDefaults.Timeout = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timeout) + cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone) } // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases. diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 11473cc2..e03653ff 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -107,6 +107,7 @@ type ClaudeHeaderDefaults struct { OS string `yaml:"os" json:"os"` Arch string `yaml:"arch" json:"arch"` Timeout string `yaml:"timeout" json:"timeout"` + Timezone string `yaml:"timezone" json:"timezone"` StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"` } diff --git a/internal/httpwire/ordered_conn.go b/internal/httpwire/ordered_conn.go new file mode 100644 index 00000000..77386f47 --- /dev/null +++ b/internal/httpwire/ordered_conn.go @@ -0,0 +1,186 @@ +// Package httpwire contains narrowly scoped HTTP/1.1 wire helpers. +package httpwire + +import ( + "bytes" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" +) + +const maxBufferedRequestHeader = 1 << 20 + +// RequestHeaderOrder returns the desired header-name order for one HTTP/1.1 +// request. Names are compared case-insensitively. Headers omitted from the +// returned list retain their original relative order after the listed headers. +type RequestHeaderOrder func(method, requestTarget string) []string + +// NewOrderedRequestConn wraps conn and rewrites only HTTP/1.1 request-header +// order. Request lines, header casing and values, and body bytes remain intact. +func NewOrderedRequestConn(conn net.Conn, order RequestHeaderOrder) net.Conn { + if conn == nil || order == nil { + return conn + } + return &orderedRequestConn{Conn: conn, order: order} +} + +type orderedRequestConn struct { + net.Conn + order RequestHeaderOrder + + mu sync.Mutex + header []byte + bodyRemaining int64 + passthrough bool +} + +func (c *orderedRequestConn) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.passthrough { + return c.Conn.Write(p) + } + + originalLength := len(p) + remaining := p + for len(remaining) > 0 { + if c.bodyRemaining > 0 { + bodyBytes := int64(len(remaining)) + if bodyBytes > c.bodyRemaining { + bodyBytes = c.bodyRemaining + } + if errWrite := writeAll(c.Conn, remaining[:bodyBytes]); errWrite != nil { + return 0, errWrite + } + remaining = remaining[bodyBytes:] + c.bodyRemaining -= bodyBytes + continue + } + + c.header = append(c.header, remaining...) + headerEnd := bytes.Index(c.header, []byte("\r\n\r\n")) + if headerEnd < 0 { + if len(c.header) > maxBufferedRequestHeader { + return 0, fmt.Errorf("httpwire: request header exceeds %d bytes", maxBufferedRequestHeader) + } + return originalLength, nil + } + + headerEnd += len("\r\n\r\n") + header := c.header[:headerEnd] + body := c.header[headerEnd:] + c.header = nil + + ordered, contentLength, chunked := orderRequestHeader(header, c.order) + if errWrite := writeAll(c.Conn, ordered); errWrite != nil { + return 0, errWrite + } + if chunked { + if errWrite := writeAll(c.Conn, body); errWrite != nil { + return 0, errWrite + } + c.passthrough = true + return originalLength, nil + } + c.bodyRemaining = contentLength + remaining = body + } + return originalLength, nil +} + +func orderRequestHeader(header []byte, order RequestHeaderOrder) ([]byte, int64, bool) { + lines := bytes.Split(header[:len(header)-len("\r\n\r\n")], []byte("\r\n")) + if len(lines) == 0 { + return header, 0, false + } + requestParts := strings.SplitN(string(lines[0]), " ", 3) + if len(requestParts) != 3 { + return header, requestContentLength(lines[1:]), requestUsesChunkedEncoding(lines[1:]) + } + + desired := order(requestParts[0], requestParts[1]) + if len(desired) == 0 { + return header, requestContentLength(lines[1:]), requestUsesChunkedEncoding(lines[1:]) + } + + headerLines := lines[1:] + used := make([]bool, len(headerLines)) + orderedLines := make([][]byte, 0, len(lines)) + orderedLines = append(orderedLines, lines[0]) + for _, name := range desired { + for index, line := range headerLines { + if used[index] || !headerLineNamed(line, name) { + continue + } + orderedLines = append(orderedLines, line) + used[index] = true + } + } + for index, line := range headerLines { + if !used[index] { + orderedLines = append(orderedLines, line) + } + } + + var output bytes.Buffer + for _, line := range orderedLines { + output.Write(line) + output.WriteString("\r\n") + } + output.WriteString("\r\n") + return output.Bytes(), requestContentLength(headerLines), requestUsesChunkedEncoding(headerLines) +} + +func headerLineNamed(line []byte, name string) bool { + colon := bytes.IndexByte(line, ':') + return colon > 0 && strings.EqualFold(string(line[:colon]), name) +} + +func requestContentLength(lines [][]byte) int64 { + for _, line := range lines { + if !headerLineNamed(line, "Content-Length") { + continue + } + colon := bytes.IndexByte(line, ':') + value := strings.TrimSpace(string(line[colon+1:])) + length, errParse := strconv.ParseInt(value, 10, 64) + if errParse == nil && length > 0 { + return length + } + return 0 + } + return 0 +} + +func requestUsesChunkedEncoding(lines [][]byte) bool { + for _, line := range lines { + if !headerLineNamed(line, "Transfer-Encoding") { + continue + } + colon := bytes.IndexByte(line, ':') + for _, encoding := range strings.Split(string(line[colon+1:]), ",") { + if strings.EqualFold(strings.TrimSpace(encoding), "chunked") { + return true + } + } + } + return false +} + +func writeAll(writer io.Writer, data []byte) error { + for len(data) > 0 { + written, errWrite := writer.Write(data) + if errWrite != nil { + return errWrite + } + if written <= 0 { + return io.ErrShortWrite + } + data = data[written:] + } + return nil +} diff --git a/internal/httpwire/ordered_conn_test.go b/internal/httpwire/ordered_conn_test.go new file mode 100644 index 00000000..02fbb4f9 --- /dev/null +++ b/internal/httpwire/ordered_conn_test.go @@ -0,0 +1,100 @@ +package httpwire + +import ( + "bytes" + "errors" + "io" + "net" + "testing" + "time" +) + +func TestOrderedRequestConnReordersKeepAliveRequestsWithoutChangingBodies(t *testing.T) { + t.Parallel() + + client, server := net.Pipe() + t.Cleanup(func() { + if errClose := client.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("close client connection: %v", errClose) + } + if errClose := server.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) { + t.Errorf("close server connection: %v", errClose) + } + }) + + conn := NewOrderedRequestConn(client, func(method, target string) []string { + if method == "POST" && target == "/v1/messages?beta=true" { + return []string{"Accept", "Authorization", "Content-Type", "User-Agent", "Connection", "Host", "Accept-Encoding", "Content-Length"} + } + return []string{"Accept", "Host", "Connection"} + }) + + firstInput := "POST /v1/messages?beta=true HTTP/1.1\r\nHost: api.anthropic.com\r\nUser-Agent: claude-cli/2.1.220 (external, cli)\r\nContent-Length: 7\r\nAccept: application/json\r\nX-Unknown: keep\r\nAuthorization: Bearer placeholder\r\nContent-Type: application/json\r\nConnection: keep-alive\r\nAccept-Encoding: gzip, deflate, br, zstd\r\n\r\n{\"a\":1}" + secondInput := "GET /api/oauth/profile HTTP/1.1\r\nConnection: close\r\nHost: api.anthropic.com\r\nAccept: application/json\r\n\r\n" + want := "POST /v1/messages?beta=true HTTP/1.1\r\nAccept: application/json\r\nAuthorization: Bearer placeholder\r\nContent-Type: application/json\r\nUser-Agent: claude-cli/2.1.220 (external, cli)\r\nConnection: keep-alive\r\nHost: api.anthropic.com\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nContent-Length: 7\r\nX-Unknown: keep\r\n\r\n{\"a\":1}GET /api/oauth/profile HTTP/1.1\r\nAccept: application/json\r\nHost: api.anthropic.com\r\nConnection: close\r\n\r\n" + + readDone := make(chan []byte, 1) + go func() { + if errDeadline := server.SetReadDeadline(time.Now().Add(5 * time.Second)); errDeadline != nil { + readDone <- nil + return + } + got := make([]byte, len(want)) + if _, errRead := io.ReadFull(server, got); errRead != nil { + readDone <- nil + return + } + readDone <- got + }() + + parts := [][]byte{ + []byte(firstInput[:29]), + []byte(firstInput[29 : len(firstInput)-3]), + []byte(firstInput[len(firstInput)-3:] + secondInput[:17]), + []byte(secondInput[17:]), + } + for _, part := range parts { + written, errWrite := conn.Write(part) + if errWrite != nil { + t.Fatalf("write request bytes: %v", errWrite) + } + if written != len(part) { + t.Fatalf("write length = %d, want %d", written, len(part)) + } + } + + select { + case got := <-readDone: + if !bytes.Equal(got, []byte(want)) { + t.Fatalf("wire bytes differ\n got: %q\nwant: %q", got, want) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out reading ordered request bytes") + } +} + +func TestOrderedRequestConnPreservesChunkedBody(t *testing.T) { + t.Parallel() + + client, server := net.Pipe() + t.Cleanup(func() { + _ = client.Close() + _ = server.Close() + }) + conn := NewOrderedRequestConn(client, func(_, _ string) []string { return []string{"Host", "Transfer-Encoding"} }) + input := []byte("POST /upload HTTP/1.1\r\nTransfer-Encoding: chunked\r\nHost: example.com\r\n\r\n4\r\ntest\r\n0\r\n\r\n") + want := []byte("POST /upload HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n") + + readDone := make(chan []byte, 1) + go func() { + got := make([]byte, len(want)) + _, _ = io.ReadFull(server, got) + readDone <- got + }() + if _, errWrite := conn.Write(input); errWrite != nil { + t.Fatal(errWrite) + } + if got := <-readDone; !bytes.Equal(got, want) { + t.Fatalf("chunked wire bytes differ\n got: %q\nwant: %q", got, want) + } +} diff --git a/internal/runtime/executor/claude_executor_auth.go b/internal/runtime/executor/claude_executor_auth.go index 5ca5fdf2..5302aa4d 100644 --- a/internal/runtime/executor/claude_executor_auth.go +++ b/internal/runtime/executor/claude_executor_auth.go @@ -115,9 +115,9 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) ( claudeauth.EnsureMetadataMap(&auth.Metadata) claudeauth.StoreMetadataValue(&auth.Metadata, "access_token", td.AccessToken) claudeauth.StoreMetadataString(&auth.Metadata, "refresh_token", td.RefreshToken) - // email is written unconditionally to preserve the previous reset-on-refresh - // behaviour; the remaining optional fields keep their prior value when absent. - claudeauth.StoreMetadataValue(&auth.Metadata, "email", td.Email) + // Profile fields are optional when token rotation succeeds but the follow-up + // profile lookup fails. Never erase the previously resolved credential identity. + claudeauth.StoreMetadataString(&auth.Metadata, "email", td.Email) claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", td.AccountUUID) claudeauth.StoreMetadataString(&auth.Metadata, "organization_uuid", td.OrganizationUUID) claudeauth.StoreMetadataString(&auth.Metadata, "organization_name", td.OrganizationName) diff --git a/internal/runtime/executor/claude_executor_auth_race_test.go b/internal/runtime/executor/claude_executor_auth_race_test.go index 613762a1..50e045db 100644 --- a/internal/runtime/executor/claude_executor_auth_race_test.go +++ b/internal/runtime/executor/claude_executor_auth_race_test.go @@ -66,6 +66,36 @@ func TestClaudeExecutorPrepareRequestAuthIsRaceFreeOnSharedCredential(t *testing // TestClaudeExecutorSharedCredentialMetadataMixedAccess drives the request-path // readers against the profile writer at the same time, which is the shape that // produced the reported data races. +func TestClaudeExecutorSharedCredentialMetadataReadersUseOneLock(t *testing.T) { + auth := &cliproxyauth.Auth{ID: "claude-race-all-readers", Metadata: map[string]any{ + "access_token": "sk-ant-oat-race-probe", + "cloak_mode": "always", + "cloak_sensitive_words": "secret", + }} + + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < 64; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + if i%3 == 0 { + claudeauth.StoreMetadataValue(&auth.Metadata, "access_token", "sk-ant-oat-race-probe") + claudeauth.StoreMetadataValue(&auth.Metadata, "cloak_mode", "always") + return + } + if i%3 == 1 { + _, _ = claudeCreds(auth) + return + } + _, _, _, _ = getCloakConfigFromAuth(auth) + }(i) + } + close(start) + wg.Wait() +} + func TestClaudeExecutorSharedCredentialMetadataMixedAccess(t *testing.T) { executor := NewClaudeExecutor(&config.Config{}) executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) { diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go index 2415e694..0ceb971b 100644 --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -30,7 +30,7 @@ func claudeOAuthAuthForBetaPolicy() *cliproxyauth.Auth { // A confirmed native client authenticates to CPA with the user's configured key // and cannot know CPA will pick an OAuth credential upstream, so its header never // carries the OAuth betas. Passing it through verbatim produced a Bearer request -// declaring neither of them. +// without the selected credential's OAuth/cache profile. func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing.T) { incoming := http.Header{} incoming.Set("Anthropic-Beta", claudeCodeBeta+",interleaved-thinking-2025-05-14,"+claudeEffortBeta) @@ -46,8 +46,8 @@ func TestApplyClaudeHeaders_ConfirmedClientKeepsOAuthCredentialBetas(t *testing. if len(parts) < 2 || parts[0] != claudeCodeBeta || parts[1] != claudeOAuthBeta { t.Fatalf("Anthropic-Beta = %q, want %s at position 2", got, claudeOAuthBeta) } - if parts[len(parts)-1] != claudeExtendedCacheTTLBeta { - t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta) + if parts[len(parts)-1] != claudeCacheDiagnosisBeta || parts[len(parts)-2] != claudeExtendedCacheTTLBeta { + t.Fatalf("Anthropic-Beta = %q, want OAuth cache trailer %s,%s", got, claudeExtendedCacheTTLBeta, claudeCacheDiagnosisBeta) } // The caller's own betas survive the restoration. for _, want := range []string{"interleaved-thinking-2025-05-14", claudeEffortBeta} { @@ -197,8 +197,8 @@ func TestApplyClaudeHeaders_FastModeBetaMatchesAcrossStreamModes(t *testing.T) { } } -// extended-cache-ttl is the one measured trailing invariant; fast-mode has no -// captured position and must not displace it. +// The current OAuth CLI profile places fast-mode before extended-cache-ttl and +// appends cache-diagnosis after the cache TTL beta. func TestApplyClaudeHeaders_FastModePrecedesOAuthTrailer(t *testing.T) { req := newClaudeHeaderTestRequest(t, nil) if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, true, nil, @@ -207,11 +207,14 @@ func TestApplyClaudeHeaders_FastModePrecedesOAuthTrailer(t *testing.T) { } got := req.Header.Get("Anthropic-Beta") parts := strings.Split(got, ",") - if parts[len(parts)-1] != claudeExtendedCacheTTLBeta { - t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeExtendedCacheTTLBeta) + if parts[len(parts)-1] != claudeCacheDiagnosisBeta { + t.Fatalf("Anthropic-Beta = %q, want %s last", got, claudeCacheDiagnosisBeta) } - if parts[len(parts)-2] != claudeFastModeBeta { - t.Fatalf("Anthropic-Beta = %q, want %s immediately before the OAuth trailer", got, claudeFastModeBeta) + if parts[len(parts)-2] != claudeExtendedCacheTTLBeta { + t.Fatalf("Anthropic-Beta = %q, want %s before cache diagnosis", got, claudeExtendedCacheTTLBeta) + } + if parts[len(parts)-3] != claudeFastModeBeta { + t.Fatalf("Anthropic-Beta = %q, want %s before the OAuth cache trailer", got, claudeFastModeBeta) } } diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go index 29688e37..c1d01b76 100644 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -11,6 +11,7 @@ import ( "strings" "time" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -65,10 +66,8 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (cloakMode string, strictMo return value } } - if auth.Metadata != nil { - if value, ok := auth.Metadata[key].(string); ok { - return strings.TrimSpace(value) - } + if value := claudeauth.ReadMetadataString(&auth.Metadata, key); value != "" { + return strings.TrimSpace(value) } return "" } @@ -213,6 +212,10 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte { // Claude models give it operator-level authority without changing the cached // top-level prefix. func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string) []byte { + return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, time.Now()) +} + +func checkSystemInstructionsWithSigningModeAt(payload []byte, strictMode bool, cchSigning bool, version, entrypoint, workload string, now time.Time) []byte { system := gjson.GetBytes(payload, "system") messageText := claudeBillingFingerprintMessageText(payload) @@ -221,12 +224,12 @@ func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cch agentBlock := buildTextBlock(claudeCodeCLIIdentity, map[string]string{"type": "ephemeral"}) payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+billingBlock+","+agentBlock+"]")) if strictMode { - return injectClaudeCodeCurrentDate(payload, time.Now()) + return injectClaudeCodeCurrentDate(payload, now) } forwardedSystem := collectForwardedClaudeSystemPrompt(system) if strings.TrimSpace(forwardedSystem) == "" { - return injectClaudeCodeCurrentDate(payload, time.Now()) + return injectClaudeCodeCurrentDate(payload, now) } if claudeUsesLegacySystemReminder(payload) { payload = prependClaudeSystemReminderToFirstUserMessage(payload, forwardedSystem) @@ -236,7 +239,7 @@ func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cch // stay on the user-reminder compatibility path. payload = insertClaudeMidConversationSystemMessage(payload, forwardedSystem) } - return injectClaudeCodeCurrentDate(payload, time.Now()) + return injectClaudeCodeCurrentDate(payload, now) } // claudeLegacySystemReminderModels lists the official Anthropic model IDs and @@ -431,6 +434,42 @@ func claudeCodeLocalDate(now time.Time) string { return fmt.Sprintf("%04d-%02d-%02d", year, int(month), day) } +func claudeCodeCurrentTime(cfg *config.Config, auth *cliproxyauth.Auth) time.Time { + return time.Now().In(claudeCodeTimezone(cfg, auth)) +} + +func claudeCodeTimezone(cfg *config.Config, auth *cliproxyauth.Auth) *time.Location { + if timezone := claudeCredentialTimezone(auth); timezone != "" { + if location, errLocation := time.LoadLocation(timezone); errLocation == nil { + return location + } + } + if cfg == nil { + return time.Local + } + timezone := strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone) + if timezone == "" { + return time.Local + } + location, errLocation := time.LoadLocation(timezone) + if errLocation != nil { + return time.Local + } + return location +} + +func claudeCredentialTimezone(auth *cliproxyauth.Auth) string { + if auth == nil { + return "" + } + if auth.Attributes != nil { + if timezone := strings.TrimSpace(auth.Attributes["timezone"]); timezone != "" { + return timezone + } + } + return strings.TrimSpace(claudeauth.ReadMetadataString(&auth.Metadata, "timezone")) +} + func claudeCodeCurrentDateReminder(now time.Time) string { return fmt.Sprintf(` As you answer the user's questions, you can use the following context: @@ -639,7 +678,7 @@ func applyCloaking( billingVersion := helps.DefaultClaudeVersion(cfg) workload := getWorkloadFromContext(ctx) - payload = checkSystemInstructionsWithSigningMode(payload, settings.strictMode, cchSigning, billingVersion, "cli", workload) + payload = checkSystemInstructionsWithSigningModeAt(payload, settings.strictMode, cchSigning, billingVersion, "cli", workload, claudeCodeCurrentTime(cfg, auth)) // OAuth metadata is rewritten after credential selection and all remaining // body mutations. Non-OAuth cloaking keeps the legacy generated identity. diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go new file mode 100644 index 00000000..183b41a6 --- /dev/null +++ b/internal/runtime/executor/claude_executor_diagnostics.go @@ -0,0 +1,90 @@ +package executor + +import ( + "bytes" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type claudeDiagnosticsRequestState struct { + key string + sequence uint64 +} + +func injectClaudeDiagnostics(body []byte, apiKey, sessionID string) ([]byte, claudeDiagnosticsRequestState) { + key, sequence, previousMessageID := helps.BeginClaudeDiagnostics(apiKey, sessionID) + if key == "" { + return body, claudeDiagnosticsRequestState{} + } + value := `{"previous_message_id":null}` + if previousMessageID != "" { + value = `{"previous_message_id":` + marshalJSONStringWithoutHTMLEscape(previousMessageID) + `}` + } + + if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.Exists() { + updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value)) + if errSet == nil { + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} + } + } + if contextManagement := gjson.GetBytes(body, "context_management"); contextManagement.Exists() { + start := contextManagement.Index + insertAt := start + len(contextManagement.Raw) + if start >= 0 && insertAt >= start && insertAt <= len(body) && bytes.Equal(body[start:insertAt], []byte(contextManagement.Raw)) { + updated := make([]byte, 0, len(body)+len(value)+len(`,"diagnostics":`)) + updated = append(updated, body[:insertAt]...) + updated = append(updated, `,"diagnostics":`...) + updated = append(updated, value...) + updated = append(updated, body[insertAt:]...) + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} + } + } + updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value)) + if errSet != nil { + return body, claudeDiagnosticsRequestState{} + } + return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence} +} + +func commitClaudeDiagnostics(state claudeDiagnosticsRequestState, messageID string) { + helps.CommitClaudeDiagnostics(state.key, state.sequence, messageID) +} + +func claudeMessageIDFromResponse(data []byte) string { + return strings.TrimSpace(gjson.GetBytes(data, "id").String()) +} + +func observeClaudeStreamLine(line []byte, messageID *string, completed *bool) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + return + } + payload := bytes.TrimSpace(line[len("data:"):]) + if !gjson.ValidBytes(payload) { + return + } + root := gjson.ParseBytes(payload) + switch root.Get("type").String() { + case "message_start": + if id := strings.TrimSpace(root.Get("message.id").String()); id != "" { + *messageID = id + } + case "message_stop": + *completed = true + } +} + +func claudeMessageIDFromSSE(data []byte) string { + var messageID string + completed := false + for _, line := range bytes.Split(data, []byte("\n")) { + observeClaudeStreamLine(line, &messageID, &completed) + } + if !completed { + return "" + } + return messageID +} diff --git a/internal/runtime/executor/claude_executor_diagnostics_test.go b/internal/runtime/executor/claude_executor_diagnostics_test.go new file mode 100644 index 00000000..a4e10bf7 --- /dev/null +++ b/internal/runtime/executor/claude_executor_diagnostics_test.go @@ -0,0 +1,92 @@ +package executor + +import ( + "bytes" + "context" + "io" + "net/http" + "strings" + "testing" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing.T) { + t.Parallel() + + body := []byte(`{"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"max_tokens":1,"messages":[]}`) + first, state := injectClaudeDiagnostics(body, "credential-diagnostics-order", "session-diagnostics-order") + wantOrder := `"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"diagnostics":{"previous_message_id":null},"max_tokens"` + if !bytes.Contains(first, []byte(wantOrder)) { + t.Fatalf("diagnostics field order differs from native: %s", first) + } + if got := gjson.GetBytes(first, "diagnostics.previous_message_id"); got.Type != gjson.Null { + t.Fatalf("first previous_message_id = %s, want null", got.Raw) + } + + commitClaudeDiagnostics(state, "msg_01ABCDEF0123456789ABCDEFG") + second, _ := injectClaudeDiagnostics(body, "credential-diagnostics-order", "session-diagnostics-order") + if got := gjson.GetBytes(second, "diagnostics.previous_message_id").String(); got != "msg_01ABCDEF0123456789ABCDEFG" { + t.Fatalf("second previous_message_id = %q, want committed upstream ID", got) + } +} + +func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) { + var previousValues []gjson.Result + call := 0 + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + body, errRead := io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + previousValues = append(previousValues, gjson.GetBytes(body, "diagnostics.previous_message_id")) + call++ + response := `{"id":"msg_diagnostics_` + string(rune('0'+call)) + `","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}` + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(response)), Request: req}, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + deviceIDs := []string{"0000000000000000000000000000000000000000000000000000000000000000"} + auth := &cliproxyauth.Auth{ + ID: "diagnostics-live-path", + Attributes: map[string]string{"api_key": "sk-ant-oat-diagnostics-live-path"}, + Metadata: map[string]any{ + "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + claudeauth.ClaudeDeviceIDsMetadataKey: deviceIDs, + }, + } + executor := NewClaudeExecutor(&config.Config{}) + request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"x"}],"max_tokens":16}`)} + options := cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "diagnostics-conversation"}, + } + for range 2 { + if _, errExecute := executor.Execute(ctx, auth, request, options); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + } + if len(previousValues) != 2 || previousValues[0].Type != gjson.Null || previousValues[0].Raw != "null" { + t.Fatalf("first diagnostics value = %#v, want explicit null", previousValues) + } + if got := previousValues[1].String(); got != "msg_diagnostics_1" { + t.Fatalf("second diagnostics previous_message_id = %q, want first upstream response ID", got) + } +} + +func TestClaudeMessageIDFromSSECommitsOnlyCompletedMessage(t *testing.T) { + t.Parallel() + + complete := []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_complete\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n") + if got := claudeMessageIDFromSSE(complete); got != "msg_complete" { + t.Fatalf("completed SSE message ID = %q, want msg_complete", got) + } + incomplete := []byte(strings.Replace(string(complete), "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", "", 1)) + if got := claudeMessageIDFromSSE(incomplete); got != "" { + t.Fatalf("incomplete SSE message ID = %q, want empty", got) + } +} diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index e5a478da..73a4fcb7 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -78,8 +78,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. + diagnosticsState := claudeDiagnosticsRequestState{} if cloaked && isAnthropicUpstreamBase(baseURL) { body = injectClaudeCodeContextManagement(body) + if oauthToken { + body, diagnosticsState = injectClaudeDiagnostics(body, apiKey, claudeSessionID) + } } requestedModel := helps.PayloadRequestedModel(opts, req.Model) @@ -125,8 +129,9 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, fmt.Errorf("apply Claude credential metadata: %w", err) } } + fallbackBilling := "" if cchSigning { - fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + fallbackBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, fallbackBilling) if err != nil { return resp, fmt.Errorf("finalize Claude CCH: %w", err) @@ -140,12 +145,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, upstreamStream, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { return resp, errHeaders } - var authID, authLabel, authType, authValue string - if auth != nil { - authID = auth.ID - authLabel = auth.Label - authType, authValue = auth.AccountInfo() - } + authID, authLabel, authType, authValue := claudeAuthLogIdentity(auth) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, @@ -166,6 +166,22 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, err } helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + httpResp, bodyForUpstream, _, err = e.retryClaudeFastModeRefusal(httpReq, httpClient, httpResp, claudeFastFallbackOptions{ + auth: auth, + apiKey: apiKey, + stream: upstreamStream, + extraBetas: extraBetas, + body: bodyForUpstream, + fallbackBilling: fallbackBilling, + cchSigning: cchSigning, + incomingHeaders: incomingHeaders, + confirmedNative: confirmedClaudeCode && !cloaked, + sessionID: claudeSessionID, + allowEntitlementFallback: oauthToken && cloaked && isAnthropicUpstreamBase(baseURL), + }) + if err != nil { + return resp, err + } if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { // Decompress error responses — pass the Content-Encoding value (may be empty) // and let decodeResponseBody handle both header-declared and magic-byte-detected @@ -216,6 +232,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r helps.RecordAPIResponseError(ctx, e.cfg, errValidate) return resp, errValidate } + commitClaudeDiagnostics(diagnosticsState, claudeMessageIDFromSSE(data)) lines := bytes.Split(data, []byte("\n")) for i, line := range lines { if detail, ok := helps.ParseClaudeStreamUsage(line); ok { @@ -225,6 +242,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } data = bytes.Join(lines, []byte("\n")) } else { + commitClaudeDiagnostics(diagnosticsState, claudeMessageIDFromResponse(data)) reporter.Publish(ctx, helps.ParseClaudeUsage(data)) data = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) } diff --git a/internal/runtime/executor/claude_executor_fast_fallback.go b/internal/runtime/executor/claude_executor_fast_fallback.go new file mode 100644 index 00000000..d288c7cd --- /dev/null +++ b/internal/runtime/executor/claude_executor_fast_fallback.go @@ -0,0 +1,120 @@ +package executor + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" + "github.com/tidwall/sjson" +) + +type claudeFastFallbackOptions struct { + auth *cliproxyauth.Auth + apiKey string + stream bool + extraBetas []string + body []byte + fallbackBilling string + cchSigning bool + incomingHeaders http.Header + confirmedNative bool + sessionID string + allowEntitlementFallback bool +} + +func (e *ClaudeExecutor) retryClaudeFastModeRefusal( + ctxReq *http.Request, + client *http.Client, + initialResp *http.Response, + options claudeFastFallbackOptions, +) (*http.Response, []byte, bool, error) { + if initialResp == nil || ctxReq == nil || client == nil || !options.allowEntitlementFallback || initialResp.StatusCode != http.StatusTooManyRequests { + return initialResp, options.body, false, nil + } + + errorBody, errDecode := decodeResponseBody(initialResp.Body, initialResp.Header.Get("Content-Encoding")) + if errDecode != nil { + return nil, options.body, false, fmt.Errorf("decode Claude Fast refusal: %w", errDecode) + } + body, errRead := io.ReadAll(errorBody) + if errClose := errorBody.Close(); errClose != nil { + log.Errorf("response body close error: %v", errClose) + } + if errRead != nil { + return nil, options.body, false, fmt.Errorf("read Claude Fast refusal: %w", errRead) + } + if !claudeBodyIndicatesFastModeCredits(body) { + initialResp.Body = io.NopCloser(bytes.NewReader(body)) + initialResp.ContentLength = int64(len(body)) + initialResp.Header.Del("Content-Encoding") + initialResp.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) + return initialResp, options.body, false, nil + } + + helps.AppendAPIResponseChunk(ctxReq.Context(), e.cfg, body) + fallbackBody, errDelete := sjson.DeleteBytes(options.body, "speed") + if errDelete != nil { + return nil, options.body, false, fmt.Errorf("remove Claude Fast speed: %w", errDelete) + } + if options.cchSigning { + var errCCH error + fallbackBody, errCCH = finalizeAnthropicMessagesBodyCCH(fallbackBody, options.fallbackBilling) + if errCCH != nil { + return nil, options.body, false, fmt.Errorf("re-finalize Claude CCH for Fast fallback: %w", errCCH) + } + } + + fallbackReq, errRequest := http.NewRequestWithContext(ctxReq.Context(), http.MethodPost, ctxReq.URL.String(), bytes.NewReader(fallbackBody)) + if errRequest != nil { + return nil, options.body, false, fmt.Errorf("create Claude Fast fallback request: %w", errRequest) + } + fallbackBetas := append([]string(nil), options.extraBetas...) + fallbackBetas = append(fallbackBetas, claudeFastModeBeta) + if errHeaders := applyClaudeHeaders( + fallbackReq, + options.auth, + options.apiKey, + options.stream, + fallbackBetas, + fallbackBody, + e.cfg, + options.incomingHeaders, + options.confirmedNative, + options.sessionID, + ); errHeaders != nil { + return nil, options.body, false, errHeaders + } + + authID, authLabel, authType, authValue := claudeAuthLogIdentity(options.auth) + helps.RecordAPIRequest(ctxReq.Context(), e.cfg, helps.UpstreamRequestLog{ + URL: fallbackReq.URL.String(), + Method: http.MethodPost, + Headers: fallbackReq.Header.Clone(), + Body: fallbackBody, + Provider: e.upstreamRequestLogProvider(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + fallbackResp, errDo := doClaudeUpstreamRequest(client, fallbackReq) + if errDo != nil { + helps.RecordAPIResponseError(ctxReq.Context(), e.cfg, errDo) + return nil, fallbackBody, true, errDo + } + helps.RecordAPIResponseMetadata(ctxReq.Context(), e.cfg, fallbackResp.StatusCode, fallbackResp.Header.Clone()) + return fallbackResp, fallbackBody, true, nil +} + +func claudeAuthLogIdentity(auth *cliproxyauth.Auth) (id, label, authType, authValue string) { + if auth == nil { + return "", "", "", "" + } + authType, authValue = auth.AccountInfo() + return auth.ID, auth.Label, authType, authValue +} diff --git a/internal/runtime/executor/claude_executor_fast_fallback_test.go b/internal/runtime/executor/claude_executor_fast_fallback_test.go new file mode 100644 index 00000000..f95a4b41 --- /dev/null +++ b/internal/runtime/executor/claude_executor_fast_fallback_test.go @@ -0,0 +1,120 @@ +package executor + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/tidwall/gjson" +) + +func TestRetryClaudeFastModeRefusalMatchesNative220Fallback(t *testing.T) { + t.Parallel() + + fastBody := []byte(strings.Replace(claudeCCH21220BaseBody, `"stream":true}`, `"speed":"fast","stream":true}`, 1)) + fastBody, errSign := finalizeAnthropicMessagesBodyCCH(fastBody, "") + if errSign != nil { + t.Fatal(errSign) + } + initialReq, errRequest := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", bytes.NewReader(fastBody)) + if errRequest != nil { + t.Fatal(errRequest) + } + initialReq.Header.Set("X-Claude-Code-Session-Id", "11111111-2222-4333-8444-555555555555") + initialReq.Header.Set("x-client-request-id", "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee") + initialResp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"rate_limit_error","message":"Usage credits are required for fast mode."}}`)), + Request: initialReq, + } + + var fallbackBody []byte + var fallbackHeaders http.Header + client := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var errRead error + fallbackBody, errRead = io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + fallbackHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")), + Request: req, + }, nil + })} + + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-fast-fallback"}} + finalResp, gotBody, retried, errRetry := executor.retryClaudeFastModeRefusal(initialReq, client, initialResp, claudeFastFallbackOptions{ + auth: auth, + apiKey: "sk-ant-oat-fast-fallback", + stream: true, + body: fastBody, + cchSigning: true, + sessionID: "11111111-2222-4333-8444-555555555555", + allowEntitlementFallback: true, + }) + if errRetry != nil { + t.Fatalf("retryClaudeFastModeRefusal() error = %v", errRetry) + } + if !retried || finalResp.StatusCode != http.StatusOK { + t.Fatalf("retried/status = %v/%d, want true/200", retried, finalResp.StatusCode) + } + if !bytes.Equal(gotBody, fallbackBody) { + t.Fatal("returned fallback body differs from sent body") + } + if got := gjson.GetBytes(fallbackBody, "speed"); got.Exists() { + t.Fatalf("fallback speed = %s, want absent", got.Raw) + } + if got := len(fastBody) - len(fallbackBody); got != 15 { + t.Fatalf("fallback body length delta = %d, want 15", got) + } + beforeSystem := gjson.GetBytes(fastBody, "system.0.text").String() + afterSystem := gjson.GetBytes(fallbackBody, "system.0.text").String() + if beforeSystem == afterSystem { + t.Fatal("Fast fallback did not recalculate the CCH-bearing system block") + } + resigned, errResign := finalizeAnthropicMessagesBodyCCH(fallbackBody, "") + if errResign != nil { + t.Fatal(errResign) + } + if !bytes.Equal(resigned, fallbackBody) { + t.Fatal("fallback body CCH is not final") + } + if got := strings.Join(fallbackHeaders["anthropic-beta"], ","); !strings.Contains(got, claudeFastModeBeta) { + t.Fatalf("fallback beta = %q, want Fast beta retained", got) + } + if got := fallbackHeaders.Get("X-Claude-Code-Session-Id"); got != "11111111-2222-4333-8444-555555555555" { + t.Fatalf("fallback session ID = %q, want original session", got) + } + if got := strings.Join(fallbackHeaders["x-client-request-id"], ","); got == "" || got == initialReq.Header.Get("x-client-request-id") { + t.Fatalf("fallback request ID = %q, want a new ID", got) + } + if got := fallbackHeaders.Get("X-Stainless-Retry-Count"); got != "0" { + t.Fatalf("fallback retry count = %q, want 0", got) + } +} + +func TestRetryClaudeFastModeRefusalLeavesConfirmedNativeToRetry(t *testing.T) { + t.Parallel() + + req, errRequest := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.anthropic.com/v1/messages?beta=true", strings.NewReader(`{"speed":"fast"}`)) + if errRequest != nil { + t.Fatal(errRequest) + } + resp := &http.Response{StatusCode: http.StatusTooManyRequests, Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Usage credits are required for fast mode."}}`)), Header: make(http.Header)} + gotResp, _, retried, errRetry := NewClaudeExecutor(&config.Config{}).retryClaudeFastModeRefusal(req, http.DefaultClient, resp, claudeFastFallbackOptions{allowEntitlementFallback: false}) + if errRetry != nil { + t.Fatal(errRetry) + } + if retried || gotResp != resp { + t.Fatal("confirmed native refusal must be returned for the native client to retry") + } +} diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 25bd571d..382b838c 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -16,6 +16,7 @@ import ( "github.com/andybalholm/brotli" "github.com/google/uuid" "github.com/klauspost/compress/zstd" + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" @@ -35,6 +36,8 @@ const ( claudeContext1MBeta = "context-1m-2025-08-07" claudeMidConvSystemBeta = "mid-conversation-system-2026-04-07" claudeAdvancedToolUseBeta = "advanced-tool-use-2025-11-20" + claudeAdvisorToolBeta = "advisor-tool-2026-03-01" + claudeCacheDiagnosisBeta = "cache-diagnosis-2026-04-07" claudeEffortBeta = "effort-2025-11-24" claudeServerSideFallbackBeta = "server-side-fallback-2026-06-01" claudeFallbackCreditBeta = "fallback-credit-2026-06-01" @@ -82,14 +85,14 @@ var claudeCodeTrailingBetas = []string{ // 7 context-management-2025-06-27 // 8 prompt-caching-scope-2026-01-05 // 9 mid-conversation-system-2026-04-07 models accepting a role=system turn -// 10 advanced-tool-use-2025-11-20 requests declaring tools +// 10 advisor-tool-2026-03-01 current OAuth tool profile +// advanced-tool-use-2025-11-20 captured API-key tool profile // 11 effort-2025-11-24 // 12 server-side-fallback-2026-06-01 // 13 fallback-credit-2026-06-01 -// 14 extended-cache-ttl-2025-04-11 OAuth credentials only, always last -// -// fast-mode-2026-02-01 has no captured position; it is emitted just before the -// OAuth trailer so the one measured invariant, extended-cache-ttl last, holds. +// 14 fast-mode-2026-02-01 speed:fast requests only +// 15 extended-cache-ttl-2025-04-11 OAuth credentials only +// 16 cache-diagnosis-2026-04-07 current OAuth profile trailer // // An empty body keeps the optimistic role=system default, matching the cloaking // policy for unknown and future model IDs. @@ -107,9 +110,16 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) betas = append(betas, claudeMidConvSystemBeta) } if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 { - betas = append(betas, claudeAdvancedToolUseBeta) + if oauthToken { + betas = append(betas, claudeAdvisorToolBeta) + } else { + betas = append(betas, claudeAdvancedToolUseBeta) + } } betas = append(betas, claudeEffortBeta) + if oauthToken && !requested[claudeFallbackCreditBeta] { + betas = append(betas, claudeFallbackCreditBeta) + } for _, beta := range claudeCodeTrailingBetas { if requested[beta] { betas = append(betas, beta) @@ -119,7 +129,7 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool) betas = append(betas, claudeFastModeBeta) } if oauthToken { - betas = append(betas, claudeExtendedCacheTTLBeta) + betas = append(betas, claudeExtendedCacheTTLBeta, claudeCacheDiagnosisBeta) } return strings.Join(betas, ",") } @@ -149,8 +159,40 @@ var claudeCountTokensBetas = []string{ claudeTokenCountingBeta, } -// withClaudeOAuthCredentialBetas restores the two betas that describe the -// upstream credential rather than the caller's capabilities. +func claudeCountTokensBetasForCredential(oauthToken bool) string { + betas := make([]string, 0, len(claudeCountTokensBetas)+1) + betas = append(betas, claudeCodeBeta) + if oauthToken { + betas = append(betas, claudeOAuthBeta) + } + betas = append(betas, claudeCountTokensBetas[1:]...) + return strings.Join(betas, ",") +} + +func withClaudeCountTokensOAuthBeta(betas string) string { + parts := make([]string, 0, len(claudeCountTokensBetas)+1) + seen := make(map[string]bool) + for _, beta := range strings.Split(betas, ",") { + if beta = strings.TrimSpace(beta); beta != "" && !seen[beta] { + parts = append(parts, beta) + seen[beta] = true + } + } + if seen[claudeOAuthBeta] { + return strings.Join(parts, ",") + } + insertAt := 0 + if len(parts) > 0 && parts[0] == claudeCodeBeta { + insertAt = 1 + } + parts = append(parts, "") + copy(parts[insertAt+1:], parts[insertAt:]) + parts[insertAt] = claudeOAuthBeta + return strings.Join(parts, ",") +} + +// withClaudeOAuthCredentialBetas restores the credential-scoped betas that +// describe the selected upstream OAuth account rather than caller capability. // // A confirmed native client authenticates to CPA with whatever key the user // configured and cannot know that CPA will select an OAuth credential upstream, @@ -181,7 +223,20 @@ func withClaudeOAuthCredentialBetas(betas string) string { parts[insertAt] = claudeOAuthBeta } if !seen[claudeExtendedCacheTTLBeta] { - parts = append(parts, claudeExtendedCacheTTLBeta) + insertAt := len(parts) + for index, beta := range parts { + if beta == claudeCacheDiagnosisBeta { + insertAt = index + break + } + } + parts = append(parts, "") + copy(parts[insertAt+1:], parts[insertAt:]) + parts[insertAt] = claudeExtendedCacheTTLBeta + seen[claudeExtendedCacheTTLBeta] = true + } + if !seen[claudeCacheDiagnosisBeta] { + parts = append(parts, claudeCacheDiagnosisBeta) } return strings.Join(parts, ",") } @@ -494,12 +549,16 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, countTokens := r.URL != nil && strings.HasSuffix(r.URL.Path, "/count_tokens") baseBetas := claudeCodeCLIBetas(body, claudeRequestedBetas(incomingBetas, extraBetas), oauthToken) if countTokens { - baseBetas = strings.Join(claudeCountTokensBetas, ",") + baseBetas = claudeCountTokensBetasForCredential(oauthToken) } if confirmedClaudeCode && incomingBetas != "" { baseBetas = incomingBetas - if oauthToken && !countTokens { - baseBetas = withClaudeOAuthCredentialBetas(baseBetas) + if oauthToken { + if countTokens { + baseBetas = withClaudeCountTokensOAuthBeta(baseBetas) + } else { + baseBetas = withClaudeOAuthCredentialBetas(baseBetas) + } } } existingSet := make(map[string]bool) @@ -527,12 +586,6 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string, appendBeta(beta) } } - // The OAuth betas have known positions on /v1/messages and are placed by - // claudeCodeCLIBetas. count_tokens was only captured over an API key, so its - // OAuth shape keeps the previous appended form until it can be measured. - if oauthToken && countTokens { - appendBeta(claudeOAuthBeta) - } // Betas lifted out of the body follow the same policy as header-supplied ones. // Known betas already reached the assembled baseline through the requested map, // which places them at their captured positions; anything left over is unknown @@ -709,10 +762,8 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { apiKey = a.Attributes["api_key"] baseURL = a.Attributes["base_url"] } - if apiKey == "" && a.Metadata != nil { - if v, ok := a.Metadata["access_token"].(string); ok { - apiKey = v - } + if apiKey == "" { + apiKey = claudeauth.ReadMetadataString(&a.Metadata, "access_token") } return } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 67e91b65..0614f762 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -78,8 +78,12 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } // Only the Messages endpoint on Anthropic itself was captured; count_tokens // keeps its own shape and other gateways never see this field. + diagnosticsState := claudeDiagnosticsRequestState{} if cloaked && isAnthropicUpstreamBase(baseURL) { body = injectClaudeCodeContextManagement(body) + if oauthToken { + body, diagnosticsState = injectClaudeDiagnostics(body, apiKey, claudeSessionID) + } } requestedModel := helps.PayloadRequestedModel(opts, req.Model) @@ -119,8 +123,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return nil, fmt.Errorf("apply Claude credential metadata: %w", err) } } + fallbackBilling := "" if cchSigning { - fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) + fallbackBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint) bodyForUpstream, err = finalizeAnthropicMessagesBodyCCH(bodyForUpstream, fallbackBilling) if err != nil { return nil, fmt.Errorf("finalize Claude CCH: %w", err) @@ -134,12 +139,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if errHeaders := applyClaudeHeaders(httpReq, auth, apiKey, true, extraBetas, bodyForUpstream, e.cfg, incomingHeaders, confirmedClaudeCode && !cloaked, claudeSessionID); errHeaders != nil { return nil, errHeaders } - var authID, authLabel, authType, authValue string - if auth != nil { - authID = auth.ID - authLabel = auth.Label - authType, authValue = auth.AccountInfo() - } + authID, authLabel, authType, authValue := claudeAuthLogIdentity(auth) helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, @@ -160,6 +160,22 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return nil, err } helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + httpResp, bodyForUpstream, _, err = e.retryClaudeFastModeRefusal(httpReq, httpClient, httpResp, claudeFastFallbackOptions{ + auth: auth, + apiKey: apiKey, + stream: true, + extraBetas: extraBetas, + body: bodyForUpstream, + fallbackBilling: fallbackBilling, + cchSigning: cchSigning, + incomingHeaders: incomingHeaders, + confirmedNative: confirmedClaudeCode && !cloaked, + sessionID: claudeSessionID, + allowEntitlementFallback: oauthToken && cloaked && isAnthropicUpstreamBase(baseURL), + }) + if err != nil { + return nil, err + } if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { // Decompress error responses — pass the Content-Encoding value (may be empty) // and let decodeResponseBody handle both header-declared and magic-byte-detected @@ -208,6 +224,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner := bufio.NewScanner(decodedBody) scanner.Buffer(nil, 52_428_800) // 50MB var event bytes.Buffer + var upstreamMessageID string + upstreamCompleted := false flushEvent := func() bool { if event.Len() == 0 { return true @@ -223,6 +241,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A } for scanner.Scan() { line := scanner.Bytes() + observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted) helps.AppendAPIResponseChunk(ctx, e.cfg, line) if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) @@ -245,6 +264,10 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): } + return + } + if upstreamCompleted { + commitClaudeDiagnostics(diagnosticsState, upstreamMessageID) } return } @@ -253,8 +276,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner := bufio.NewScanner(decodedBody) scanner.Buffer(nil, 52_428_800) // 50MB var param any + var upstreamMessageID string + upstreamCompleted := false for scanner.Scan() { line := scanner.Bytes() + observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted) helps.AppendAPIResponseChunk(ctx, e.cfg, line) if detail, ok := helps.ParseClaudeStreamUsage(line); ok { reporter.Publish(ctx, detail) @@ -286,6 +312,10 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): } + return + } + if upstreamCompleted { + commitClaudeDiagnostics(diagnosticsState, upstreamMessageID) } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 138cbfe6..a79eb748 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1855,6 +1855,29 @@ func TestClaudeExecutor_CountTokensExcludesInvalidOpenAIThinking(t *testing.T) { } } +func TestClaudeCountTokensBetasForCredentialMatchesNativeOAuth220(t *testing.T) { + want := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,token-counting-2024-11-01" + if got := claudeCountTokensBetasForCredential(true); got != want { + t.Fatalf("OAuth count_tokens betas = %q, want %q", got, want) + } + wantAPIKey := "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,token-counting-2024-11-01" + if got := claudeCountTokensBetasForCredential(false); got != wantAPIKey { + t.Fatalf("API-key count_tokens betas = %q, want %q", got, wantAPIKey) + } + if got := withClaudeCountTokensOAuthBeta(wantAPIKey); got != want { + t.Fatalf("confirmed-client count_tokens betas = %q, want %q", got, want) + } +} + +func TestShouldFinalizeClaudeCountTokensCCHSkipsDirectAnthropic(t *testing.T) { + if shouldFinalizeClaudeCountTokensCCH(true, true) { + t.Fatal("direct Anthropic count_tokens must not receive CPA CCH") + } + if !shouldFinalizeClaudeCountTokensCCH(true, false) { + t.Fatal("custom-gateway count_tokens should retain existing CCH behavior") + } +} + func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) { var upstreamAlias string var upstreamBody []byte @@ -1909,7 +1932,7 @@ func TestClaudeExecutor_CountTokensOAuthUsesUpstreamCLIShape(t *testing.T) { t.Fatalf("count_tokens User-Agent = %q, want CLI identity", got) } // count_tokens carries its own much smaller profile, not the inference baseline. - wantBetas := strings.Join(claudeCountTokensBetas, ",") + "," + claudeOAuthBeta + wantBetas := claudeCountTokensBetasForCredential(true) if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas { t.Fatalf("count_tokens Anthropic-Beta = %q, want %q", got, wantBetas) } @@ -2120,6 +2143,86 @@ func TestClaudeExecutor_CountTokensUpstreamConfirmedVSCodePreservesCustomTool(t } } +func TestClaudeExecutor_CountTokensCloakMatchesMeasuredDirectAnthropicShape(t *testing.T) { + var upstreamBody []byte + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var errRead error + upstreamBody, errRead = io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"input_tokens":34}`)), Request: req}, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-cloaked-count-shape"}} + payload := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":[{"type":"text","text":"x"}]}],"tools":[{"name":"search_web","input_schema":{"type":"object"}}],"metadata":{"user_id":"remove"},"context_management":{"edits":[]},"diagnostics":{"previous_message_id":"remove"}}`) + _, errCount := NewClaudeExecutor(&config.Config{}).countTokensUpstream(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}) + if errCount != nil { + t.Fatalf("countTokensUpstream() error = %v", errCount) + } + if got := gjson.GetBytes(upstreamBody, "system"); got.Exists() { + t.Fatalf("cloaked direct count system = %s, want absent", got.Raw) + } + for _, field := range []string{"metadata", "context_management", "diagnostics", "betas"} { + if got := gjson.GetBytes(upstreamBody, field); got.Exists() { + t.Fatalf("cloaked direct count %s = %s, want absent", field, got.Raw) + } + } + if got := gjson.GetBytes(upstreamBody, "tools.0.name").String(); !helps.IsClaudeMCPToolName(got) { + t.Fatalf("cloaked direct count tool = %q, want OAuth MCP alias", got) + } +} + +func TestClaudeExecutor_CountTokensConfirmedNativePreservesMeasuredOAuthBody(t *testing.T) { + var upstreamBody []byte + var upstreamHeaders http.Header + transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + var errRead error + upstreamBody, errRead = io.ReadAll(req.Body) + if errRead != nil { + t.Fatal(errRead) + } + upstreamHeaders = req.Header.Clone() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"input_tokens":34}`)), + Request: req, + }, nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport)) + executor := NewClaudeExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "sk-ant-oat-native-count-shape"}} + payload := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":[{"type":"text","text":"x"}]}],"tools":[]}`) + incomingBetas := "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,token-counting-2024-11-01" + wantBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,token-counting-2024-11-01" + _, errCount := executor.countTokensUpstream(ctx, auth, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: payload}, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + Headers: http.Header{ + "User-Agent": {"claude-cli/2.1.220 (external, cli)"}, + "X-App": {"cli"}, + "Anthropic-Beta": {incomingBetas}, + }, + }) + if errCount != nil { + t.Fatalf("countTokensUpstream() error = %v", errCount) + } + if !bytes.Equal(upstreamBody, payload) { + t.Fatalf("confirmed native count body changed\n got: %s\nwant: %s", upstreamBody, payload) + } + for _, field := range []string{"system", "metadata", "context_management", "betas"} { + if got := gjson.GetBytes(upstreamBody, field); got.Exists() { + t.Fatalf("confirmed native count body %s = %s, want absent", field, got.Raw) + } + } + if got := strings.Join(upstreamHeaders["anthropic-beta"], ","); got != wantBetas { + t.Fatalf("confirmed native count beta = %q, want %q", got, wantBetas) + } + if got := upstreamHeaders.Get("X-Stainless-Timeout"); got != "" { + t.Fatalf("confirmed native count timeout = %q, want absent", got) + } +} + func TestClaudeExecutor_CountTokensCountsLocallyWithoutUpstreamRequest(t *testing.T) { payload := []byte(`{ "system":"client system instructions", @@ -3376,6 +3479,26 @@ func TestClaudeCodeLocalDateMatchesNativeLocalCalendarAlgorithm(t *testing.T) { } } +func TestClaudeCodeTimezoneUsesCredentialThenConfiguredProfile(t *testing.T) { + instant := time.Date(2026, time.August, 2, 1, 30, 0, 0, time.UTC) + cfg := &config.Config{ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{Timezone: "Asia/Tokyo"}} + auth := &cliproxyauth.Auth{Metadata: map[string]any{"timezone": "Pacific/Honolulu"}} + if got := claudeCodeLocalDate(instant.In(claudeCodeTimezone(cfg, auth))); got != "2026-08-01" { + t.Fatalf("credential currentDate = %q, want 2026-08-01", got) + } + if got := claudeCodeLocalDate(instant.In(claudeCodeTimezone(cfg, nil))); got != "2026-08-02" { + t.Fatalf("configured currentDate = %q, want 2026-08-02", got) + } + invalidAuth := &cliproxyauth.Auth{Metadata: map[string]any{"timezone": "not/a-timezone"}} + if got := claudeCodeTimezone(cfg, invalidAuth).String(); got != "Asia/Tokyo" { + t.Fatalf("invalid credential timezone = %q, want config fallback", got) + } + invalid := &config.Config{ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{Timezone: "not/a-timezone"}} + if got := claudeCodeTimezone(invalid, nil); got != time.Local { + t.Fatalf("invalid timezone location = %v, want time.Local", got) + } +} + func TestInjectClaudeCodeCurrentDateIsIdempotentAndAlignsFirstUserCache(t *testing.T) { fixed := time.Date(2026, time.August, 1, 9, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) payload := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]}`) @@ -4741,14 +4864,15 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { want: constants + ",effort-2025-11-24", }, { - name: "oauth sits second and extended-cache-ttl last", + name: "oauth uses the current advisor fallback and cache diagnosis profile", body: `{"model":"claude-opus-4-6","tools":[{"name":"Read"}]}`, oauth: true, want: "claude-code-20250219,oauth-2025-04-20," + "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + "thinking-token-count-2026-05-13,context-management-2025-06-27," + - "prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20," + - "effort-2025-11-24,extended-cache-ttl-2025-04-11", + "prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01," + + "effort-2025-11-24,fallback-credit-2026-06-01," + + "extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", }, { name: "oauth precedes context-1m", @@ -4763,9 +4887,9 @@ func TestClaudeCodeCLIBetas_MatchesObservedClientMatrix(t *testing.T) { "interleaved-thinking-2025-05-14,redact-thinking-2026-02-12," + "thinking-token-count-2026-05-13,context-management-2025-06-27," + "prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07," + - "advanced-tool-use-2025-11-20,effort-2025-11-24," + + "advisor-tool-2026-03-01,effort-2025-11-24," + "server-side-fallback-2026-06-01,fallback-credit-2026-06-01," + - "extended-cache-ttl-2025-04-11", + "extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", }, { name: "api key path sends neither oauth beta", diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go index d89f3ef3..4c786388 100644 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -113,6 +113,10 @@ func validateClaudeTokenCountRequest(body []byte) error { // countTokensUpstream preserves native token counting for Claude-compatible // providers that expose their own count_tokens endpoint. +func shouldFinalizeClaudeCountTokensCCH(cchSigning, directAnthropic bool) bool { + return cchSigning && !directAnthropic +} + func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName upstreamModel := e.upstreamModel(baseModel) @@ -151,19 +155,25 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy body = rebuildMidSystemMessagesToTopLevel(body) } + directAnthropic := isAnthropicUpstreamBase(baseURL) var cloaked bool - var errCloaking error - body, cloaked, errCloaking = applyCloaking( - ctx, - e.cfg, - auth, - body, - apiKey, - confirmedClaudeCode, - cchSigning, - ) - if errCloaking != nil { - return cliproxyexecutor.Response{}, errCloaking + if directAnthropic { + policy, _ := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode) + cloaked = policy.Cloak + } else { + var errCloaking error + body, cloaked, errCloaking = applyCloaking( + ctx, + e.cfg, + auth, + body, + apiKey, + confirmedClaudeCode, + cchSigning, + ) + if errCloaking != nil { + return cliproxyexecutor.Response{}, errCloaking + } } // Keep count_tokens requests compatible with Anthropic cache-control constraints too. @@ -183,10 +193,12 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy // Claude Code never sends metadata on count_tokens, and Anthropic rejects the // field outright there ("metadata: Extra inputs are not permitted"). The // Messages path still carries the credential identity; this endpoint must not. - if isAnthropicUpstreamBase(baseURL) { + if directAnthropic { body, _ = sjson.DeleteBytes(body, "metadata") + body, _ = sjson.DeleteBytes(body, "context_management") + body, _ = sjson.DeleteBytes(body, "diagnostics") } - if cchSigning { + if shouldFinalizeClaudeCountTokensCCH(cchSigning, directAnthropic) { fallbackBilling := claudeCCHFallbackBillingHeader(ctx, e.cfg, body, claudeCodeDetection.Entrypoint) var errCCH error body, errCCH = finalizeAnthropicMessagesBodyCCH(body, fallbackBilling) diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go new file mode 100644 index 00000000..47520f17 --- /dev/null +++ b/internal/runtime/executor/helps/claude_diagnostics.go @@ -0,0 +1,91 @@ +package helps + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "sync" + "time" +) + +const ( + claudeDiagnosticsTTL = time.Hour + claudeDiagnosticsCleanupPeriod = 15 * time.Minute +) + +type claudeDiagnosticsEntry struct { + previousMessageID string + nextSequence uint64 + committedSequence uint64 + expiresAt time.Time +} + +var claudeDiagnosticsState = struct { + sync.Mutex + entries map[string]claudeDiagnosticsEntry + lastCleanup time.Time +}{entries: make(map[string]claudeDiagnosticsEntry)} + +// BeginClaudeDiagnostics starts one request generation for a credential and +// Claude conversation. It returns the last successfully completed upstream +// message ID, if any. Only a SHA-256 digest of the credential and session is +// retained as the cache key. +func BeginClaudeDiagnostics(apiKey, sessionID string) (key string, sequence uint64, previousMessageID string) { + apiKey = strings.TrimSpace(apiKey) + sessionID = strings.TrimSpace(sessionID) + if apiKey == "" || sessionID == "" { + return "", 0, "" + } + digest := sha256.Sum256([]byte(apiKey + "\x00" + sessionID)) + key = hex.EncodeToString(digest[:]) + now := time.Now() + + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + if claudeDiagnosticsState.lastCleanup.IsZero() || now.Sub(claudeDiagnosticsState.lastCleanup) >= claudeDiagnosticsCleanupPeriod { + for candidateKey, candidate := range claudeDiagnosticsState.entries { + if !candidate.expiresAt.IsZero() && now.After(candidate.expiresAt) { + delete(claudeDiagnosticsState.entries, candidateKey) + } + } + claudeDiagnosticsState.lastCleanup = now + } + entry := claudeDiagnosticsState.entries[key] + if !entry.expiresAt.IsZero() && now.After(entry.expiresAt) { + entry = claudeDiagnosticsEntry{} + } + entry.nextSequence++ + entry.expiresAt = now.Add(claudeDiagnosticsTTL) + claudeDiagnosticsState.entries[key] = entry + return key, entry.nextSequence, entry.previousMessageID +} + +// CommitClaudeDiagnostics advances continuity only after a response completes. +// A response from an older concurrently-started request cannot overwrite a +// newer committed generation. +func CommitClaudeDiagnostics(key string, sequence uint64, messageID string) { + key = strings.TrimSpace(key) + messageID = strings.TrimSpace(messageID) + if key == "" || sequence == 0 || messageID == "" { + return + } + now := time.Now() + + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + entry, ok := claudeDiagnosticsState.entries[key] + if !ok || sequence < entry.committedSequence { + return + } + entry.previousMessageID = messageID + entry.committedSequence = sequence + entry.expiresAt = now.Add(claudeDiagnosticsTTL) + claudeDiagnosticsState.entries[key] = entry +} + +func resetClaudeDiagnosticsForTest() { + claudeDiagnosticsState.Lock() + defer claudeDiagnosticsState.Unlock() + claudeDiagnosticsState.entries = make(map[string]claudeDiagnosticsEntry) + claudeDiagnosticsState.lastCleanup = time.Time{} +} diff --git a/internal/runtime/executor/helps/claude_diagnostics_test.go b/internal/runtime/executor/helps/claude_diagnostics_test.go new file mode 100644 index 00000000..454bf900 --- /dev/null +++ b/internal/runtime/executor/helps/claude_diagnostics_test.go @@ -0,0 +1,38 @@ +package helps + +import "testing" + +func TestClaudeDiagnosticsTracksCompletedMessagePerCredentialSession(t *testing.T) { + resetClaudeDiagnosticsForTest() + defer resetClaudeDiagnosticsForTest() + + key, sequence, previous := BeginClaudeDiagnostics("credential-a", "session-a") + if key == "" || sequence != 1 || previous != "" { + t.Fatalf("first begin = %q/%d/%q, want key/1/empty", key, sequence, previous) + } + CommitClaudeDiagnostics(key, sequence, "msg_first") + _, secondSequence, previous := BeginClaudeDiagnostics("credential-a", "session-a") + if secondSequence != 2 || previous != "msg_first" { + t.Fatalf("second begin = %d/%q, want 2/msg_first", secondSequence, previous) + } + + _, _, otherSession := BeginClaudeDiagnostics("credential-a", "session-b") + _, _, otherCredential := BeginClaudeDiagnostics("credential-b", "session-a") + if otherSession != "" || otherCredential != "" { + t.Fatalf("diagnostics leaked across identity: session=%q credential=%q", otherSession, otherCredential) + } +} + +func TestClaudeDiagnosticsRejectsLateOlderCommit(t *testing.T) { + resetClaudeDiagnosticsForTest() + defer resetClaudeDiagnosticsForTest() + + key, first, _ := BeginClaudeDiagnostics("credential", "session") + _, second, _ := BeginClaudeDiagnostics("credential", "session") + CommitClaudeDiagnostics(key, second, "msg_newer") + CommitClaudeDiagnostics(key, first, "msg_older") + _, _, previous := BeginClaudeDiagnostics("credential", "session") + if previous != "msg_newer" { + t.Fatalf("previous message = %q, want newer completed generation", previous) + } +} diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go index 2d079c5f..529f0bed 100644 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -11,6 +11,7 @@ import ( tls "github.com/refraction-networking/utls" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/httpwire" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" "github.com/router-for-me/CLIProxyAPI/v7/sdk/proxyutil" log "github.com/sirupsen/logrus" @@ -185,6 +186,79 @@ func claudeCodeTLSClientHelloSpec() *tls.ClientHelloSpec { } } +var claudeCodeRoundTripperCache sync.Map + +var claudeCodeMessagesHeaderOrder = []string{ + "Accept", + "Authorization", + "Content-Type", + "User-Agent", + "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", + "Connection", + "Host", + "Accept-Encoding", + "Content-Length", +} + +var claudeCodeCountTokensHeaderOrder = []string{ + "Accept", + "Authorization", + "Content-Type", + "User-Agent", + "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", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "anthropic-version", + "x-app", + "x-client-request-id", + "Connection", + "Host", + "Accept-Encoding", + "Content-Length", +} + +func claudeCodeRequestHeaderOrder(_, requestTarget string) []string { + if strings.HasPrefix(requestTarget, "/v1/messages/count_tokens") { + return claudeCodeCountTokensHeaderOrder + } + return claudeCodeMessagesHeaderOrder +} + +func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { + if cached, ok := claudeCodeRoundTripperCache.Load(proxyURL); ok { + return cached.(http.RoundTripper) + } + created := newClaudeCodeRoundTripper(proxyURL) + actual, loaded := claudeCodeRoundTripperCache.LoadOrStore(proxyURL, created) + if loaded { + if transport, ok := created.(*http.Transport); ok { + transport.CloseIdleConnections() + } + return actual.(http.RoundTripper) + } + return created +} + func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { var dialer proxy.Dialer = proxy.Direct if proxyURL != "" { @@ -232,7 +306,7 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper { } return nil, fmt.Errorf("claude tls: handshake upstream: %w", errHandshake) } - return tlsConn, nil + return httpwire.NewOrderedRequestConn(tlsConn, claudeCodeRequestHeaderOrder), nil }, } return transport @@ -277,7 +351,7 @@ func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyau } var chromeRT http.RoundTripper = newUtlsRoundTripper(proxyURL) - var anthropicRT http.RoundTripper = newClaudeCodeRoundTripper(proxyURL) + var anthropicRT http.RoundTripper = cachedClaudeCodeRoundTripper(proxyURL) var standardTransport http.RoundTripper = http.DefaultTransport if proxyURL != "" { if transport := buildProxyTransport(proxyURL); transport != nil { diff --git a/internal/runtime/executor/helps/utls_client_test.go b/internal/runtime/executor/helps/utls_client_test.go index 78b049d4..a1b8270c 100644 --- a/internal/runtime/executor/helps/utls_client_test.go +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -119,6 +119,33 @@ func TestClaudeCodeTLSClientHelloSpecMatches220Capture(t *testing.T) { } } +func TestClaudeCodeRequestHeaderOrderMatchesNative220Capture(t *testing.T) { + t.Parallel() + + if got, want := claudeCodeRequestHeaderOrder(http.MethodPost, "/v1/messages?beta=true"), claudeCodeMessagesHeaderOrder; !reflect.DeepEqual(got, want) { + t.Fatalf("Messages header order = %v, want %v", got, want) + } + if got, want := claudeCodeRequestHeaderOrder(http.MethodPost, "/v1/messages/count_tokens?beta=true"), claudeCodeCountTokensHeaderOrder; !reflect.DeepEqual(got, want) { + t.Fatalf("count_tokens header order = %v, want %v", got, want) + } + for _, name := range claudeCodeCountTokensHeaderOrder { + if name == "X-Stainless-Timeout" { + t.Fatal("count_tokens header order unexpectedly contains X-Stainless-Timeout") + } + } +} + +func TestCachedClaudeCodeRoundTripperReusesTransport(t *testing.T) { + t.Parallel() + + const proxyURL = "http://127.0.0.1:29653" + first := cachedClaudeCodeRoundTripper(proxyURL) + second := cachedClaudeCodeRoundTripper(proxyURL) + if first != second { + t.Fatal("Claude Code transport cache returned different transports for one proxy") + } +} + func TestClaudeCodeTLSClientHelloCapture(t *testing.T) { proxyURL := os.Getenv("CPA_TLS_FP_PROXY") if proxyURL == "" { -- 2.51.2