From 470363a0d9843fc6a7b7065a4185853e2d3a4da2 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 3 Oct 2026 22:43:45 -0400 Subject: [PATCH 1/3] feat(auth): send the real CLI identity on usage reads (MASTRA-787) The quota usage reads now go through each provider's executor with the identity the proxy already sends for that provider's real CLI traffic. Claude reads use the OAuth control-plane uTLS transport, the native header set and order, and the baseline Claude Code User-Agent, and decode compressed responses. Codex reads share the inference path's credential, User-Agent, Originator, account, custom-header and cloaking logic. Co-Authored-By: Claude Opus 5.5 --- internal/auth/claude/oauth_usage.go | 65 ++++++ internal/auth/claude/oauth_usage_test.go | 183 +++++++++++++++++ internal/auth/claude/utls_transport.go | 17 ++ internal/runtime/executor/claude_executor.go | 3 + .../runtime/executor/claude_executor_usage.go | 36 ++++ .../executor/codex_executor_request.go | 31 ++- .../runtime/executor/helps/quota_usage.go | 12 ++ .../executor/usage_read_identity_test.go | 189 ++++++++++++++++++ sdk/cliproxy/service_quota.go | 16 +- 9 files changed, 531 insertions(+), 21 deletions(-) create mode 100644 internal/auth/claude/oauth_usage.go create mode 100644 internal/auth/claude/oauth_usage_test.go create mode 100644 internal/runtime/executor/claude_executor_usage.go create mode 100644 internal/runtime/executor/usage_read_identity_test.go diff --git a/internal/auth/claude/oauth_usage.go b/internal/auth/claude/oauth_usage.go new file mode 100644 index 000000000..aa4528a73 --- /dev/null +++ b/internal/auth/claude/oauth_usage.go @@ -0,0 +1,65 @@ +package claude + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + + log "github.com/sirupsen/logrus" +) + +const ( + // UsageURL is the subscription usage endpoint Claude Code reads through its + // Axios API client. + UsageURL = "https://api.anthropic.com" + claudeOAuthUsagePath + claudeOAuthUsagePath = "/api/oauth/usage" +) + +// NewClaudeAuthWithRoundTripper creates a ClaudeAuth whose requests go through rt +// instead of the control-plane uTLS transport. +func NewClaudeAuthWithRoundTripper(rt http.RoundTripper) *ClaudeAuth { + return &ClaudeAuth{httpClient: &http.Client{Transport: rt}} +} + +// FetchOAuthUsage reads the account's subscription usage with the request shape +// Claude Code's API client sends: the Axios control-plane headers with the CLI +// User-Agent and the OAuth beta. The returned response carries the decoded body, +// because the request advertises compression; the caller closes it. +func (o *ClaudeAuth) FetchOAuthUsage(ctx context.Context, accessToken, userAgent string) (*http.Response, error) { + if o == nil || o.httpClient == nil { + return nil, fmt.Errorf("fetch Claude OAuth usage: HTTP client is nil") + } + accessToken = strings.TrimSpace(accessToken) + if accessToken == "" { + return nil, fmt.Errorf("fetch Claude OAuth usage: access token is empty") + } + req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, UsageURL, nil) + if errRequest != nil { + return nil, fmt.Errorf("create Claude OAuth usage request: %w", errRequest) + } + applyClaudeOAuthAxiosHeaders(req) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + resp, errDo := o.httpClient.Do(req) + if errDo != nil { + return nil, fmt.Errorf("fetch Claude OAuth usage: %w", errDo) + } + body, errRead := readClaudeOAuthResponseBody(resp) + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("failed to close Claude OAuth usage response body: %v", errClose) + } + if errRead != nil { + return nil, fmt.Errorf("read Claude OAuth usage response: %w", errRead) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") + resp.ContentLength = int64(len(body)) + resp.Uncompressed = true + return resp, nil +} diff --git a/internal/auth/claude/oauth_usage_test.go b/internal/auth/claude/oauth_usage_test.go new file mode 100644 index 000000000..d6e6fd1f4 --- /dev/null +++ b/internal/auth/claude/oauth_usage_test.go @@ -0,0 +1,183 @@ +package claude + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/httpwire" +) + +// TestFetchOAuthUsageSerializesNativeHeaderOrder checks the bytes the usage read +// writes: header names, casing and order as Claude Code's API client sends them. +// The connection is the ordered serializer the control-plane dial installs, over a +// pipe without TLS so the request bytes are readable. +func TestFetchOAuthUsageSerializesNativeHeaderOrder(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer func() { _ = serverConn.Close() }() + auth := &ClaudeAuth{httpClient: &http.Client{Transport: &http.Transport{ + DialTLSContext: func(context.Context, string, string) (net.Conn, error) { + return httpwire.NewOrderedRequestConn(clientConn, claudeOAuthRequestHeaderOrder), nil + }, + }}} + + lines := make(chan []string, 1) + go func() { + reader := bufio.NewReader(serverConn) + var got []string + for { + line, errRead := reader.ReadString('\n') + if errRead != nil { + break + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + got = append(got, line) + } + lines <- got + body := `{"five_hour":{"utilization":14.0,"resets_at":"2026-10-04T02:40:00+00:00"}}` + _, _ = fmt.Fprintf(serverConn, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", len(body), body) + _ = serverConn.Close() + }() + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + resp, errFetch := auth.FetchOAuthUsage(ctx, "tok-usage", "claude-cli/2.1.288 (external, cli)") + if errFetch != nil { + t.Fatalf("FetchOAuthUsage() error = %v", errFetch) + } + if errClose := resp.Body.Close(); errClose != nil { + t.Fatal(errClose) + } + want := []string{ + "GET /api/oauth/usage HTTP/1.1", + "Accept: application/json, text/plain, */*", + "Content-Type: application/json", + "User-Agent: claude-cli/2.1.288 (external, cli)", + "Authorization: Bearer tok-usage", + "anthropic-beta: oauth-2025-04-20", + "Accept-Encoding: gzip, compress, deflate, br", + "Host: api.anthropic.com", + "Connection: close", + } + if got := <-lines; !reflect.DeepEqual(got, want) { + t.Fatalf("usage request header lines =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +// TestFetchOAuthUsageClientHelloMatchesControlPlane dials the usage read and the +// profile lookup through the production control-plane transport and compares their +// ClientHello records with the per-connection random values masked. +func TestFetchOAuthUsageClientHelloMatchesControlPlane(t *testing.T) { + usage := captureControlPlaneClientHello(t, func(ctx context.Context, auth *ClaudeAuth) error { + _, errFetch := auth.FetchOAuthUsage(ctx, "tok", "claude-cli/2.1.288 (external, cli)") + return errFetch + }) + profile := captureControlPlaneClientHello(t, func(ctx context.Context, auth *ClaudeAuth) error { + _, errFetch := auth.FetchOAuthProfile(ctx, "tok") + return errFetch + }) + if !bytes.Equal(maskClientHelloRandomness(t, usage), maskClientHelloRandomness(t, profile)) { + t.Fatalf("usage ClientHello differs from the control-plane ClientHello\nusage: %x\nprofile: %x", usage, profile) + } +} + +func captureControlPlaneClientHello(t *testing.T, fetch func(context.Context, *ClaudeAuth) error) []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) + } + }) + transport := newUtlsRoundTripper(nil) + transport.dialer = claudeTestDialer{conn: clientConn} + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + done := make(chan error, 1) + go func() { done <- fetch(ctx, &ClaudeAuth{httpClient: &http.Client{Transport: transport}}) }() + + 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 errFetch := <-done: + if errFetch == nil { + t.Fatal("fetch succeeded although the server closed during the handshake") + } + case <-time.After(5 * time.Second): + t.Fatal("fetch did not exit after the server closed") + } + return append(header, payload...) +} + +// maskClientHelloRandomness zeroes the client random, the legacy session ID and the +// key share, which differ on every connection, and keeps every other byte. +func maskClientHelloRandomness(t *testing.T, record []byte) []byte { + t.Helper() + masked := append([]byte(nil), record...) + fail := func() { t.Fatalf("malformed ClientHello record %x", record) } + // Record header (5), handshake header (4), legacy version (2), then random (32). + offset := 5 + 4 + 2 + if len(masked) < offset+33 { + fail() + } + clear(masked[offset : offset+32]) + offset += 32 + sessionIDLength := int(masked[offset]) + offset++ + if len(masked) < offset+sessionIDLength+2 { + fail() + } + clear(masked[offset : offset+sessionIDLength]) + offset += sessionIDLength + offset += 2 + int(binary.BigEndian.Uint16(masked[offset:])) + if len(masked) < offset+1 { + fail() + } + offset += 1 + int(masked[offset]) + if len(masked) < offset+2 { + fail() + } + offset += 2 + for offset+4 <= len(masked) { + extensionType := binary.BigEndian.Uint16(masked[offset:]) + extensionLength := int(binary.BigEndian.Uint16(masked[offset+2:])) + offset += 4 + if offset+extensionLength > len(masked) { + fail() + } + if extensionType == 51 { + // key_share: client_shares length (2), group (2), key length (2), key. + clear(masked[offset+6 : offset+extensionLength]) + } + offset += extensionLength + } + if offset != len(masked) { + fail() + } + return masked +} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 7d69d4654..ca1b9b4ec 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -43,6 +43,20 @@ var claudeOAuthInspectHeaderOrder = []string{ "Connection", } +// claudeOAuthUsageHeaderOrder is the order Claude Code's Axios API client emits +// for the subscription usage read, which carries the CLI User-Agent and the +// OAuth beta instead of the profile lookup's Cache-Control. +var claudeOAuthUsageHeaderOrder = []string{ + "Accept", + "Content-Type", + "User-Agent", + "Authorization", + "anthropic-beta", + "Accept-Encoding", + "Host", + "Connection", +} + // claudeOAuthInspectTargets are the authenticated control-plane GET paths that // use claudeOAuthInspectHeaderOrder. var claudeOAuthInspectTargets = []string{ @@ -52,6 +66,9 @@ var claudeOAuthInspectTargets = []string{ func claudeOAuthRequestHeaderOrder(method, requestTarget string) []string { if method == http.MethodGet { + if strings.HasPrefix(requestTarget, claudeOAuthUsagePath) { + return claudeOAuthUsageHeaderOrder + } for _, target := range claudeOAuthInspectTargets { if strings.HasPrefix(requestTarget, target) { return claudeOAuthInspectHeaderOrder diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 0fc1f0acf..a5dd92487 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -246,6 +246,9 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut if ctx == nil { ctx = req.Context() } + if isClaudeOAuthUsageRead(auth, req) { + return e.readClaudeOAuthUsage(ctx, auth) + } httpReq := req.WithContext(ctx) if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err diff --git a/internal/runtime/executor/claude_executor_usage.go b/internal/runtime/executor/claude_executor_usage.go new file mode 100644 index 000000000..0a0b533c6 --- /dev/null +++ b/internal/runtime/executor/claude_executor_usage.go @@ -0,0 +1,36 @@ +package executor + +import ( + "context" + "net/http" + "strings" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +func isClaudeOAuthUsageRead(auth *cliproxyauth.Auth, req *http.Request) bool { + return auth != nil && auth.AuthKind() == cliproxyauth.AuthKindOAuth && + req.Method == http.MethodGet && req.URL != nil && req.URL.String() == claudeauth.UsageURL +} + +// readClaudeOAuthUsage sends the usage read the way Claude Code does: through the +// OAuth control-plane transport with the credential's CLI User-Agent. The caller's +// headers are replaced, so the read carries only the native header set. +func (e *ClaudeExecutor) readClaudeOAuthUsage(ctx context.Context, auth *cliproxyauth.Auth) (*http.Response, error) { + apiKey, _ := claudeCreds(auth) + return e.claudeOAuthUsageService(ctx, auth).FetchOAuthUsage(ctx, apiKey, helps.ClaudeUsageUserAgent(e.cfg)) +} + +// claudeOAuthUsageService selects the proxy the profile lookup uses. Like +// helps.NewUtlsHTTPClient, a round tripper injected through the context replaces +// the transport only when no proxy applies. +func (e *ClaudeExecutor) claudeOAuthUsageService(ctx context.Context, auth *cliproxyauth.Auth) *claudeauth.ClaudeAuth { + if strings.TrimSpace(auth.ProxyURL) == "" && (e.cfg == nil || strings.TrimSpace(e.cfg.ProxyURL) == "") { + if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { + return claudeauth.NewClaudeAuthWithRoundTripper(rt) + } + } + return claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL) +} diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 6e948f9bd..61ff3b3d6 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -84,7 +84,13 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth ctx = req.Context() } httpReq := req.WithContext(ctx) - if err := e.PrepareRequest(httpReq, auth); err != nil { + if httpReq.Method == http.MethodGet && httpReq.URL != nil && httpReq.URL.String() == helps.CodexUsageURL { + // The usage read carries the identity the credential's inference sends. + apiKey, _ := codexCreds(auth) + httpReq.Header = make(http.Header) + applyCodexIdentityHeaders(httpReq, auth, apiKey, e.cfg, nil) + httpReq.Header.Set("Accept", "application/json") + } else if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) @@ -188,12 +194,6 @@ func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, toke func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, ginHeaders http.Header) { r.Header.Set("Content-Type", "application/json") - if strings.TrimSpace(token) != "" { - r.Header.Set("Authorization", "Bearer "+token) - } else { - r.Header.Del("Authorization") - } - if ginHeaders != nil && ginHeaders.Get("X-Codex-Beta-Features") != "" { r.Header.Set("X-Codex-Beta-Features", ginHeaders.Get("X-Codex-Beta-Features")) } @@ -206,15 +206,26 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke misc.EnsureHeader(r.Header, ginHeaders, "Session-Id", "") misc.EnsureHeader(r.Header, ginHeaders, "X-Openai-Internal-Codex-Responses-Lite", "") - cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) - ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) - if stream { r.Header.Set("Accept", "text/event-stream") } else { r.Header.Set("Accept", "application/json") } r.Header.Set("Connection", "Keep-Alive") + applyCodexIdentityHeaders(r, auth, token, cfg, ginHeaders) +} + +// applyCodexIdentityHeaders sets the credential and client identity every Codex +// request carries: the bearer token, User-Agent, Originator, ChatGPT account, +// credential custom headers and cloaking. Inference and the usage read share it. +func applyCodexIdentityHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, cfg *config.Config, ginHeaders http.Header) { + if strings.TrimSpace(token) != "" { + r.Header.Set("Authorization", "Bearer "+token) + } else { + r.Header.Del("Authorization") + } + cfgUserAgent, _ := codexHeaderDefaults(cfg, auth) + ensureHeaderWithConfigPrecedence(r.Header, ginHeaders, "User-Agent", cfgUserAgent, codexUserAgent) isAPIKey := codexAuthUsesAPIKey(auth) if originator := strings.TrimSpace(ginHeaders.Get("Originator")); originator != "" { diff --git a/internal/runtime/executor/helps/quota_usage.go b/internal/runtime/executor/helps/quota_usage.go index 30753d6ad..67efcb4d7 100644 --- a/internal/runtime/executor/helps/quota_usage.go +++ b/internal/runtime/executor/helps/quota_usage.go @@ -6,9 +6,21 @@ import ( "strings" "time" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/tidwall/gjson" ) +// CodexUsageURL is the ChatGPT backend usage endpoint the Codex CLI reads. +const CodexUsageURL = "https://chatgpt.com/backend-api/wham/usage" + +// ClaudeUsageUserAgent returns the Claude Code User-Agent a usage read carries: +// the configured CLI baseline. Inference sends it for every request without a +// confirmed Claude Code client, and a stabilized device profile is accepted only +// at the baseline version, so a credential's resolved profile has it too. +func ClaudeUsageUserAgent(cfg *config.Config) string { + return defaultClaudeDeviceProfile(cfg).UserAgent +} + // ParseCodexUsageHeaders converts a /backend-api/wham/usage response into the // X-Codex primary and secondary window headers that Codex responses carry. // Each non-null window's fields are copied as given, like the proxied-response diff --git a/internal/runtime/executor/usage_read_identity_test.go b/internal/runtime/executor/usage_read_identity_test.go new file mode 100644 index 000000000..13ade98ff --- /dev/null +++ b/internal/runtime/executor/usage_read_identity_test.go @@ -0,0 +1,189 @@ +package executor + +import ( + "bytes" + "compress/gzip" + "context" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +type usageReadCapture struct { + method string + url string + header http.Header +} + +// captureUsageRead sends one usage read through the executor and returns what the +// transport received. The injected round tripper answers every request, so the +// read cannot reach a real endpoint. +func captureUsageRead(t *testing.T, httpRequest func(context.Context, *cliproxyauth.Auth, *http.Request) (*http.Response, error), auth *cliproxyauth.Auth, usageURL string) usageReadCapture { + t.Helper() + var captured []usageReadCapture + ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + captured = append(captured, usageReadCapture{method: req.Method, url: req.URL.String(), header: req.Header.Clone()}) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + })) + req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil) + if errRequest != nil { + t.Fatal(errRequest) + } + resp, errDo := httpRequest(ctx, auth, req) + if errDo != nil { + t.Fatalf("usage read: %v", errDo) + } + if errClose := resp.Body.Close(); errClose != nil { + t.Fatal(errClose) + } + if len(captured) != 1 { + t.Fatalf("transport received %d requests, want 1", len(captured)) + } + return captured[0] +} + +func usageReadOAuthAuth(provider string, attrs map[string]string) *cliproxyauth.Auth { + // No refresh token and a far expiry, so nothing can attempt a refresh. + return &cliproxyauth.Auth{ID: provider + "-usage", Provider: provider, Attributes: attrs, Metadata: map[string]any{ + "type": provider, "access_token": "tok-" + provider, "account_id": "account-1", + "expired": time.Now().Add(240 * time.Hour).Format(time.RFC3339), + }} +} + +// TestUsageReadTransportReceivesCLIIdentity compares the full request each provider's +// usage read hands its transport against the native CLI header set, written out here +// rather than derived from the helpers under test. +func TestUsageReadTransportReceivesCLIIdentity(t *testing.T) { + cfg := &config.Config{} + cfg.ClaudeHeaderDefaults.UserAgent = "claude-cli/2.1.288 (external, cli)" + + claude := captureUsageRead(t, NewClaudeExecutor(cfg).HttpRequest, usageReadOAuthAuth("claude", nil), "https://api.anthropic.com/api/oauth/usage") + wantClaude := http.Header{ + "Accept": {"application/json, text/plain, */*"}, + "Content-Type": {"application/json"}, + "User-Agent": {"claude-cli/2.1.288 (external, cli)"}, + "Authorization": {"Bearer tok-claude"}, + "Anthropic-Beta": {"oauth-2025-04-20"}, + "Accept-Encoding": {"gzip, compress, deflate, br"}, + "Connection": {"close"}, + } + if claude.method != http.MethodGet || claude.url != "https://api.anthropic.com/api/oauth/usage" { + t.Errorf("Claude usage read = %s %s", claude.method, claude.url) + } + if !reflect.DeepEqual(claude.header, wantClaude) { + t.Errorf("Claude usage read headers =\n%v\nwant\n%v", claude.header, wantClaude) + } + + codex := captureUsageRead(t, NewCodexExecutor(cfg).HttpRequest, usageReadOAuthAuth("codex", nil), "https://chatgpt.com/backend-api/wham/usage") + wantCodex := http.Header{ + "Authorization": {"Bearer tok-codex"}, + "User-Agent": {"codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)"}, + "Originator": {"codex-tui"}, + "Chatgpt-Account-Id": {"account-1"}, + "Accept": {"application/json"}, + } + if codex.method != http.MethodGet || codex.url != "https://chatgpt.com/backend-api/wham/usage" { + t.Errorf("Codex usage read = %s %s", codex.method, codex.url) + } + if !reflect.DeepEqual(codex.header, wantCodex) { + t.Errorf("Codex usage read headers =\n%v\nwant\n%v", codex.header, wantCodex) + } +} + +// TestCodexUsageReadMatchesInferenceIdentity checks that the usage read sends the +// User-Agent, Originator and account the inference path sends for the same +// credential and configuration. +func TestCodexUsageReadMatchesInferenceIdentity(t *testing.T) { + const customUA = "custom-agent/1.0" + cloakingOff := &config.Config{} + cloakingOff.Codex.DisableCodexCloaking = true + configuredUA := &config.Config{} + configuredUA.Codex.DisableCodexCloaking = true + configuredUA.CodexHeaderDefaults.UserAgent = "codex-configured/2.0" + + cases := []struct { + name string + cfg *config.Config + attrs map[string]string + wantUA string + }{ + {name: "cloaking enabled", cfg: &config.Config{}, wantUA: codexUserAgent}, + {name: "cloaking enabled overrides credential User-Agent", cfg: &config.Config{}, attrs: map[string]string{"header:User-Agent": customUA}, wantUA: codexUserAgent}, + {name: "cloaking disabled with credential User-Agent", cfg: cloakingOff, attrs: map[string]string{"header:User-Agent": customUA}, wantUA: customUA}, + {name: "cloaking disabled with configured User-Agent", cfg: configuredUA, wantUA: "codex-configured/2.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + auth := usageReadOAuthAuth("codex", tc.attrs) + usage := captureUsageRead(t, NewCodexExecutor(tc.cfg).HttpRequest, auth, "https://chatgpt.com/backend-api/wham/usage") + + inference, errRequest := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) + if errRequest != nil { + t.Fatal(errRequest) + } + apiKey, _ := codexCreds(auth) + applyCodexHeaders(inference, auth, apiKey, true, tc.cfg, nil) + + for _, name := range []string{"Authorization", "User-Agent", "Originator", "Chatgpt-Account-Id"} { + if got, want := usage.header.Get(name), inference.Header.Get(name); got != want { + t.Errorf("usage read %s = %q, inference sends %q", name, got, want) + } + } + if got := usage.header.Get("User-Agent"); got != tc.wantUA { + t.Errorf("usage read User-Agent = %q, want %q", got, tc.wantUA) + } + for _, name := range []string{"Session-Id", "Version", "X-Codex-Beta-Features", "Content-Type", "Connection"} { + if got := usage.header.Get(name); got != "" { + t.Errorf("usage read carries inference-only header %s = %q", name, got) + } + } + }) + } +} + +// TestClaudeUsageReadDecodesCompressedResponse checks that a gzip-compressed usage +// response, which the advertised Accept-Encoding invites, still parses. +func TestClaudeUsageReadDecodesCompressedResponse(t *testing.T) { + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, errWrite := writer.Write([]byte(`{"five_hour":{"utilization":14.0,"resets_at":"2026-10-04T02:40:00+00:00"},"seven_day":{"utilization":31.0,"resets_at":"2026-10-08T23:00:00+00:00"}}`)); errWrite != nil { + t.Fatal(errWrite) + } + if errClose := writer.Close(); errClose != nil { + t.Fatal(errClose) + } + ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Request: req, + Header: http.Header{"Content-Type": {"application/json"}, "Content-Encoding": {"gzip"}}, + Body: io.NopCloser(bytes.NewReader(compressed.Bytes()))}, nil + })) + req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/api/oauth/usage", nil) + if errRequest != nil { + t.Fatal(errRequest) + } + resp, errDo := NewClaudeExecutor(&config.Config{}).HttpRequest(ctx, usageReadOAuthAuth("claude", nil), req) + if errDo != nil { + t.Fatalf("usage read: %v", errDo) + } + body, errRead := io.ReadAll(resp.Body) + if errRead != nil { + t.Fatal(errRead) + } + if errClose := resp.Body.Close(); errClose != nil { + t.Fatal(errClose) + } + headers := helps.ParseClaudeUsageHeaders(body) + if got := headers.Get("Anthropic-Ratelimit-Unified-7d-Utilization"); got != "0.31" { + t.Fatalf("parsed 7d utilization = %q, want 0.31 (headers %v)", got, headers) + } + if got := resp.Header.Get("Content-Encoding"); got != "" { + t.Fatalf("decoded response still declares Content-Encoding %q", got) + } +} diff --git a/sdk/cliproxy/service_quota.go b/sdk/cliproxy/service_quota.go index 1588329ec..a52ca3501 100644 --- a/sdk/cliproxy/service_quota.go +++ b/sdk/cliproxy/service_quota.go @@ -7,6 +7,7 @@ import ( "strings" "time" + claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" @@ -17,8 +18,6 @@ const ( // quotaUsageReadTimeout bounds one read: the executor HTTP client has no timeout, and // reads run serially, so one hung read would otherwise stall every later read. quotaUsageReadTimeout = 30 * time.Second - codexUsageURL = "https://chatgpt.com/backend-api/wham/usage" - claudeUsageURL = "https://api.anthropic.com/api/oauth/usage" ) // startQuotaUsageReads reads the usage endpoint of every Codex and Claude OAuth credential @@ -46,24 +45,20 @@ func (s *Service) startQuotaUsageReads(ctx context.Context) { } // readQuotaUsage reads one credential's usage endpoint and records the result. A failed or -// rejected read leaves the previous observation untouched. +// rejected read leaves the previous observation untouched. The provider's executor sends the +// read with the client identity of that provider's CLI, so the request carries no headers here. func (s *Service) readQuotaUsage(ctx context.Context, authID string) { auth, ok := s.coreManager.GetByID(authID) if !ok || auth.AuthKind() != coreauth.AuthKindOAuth || !auth.HasValidAccessToken(time.Now()) { return } - headers := make(http.Header) var usageURL string var parse func([]byte) http.Header switch strings.ToLower(strings.TrimSpace(auth.Provider)) { case "codex": - usageURL, parse = codexUsageURL, helps.ParseCodexUsageHeaders - if accountID, _ := auth.Metadata["account_id"].(string); accountID != "" { - headers.Set("Chatgpt-Account-Id", accountID) - } + usageURL, parse = helps.CodexUsageURL, helps.ParseCodexUsageHeaders case "claude": - usageURL, parse = claudeUsageURL, helps.ParseClaudeUsageHeaders - headers.Set("Anthropic-Beta", "oauth-2025-04-20") + usageURL, parse = claudeauth.UsageURL, helps.ParseClaudeUsageHeaders default: return } @@ -73,7 +68,6 @@ func (s *Service) readQuotaUsage(ctx context.Context, authID string) { if errRequest != nil { return } - req.Header = headers resp, errDo := s.coreManager.HttpRequest(readCtx, auth, req) if errDo != nil { log.Warnf("quota usage read failed for auth %s: %v", authID, errDo) From 20e624ccf516661d52362d5ee15ac42d81e51c0b Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 3 Oct 2026 23:17:45 -0400 Subject: [PATCH 2/3] refactor(auth): make usage reads an explicit executor capability (MASTRA-787) The quota sweep now asks the provider executor for the optional QuotaUsageReader capability instead of executors matching the usage URL inside HttpRequest, which keeps its 5fde8eeb contract. The Claude usage read reuses fetchOAuthControlPlaneJSON through a header hook, and its header order and path now live in the fork file. The Claude usage read resolves its proxy with the same precedence as helps.NewUtlsHTTPClient through an exported helps.EffectiveProxyURL. The shared Codex identity helper skips Chatgpt-Account-Id for an empty account id, the usage read no longer sends Accept, and the helper comment describes HTTP inference only. The Claude User-Agent comment says the read uses the configured CLI baseline, which inference also sends for unconfirmed clients with stabilize-device-profile off. The self-comparing ClientHello and Codex identity tests are replaced by a CONNECT-tunnel ALPN check through the production transport and literal Codex identity expectations, including the cloaking-off and empty-account cases. Co-Authored-By: Claude Opus 5.5 --- internal/auth/claude/anthropic_auth.go | 10 +- internal/auth/claude/oauth_usage.go | 69 ++--- internal/auth/claude/oauth_usage_test.go | 115 +-------- internal/auth/claude/utls_transport.go | 14 - internal/runtime/executor/claude_executor.go | 3 - .../runtime/executor/claude_executor_usage.go | 32 +-- .../executor/codex_executor_request.go | 18 +- .../runtime/executor/codex_executor_usage.go | 56 ++++ .../runtime/executor/helps/quota_usage.go | 18 +- .../executor/usage_read_identity_test.go | 243 +++++++++++++----- sdk/cliproxy/auth/quota_usage_reader.go | 15 ++ sdk/cliproxy/service_quota.go | 47 +--- 12 files changed, 332 insertions(+), 308 deletions(-) create mode 100644 internal/runtime/executor/codex_executor_usage.go create mode 100644 sdk/cliproxy/auth/quota_usage_reader.go diff --git a/internal/auth/claude/anthropic_auth.go b/internal/auth/claude/anthropic_auth.go index b3b2618fd..aaa776b8c 100644 --- a/internal/auth/claude/anthropic_auth.go +++ b/internal/auth/claude/anthropic_auth.go @@ -226,7 +226,8 @@ func applyClaudeOAuthAxiosHeaders(req *http.Request) { // fetchOAuthControlPlaneJSON issues an Axios-shaped OAuth control-plane GET and // returns the decoded response body. label names the endpoint in error text. -func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, accessToken, label string) ([]byte, error) { +// setHeaders, when non-nil, adjusts the default headers before the request is sent. +func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, accessToken, label string, setHeaders func(http.Header)) ([]byte, error) { if o == nil || o.httpClient == nil { return nil, fmt.Errorf("fetch Claude OAuth %s: HTTP client is nil", label) } @@ -241,6 +242,9 @@ func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, a applyClaudeOAuthAxiosHeaders(req) req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Cache-Control", "no-cache") + if setHeaders != nil { + setHeaders(req.Header) + } resp, errDo := o.httpClient.Do(req) if errDo != nil { @@ -263,7 +267,7 @@ func (o *ClaudeAuth) fetchOAuthControlPlaneJSON(ctx context.Context, endpoint, a // FetchOAuthProfile retrieves the account identity associated with an OAuth access token. func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) (*OAuthProfile, error) { - body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, ProfileURL, accessToken, "profile") + body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, ProfileURL, accessToken, "profile", nil) if errFetch != nil { return nil, errFetch } @@ -282,7 +286,7 @@ func (o *ClaudeAuth) FetchOAuthProfile(ctx context.Context, accessToken string) // covered by captured evidence, so the payload stays opaque and is returned raw // instead of being decoded into a guessed structure. func (o *ClaudeAuth) FetchOAuthRoles(ctx context.Context, accessToken string) (json.RawMessage, error) { - body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, RolesURL, accessToken, "claude_cli roles") + body, errFetch := o.fetchOAuthControlPlaneJSON(ctx, RolesURL, accessToken, "claude_cli roles", nil) if errFetch != nil { return nil, errFetch } diff --git a/internal/auth/claude/oauth_usage.go b/internal/auth/claude/oauth_usage.go index aa4528a73..fbfd2fe4c 100644 --- a/internal/auth/claude/oauth_usage.go +++ b/internal/auth/claude/oauth_usage.go @@ -1,23 +1,31 @@ package claude import ( - "bytes" "context" - "fmt" - "io" "net/http" - "strings" - - log "github.com/sirupsen/logrus" ) const ( - // UsageURL is the subscription usage endpoint Claude Code reads through its - // Axios API client. - UsageURL = "https://api.anthropic.com" + claudeOAuthUsagePath + // claudeOAuthUsageURL is the subscription usage endpoint Claude Code reads through + // its Axios API client. + claudeOAuthUsageURL = "https://api.anthropic.com" + claudeOAuthUsagePath claudeOAuthUsagePath = "/api/oauth/usage" ) +// claudeOAuthUsageHeaderOrder is the order Claude Code's Axios API client emits +// for the subscription usage read, which carries the CLI User-Agent and the +// OAuth beta instead of the profile lookup's Cache-Control. +var claudeOAuthUsageHeaderOrder = []string{ + "Accept", + "Content-Type", + "User-Agent", + "Authorization", + "anthropic-beta", + "Accept-Encoding", + "Host", + "Connection", +} + // NewClaudeAuthWithRoundTripper creates a ClaudeAuth whose requests go through rt // instead of the control-plane uTLS transport. func NewClaudeAuthWithRoundTripper(rt http.RoundTripper) *ClaudeAuth { @@ -26,40 +34,11 @@ func NewClaudeAuthWithRoundTripper(rt http.RoundTripper) *ClaudeAuth { // FetchOAuthUsage reads the account's subscription usage with the request shape // Claude Code's API client sends: the Axios control-plane headers with the CLI -// User-Agent and the OAuth beta. The returned response carries the decoded body, -// because the request advertises compression; the caller closes it. -func (o *ClaudeAuth) FetchOAuthUsage(ctx context.Context, accessToken, userAgent string) (*http.Response, error) { - if o == nil || o.httpClient == nil { - return nil, fmt.Errorf("fetch Claude OAuth usage: HTTP client is nil") - } - accessToken = strings.TrimSpace(accessToken) - if accessToken == "" { - return nil, fmt.Errorf("fetch Claude OAuth usage: access token is empty") - } - req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, UsageURL, nil) - if errRequest != nil { - return nil, fmt.Errorf("create Claude OAuth usage request: %w", errRequest) - } - applyClaudeOAuthAxiosHeaders(req) - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Authorization", "Bearer "+accessToken) - req.Header.Set("anthropic-beta", "oauth-2025-04-20") - - resp, errDo := o.httpClient.Do(req) - if errDo != nil { - return nil, fmt.Errorf("fetch Claude OAuth usage: %w", errDo) - } - body, errRead := readClaudeOAuthResponseBody(resp) - if errClose := resp.Body.Close(); errClose != nil { - log.Errorf("failed to close Claude OAuth usage response body: %v", errClose) - } - if errRead != nil { - return nil, fmt.Errorf("read Claude OAuth usage response: %w", errRead) - } - resp.Body = io.NopCloser(bytes.NewReader(body)) - resp.Header.Del("Content-Encoding") - resp.Header.Del("Content-Length") - resp.ContentLength = int64(len(body)) - resp.Uncompressed = true - return resp, nil +// User-Agent and the OAuth beta, and no Cache-Control. It returns the decoded body. +func (o *ClaudeAuth) FetchOAuthUsage(ctx context.Context, accessToken, userAgent string) ([]byte, error) { + return o.fetchOAuthControlPlaneJSON(ctx, claudeOAuthUsageURL, accessToken, "usage", func(header http.Header) { + header.Del("Cache-Control") + header.Set("User-Agent", userAgent) + header.Set("anthropic-beta", "oauth-2025-04-20") + }) } diff --git a/internal/auth/claude/oauth_usage_test.go b/internal/auth/claude/oauth_usage_test.go index d6e6fd1f4..e6c0cbe0f 100644 --- a/internal/auth/claude/oauth_usage_test.go +++ b/internal/auth/claude/oauth_usage_test.go @@ -2,12 +2,8 @@ package claude import ( "bufio" - "bytes" "context" - "encoding/binary" - "errors" "fmt" - "io" "net" "net/http" "reflect" @@ -54,13 +50,9 @@ func TestFetchOAuthUsageSerializesNativeHeaderOrder(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) defer cancel() - resp, errFetch := auth.FetchOAuthUsage(ctx, "tok-usage", "claude-cli/2.1.288 (external, cli)") - if errFetch != nil { + if _, errFetch := auth.FetchOAuthUsage(ctx, "tok-usage", "claude-cli/2.1.288 (external, cli)"); errFetch != nil { t.Fatalf("FetchOAuthUsage() error = %v", errFetch) } - if errClose := resp.Body.Close(); errClose != nil { - t.Fatal(errClose) - } want := []string{ "GET /api/oauth/usage HTTP/1.1", "Accept: application/json, text/plain, */*", @@ -76,108 +68,3 @@ func TestFetchOAuthUsageSerializesNativeHeaderOrder(t *testing.T) { t.Fatalf("usage request header lines =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) } } - -// TestFetchOAuthUsageClientHelloMatchesControlPlane dials the usage read and the -// profile lookup through the production control-plane transport and compares their -// ClientHello records with the per-connection random values masked. -func TestFetchOAuthUsageClientHelloMatchesControlPlane(t *testing.T) { - usage := captureControlPlaneClientHello(t, func(ctx context.Context, auth *ClaudeAuth) error { - _, errFetch := auth.FetchOAuthUsage(ctx, "tok", "claude-cli/2.1.288 (external, cli)") - return errFetch - }) - profile := captureControlPlaneClientHello(t, func(ctx context.Context, auth *ClaudeAuth) error { - _, errFetch := auth.FetchOAuthProfile(ctx, "tok") - return errFetch - }) - if !bytes.Equal(maskClientHelloRandomness(t, usage), maskClientHelloRandomness(t, profile)) { - t.Fatalf("usage ClientHello differs from the control-plane ClientHello\nusage: %x\nprofile: %x", usage, profile) - } -} - -func captureControlPlaneClientHello(t *testing.T, fetch func(context.Context, *ClaudeAuth) error) []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) - } - }) - transport := newUtlsRoundTripper(nil) - transport.dialer = claudeTestDialer{conn: clientConn} - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - done := make(chan error, 1) - go func() { done <- fetch(ctx, &ClaudeAuth{httpClient: &http.Client{Transport: transport}}) }() - - 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 errFetch := <-done: - if errFetch == nil { - t.Fatal("fetch succeeded although the server closed during the handshake") - } - case <-time.After(5 * time.Second): - t.Fatal("fetch did not exit after the server closed") - } - return append(header, payload...) -} - -// maskClientHelloRandomness zeroes the client random, the legacy session ID and the -// key share, which differ on every connection, and keeps every other byte. -func maskClientHelloRandomness(t *testing.T, record []byte) []byte { - t.Helper() - masked := append([]byte(nil), record...) - fail := func() { t.Fatalf("malformed ClientHello record %x", record) } - // Record header (5), handshake header (4), legacy version (2), then random (32). - offset := 5 + 4 + 2 - if len(masked) < offset+33 { - fail() - } - clear(masked[offset : offset+32]) - offset += 32 - sessionIDLength := int(masked[offset]) - offset++ - if len(masked) < offset+sessionIDLength+2 { - fail() - } - clear(masked[offset : offset+sessionIDLength]) - offset += sessionIDLength - offset += 2 + int(binary.BigEndian.Uint16(masked[offset:])) - if len(masked) < offset+1 { - fail() - } - offset += 1 + int(masked[offset]) - if len(masked) < offset+2 { - fail() - } - offset += 2 - for offset+4 <= len(masked) { - extensionType := binary.BigEndian.Uint16(masked[offset:]) - extensionLength := int(binary.BigEndian.Uint16(masked[offset+2:])) - offset += 4 - if offset+extensionLength > len(masked) { - fail() - } - if extensionType == 51 { - // key_share: client_shares length (2), group (2), key length (2), key. - clear(masked[offset+6 : offset+extensionLength]) - } - offset += extensionLength - } - if offset != len(masked) { - fail() - } - return masked -} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index ca1b9b4ec..55f2ecdf8 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -43,20 +43,6 @@ var claudeOAuthInspectHeaderOrder = []string{ "Connection", } -// claudeOAuthUsageHeaderOrder is the order Claude Code's Axios API client emits -// for the subscription usage read, which carries the CLI User-Agent and the -// OAuth beta instead of the profile lookup's Cache-Control. -var claudeOAuthUsageHeaderOrder = []string{ - "Accept", - "Content-Type", - "User-Agent", - "Authorization", - "anthropic-beta", - "Accept-Encoding", - "Host", - "Connection", -} - // claudeOAuthInspectTargets are the authenticated control-plane GET paths that // use claudeOAuthInspectHeaderOrder. var claudeOAuthInspectTargets = []string{ diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index a5dd92487..0fc1f0acf 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -246,9 +246,6 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut if ctx == nil { ctx = req.Context() } - if isClaudeOAuthUsageRead(auth, req) { - return e.readClaudeOAuthUsage(ctx, auth) - } httpReq := req.WithContext(ctx) if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err diff --git a/internal/runtime/executor/claude_executor_usage.go b/internal/runtime/executor/claude_executor_usage.go index 0a0b533c6..a7ba7a3f7 100644 --- a/internal/runtime/executor/claude_executor_usage.go +++ b/internal/runtime/executor/claude_executor_usage.go @@ -3,34 +3,34 @@ package executor import ( "context" "net/http" - "strings" claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" ) -func isClaudeOAuthUsageRead(auth *cliproxyauth.Auth, req *http.Request) bool { - return auth != nil && auth.AuthKind() == cliproxyauth.AuthKindOAuth && - req.Method == http.MethodGet && req.URL != nil && req.URL.String() == claudeauth.UsageURL -} +var _ cliproxyauth.QuotaUsageReader = (*ClaudeExecutor)(nil) -// readClaudeOAuthUsage sends the usage read the way Claude Code does: through the -// OAuth control-plane transport with the credential's CLI User-Agent. The caller's -// headers are replaced, so the read carries only the native header set. -func (e *ClaudeExecutor) readClaudeOAuthUsage(ctx context.Context, auth *cliproxyauth.Auth) (*http.Response, error) { - apiKey, _ := claudeCreds(auth) - return e.claudeOAuthUsageService(ctx, auth).FetchOAuthUsage(ctx, apiKey, helps.ClaudeUsageUserAgent(e.cfg)) +// ReadQuotaUsage reads the credential's subscription usage the way Claude Code does: +// through the OAuth control-plane transport with the configured Claude Code User-Agent. +func (e *ClaudeExecutor) ReadQuotaUsage(ctx context.Context, auth *cliproxyauth.Auth) (http.Header, error) { + accessToken, _ := claudeCreds(auth) + body, errFetch := e.claudeOAuthUsageService(ctx, auth).FetchOAuthUsage(ctx, accessToken, helps.ClaudeUsageUserAgent(e.cfg)) + if errFetch != nil { + return nil, errFetch + } + return helps.ParseClaudeUsageHeaders(body), nil } -// claudeOAuthUsageService selects the proxy the profile lookup uses. Like -// helps.NewUtlsHTTPClient, a round tripper injected through the context replaces -// the transport only when no proxy applies. +// claudeOAuthUsageService resolves the proxy the way helps.NewUtlsHTTPClient does: the +// request-scoped override, then the credential proxy, then the global proxy. A round +// tripper injected through the context replaces the transport only when no proxy applies. func (e *ClaudeExecutor) claudeOAuthUsageService(ctx context.Context, auth *cliproxyauth.Auth) *claudeauth.ClaudeAuth { - if strings.TrimSpace(auth.ProxyURL) == "" && (e.cfg == nil || strings.TrimSpace(e.cfg.ProxyURL) == "") { + proxyURL := helps.EffectiveProxyURL(ctx, e.cfg, auth) + if proxyURL == "" { if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil { return claudeauth.NewClaudeAuthWithRoundTripper(rt) } } - return claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL) + return claudeauth.NewClaudeAuthWithProxyURL(e.cfg, proxyURL) } diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 61ff3b3d6..d300bda2a 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -84,13 +84,7 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth ctx = req.Context() } httpReq := req.WithContext(ctx) - if httpReq.Method == http.MethodGet && httpReq.URL != nil && httpReq.URL.String() == helps.CodexUsageURL { - // The usage read carries the identity the credential's inference sends. - apiKey, _ := codexCreds(auth) - httpReq.Header = make(http.Header) - applyCodexIdentityHeaders(httpReq, auth, apiKey, e.cfg, nil) - httpReq.Header.Set("Accept", "application/json") - } else if err := e.PrepareRequest(httpReq, auth); err != nil { + if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) @@ -215,9 +209,11 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke applyCodexIdentityHeaders(r, auth, token, cfg, ginHeaders) } -// applyCodexIdentityHeaders sets the credential and client identity every Codex -// request carries: the bearer token, User-Agent, Originator, ChatGPT account, -// credential custom headers and cloaking. Inference and the usage read share it. +// applyCodexIdentityHeaders sets the credential and client identity HTTP inference +// sends: the bearer token, User-Agent, Originator, ChatGPT account, credential custom +// headers and cloaking. The usage read calls it without client headers, so with +// cloaking off and no configured User-Agent, inference forwards the client's +// User-Agent while the usage read sends the default Codex User-Agent. func applyCodexIdentityHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, cfg *config.Config, ginHeaders http.Header) { if strings.TrimSpace(token) != "" { r.Header.Set("Authorization", "Bearer "+token) @@ -235,7 +231,7 @@ func applyCodexIdentityHeaders(r *http.Request, auth *cliproxyauth.Auth, token s } if !isAPIKey { if auth != nil && auth.Metadata != nil { - if accountID, ok := auth.Metadata["account_id"].(string); ok { + if accountID, ok := auth.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" { r.Header.Set("Chatgpt-Account-Id", accountID) } } diff --git a/internal/runtime/executor/codex_executor_usage.go b/internal/runtime/executor/codex_executor_usage.go new file mode 100644 index 000000000..985bddef0 --- /dev/null +++ b/internal/runtime/executor/codex_executor_usage.go @@ -0,0 +1,56 @@ +package executor + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +// codexUsageURL is the ChatGPT backend usage endpoint the Codex CLI reads. +const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage" + +var ( + _ cliproxyauth.QuotaUsageReader = (*CodexExecutor)(nil) + _ cliproxyauth.QuotaUsageReader = (*CodexAutoExecutor)(nil) +) + +// ReadQuotaUsage reads the credential's ChatGPT usage with the credential and client +// identity HTTP inference sends for the same credential. +func (e *CodexExecutor) ReadQuotaUsage(ctx context.Context, auth *cliproxyauth.Auth) (http.Header, error) { + req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL, nil) + if errRequest != nil { + return nil, fmt.Errorf("create codex usage request: %w", errRequest) + } + accessToken, _ := codexCreds(auth) + applyCodexIdentityHeaders(req, auth, accessToken, e.cfg, nil) + resp, errDo := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0).Do(req) + if errDo != nil { + return nil, fmt.Errorf("fetch codex usage: %w", errDo) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("failed to close codex usage response body: %v", errClose) + } + }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("fetch codex usage failed with status %d", resp.StatusCode) + } + body, errRead := io.ReadAll(resp.Body) + if errRead != nil { + return nil, fmt.Errorf("read codex usage response: %w", errRead) + } + return helps.ParseCodexUsageHeaders(body), nil +} + +// ReadQuotaUsage reads usage through the HTTP executor. +func (e *CodexAutoExecutor) ReadQuotaUsage(ctx context.Context, auth *cliproxyauth.Auth) (http.Header, error) { + if e == nil || e.httpExec == nil { + return nil, fmt.Errorf("codex auto executor: http executor is nil") + } + return e.httpExec.ReadQuotaUsage(ctx, auth) +} diff --git a/internal/runtime/executor/helps/quota_usage.go b/internal/runtime/executor/helps/quota_usage.go index 67efcb4d7..c18875f0d 100644 --- a/internal/runtime/executor/helps/quota_usage.go +++ b/internal/runtime/executor/helps/quota_usage.go @@ -1,26 +1,30 @@ package helps import ( + "context" "net/http" "strconv" "strings" "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" "github.com/tidwall/gjson" ) -// CodexUsageURL is the ChatGPT backend usage endpoint the Codex CLI reads. -const CodexUsageURL = "https://chatgpt.com/backend-api/wham/usage" - -// ClaudeUsageUserAgent returns the Claude Code User-Agent a usage read carries: -// the configured CLI baseline. Inference sends it for every request without a -// confirmed Claude Code client, and a stabilized device profile is accepted only -// at the baseline version, so a credential's resolved profile has it too. +// ClaudeUsageUserAgent returns the Claude Code User-Agent a usage read carries: the +// configured CLI baseline. Inference also sends it for clients it has not confirmed as +// Claude Code, with stabilize-device-profile off (the default). func ClaudeUsageUserAgent(cfg *config.Config) string { return defaultClaudeDeviceProfile(cfg).UserAgent } +// EffectiveProxyURL returns the proxy NewUtlsHTTPClient and NewProxyAwareHTTPClient use: +// the request-scoped override, then the credential proxy, then the global proxy. +func EffectiveProxyURL(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) string { + return effectiveProxyURL(ctx, cfg, auth) +} + // ParseCodexUsageHeaders converts a /backend-api/wham/usage response into the // X-Codex primary and secondary window headers that Codex responses carry. // Each non-null window's fields are copied as given, like the proxied-response diff --git a/internal/runtime/executor/usage_read_identity_test.go b/internal/runtime/executor/usage_read_identity_test.go index 13ade98ff..e69b746af 100644 --- a/internal/runtime/executor/usage_read_identity_test.go +++ b/internal/runtime/executor/usage_read_identity_test.go @@ -1,18 +1,21 @@ package executor import ( + "bufio" "bytes" "compress/gzip" "context" + "encoding/binary" "io" + "net" "net/http" "reflect" + "slices" "strings" "testing" "time" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" - "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" ) @@ -22,26 +25,19 @@ type usageReadCapture struct { header http.Header } -// captureUsageRead sends one usage read through the executor and returns what the -// transport received. The injected round tripper answers every request, so the -// read cannot reach a real endpoint. -func captureUsageRead(t *testing.T, httpRequest func(context.Context, *cliproxyauth.Auth, *http.Request) (*http.Response, error), auth *cliproxyauth.Auth, usageURL string) usageReadCapture { +type quotaUsageReadFunc func(context.Context, *cliproxyauth.Auth) (http.Header, error) + +// captureUsageRead runs one usage read and returns what the transport received. The +// injected round tripper answers every request, so the read cannot reach a real endpoint. +func captureUsageRead(t *testing.T, read quotaUsageReadFunc, auth *cliproxyauth.Auth) usageReadCapture { t.Helper() var captured []usageReadCapture ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) { captured = append(captured, usageReadCapture{method: req.Method, url: req.URL.String(), header: req.Header.Clone()}) return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil })) - req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, usageURL, nil) - if errRequest != nil { - t.Fatal(errRequest) - } - resp, errDo := httpRequest(ctx, auth, req) - if errDo != nil { - t.Fatalf("usage read: %v", errDo) - } - if errClose := resp.Body.Close(); errClose != nil { - t.Fatal(errClose) + if _, errRead := read(ctx, auth); errRead != nil { + t.Fatalf("usage read: %v", errRead) } if len(captured) != 1 { t.Fatalf("transport received %d requests, want 1", len(captured)) @@ -64,7 +60,7 @@ func TestUsageReadTransportReceivesCLIIdentity(t *testing.T) { cfg := &config.Config{} cfg.ClaudeHeaderDefaults.UserAgent = "claude-cli/2.1.288 (external, cli)" - claude := captureUsageRead(t, NewClaudeExecutor(cfg).HttpRequest, usageReadOAuthAuth("claude", nil), "https://api.anthropic.com/api/oauth/usage") + claude := captureUsageRead(t, NewClaudeExecutor(cfg).ReadQuotaUsage, usageReadOAuthAuth("claude", nil)) wantClaude := http.Header{ "Accept": {"application/json, text/plain, */*"}, "Content-Type": {"application/json"}, @@ -81,13 +77,12 @@ func TestUsageReadTransportReceivesCLIIdentity(t *testing.T) { t.Errorf("Claude usage read headers =\n%v\nwant\n%v", claude.header, wantClaude) } - codex := captureUsageRead(t, NewCodexExecutor(cfg).HttpRequest, usageReadOAuthAuth("codex", nil), "https://chatgpt.com/backend-api/wham/usage") + codex := captureUsageRead(t, NewCodexAutoExecutor(cfg).ReadQuotaUsage, usageReadOAuthAuth("codex", nil)) wantCodex := http.Header{ "Authorization": {"Bearer tok-codex"}, "User-Agent": {"codex-tui/0.154.0 (Mac OS 26.5.2; arm64) iTerm.app/3.6.11 (codex-tui; 0.154.0)"}, "Originator": {"codex-tui"}, "Chatgpt-Account-Id": {"account-1"}, - "Accept": {"application/json"}, } if codex.method != http.MethodGet || codex.url != "https://chatgpt.com/backend-api/wham/usage" { t.Errorf("Codex usage read = %s %s", codex.method, codex.url) @@ -97,49 +92,77 @@ func TestUsageReadTransportReceivesCLIIdentity(t *testing.T) { } } -// TestCodexUsageReadMatchesInferenceIdentity checks that the usage read sends the -// User-Agent, Originator and account the inference path sends for the same -// credential and configuration. +// TestCodexUsageReadMatchesInferenceIdentity checks the User-Agent and account the +// usage read sends against literal expectations, next to what HTTP inference sends for +// the same credential and configuration when the client sends its own User-Agent. func TestCodexUsageReadMatchesInferenceIdentity(t *testing.T) { - const customUA = "custom-agent/1.0" + const ( + customUA = "custom-agent/1.0" + configuredUA = "codex-configured/2.0" + clientUA = "client-agent/3.0" + ) cloakingOff := &config.Config{} cloakingOff.Codex.DisableCodexCloaking = true - configuredUA := &config.Config{} - configuredUA.Codex.DisableCodexCloaking = true - configuredUA.CodexHeaderDefaults.UserAgent = "codex-configured/2.0" + cloakingOffConfiguredUA := &config.Config{} + cloakingOffConfiguredUA.Codex.DisableCodexCloaking = true + cloakingOffConfiguredUA.CodexHeaderDefaults.UserAgent = configuredUA cases := []struct { - name string - cfg *config.Config - attrs map[string]string - wantUA string + name string + cfg *config.Config + attrs map[string]string + accountID string + wantUsageUA string + wantInferenceUA string }{ - {name: "cloaking enabled", cfg: &config.Config{}, wantUA: codexUserAgent}, - {name: "cloaking enabled overrides credential User-Agent", cfg: &config.Config{}, attrs: map[string]string{"header:User-Agent": customUA}, wantUA: codexUserAgent}, - {name: "cloaking disabled with credential User-Agent", cfg: cloakingOff, attrs: map[string]string{"header:User-Agent": customUA}, wantUA: customUA}, - {name: "cloaking disabled with configured User-Agent", cfg: configuredUA, wantUA: "codex-configured/2.0"}, + {name: "cloaking enabled", cfg: &config.Config{}, accountID: "account-1", + wantUsageUA: codexUserAgent, wantInferenceUA: codexUserAgent}, + {name: "cloaking enabled overrides credential User-Agent", cfg: &config.Config{}, accountID: "account-1", + attrs: map[string]string{"header:User-Agent": customUA}, wantUsageUA: codexUserAgent, wantInferenceUA: codexUserAgent}, + {name: "cloaking disabled with credential User-Agent", cfg: cloakingOff, accountID: "account-1", + attrs: map[string]string{"header:User-Agent": customUA}, wantUsageUA: customUA, wantInferenceUA: customUA}, + {name: "cloaking disabled with configured User-Agent", cfg: cloakingOffConfiguredUA, accountID: "account-1", + wantUsageUA: configuredUA, wantInferenceUA: configuredUA}, + {name: "cloaking disabled without configured User-Agent", cfg: cloakingOff, accountID: "account-1", + wantUsageUA: codexUserAgent, wantInferenceUA: clientUA}, + {name: "empty account id", cfg: &config.Config{}, accountID: "", + wantUsageUA: codexUserAgent, wantInferenceUA: codexUserAgent}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { auth := usageReadOAuthAuth("codex", tc.attrs) - usage := captureUsageRead(t, NewCodexExecutor(tc.cfg).HttpRequest, auth, "https://chatgpt.com/backend-api/wham/usage") + auth.Metadata["account_id"] = tc.accountID + usage := captureUsageRead(t, NewCodexExecutor(tc.cfg).ReadQuotaUsage, auth) inference, errRequest := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://chatgpt.com/backend-api/codex/responses", nil) if errRequest != nil { t.Fatal(errRequest) } - apiKey, _ := codexCreds(auth) - applyCodexHeaders(inference, auth, apiKey, true, tc.cfg, nil) + applyCodexHeaders(inference, auth, "tok-codex", true, tc.cfg, http.Header{"User-Agent": {clientUA}}) - for _, name := range []string{"Authorization", "User-Agent", "Originator", "Chatgpt-Account-Id"} { - if got, want := usage.header.Get(name), inference.Header.Get(name); got != want { - t.Errorf("usage read %s = %q, inference sends %q", name, got, want) + for _, sent := range []struct { + name string + header http.Header + wantUA string + }{{"usage read", usage.header, tc.wantUsageUA}, {"inference", inference.Header, tc.wantInferenceUA}} { + if got := sent.header.Get("User-Agent"); got != sent.wantUA { + t.Errorf("%s User-Agent = %q, want %q", sent.name, got, sent.wantUA) + } + if got := sent.header.Get("Authorization"); got != "Bearer tok-codex" { + t.Errorf("%s Authorization = %q", sent.name, got) + } + if got := sent.header.Get("Originator"); got != codexOriginator { + t.Errorf("%s Originator = %q, want %q", sent.name, got, codexOriginator) + } + got, present := sent.header["Chatgpt-Account-Id"] + if tc.accountID == "" && present { + t.Errorf("%s carries Chatgpt-Account-Id %q for an empty account id", sent.name, got) + } + if tc.accountID != "" && sent.header.Get("Chatgpt-Account-Id") != tc.accountID { + t.Errorf("%s Chatgpt-Account-Id = %q, want %q", sent.name, got, tc.accountID) } } - if got := usage.header.Get("User-Agent"); got != tc.wantUA { - t.Errorf("usage read User-Agent = %q, want %q", got, tc.wantUA) - } - for _, name := range []string{"Session-Id", "Version", "X-Codex-Beta-Features", "Content-Type", "Connection"} { + for _, name := range []string{"Accept", "Session-Id", "Version", "X-Codex-Beta-Features", "Content-Type", "Connection"} { if got := usage.header.Get(name); got != "" { t.Errorf("usage read carries inference-only header %s = %q", name, got) } @@ -164,26 +187,128 @@ func TestClaudeUsageReadDecodesCompressedResponse(t *testing.T) { Header: http.Header{"Content-Type": {"application/json"}, "Content-Encoding": {"gzip"}}, Body: io.NopCloser(bytes.NewReader(compressed.Bytes()))}, nil })) - req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.anthropic.com/api/oauth/usage", nil) - if errRequest != nil { - t.Fatal(errRequest) - } - resp, errDo := NewClaudeExecutor(&config.Config{}).HttpRequest(ctx, usageReadOAuthAuth("claude", nil), req) - if errDo != nil { - t.Fatalf("usage read: %v", errDo) - } - body, errRead := io.ReadAll(resp.Body) + headers, errRead := NewClaudeExecutor(&config.Config{}).ReadQuotaUsage(ctx, usageReadOAuthAuth("claude", nil)) if errRead != nil { - t.Fatal(errRead) - } - if errClose := resp.Body.Close(); errClose != nil { - t.Fatal(errClose) + t.Fatalf("usage read: %v", errRead) } - headers := helps.ParseClaudeUsageHeaders(body) if got := headers.Get("Anthropic-Ratelimit-Unified-7d-Utilization"); got != "0.31" { t.Fatalf("parsed 7d utilization = %q, want 0.31 (headers %v)", got, headers) } - if got := resp.Header.Get("Content-Encoding"); got != "" { - t.Fatalf("decoded response still declares Content-Encoding %q", got) +} + +// TestClaudeUsageReadDialsControlPlaneTLSThroughCredentialProxy sends the usage read +// through the production transport, with the credential's proxy set to a local CONNECT +// listener and no context round tripper. The first TLS record in the tunnel must carry +// no ALPN extension (type 16): the OAuth control-plane ClientHello has none, while the +// inference ClientHello advertises http/1.1. +func TestClaudeUsageReadDialsControlPlaneTLSThroughCredentialProxy(t *testing.T) { + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatal(errListen) + } + defer func() { _ = listener.Close() }() + + type tunnel struct { + target string + record []byte + err error + } + tunnels := make(chan tunnel, 1) + go func() { + conn, errAccept := listener.Accept() + if errAccept != nil { + tunnels <- tunnel{err: errAccept} + return + } + defer func() { _ = conn.Close() }() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReader(conn) + connect, errConnect := http.ReadRequest(reader) + if errConnect != nil { + tunnels <- tunnel{err: errConnect} + return + } + if _, errWrite := io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n"); errWrite != nil { + tunnels <- tunnel{err: errWrite} + return + } + header := make([]byte, 5) + if _, errRead := io.ReadFull(reader, header); errRead != nil { + tunnels <- tunnel{err: errRead} + return + } + payload := make([]byte, binary.BigEndian.Uint16(header[3:5])) + if _, errRead := io.ReadFull(reader, payload); errRead != nil { + tunnels <- tunnel{err: errRead} + return + } + tunnels <- tunnel{target: connect.Method + " " + connect.Host, record: append(header, payload...)} + }() + + auth := usageReadOAuthAuth("claude", nil) + auth.ProxyURL = "http://" + listener.Addr().String() + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if _, errRead := NewClaudeExecutor(&config.Config{}).ReadQuotaUsage(ctx, auth); errRead == nil { + t.Fatal("usage read succeeded although the tunnel closed during the handshake") + } + got := <-tunnels + if got.err != nil { + t.Fatal(got.err) + } + if got.target != "CONNECT api.anthropic.com:443" { + t.Fatalf("proxy received %q, want CONNECT api.anthropic.com:443", got.target) + } + extensions := clientHelloExtensionTypes(t, got.record) + if !slices.Contains(extensions, 0) { + t.Fatalf("ClientHello extensions %v carry no server_name; the record is not a ClientHello", extensions) + } + if slices.Contains(extensions, 16) { + t.Fatalf("ClientHello extensions %v advertise ALPN, which only the inference profile sends", extensions) + } +} + +// clientHelloExtensionTypes lists the extension types of a TLS record holding one ClientHello. +func clientHelloExtensionTypes(t *testing.T, record []byte) []uint16 { + t.Helper() + fail := func() { t.Fatalf("malformed ClientHello record %x", record) } + if len(record) < 5 || record[0] != 22 { + fail() + } + // Record header (5), handshake header (4), legacy version (2), random (32). + offset := 5 + 4 + 2 + 32 + skip := func(lengthBytes int) { + if offset+lengthBytes > len(record) { + fail() + } + length := 0 + for _, b := range record[offset : offset+lengthBytes] { + length = length<<8 | int(b) + } + offset += lengthBytes + length + } + skip(1) // legacy session ID + skip(2) // cipher suites + skip(1) // compression methods + if offset+2 > len(record) { + fail() + } + end := offset + 2 + int(binary.BigEndian.Uint16(record[offset:])) + offset += 2 + if end != len(record) { + fail() + } + var types []uint16 + for offset < end { + if offset+4 > end { + fail() + } + types = append(types, binary.BigEndian.Uint16(record[offset:])) + offset += 2 + skip(2) + } + if offset != end { + fail() } + return types } diff --git a/sdk/cliproxy/auth/quota_usage_reader.go b/sdk/cliproxy/auth/quota_usage_reader.go new file mode 100644 index 000000000..d183c9f05 --- /dev/null +++ b/sdk/cliproxy/auth/quota_usage_reader.go @@ -0,0 +1,15 @@ +package auth + +import ( + "context" + "net/http" +) + +// QuotaUsageReader is an optional ProviderExecutor capability. ReadQuotaUsage reads the +// credential's subscription usage endpoint with the identity of the provider's CLI and +// returns the quota observation headers mapped from the response. Nil headers mean the +// response held no usable window. A non-2xx response is an error that carries the status. +// The caller decides which credentials are eligible. +type QuotaUsageReader interface { + ReadQuotaUsage(ctx context.Context, auth *Auth) (http.Header, error) +} diff --git a/sdk/cliproxy/service_quota.go b/sdk/cliproxy/service_quota.go index a52ca3501..12d69c80c 100644 --- a/sdk/cliproxy/service_quota.go +++ b/sdk/cliproxy/service_quota.go @@ -2,13 +2,8 @@ package cliproxy import ( "context" - "io" - "net/http" - "strings" "time" - claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude" - "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" ) @@ -44,50 +39,30 @@ func (s *Service) startQuotaUsageReads(ctx context.Context) { }() } -// readQuotaUsage reads one credential's usage endpoint and records the result. A failed or -// rejected read leaves the previous observation untouched. The provider's executor sends the -// read with the client identity of that provider's CLI, so the request carries no headers here. +// readQuotaUsage reads one credential's usage endpoint and records the result. It decides +// eligibility: OAuth credentials with a valid access token whose executor implements +// coreauth.QuotaUsageReader. A failed or rejected read leaves the previous observation untouched. func (s *Service) readQuotaUsage(ctx context.Context, authID string) { auth, ok := s.coreManager.GetByID(authID) if !ok || auth.AuthKind() != coreauth.AuthKindOAuth || !auth.HasValidAccessToken(time.Now()) { return } - var usageURL string - var parse func([]byte) http.Header - switch strings.ToLower(strings.TrimSpace(auth.Provider)) { - case "codex": - usageURL, parse = helps.CodexUsageURL, helps.ParseCodexUsageHeaders - case "claude": - usageURL, parse = claudeauth.UsageURL, helps.ParseClaudeUsageHeaders - default: + exec, ok := s.coreManager.Executor(auth.Provider) + if !ok { return } - readCtx, cancel := context.WithTimeout(ctx, quotaUsageReadTimeout) - defer cancel() - req, errRequest := http.NewRequestWithContext(readCtx, http.MethodGet, usageURL, nil) - if errRequest != nil { - return - } - resp, errDo := s.coreManager.HttpRequest(readCtx, auth, req) - if errDo != nil { - log.Warnf("quota usage read failed for auth %s: %v", authID, errDo) - return - } - defer func() { - if errClose := resp.Body.Close(); errClose != nil { - log.Errorf("quota usage read: close response body: %v", errClose) - } - }() - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - log.Warnf("quota usage read for auth %s returned status %d", authID, resp.StatusCode) + reader, ok := exec.(coreauth.QuotaUsageReader) + if !ok { return } - body, errRead := io.ReadAll(resp.Body) + readCtx, cancel := context.WithTimeout(ctx, quotaUsageReadTimeout) + defer cancel() + observed, errRead := reader.ReadQuotaUsage(readCtx, auth) if errRead != nil { log.Warnf("quota usage read failed for auth %s: %v", authID, errRead) return } - if observed := parse(body); observed != nil { + if observed != nil { s.coreManager.ObserveQuotaHeaders(authID, observed) } } From a298c45c76cf21d98a60ef5fd279d0a4ead9bdd3 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Sat, 3 Oct 2026 23:42:14 -0400 Subject: [PATCH 3/3] fix(auth): read usage only for enabled Codex and Claude credentials (MASTRA-787) KimiExecutor embeds ClaudeExecutor and so inherits ReadQuotaUsage; without a provider check the sweep sent Kimi OAuth tokens to api.anthropic.com every five minutes. The sweep now reads only enabled codex and claude credentials, matching the refresh loop's skip of disabled ones, and a regression test asserts which tokens reach the transport. Co-Authored-By: Claude Opus 5.5 --- sdk/cliproxy/auth/quota_usage_reader.go | 3 +- sdk/cliproxy/service_quota.go | 15 +++++---- sdk/cliproxy/service_quota_test.go | 41 +++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/sdk/cliproxy/auth/quota_usage_reader.go b/sdk/cliproxy/auth/quota_usage_reader.go index d183c9f05..42c6ba7e9 100644 --- a/sdk/cliproxy/auth/quota_usage_reader.go +++ b/sdk/cliproxy/auth/quota_usage_reader.go @@ -9,7 +9,8 @@ import ( // credential's subscription usage endpoint with the identity of the provider's CLI and // returns the quota observation headers mapped from the response. Nil headers mean the // response held no usable window. A non-2xx response is an error that carries the status. -// The caller decides which credentials are eligible. +// The caller decides which credentials are eligible, by provider as well as capability: +// an executor that embeds a reader, such as KimiExecutor embedding ClaudeExecutor, inherits it. type QuotaUsageReader interface { ReadQuotaUsage(ctx context.Context, auth *Auth) (http.Header, error) } diff --git a/sdk/cliproxy/service_quota.go b/sdk/cliproxy/service_quota.go index 12d69c80c..08dcd4e4f 100644 --- a/sdk/cliproxy/service_quota.go +++ b/sdk/cliproxy/service_quota.go @@ -40,11 +40,16 @@ func (s *Service) startQuotaUsageReads(ctx context.Context) { } // readQuotaUsage reads one credential's usage endpoint and records the result. It decides -// eligibility: OAuth credentials with a valid access token whose executor implements -// coreauth.QuotaUsageReader. A failed or rejected read leaves the previous observation untouched. +// eligibility: enabled Codex and Claude OAuth credentials with a valid access token whose +// executor implements coreauth.QuotaUsageReader. The provider check is needed because Go +// embedding passes the capability on: KimiExecutor embeds ClaudeExecutor, and a Kimi token +// must never reach Anthropic. A failed or rejected read leaves the previous observation untouched. func (s *Service) readQuotaUsage(ctx context.Context, authID string) { auth, ok := s.coreManager.GetByID(authID) - if !ok || auth.AuthKind() != coreauth.AuthKindOAuth || !auth.HasValidAccessToken(time.Now()) { + if !ok || auth.Disabled || auth.AuthKind() != coreauth.AuthKindOAuth || !auth.HasValidAccessToken(time.Now()) { + return + } + if auth.Provider != "codex" && auth.Provider != "claude" { return } exec, ok := s.coreManager.Executor(auth.Provider) @@ -62,7 +67,5 @@ func (s *Service) readQuotaUsage(ctx context.Context, authID string) { log.Warnf("quota usage read failed for auth %s: %v", authID, errRead) return } - if observed != nil { - s.coreManager.ObserveQuotaHeaders(authID, observed) - } + s.coreManager.ObserveQuotaHeaders(authID, observed) } diff --git a/sdk/cliproxy/service_quota_test.go b/sdk/cliproxy/service_quota_test.go index db0b7f2be..3c499ec6b 100644 --- a/sdk/cliproxy/service_quota_test.go +++ b/sdk/cliproxy/service_quota_test.go @@ -303,3 +303,44 @@ func TestQuotaUsageSweepRoutesNewSessionToUnusedCredential(t *testing.T) { t.Fatalf("new session went to %v, want [tok-c]: the never-used credential resets soonest", responses) } } + +// TestQuotaUsageReadSkipsKimiAndDisabledCredentials guards the provider check: KimiExecutor +// embeds ClaudeExecutor and so implements QuotaUsageReader, yet a Kimi token must never be sent +// to Anthropic. A disabled Claude credential is not read either; its enabled twin is. +func TestQuotaUsageReadSkipsKimiAndDisabledCredentials(t *testing.T) { + var mu sync.Mutex + var tokens []string + rt := quotaTestRoundTripper(func(req *http.Request) (*http.Response, error) { + mu.Lock() + tokens = append(tokens, strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")) + mu.Unlock() + return quotaTestResponse(req, http.StatusOK, "application/json", `{}`), nil + }) + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(rt)) + service := &Service{cfg: &config.Config{}, coreManager: coreauth.NewManager(nil, nil, nil)} + far := time.Now().Add(240 * time.Hour).Format(time.RFC3339) + for _, auth := range []*coreauth.Auth{ + {ID: "kimi-a", Provider: "kimi", Status: coreauth.StatusActive, + Metadata: map[string]any{"type": "kimi", "access_token": "kimi-token", "expired": far}}, + {ID: "claude-off", Provider: "claude", Status: coreauth.StatusActive, Disabled: true, + Metadata: map[string]any{"type": "claude", "access_token": "claude-off-token", "expired": far}}, + {ID: "claude-on", Provider: "claude", Status: coreauth.StatusActive, + Metadata: map[string]any{"type": "claude", "access_token": "claude-on-token", "expired": far}}, + } { + service.ensureExecutorsForAuth(auth) + if _, err := service.coreManager.Register(ctx, auth); err != nil { + t.Fatalf("register %s: %v", auth.ID, err) + } + } + if exec, ok := service.coreManager.Executor("kimi"); !ok { + t.Fatal("kimi executor not registered") + } else if _, isReader := exec.(coreauth.QuotaUsageReader); !isReader { + t.Fatal("kimi executor no longer implements QuotaUsageReader; the provider check may be obsolete") + } + for _, id := range []string{"kimi-a", "claude-off", "claude-on"} { + service.readQuotaUsage(ctx, id) + } + if want := []string{"claude-on-token"}; !reflect.DeepEqual(tokens, want) { + t.Fatalf("usage reads sent tokens %v, want %v", tokens, want) + } +}