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 new file mode 100644 index 000000000..fbfd2fe4c --- /dev/null +++ b/internal/auth/claude/oauth_usage.go @@ -0,0 +1,44 @@ +package claude + +import ( + "context" + "net/http" +) + +const ( + // 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 { + 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, 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 new file mode 100644 index 000000000..e6c0cbe0f --- /dev/null +++ b/internal/auth/claude/oauth_usage_test.go @@ -0,0 +1,70 @@ +package claude + +import ( + "bufio" + "context" + "fmt" + "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() + if _, errFetch := auth.FetchOAuthUsage(ctx, "tok-usage", "claude-cli/2.1.288 (external, cli)"); errFetch != nil { + t.Fatalf("FetchOAuthUsage() error = %v", errFetch) + } + 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")) + } +} diff --git a/internal/auth/claude/utls_transport.go b/internal/auth/claude/utls_transport.go index 7d69d4654..55f2ecdf8 100644 --- a/internal/auth/claude/utls_transport.go +++ b/internal/auth/claude/utls_transport.go @@ -52,6 +52,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_usage.go b/internal/runtime/executor/claude_executor_usage.go new file mode 100644 index 000000000..a7ba7a3f7 --- /dev/null +++ b/internal/runtime/executor/claude_executor_usage.go @@ -0,0 +1,36 @@ +package executor + +import ( + "context" + "net/http" + + 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" +) + +var _ cliproxyauth.QuotaUsageReader = (*ClaudeExecutor)(nil) + +// 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 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 { + 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, proxyURL) +} diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go index 6e948f9bd..d300bda2a 100644 --- a/internal/runtime/executor/codex_executor_request.go +++ b/internal/runtime/executor/codex_executor_request.go @@ -188,12 +188,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 +200,28 @@ 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 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) + } 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 != "" { @@ -224,7 +231,7 @@ func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, toke } 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 30753d6ad..c18875f0d 100644 --- a/internal/runtime/executor/helps/quota_usage.go +++ b/internal/runtime/executor/helps/quota_usage.go @@ -1,14 +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" ) +// 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 new file mode 100644 index 000000000..e69b746af --- /dev/null +++ b/internal/runtime/executor/usage_read_identity_test.go @@ -0,0 +1,314 @@ +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" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +type usageReadCapture struct { + method string + url string + header http.Header +} + +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 + })) + 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)) + } + 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).ReadQuotaUsage, usageReadOAuthAuth("claude", nil)) + 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, 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"}, + } + 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 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" + configuredUA = "codex-configured/2.0" + clientUA = "client-agent/3.0" + ) + cloakingOff := &config.Config{} + cloakingOff.Codex.DisableCodexCloaking = true + cloakingOffConfiguredUA := &config.Config{} + cloakingOffConfiguredUA.Codex.DisableCodexCloaking = true + cloakingOffConfiguredUA.CodexHeaderDefaults.UserAgent = configuredUA + + cases := []struct { + name string + cfg *config.Config + attrs map[string]string + accountID string + wantUsageUA string + wantInferenceUA string + }{ + {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) + 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) + } + applyCodexHeaders(inference, auth, "tok-codex", true, tc.cfg, http.Header{"User-Agent": {clientUA}}) + + 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) + } + } + 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) + } + } + }) + } +} + +// 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 + })) + headers, errRead := NewClaudeExecutor(&config.Config{}).ReadQuotaUsage(ctx, usageReadOAuthAuth("claude", nil)) + if errRead != nil { + t.Fatalf("usage read: %v", errRead) + } + 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) + } +} + +// 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..42c6ba7e9 --- /dev/null +++ b/sdk/cliproxy/auth/quota_usage_reader.go @@ -0,0 +1,16 @@ +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, 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 1588329ec..08dcd4e4f 100644 --- a/sdk/cliproxy/service_quota.go +++ b/sdk/cliproxy/service_quota.go @@ -2,12 +2,8 @@ package cliproxy import ( "context" - "io" - "net/http" - "strings" "time" - "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 +13,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 @@ -45,55 +39,33 @@ 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. +// readQuotaUsage reads one credential's usage endpoint and records the result. It decides +// 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 } - 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) - } - case "claude": - usageURL, parse = claudeUsageURL, helps.ParseClaudeUsageHeaders - headers.Set("Anthropic-Beta", "oauth-2025-04-20") - default: - return - } - readCtx, cancel := context.WithTimeout(ctx, quotaUsageReadTimeout) - defer cancel() - req, errRequest := http.NewRequestWithContext(readCtx, http.MethodGet, usageURL, nil) - if errRequest != nil { + if auth.Provider != "codex" && auth.Provider != "claude" { 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) + exec, ok := s.coreManager.Executor(auth.Provider) + if !ok { 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 { - 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) + } +}