Skip to content

Commit 48b6577

Browse files
authored
feat: passive quota-header harvest + account metadata surfacing (#129)
Harvest anthropic-ratelimit-unified-* headers in the fetch/relay path and normalize them into the shared OAuthQuotaSnapshot alongside poll data; surface account tier/credits/failover metadata in the expanded sidebar. Token-fenced profile-only persistence with delayed-clear protection. Profile persistence is detached from the /claude-quota and /claude-account display path (render immediately, persist fire-and-forget) so an optional tier-label save can never block a command on the account write lock.
1 parent 23e4e7b commit 48b6577

28 files changed

Lines changed: 5267 additions & 277 deletions

docs/parity-backlog.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,24 @@ production-only regression (env-override bypass) — first-hand DIFF review caug
337337
338338
---
339339
340+
## 8. Relay response eligibility for quota harvest — FOLLOW-UP
341+
342+
The corrected Miniflare gate confirms both relay transports preserve
343+
`anthropic-ratelimit-unified-*`: the HTTP Worker copies `upstream.headers` into its response and
344+
WebSocket sends them in `response_start`. The earlier HTTP-negative test omitted
345+
`x-session-affinity`, so `sendViaRelay` returned its direct fallback without reaching the Worker.
346+
Quota harvest remains direct-only at the client `usedRelay` guard because WebSocket response
347+
headers are transport-reconstructed and are not yet canonical harvest evidence. Resolve that
348+
eligibility/synthetic-header question at the client guard before enabling relay harvest. Do not add
349+
a quota side channel.
350+
351+
## 9. Pi quota-header harvest parity — FOLLOW-UP
352+
353+
Pi uses the distinct `packages/pi/src/stream.ts` response path. Header harvest, served-account
354+
attribution, sidecar persistence, and quota display parity remain out of scope for v1. Port the
355+
OpenCode direct-path behavior without sharing request-path state implicitly, then gate Pi's own
356+
streaming response headers and malformed-header handling.
357+
340358
## Implementation phase (operator directive)
341359
342360
When implementation begins, create a **fresh parity branch off `upstream/main`** — NOT off `dev`,

docs/quota-surfaces.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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

Comments
 (0)