Skip to content

feat(auth): send the real CLI identity on usage reads (MASTRA-787) - #8

Merged
ericlitman merged 3 commits into
mainfrom
claude/MASTRA-787-cli-identity
Oct 4, 2026
Merged

ericlitman merged 3 commits into
mainfrom
claude/MASTRA-787-cli-identity

Conversation

@ericlitman

@ericlitman ericlitman commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Linear: MASTRA-787, AC7. Follows #7.

The usage reads from #7 went out with only the token and one provider header, so they presented Go's default User-Agent. Eric decided on 2026-10-04 that, before deploy, they must present the same client identity as each account's real CLI traffic. This PR does that by having each executor send its own usage read.

What changed

a298c45c: eligibility fix after re-review. The service reads usage only for enabled codex and claude OAuth credentials.

  • Why: KimiExecutor embeds ClaudeExecutor, so it inherits ReadQuotaUsage. At 20e624c the sweep therefore sent Kimi OAuth tokens to api.anthropic.com every 5 minutes (found by the Opus re-review, which reproduced it).
  • Disabled credentials: they are now skipped too, matching the refresh loop.
  • Test: TestQuotaUsageReadSkipsKimiAndDisabledCredentials asserts the exact tokens that reach the transport (only the enabled Claude credential's). Removing the provider check makes the test report kimi-token. Removing the disabled check makes it report claude-off-token.
  • Deliberate upstream change: HTTP Codex inference no longer sends an empty Chatgpt-Account-Id, matching the websocket path.

20e624cc, the design after review. Usage reads are an explicit optional executor capability, coreauth.QuotaUsageReader (ReadQuotaUsage(ctx, auth) (http.Header, error), in the fork file sdk/cliproxy/auth/quota_usage_reader.go).

  • ClaudeExecutor, CodexExecutor and CodexAutoExecutor implement it. CodexWebsocketsExecutor gets it by embedding.
  • service_quota.go gets the executor through Manager.Executor and owns eligibility: OAuth, a valid token, and the capability. It no longer has a URL switch, parser selection or response handling.
  • Both HttpRequest methods are back to their upstream contract.
  • Claude's usage fetch shares fetchOAuthControlPlaneJSON through a header hook instead of copying it.
  • The proxy follows the single EffectiveProxyURL precedence.
  • Codex skips an empty Chatgpt-Account-Id, which now matches the websocket path. The usage read sets no Accept.

470363a0, first version

  • Claude. ClaudeExecutor.HttpRequest recognizes an OAuth GET https://api.anthropic.com/api/oauth/usage and sends it as Claude Code 2.1.288's API client does.
    • Transport: the fork's OAuth control-plane transport, with the captured Node/axios TLS profile and the ordered HTTP/1.1 serializer. The proxy is selected the same way as for the profile fetch.
    • Headers, in order: Accept: application/json, text/plain, */*, Content-Type: application/json, User-Agent: claude-cli/2.1.280 (external, cli), Authorization, anthropic-beta: oauth-2025-04-20, Accept-Encoding: gzip, compress, deflate, br, Host, Connection: close.
    • User-Agent version: it is the configured CLI baseline the account's inference already presents (defaultClaudeDeviceProfile), so one account shows one CLI version.
    • Body: decoded with readClaudeOAuthResponseBody, because the request advertises compression.
    • Files: the code is in the new files internal/auth/claude/oauth_usage.go and internal/runtime/executor/claude_executor_usage.go. Upstream files gained 3 lines in claude_executor.go and the header-order entry in utls_transport.go.
  • Codex. The bearer, User-Agent, Originator, Chatgpt-Account-Id, custom-header and cloaking logic moved out of applyCodexHeadersFromSources into applyCodexIdentityHeaders. Inference and the usage GET both call it, and the usage GET adds Accept: application/json. Codex keeps the executor's Chrome uTLS transport. Inference behaviour is unchanged.
  • Service. service_quota.go no longer sets any headers. The provider switch now selects only the URL and the parser.

Size: 159 non-test lines added, 21 removed. Tests: 372 lines.

Where the header set comes from

The source is the installed CLIs, through a design pass by Astra (gpt-6-astra, high). The adjudication is on MASTRA-787.

  • Claude Code 2.1.288: its usage call is CF -> qt.get -> axios, with User-Agent nO() = claude-cli/<ver> (external, cli) and the OAuth beta header. It sends no Cache-Control.
  • Codex 0.160.0: its binary contains the /wham/usage path and the codex_cli_rs/codex-tui identity code. The executor's inference identity is the closest evidenced match.
  • Not proven: byte-exact parity with the 2.1.288 and 0.160.0 TLS handshakes and header order. That would need native packet captures. The Claude TLS profile is the fork's capture from Claude Code 2.1.220.

Acceptance criterion AC7

  • "A test captures what the transport receives and compares it with the provider's existing request path."
    • TestUsageReadTransportReceivesCLIIdentity compares the method, URL and full header map for each provider with a literal fixture.
    • TestFetchOAuthUsageSerializesNativeHeaderOrder checks the exact Claude request lines, including order and casing.
    • TestClaudeUsageReadDialsControlPlaneTLSThroughCredentialProxy sets auth.ProxyURL to a local CONNECT listener and runs the production path with no injected transport. It asserts CONNECT api.anthropic.com:443 and a ClientHello with no ALPN, which is the control-plane profile. The inference profile carries ALPN, and adding ALPN makes the test fail.
    • TestCodexUsageReadMatchesInferenceIdentity covers six cases with literal expectations built from codexUserAgent and codexOriginator, including cloaking off with no configured User-Agent, and an empty account ID.
    • TestClaudeUsageReadDecodesCompressedResponse checks that a gzip body still parses.
  • "A live read through the production binary returns 200 for every studio2 credential." I built a throwaway binary from 470363a and ran it on studio2, calling HttpRequest with no caller headers. I re-ran it from 20e624c through ReadQuotaUsage, and all 6 succeeded with 4 or 5 mapped signals and no auth-file mtime change.
    • All 6 credentials returned 200: the 3 Claude credentials over HTTP/1.1 and the 3 Codex credentials over HTTP/2.
    • Each mapped 4 or 5 signals.
    • No auth file's mtime changed.

Checks

I ran these myself on the committed tree a298c45, and the same results held on 20e624c and 470363a:

  • go build ./...: exit 0.
  • go vet ./sdk/cliproxy/... ./internal/...: exit 0.
  • go test ./...: exit 0.
  • go test -race -count=1 over sdk/cliproxy, sdk/cliproxy/auth, internal/runtime/executor/... and internal/auth/claude: two of three runs exited 0, and one exited 1. The failures were only in the three upstream tests that also fail on main: TestHandleAuthUpdates_SameRevisionWaitDoesNotWaitForOtherAuthInBatch and TestEndToEndAuthFile{Replacement,Patch}_RestoresModelsInV1ModelsWithoutRestart.

Review

Round 1: /pstack:interrogate on 470363a. Three reviewers: Opus 5.5 at max (8 findings), gpt-5.6-sol at max (4) and Grok 4.7 at xhigh (2).

  • Acted on in 20e624c:
    • URL matching inside the generic HttpRequest breaks its contract and splits eligibility. Raised by Opus. Fixed with the executor capability.
    • The usage fetch copied the control-plane fetch. Raised by Opus.
    • No test ran the production Claude transport. Raised by Opus, Sol and Grok. Fixed with the CONNECT test.
    • A third proxy rule. Raised by Opus and Sol.
    • The order slice sat in an upstream file, and the test pinned an upstream constant. Raised by Opus.
    • User-Agent and Codex comments, and the empty account ID. Raised by Opus.
    • The Accept provenance and override. Raised by Opus and Grok.
  • Round 2: Opus 5.5 max re-review of 20e624c.
    • Fixed in a298c45: one critical, the Kimi token leak described above.
    • Nits, not acted on:
      • The helper extraction inside the upstream Codex header file costs rebase work. The alternative depends on a header delete list.
      • A test for the proxy taking precedence over an injected transport.
  • Noted:
    • The advertised compress encoding can't be decoded (Sol). The fork's profile fetch has the same issue, so it predates this PR.
    • The no-refresh test uses credentials with no refresh token (Sol). The executor path never refreshes, and the test avoids reaching a real token endpoint.
    • Executor(auth.Provider) and executorKeyFromAuth differ only for compat_name/provider_key credentials (fix lane). The Codex and Claude OAuth credentials have neither.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
MASTRA-787 Read Codex and Claude usage on a timer so soonest-reset sees quota used outside the proxy

Outcome

The soonest-reset selector in mobilyze-llc/CLIProxyAPI ranks every Codex and Claude OAuth credential by its latest usage reading. That includes quota consumed outside the proxy, and credentials the proxy has never sent a request. A reading can be up to one read interval old.

Why

Today the selector knows a credential's quota only from the proxy's own request traffic: response headers, and Codex websocket quota events (MASTRA-765). On 2026-10-04, reading the provider usage endpoints on studio2 showed two problems (details on MASTRA-766):

  • Codex. litman.org-pro resets soonest (2026-10-09 21:13Z, 9% used). It has never served a proxy request, so it sorts last, and every new Codex session goes to mobilyze.com-pro (resets 2026-10-10 03:08Z). Eric uses litman.org outside the proxy.
  • Claude. The proxy's observation of litman.org was stale: 24% weekly against 31% read from usage. The ordering stays right because Claude's weekly reset is on a fixed schedule. The exhaustion gate, however, runs on old utilization.

Eric chose (2026-10-04) to read usage for every credential on a timer, rather than only once for credentials with no observation.

Scope

  • On a timer, read each Codex credential's GET https://chatgpt.com/backend-api/wham/usage and each Claude credential's GET https://api.anthropic.com/api/oauth/usage. Use the proxy's own current access token for each credential.
  • Turn each read into the same quota observation the selector already ranks and gates on, and persist it with save-cooldown-status like any other observation.
  • Never refresh a token on behalf of a read. The proxy's existing refresh path stays the only refresher for each lineage. A read whose token is expired or rejected is skipped, and the selector keeps the last observation.
  • Suggestion, not a requirement: map the usage response onto the existing X-Codex-* and Anthropic-Ratelimit-Unified-* signal names, so the ranking and gate code do not change. Upstream already parses the wham/usage array form (internal/runtime/executor/helps/codex_quota.go).

Acceptance criteria

  1. Unit tests. Go tests cover the mapping from a recorded wham/usage response and a recorded api/oauth/usage response to the observation. A recorded response is one captured with tokens and identities removed.
  2. Service-level test. A Go test runs the real proxy HTTP handler with synthetic OAuth credentials and routes the usage reads to a mock through the existing transport injection. A never-used Codex credential with the soonest reset wins a new session. (Revised from a Tester Army hermetic test: that suite uses API-key credentials, and reaching a mock usage endpoint there would need a production endpoint override.)
  3. Live on studio2. After deploy, a new Codex session goes to litman.org-pro, or whichever credential the usage endpoint shows resetting soonest at that time. Check it with ordering_check.py against the usage readings.
  4. No window started. Show that a read does not start an idle window, for Codex and for Claude. Codex evidence: two reads of litman.org-pro's idle gpt-reserve limit 9 s apart moved reset_at by 9 s (MASTRA-766). Claude is not yet shown. Read a Claude credential whose 5-hour window has lapsed, twice, before deploy.
  5. No token refresh outside the existing path. No read triggers a token-endpoint call or an auth-file write. A test counts refresh calls and token-store saves during a sweep, and both are zero. The observation .cds write is expected.
  6. A rejected or failed read keeps the previous observation, and a read never clears an existing cooldown.
  7. The reads present the same client identity as the account's real CLI traffic. Eric decided this on 2026-10-04, before deploy. Each usage read sends the headers, TLS profile and header order that the proxy already uses for that provider's equivalent real-CLI requests, not Go's defaults. For Claude, that is the fork's authenticated /api/oauth/* control-plane path. For Codex, it is the executor's Codex header path. A test captures the bytes or the header set the transport receives and compares it with the provider's existing request path. A live read through the production binary returns 200 for every studio2 credential.

Constraints

  • One commit per change on top of v8.0.13-mob.1. Release as v8.0.13-mob.2, and deploy by the studio2-ops runbook section "CLIProxyAPI upgrades".
  • No cap, retry budget or backoff machinery that no criterion above needs.
  • Read interval: 5 minutes, a constant. The observation that settles it: the read log over the first week after deploy shows no 429 from either usage endpoint. Each read logs its non-2xx status.

Source refs

  • Parent: MASTRA-763. Related: MASTRA-765 (selector), MASTRA-766 (deploy and measurement, with the usage readings), MASTRA-768 (e2e).
  • Probe scripts on studio2: ~/.local/share/cliproxyapi/mastra766/codex_usage_probe.py and claude_usage_probe.py.

Design pass (Astra, gpt-6-astra high, 2026-10-04) and adjudication

Accepted, verified against code:

  • One service-owned loop in a new sdk/cliproxy/service_quota.go: an immediate sweep at start, then every interval, cancelled on shutdown.
  • Per read: GetByID for the current credential, skip it unless HasValidAccessToken, then Manager.HttpRequest. That path goes straight to the executor, and PrepareRequest only sets Authorization from the current token (codex_executor_request.go:60-91, claude_executor.go:214-254). No refresh, no MarkResult, no auth-file write.
  • Map the response onto the existing signal names, then call QuotaState.ObserveResponseHeadersForProvider on Auth.Quota only, under m.mu, and persistCooldownStates. Claude utilization is a percent, so divide it by 100. resets_at is RFC3339, so emit Unix seconds. Codex limit_window_seconds divided by 60 gives Window-Minutes.
  • The Codex websocket-event parser does not accept the usage shape (codex_quota.go:28). Add a usage entry point.

Added by me:

  • A per-read context deadline of 30 s. NewUtlsHTTPClient(..., 0) sets no client timeout, so one hung read would stall every later read and stop observations. That fails the outcome.

Rejected:

  • Mapping Codex additional_rate_limits and Claude iguana_necktie, limits, spend and extra_usage. The selector does not rank them, so nothing consumes them.
  • Any retry, backoff, worker pool or feature flag.

Review in Linear

ericlitman and others added 2 commits October 3, 2026 23:17
…TRA-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 5fde8ee 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@ericlitman
ericlitman merged commit f2cc995 into main Oct 4, 2026
2 checks passed
@ericlitman
ericlitman deleted the claude/MASTRA-787-cli-identity branch October 4, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant