Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions internal/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
44 changes: 44 additions & 0 deletions internal/auth/claude/oauth_usage.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
70 changes: 70 additions & 0 deletions internal/auth/claude/oauth_usage_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
}
3 changes: 3 additions & 0 deletions internal/auth/claude/utls_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions internal/runtime/executor/claude_executor_usage.go
Original file line number Diff line number Diff line change
@@ -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)
}
27 changes: 17 additions & 10 deletions internal/runtime/executor/codex_executor_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand All @@ -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 != "" {
Expand All @@ -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)
}
}
Expand Down
56 changes: 56 additions & 0 deletions internal/runtime/executor/codex_executor_usage.go
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions internal/runtime/executor/helps/quota_usage.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading