diff --git a/config.example.yaml b/config.example.yaml --- a/config.example.yaml +++ b/config.example.yaml @@ -447,6 +447,7 @@ # 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/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go --- a/internal/config/claude_header_defaults_test.go +++ b/internal/config/claude_header_defaults_test.go @@ -17,6 +17,7 @@ os: " MacOS " arch: " arm64 " timeout: " 900 " + timezone: " Pacific/Honolulu " stabilize-device-profile: false `) if err := os.WriteFile(configPath, configYAML, 0o600); err != nil { @@ -45,6 +46,9 @@ } 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 --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -54,6 +54,7 @@ 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 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -107,6 +107,7 @@ 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 --- /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 --- /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/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -8,7 +8,6 @@ "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strings" @@ -22,11 +21,13 @@ // 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 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 @@ 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 @@ 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 @@ "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 @@ } }() - 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 @@ "client_id": ClientID, "grant_type": "refresh_token", "refresh_token": refreshToken, + "scope": ClaudeOAuthScope, } jsonBody, err := json.Marshal(reqBody) @@ -445,13 +459,11 @@ 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 @@ _ = 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 @@ 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 @@ 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 --- a/internal/auth/claude/anthropic_auth_test.go +++ b/internal/auth/claude/anthropic_auth_test.go @@ -152,7 +152,8 @@ 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 @@ 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 @@ <-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,8 +230,83 @@ 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) } } @@ -227,6 +319,22 @@ } 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, @@ -255,6 +363,7 @@ 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 @@ (&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 --- /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 --- /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 --- 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. -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 +var claudeOAuthRefreshHeaderOrder = []string{ + "Accept", + "Content-Type", + "User-Agent", + "Content-Length", + "Accept-Encoding", + "Host", + "Connection", } -// newUtlsRoundTripper creates a new utls-based round tripper with optional proxy support +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 { + dialer proxy.Dialer + transport *http.Transport +} + func newUtlsRoundTripper(cfg *config.SDKConfig) *utlsRoundTripper { var dialer proxy.Dialer = proxy.Direct if cfg != nil { @@ -44,137 +118,65 @@ } } - 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 + 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) + } + 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) + } + handshakeCtx := ctx + if handshakeTimeout, _ := ctx.Value(claudeRefreshHandshakeTimeoutContextKey{}).(time.Duration); handshakeTimeout > 0 { + var cancelHandshake context.CancelFunc + handshakeCtx, cancelHandshake = context.WithTimeout(ctx, handshakeTimeout) + defer cancelHandshake() + } + 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) + } + return httpwire.NewOrderedRequestConn(tlsConn, claudeOAuthRequestHeaderOrder), 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 - } - - 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) - } - } - - tlsConfig := &tls.Config{ServerName: host} - tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto) - - if errHandshake := tlsConn.Handshake(); errHandshake != nil { - _ = conn.Close() - return nil, errHandshake - } - 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) - } - } - - tr := &http2.Transport{} - h2Conn, errClientConn := tr.NewClientConn(tlsConn) - if errClientConn != nil { - _ = tlsConn.Close() - return nil, errClientConn - } - - return h2Conn, 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 resp, nil + return t.transport.RoundTrip(req) } -// 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 (t *utlsRoundTripper) CloseIdleConnections() { + t.transport.CloseIdleConnections() +} + 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 --- 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 @@ }() 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") } @@ -36,4 +47,148 @@ if elapsed := time.Since(startedAt); elapsed > time.Second { 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/runtime/executor/claude_executor_auth.go b/internal/runtime/executor/claude_executor_auth.go --- a/internal/runtime/executor/claude_executor_auth.go +++ b/internal/runtime/executor/claude_executor_auth.go @@ -115,9 +115,9 @@ 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 --- a/internal/runtime/executor/claude_executor_auth_race_test.go +++ b/internal/runtime/executor/claude_executor_auth_race_test.go @@ -66,6 +66,36 @@ // 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 --- a/internal/runtime/executor/claude_executor_beta_policy_test.go +++ b/internal/runtime/executor/claude_executor_beta_policy_test.go @@ -30,7 +30,7 @@ // 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 @@ 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 @@ } } -// 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 @@ } 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 --- a/internal/runtime/executor/claude_executor_cloaking.go +++ b/internal/runtime/executor/claude_executor_cloaking.go @@ -11,6 +11,7 @@ "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 @@ 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 @@ // 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 @@ 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 @@ // 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 @@ -429,6 +432,42 @@ func claudeCodeLocalDate(now time.Time) string { year, month, day := now.Date() 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 { @@ -639,7 +678,7 @@ 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 --- /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 --- /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 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -78,8 +78,12 @@ } // 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ } 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 --- /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 --- /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 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -16,6 +16,7 @@ "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 @@ 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 @@ // 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 @@ 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 @@ betas = append(betas, claudeFastModeBeta) } if oauthToken { - betas = append(betas, claudeExtendedCacheTTLBeta) + betas = append(betas, claudeExtendedCacheTTLBeta, claudeCacheDiagnosisBeta) } return strings.Join(betas, ",") } @@ -149,8 +159,40 @@ 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 @@ 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 @@ 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) @@ -526,12 +585,6 @@ for _, beta := range strings.Split(incomingBetas, ",") { 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, @@ -709,10 +762,8 @@ 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 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -78,8 +78,12 @@ } // 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ } 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 @@ case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): } + return + } + if upstreamCompleted { + commitClaudeDiagnostics(diagnosticsState, upstreamMessageID) } return } @@ -253,8 +276,11 @@ 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 @@ 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 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -1855,6 +1855,29 @@ } } +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 @@ 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) } @@ -2117,6 +2140,86 @@ } if upstreamName != "search_web" { t.Fatalf("confirmed VSCode count_tokens tool name = %q, want unchanged", upstreamName) + } +} + +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) } } @@ -3373,6 +3476,26 @@ wantReminder := "\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is 2026-08-01.\n\n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n\n\n" if got := claudeCodeCurrentDateReminder(instant.In(kiritimati)); got != wantReminder { t.Fatalf("currentDate reminder = %q, want exact native text %q", got, wantReminder) + } +} + +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) } } @@ -4741,14 +4864,15 @@ 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 @@ "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 --- a/internal/runtime/executor/claude_executor_tokens.go +++ b/internal/runtime/executor/claude_executor_tokens.go @@ -113,6 +113,10 @@ // 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 @@ 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 @@ // 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 --- /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 --- /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 --- a/internal/runtime/executor/helps/utls_client.go +++ b/internal/runtime/executor/helps/utls_client.go @@ -11,6 +11,7 @@ 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 @@ } } +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 @@ } 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 @@ } 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 --- a/internal/runtime/executor/helps/utls_client_test.go +++ b/internal/runtime/executor/helps/utls_client_test.go @@ -119,6 +119,33 @@ } } +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 == "" {