|
| 1 | +# Anthropic quota surfaces |
| 2 | + |
| 3 | +Three independent surfaces expose Claude plan quota and account identity to OAuth clients. All captured live on 2026-07-16 against two accounts of **different kinds** — `main`: personal Max plan (`organization_type: claude_max`, `rate_limit_tier: default_claude_max_20x`, extra usage disabled) and `work-alt`: Team seat (`organization_type: claude_team`, `seat_tier: team_tier_1`, `rate_limit_tier: default_claude_max_5x`, extra usage enabled and exhausted). Account kind drives which quota headers/fields appear. Structural mirror of the openai-auth catalogue: Codex exposes the same two ideas as `x-codex-*` response headers (passive) — Anthropic additionally has a rich poll endpoint. |
| 4 | + |
| 5 | +| Surface | Transport | Freshness | Scoped per-model windows | Idle accounts | |
| 6 | +| --- | --- | --- | --- | --- | |
| 7 | +| Usage API (`GET /api/oauth/usage`) | active poll, per token | on demand | **yes** (`limits[]`) | **yes** — pollable without traffic | |
| 8 | +| `anthropic-ratelimit-unified-*` headers | passive, on every `/v1/messages` response | every request | no | no — only accounts you send through | |
| 9 | + |
| 10 | +The plugin combines surface 1 background polling (`fetchOAuthQuotaSnapshot` + `QuotaManager`) with passive direct-path harvest from surface 2. Relay responses remain gated from harvest. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Surface 1 — usage API |
| 15 | + |
| 16 | +``` |
| 17 | +GET https://api.anthropic.com/api/oauth/usage |
| 18 | +authorization: Bearer <oauth access token> |
| 19 | +anthropic-beta: oauth-2025-04-20 |
| 20 | +``` |
| 21 | + |
| 22 | +Consumed by: `fetchOAuthQuotaSnapshot()` (`packages/core/src/accounts.ts`), which maps it to `OAuthQuotaSnapshot` (`five_hour`/`seven_day`/`scoped[]` + `checkedAt`). |
| 23 | + |
| 24 | +### Top-level shape (observed 2026-07-16) |
| 25 | + |
| 26 | +```jsonc |
| 27 | +{ |
| 28 | + "five_hour": { /* window */ }, |
| 29 | + "seven_day": { /* window */ }, |
| 30 | + // legacy per-model window slots — null on both probed accounts: |
| 31 | + "seven_day_oauth_apps": null, |
| 32 | + "seven_day_opus": null, |
| 33 | + "seven_day_sonnet": null, |
| 34 | + "seven_day_cowork": null, |
| 35 | + "seven_day_omelette": null, |
| 36 | + // unreleased feature-flag slots (codenames) — null on both probed accounts: |
| 37 | + "tangelo": null, |
| 38 | + "iguana_necktie": null, |
| 39 | + "omelette_promotional": null, |
| 40 | + "nimbus_quill": null, |
| 41 | + "cinder_cove": null, |
| 42 | + "amber_ladder": null, |
| 43 | + "extra_usage": { /* extra-usage credits block */ }, |
| 44 | + "limits": [ /* unified limits array — the modern surface */ ], |
| 45 | + "spend": { /* extra-usage spend detail */ }, |
| 46 | + "member_dashboard_available": false |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +### Window object (`five_hour`, `seven_day`) |
| 51 | + |
| 52 | +| Field | Type | Notes | |
| 53 | +| --- | --- | --- | |
| 54 | +| `utilization` | int percent | integer only — no sub-percent precision | |
| 55 | +| `resets_at` | ISO 8601 (µs precision, +00:00) | end of current window | |
| 56 | +| `limit_dollars` / `used_dollars` / `remaining_dollars` | null on plan accounts | presumably populated for pay-as-you-go/org billing | |
| 57 | + |
| 58 | +### `limits[]` — the modern unified surface |
| 59 | + |
| 60 | +One entry per active limit class. Observed kinds: |
| 61 | + |
| 62 | +| `kind` | `group` | `scope` | Meaning | |
| 63 | +| --- | --- | --- | --- | |
| 64 | +| `session` | `session` | null | the 5h window | |
| 65 | +| `weekly_all` | `weekly` | null | the 7d all-models window | |
| 66 | +| `weekly_scoped` | `weekly` | `{ model: { id, display_name }, surface }` | per-model weekly carve-out (e.g. Fable promo) | |
| 67 | + |
| 68 | +Entry fields: |
| 69 | + |
| 70 | +| Field | Type | Notes | |
| 71 | +| --- | --- | --- | |
| 72 | +| `percent` | int | utilization | |
| 73 | +| `severity` | `normal` \| `warning` \| … | observed `warning` at 77%; captured but not used for plugin tone | |
| 74 | +| `resets_at` | ISO 8601 | per-limit reset | |
| 75 | +| `scope.model.id` | string \| null | **null observed even for Fable** — only `display_name` present ("Fable"); this is why `scopedQuotaModelKey` normalizes display names | |
| 76 | +| `is_active` | bool | **inferred:** marks the currently *binding* limit — on both accounts the entry with the highest percent carried `is_active: true` (main: Fable 15% > 7d 13% > 5h 4%; work-alt: session 77% > Fable 51% > weekly 40%). Not documented by Anthropic; treat as heuristic | |
| 77 | + |
| 78 | +`limits[]` supersedes the legacy `seven_day_opus`/`seven_day_sonnet` slots (always null in our captures). The plugin reads `limits[]` for scoped windows (PR #108/#109 work: empty-`[]` presence contract, scoped killswitch). |
| 79 | + |
| 80 | +### `extra_usage` + `spend` (extra-usage credits) |
| 81 | + |
| 82 | +Observed on work-alt (enabled, **exhausted**): `monthly_limit: 10000` minor units, `used_credits: 10035`, `utilization: 100`; `spend.severity: "critical"`, `spend.limit.amount_minor: 10000`, `spend.cap.credits`, `can_purchase_credits: false`. On main (disabled): all null, `is_enabled: false` (header equivalent: `overage-disabled-reason: org_level_disabled`). |
| 83 | + |
| 84 | +Money is `{ amount_minor, currency, exponent }` — e.g. `10035` minor / exponent 2 = $100.35. |
| 85 | + |
| 86 | +Consumed for `/claude-quota` and expanded TUI/sidebar credit display. Extra usage remains display-only and does not affect routing. |
| 87 | + |
| 88 | +### Response headers on the usage API itself |
| 89 | + |
| 90 | +Only `anthropic-organization-id` + `request-id` — the `ratelimit-unified` family does NOT appear on the usage endpoint, only on `/v1/messages`. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## Surface 2 — `anthropic-ratelimit-unified-*` response headers |
| 95 | + |
| 96 | +Present on every `/v1/messages` response (200s included; OAuth transport). Captured live 2026-07-16 on both accounts — the header SET is conditional on account state and kind, not fixed: |
| 97 | + |
| 98 | +| Header | main — personal Max 20x (3%/12%, overage disabled) | work-alt — Team seat, Max-5x tier (78%/40%, credits exhausted) | |
| 99 | +| --- | --- | --- | |
| 100 | +| `…-unified-status` | `allowed` | `allowed` | |
| 101 | +| `…-unified-reset` | `1784252400` (epoch **seconds**) | `1784246400` | |
| 102 | +| `…-unified-representative-claim` | `five_hour` | `five_hour` | |
| 103 | +| `…-unified-5h-status` | `allowed` | `allowed` | |
| 104 | +| `…-unified-5h-utilization` | `0.03` (**fraction**, not percent) | `0.78` | |
| 105 | +| `…-unified-5h-reset` | `1784252400` | `1784246400` | |
| 106 | +| `…-unified-7d-status` | `allowed` | `allowed` | |
| 107 | +| `…-unified-7d-utilization` | `0.12` | `0.4` | |
| 108 | +| `…-unified-7d-reset` | `1784502000` | `1784628000` | |
| 109 | +| `…-unified-fallback` | — absent | `available` | |
| 110 | +| `…-unified-fallback-percentage` | `0.5` | `0.5` | |
| 111 | +| `…-unified-overage-status` | `rejected` | `rejected` | |
| 112 | +| `…-unified-overage-disabled-reason` | `org_level_disabled` | `org_spend_cap_reached` | |
| 113 | +| `…-unified-overage-utilization` | — absent | `1.0` | |
| 114 | +| `…-unified-overage-surpassed-threshold` | — absent | `1.0` | |
| 115 | +| `…-unified-overage-reset` | — absent | `1785542400` | |
| 116 | + |
| 117 | +Conditional headers (absent on main, present on work-alt): `fallback` appears once utilization is high enough that a client-side fallback is advisable (5h 78% > the 0.5 `fallback-percentage` threshold — consistent with `fallback-percentage` being the trip point); the three extra `overage-*` headers appear when extra-usage credits have actually been consumed (work-alt: 100% used, spend cap reached, `overage-reset` = when the monthly credit window resets). A header consumer must treat every non-core header as optional. |
| 118 | + |
| 119 | +| Header | Semantics | |
| 120 | +| --- | --- | |
| 121 | +| `…-status` | overall admit decision (`allowed`; presumably `rejected`/throttle states near limits) | |
| 122 | +| `…-reset` | top-level reset = reset of the representative claim | |
| 123 | +| `…-representative-claim` | which window is currently binding (`five_hour`/`seven_day`) — header analogue of `limits[].is_active` | |
| 124 | +| `…-5h-*` / `…-7d-*` | per-window status / **fractional** utilization (0.03 = 3%) / epoch-seconds reset | |
| 125 | +| `…-fallback` | conditional — `available` appears when a window's utilization exceeds the fallback threshold (observed at 5h 0.78); Anthropic's hint that the client should consider failing over | |
| 126 | +| `…-fallback-percentage` | the fallback trip point (0.5 on both accounts) — consistent with `fallback` appearing once utilization crosses it | |
| 127 | +| `…-overage-status` / `…-overage-disabled-reason` | extra-usage credits admit state — `org_level_disabled` (feature off, main) vs `org_spend_cap_reached` (credits exhausted, work-alt) | |
| 128 | +| `…-overage-utilization` / `…-overage-surpassed-threshold` / `…-overage-reset` | conditional — only once credits are consumed: fraction used (1.0), threshold crossed, epoch-seconds reset of the credit window | |
| 129 | + |
| 130 | +### Differences vs the usage API |
| 131 | + |
| 132 | +1. **No scoped per-model windows** — Fable/haiku carve-outs exist only in `limits[]`. Scoped killswitch + prime's model-aware checks cannot run on headers alone. |
| 133 | +2. **Passive** — idle fallback accounts emit nothing; pre-visibility requires the poll. |
| 134 | +3. **Coarser numbers** — fraction (2 decimals) vs integer percent; no severity, no dollars, no extra-usage detail beyond admit state. |
| 135 | +4. **Free freshness** — every real request refreshes main's 5h/7d at zero API cost. |
| 136 | + |
| 137 | +### Comparison with OpenAI/Codex (`x-codex-*`) |
| 138 | + |
| 139 | +| | Anthropic | OpenAI/Codex | |
| 140 | +| --- | --- | --- | |
| 141 | +| Passive headers | `anthropic-ratelimit-unified-*` (5h/7d + overage) | `x-codex-primary/secondary-*` (5h/weekly) | |
| 142 | +| Active poll endpoint | `GET /api/oauth/usage` (rich: scoped, severity, spend) | **none** — headers are the only quota surface | |
| 143 | +| Scoped per-model windows | `limits[]` `weekly_scoped` | n/a | |
| 144 | +| Representative/binding marker | `representative-claim` header + `is_active` (inferred) | `x-codex-…-over-…` style flags | |
| 145 | + |
| 146 | +openai-auth is push-based by necessity (QuotaManager fed via `setMain`/`setFallback`); anthropic-auth now combines the usage poll with passive header pushes on direct requests. |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## Surface 3 — profile API (account kind) |
| 151 | + |
| 152 | +``` |
| 153 | +GET https://api.anthropic.com/api/oauth/profile |
| 154 | +authorization: Bearer <oauth access token> |
| 155 | +anthropic-beta: oauth-2025-04-20 |
| 156 | +``` |
| 157 | + |
| 158 | +Identity + plan metadata; the only surface exposing account KIND. Observed key fields: |
| 159 | + |
| 160 | +| Field | main | work-alt | Notes | |
| 161 | +| --- | --- | --- | --- | |
| 162 | +| `account.has_claude_max` | `true` | `false` | personal-plan flag only — false for Team seats | |
| 163 | +| `organization.organization_type` | `claude_max` | `claude_team` | the account-kind discriminator | |
| 164 | +| `organization.rate_limit_tier` | `default_claude_max_20x` | `default_claude_max_5x` | **quota multiplier tier** — a Team tier-1 seat gets Max-5x-equivalent limits | |
| 165 | +| `organization.seat_tier` | `null` | `team_tier_1` | Team seat level | |
| 166 | +| `organization.has_extra_usage_enabled` | `false` | `true` | matches `extra_usage.is_enabled` in the usage API + `overage-*` headers | |
| 167 | +| `organization.billing_type` | `stripe_subscription` | `stripe_subscription` | | |
| 168 | +| `application.slug` | `claude-code` | `claude-code` | OAuth app identity | |
| 169 | + |
| 170 | +Also returned: account/org uuids, email, subscription status/created, `enabled_plugins`. The plugin stores only `organization_type`, `rate_limit_tier`, the check time, and an access-token fingerprint. Profile reads run from boot/background sidebar hydration and quota/account display paths at most once per account per process, persist in the sidecar, and reuse matching-token results for seven days. Model request dispatch does not call the profile endpoint. |
| 171 | + |
| 172 | +## Implemented behavior |
| 173 | + |
| 174 | +- Direct `/v1/messages` responses harvest the unified 5h and 7d windows. Utilization fractions are multiplied by 100, then rounded; reset values are epoch seconds converted to ISO timestamps. |
| 175 | +- Header pushes merge into the last poll snapshot. They preserve poll-owned `scoped`, including meaningful empty `[]`, and `extraUsage` credit data. |
| 176 | +- Poll `limits[].is_active` owns `bindingWindow` when present. The header `representative-claim` fills the marker only when the poll did not supply one. |
| 177 | +- Money stays in integer minor units with an explicit currency exponent. Formatting happens at the display boundary. |
| 178 | +- `fallback: available` becomes `fallbackAdvised`; it appears only in expanded quota views and does not change routing. |
| 179 | +- Profile metadata is sidecar-persisted, uses a seven-day TTL, and is absent from the request path. |
| 180 | +- Relay transport is direct-only for harvest in v1. Both the HTTP Worker (`upstream.headers` copied into its response) and WebSocket `response_start` preserve unified headers, but relay responses remain gated because transport-reconstructed WebSocket headers are not yet treated as canonical harvest evidence. See the relay parity item in `docs/parity-backlog.md`. |
| 181 | + |
| 182 | +## Gaps / opportunities |
| 183 | + |
| 184 | +- Relay-side harvest requires a client eligibility decision and synthetic-header safety gate at the `usedRelay` guard, not an HTTP Worker passthrough fix. |
| 185 | +- Pi has a separate streaming response path and does not harvest quota headers in v1. |
| 186 | + |
| 187 | +## Probe recipes |
| 188 | + |
| 189 | +```bash |
| 190 | +# usage API (token from opencode auth.json for main, anthropic-auth-state.json for fallbacks) |
| 191 | +curl -s https://api.anthropic.com/api/oauth/usage \ |
| 192 | + -H "authorization: Bearer $TOKEN" -H "anthropic-beta: oauth-2025-04-20" | jq . |
| 193 | + |
| 194 | +# headers (one ~20-token haiku request) |
| 195 | +curl -sD - -o /dev/null https://api.anthropic.com/v1/messages?beta=true \ |
| 196 | + -H "authorization: Bearer $TOKEN" -H "anthropic-version: 2023-06-01" \ |
| 197 | + -H "anthropic-beta: oauth-2025-04-20" -H "content-type: application/json" \ |
| 198 | + -d '{"model":"claude-haiku-4-5","max_tokens":1,"system":"Reply with 1 when you receive 0.","messages":[{"role":"user","content":"0"}]}' \ |
| 199 | + | grep -i anthropic-ratelimit |
| 200 | +``` |
| 201 | + |
| 202 | +Gotcha: fallback tokens in `anthropic-auth.json` (config) go stale — current tokens live in `anthropic-auth-state.json` (memory: two-file store; state holds runtime). |
0 commit comments