Skip to content

Passive quota-header harvest + account metadata surfacing - #129

Merged
ualtinok merged 1 commit into
cortexkit:mainfrom
iceteaSA:feat/quota-header-harvest
Jul 21, 2026
Merged

Passive quota-header harvest + account metadata surfacing#129
ualtinok merged 1 commit into
cortexkit:mainfrom
iceteaSA:feat/quota-header-harvest

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Anthropic sends anthropic-ratelimit-unified-* headers on every /v1/messages response, including 200s. This PR harvests them passively so the active account's 5h/7d quota stays fresh between usage-API polls at zero API cost, and surfaces account metadata that was previously invisible.

Harvest

  • packages/core/src/quota-headers.ts: classifier (isQuotaBearingHeaderFrame), normalizer (fraction → integer percent, epoch-seconds → ISO), and a window-class-aware merge that updates only header-owned fields — poll-owned scoped[] (including present-but-empty []), extraUsage, and poll-derived bindingWindow are preserved.
  • QuotaManager.pushMainFromHeaders/pushFallbackFromHeaders: token-fingerprint-bound pushes through the existing setters; pushed checkedAt defers the next poll through the existing staleness gate (no new scheduler).
  • Fetch-wrapper harvest with served-account attribution (main vs the specific fallback that served the response), best-effort try/catch — never throws across, delays, or consumes the response.
  • Header pushes never touch poll-backoff state (a /v1/messages 200 says nothing about the usage endpoint's rate limit), and the main persist path verifies the served token is still live before writing (rotation guard, matching the fallback branch).
  • Relay: direct-transport only. Miniflare evidence: the relay Worker's HTTP path strips these headers; WebSocket preserves them. Harvest skips relay-served responses until both transports pass the same gate; documented in docs/quota-surfaces.md, follow-up in the backlog.

Surfacing (display-only, no routing changes)

  • Extra-usage credits per account (credits $100.35/$100.00 · exhausted, severity tone) in /claude-quota + expanded sidebar, only when the account has the feature enabled.
  • Binding-window marker () — poll limits[].is_active wins over header representative-claim.
  • Account tier labels (Max 20x, Team · Max 5x) via GET /api/oauth/profile: lazy, once per boot per account, persisted in the state file (never the config), 7-day TTL, silent failure, never fetched in the request hot path.
  • fallback: available advisory note (informational; routing still decides by attempt).
  • Collapsed sidebar is byte-identical to before; all new fields are optional and crash-proof-normalized.

docs/quota-surfaces.md (included) catalogues all three wire surfaces with live-captured shapes from both a personal Max and a Team account — the test fixtures use those exact captures.

Verification

  • core 46 / opencode 845 / pi 48 / e2e 5 — all green; typecheck, lint, format clean; repo-root CI-shape run green.
  • Live end-to-end: a real request through the built plugin produced main.quota.source: "headers" in the state file with fresh checkedAt, preserved scoped[], and the debug log line — no usage-API poll involved.
  • Relay passthrough proven both directions (HTTP strips / WS preserves) in relay-worker-miniflare.test.ts + an e2e test proving harvest stays direct-only under relay config.

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Passively harvest anthropic-ratelimit-unified-* headers on /v1/messages to keep 5h/7d quotas fresh without extra polls, and surface account tier and credits across the CLI, account picker, and UI. Relay headers pass through on HTTP/WS; harvesting stays direct‑only behind a client guard, and profile caching is token‑bound with non‑blocking persistence.

  • New Features

    • core: Header classifier/normalizer/merger (isQuotaBearingHeaderFrame) maps 5h/7d utilization and resets, sets bindingWindow from representative-claim, parses fallback advisories, converts fractions to percents, and only updates header‑owned fields; poll‑owned scoped[], extraUsage, idle/boot, and backoff remain poll‑authored.
    • core: QuotaManager.pushMainFromHeaders/pushFallbackFromHeaders merge header snapshots with a token‑fingerprint guard, replace main 5h/7d windows from headers, share the poll staleness gate (fresh header evidence can license API‑key fallback; stale never does), set checkedAt to defer the next poll, and preserve poll backoff.
    • core: GET /api/oauth/profile hydration with formatOAuthAccountTier; cached 7 days, token‑bound, and persisted fire‑and‑forget so /claude-quota and /claude-account never wait on the write lock. Money formatter shared between core and TUI.
    • opencode: Wrapped fetch harvests headers with main/fallback attribution; best‑effort and never delays or consumes the response. Tier labels appear in the CLI list, account picker, TUI, and summaries. Surfacing adds credits, a binding‑window marker (•), and a fallback: available advisory in /claude-quota, the expanded sidebar/TUI, and the account selection dialog; the collapsed sidebar ignores these. Profile state adds delayed‑clear protection.
  • Bug Fixes

    • core: Window‑aware quota persistence; header/poll freshness by ownership; headers win on equal‑time ties; incomplete frames no longer clobber cached fields; invalid resets no longer discard valid utilization; token‑rotation mismatch drops.
    • opencode: Profile state is token‑bound and only persisted by the dedicated hydration path; generic saves cannot clobber it. Relay eligibility guard corrected and Miniflare confirms HTTP/WS header passthrough while keeping harvest disabled under relay. Malformed money metadata falls back cleanly; quota/refresh/profile state hardened; plugin exports avoid exposing test‑only hooks.

Written for commit c47af80. Summary will update on new commits.

Review in cubic

Greptile Summary

This PR adds passive quota updates and account metadata across the core and OpenCode surfaces. The main changes are:

  • Harvests quota headers from direct /v1/messages responses.
  • Merges header updates while preserving poll-owned quota fields.
  • Adds token-bound account profile caching and tier labels.
  • Displays binding windows, fallback advice, and extra-usage credits.
  • Adds persistence, relay, normalization, and display coverage.

Confidence Score: 5/5

This looks safe to merge.

  • Partial header frames retain the freshness deadlines of older main and scoped windows.
  • Invalid reset timestamps no longer discard valid utilization data.
  • Malformed money metadata is rejected or rendered through a safe fallback.
  • No blocking issues remain in the reviewed fixes.

Important Files Changed

Filename Overview
packages/core/src/quota-headers.ts Adds classification, normalization, and ownership-aware merging for passive quota headers.
packages/core/src/accounts.ts Extends persisted quota and profile state and adds per-window freshness handling.
packages/core/src/quota-manager.ts Adds token-bound quota pushes and reconciliation between header and poll updates.
packages/core/src/oauth-profile.ts Adds profile fetching, token matching, cache freshness, and tier formatting.
packages/core/src/quotas.ts Adds guarded formatting for extra-usage credit metadata.
packages/opencode/src/index.ts Integrates direct-response quota harvesting and served-account attribution.
packages/opencode/src/tui.tsx Displays tier, quota binding, fallback advice, and extra-usage information.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Direct messages response] --> B{Quota headers present?}
    B -->|No| C[Keep current quota state]
    B -->|Yes| D[Normalize 5h and 7d windows]
    D --> E[Attribute the served account]
    E --> F[Verify token fingerprint]
    F --> G[Merge header-owned fields]
    G --> H[Preserve poll-owned metadata]
    H --> I[Update freshness deadline]
    I --> J[Persist runtime state]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart LR
    A[Direct messages response] --> B{Quota headers present?}
    B -->|No| C[Keep current quota state]
    B -->|Yes| D[Normalize 5h and 7d windows]
    D --> E[Attribute the served account]
    E --> F[Verify token fingerprint]
    F --> G[Merge header-owned fields]
    G --> H[Preserve poll-owned metadata]
    H --> I[Update freshness deadline]
    I --> J[Persist runtime state]
Loading

Reviews (15): Last reviewed commit: "feat: passive quota-header harvest + acc..." | Re-trigger Greptile

Context used:

  • Context used - captures/AGENTS.md (source)
  • Context used - AGENTS.md (source)

Comment thread packages/core/src/quota-headers.ts
Comment thread packages/core/src/quota-headers.ts Outdated
Comment thread packages/core/src/quotas.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 22 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread docs/parity-backlog.md Outdated
Comment thread docs/quota-surfaces.md Outdated
Comment thread docs/quota-surfaces.md Outdated
Comment thread packages/core/src/commands/account.ts
Comment thread packages/core/src/quotas.ts Outdated
Comment thread packages/opencode/src/tui.tsx Outdated
Comment thread packages/opencode/src/index.ts
Comment thread packages/core/src/quota-manager.ts Outdated
Comment thread packages/opencode/src/tests/relay-worker-miniflare.test.ts
Comment thread packages/opencode/src/tests/index.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 20 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/oauth-profile.ts
Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/core/src/accounts.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/index.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/accounts.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/accounts.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/tests/accounts.test.ts
Comment thread packages/core/src/accounts.ts
@ualtinok

Copy link
Copy Markdown
Contributor

Thanks for the thorough implementation. I test-merged this on top of the current local routing work and also tested it with #130 stacked above it. I am holding the merge for these issues:

  1. saveAccountsLocked() now includes mainProfile: true, while applyMainProfileStatePatch() assigns storage.main?.profile unconditionally. A stale storage snapshot from another process can therefore erase a newer persisted main profile. Generic config/account saves should not own profile state, or the profile patch needs token/freshness-aware merge semantics.

  2. Boot profile hydration eventually writes sidebar state with { activeId: 'main', route: 'main' }. If a real fallback/sticky routing decision lands while profile hydration is in flight, the delayed profile completion can overwrite the active sidebar route. The completion should preserve the latest routing decision rather than publish a fixed main route.

  3. The stacked full suite passed once, but a repeat produced 1016 pass, 1 fail: does not route to API-key fallback from stale cached main OAuth exhaustion observed two non-quota requests instead of one. The test passes in isolation, so there is still shared/background request state or test-order leakage to eliminate before merge.

  4. This changes the paid API-fallback evidence boundary: an upstream response header reporting exact exhaustion becomes a fresh token-bound snapshot, allowing an API-key route on the next request without a usage-endpoint confirmation. That may be valid direct provider evidence, but because API fallback spends credits, this behavior needs an explicit regression test and an explicit decision in the strict fallback policy.

Please keep the token-bound/window-wise merge behavior; that part looks sound. I will re-review after these points and the current routing changes are incorporated.

@iceteaSA
iceteaSA force-pushed the feat/quota-header-harvest branch from 0445a98 to 9f1b85a Compare July 18, 2026 19:51
@iceteaSA

Copy link
Copy Markdown
Contributor Author

All four addressed in 9f1b85a.

  1. mainProfile is out of the generic save scope — only the dedicated token-fingerprint-bound hydration path persists profile state, so a stale cross-process snapshot can no longer erase a newer profile. Regression: a generic save from a stale snapshot now preserves the hydrated profile (red before the fix).

  2. Boot profile hydration publishes its sidebar frame non-authoritatively: the write re-reads current state and preserves the live activeId/route, so a fallback routing decision landing mid-hydration survives the delayed completion. Regression covers exactly that interleaving.

  3. The stacked-suite flake root cause: fetchOAuthAccountProfile resolved globalThis.fetch per call, and boot hydration is sequential (main first, then fallbacks) — a hydration spanning test turnover sent the later fallback-profile request through the next test's fetch mock, which is the extra non-quota request you observed. Hydration now captures its fetch per plugin instance. Proof: 5 consecutive full-suite runs, 883/0 each.

  4. On the policy boundary: our position is that a token-bound anthropic-ratelimit-unified-* frame from a genuine upstream response is the same evidence class as a live 429 — fresh by construction. Header-sourced snapshots get no exemption from the staleness gate: API-key fallback requires the token-bound entry's refreshAfter to still be in the future, so stale header evidence can never license paid routing. Verified there was no behavior change to make — the gate is shared with poll snapshots — and locked it with characterization tests in both directions (fresh header exhaustion routes to API fallback; stale source: headers snapshot stays on main), plus a comment at the policy site. If you'd rather require poll confirmation before credit spend, that's a one-line policy flip we can make — but the evidence-class argument says headers are the fresher signal of the two.

@iceteaSA
iceteaSA force-pushed the feat/quota-header-harvest branch 2 times, most recently from 2298a49 to 10bd0de Compare July 19, 2026 14:36
@ualtinok

Copy link
Copy Markdown
Contributor

The four previous findings are addressed, including the profile/sidebar interleaving and the shared-fetch test leak. Two repeated full-suite runs of the #129/#130 stack passed. I found one new release-blocking token race in the fallback profile persistence path.

ensureProfilesForQuotaDisplay() completes an asynchronous fallback profile fetch and calls persistProfileStateBestEffort() with { accounts: [account.id] }. That scope writes the whole account runtime object, not only profile. If another process rotates the OAuth credentials while profile hydration is in flight, the delayed hydration save can restore the stale access token, refresh token, and lastRefreshedAt.

I reproduced it directly:

  1. Save fallback fb with old-access / old-refresh.
  2. Load a stale snapshot for hydration.
  3. Persist rotated new-access / new-refresh with a newer lastRefreshedAt.
  4. Add a profile to the stale snapshot and call saveAccountState(..., { accounts: ['fb'] }).
  5. Reload storage: it contains old-access, old-refresh, and the old lastRefreshedAt again.

Please add a dedicated profile-only state mutation that acquires the state lock, re-reads current state, verifies the expected token fingerprint, and changes only the profile field. A late result for a superseded token should be discarded. Add an exact in-flight fallback hydration versus token-rotation regression. The main-profile path should also reject an older or mismatched result rather than blindly replacing a newer persisted profile.

On the paid-fallback policy question: I agree that a fresh, token-bound anthropic-ratelimit-unified-* frame from the actual upstream response is valid live exhaustion evidence. The new fresh/stale characterization tests preserve the strict freshness boundary, so that point is no longer a blocker.

@iceteaSA
iceteaSA force-pushed the feat/quota-header-harvest branch from 10bd0de to dadd240 Compare July 19, 2026 17:53
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Fixed in dadd240 (squashed), following your prescription.

New token-fenced saveOAuthProfileState in core: it acquires the cross-process state lock (config-write → state-write ordering, matching saveAccountsLocked; state-only saves take only the state lock), re-reads the current state under the lock, verifies the fetch-time token fingerprint against the credentials persisted at write time, and mutates only the account's profile field — a late hydration result for rotated credentials is dropped, never written, and never displayed as current. All fallback and main profile clear/hydration paths are rewired through it; persistProfileStateBestEffort no longer writes whole account sections.

The main-profile path additionally keeps a persisted profileToken marker and rejects both rotated-token results and older results for the same token, so a delayed hydration can no longer replace a newer persisted profile.

Red-first on your exact interleaving, both directions: the fallback repro (stale snapshot save after rotation) previously restored old-access/old-refresh and now retains the rotated credentials while dropping the late profile; the main equivalent previously replaced the new-token profile with the late old-token result and now preserves it.

Full gates: opencode 917, core 58, pi 53, e2e 22, typecheck/lint clean.

@ualtinok

Copy link
Copy Markdown
Contributor

The token-fenced profile-only writer fixes the credential rollback. I found one remaining race in the profile-clear operation.

saveOAuthProfileState({ profile: undefined, ... }) unconditionally clears the persisted profile after validating only the current access-token fingerprint. A delayed clear that was decided from an old snapshot can therefore delete a newer profile another process already hydrated for that same current token.

I reproduced the exact state transition:

  1. The account has current access token T and an old mismatched profile.
  2. Process A reads that stale profile and decides to clear it.
  3. Process B hydrates and persists a fresh profile bound to T.
  4. Process A's delayed saveOAuthProfileState(... profile: undefined, expectedTokenFingerprint: fp(T)) runs.
  5. Reloaded state has profile: null; B's newer valid profile was deleted.

Please make clears conditional under the state lock. The writer should clear only if the persisted profile is still the stale profile observed by the caller—or, for this specific mismatch path, only if the persisted profile is not already bound to expectedTokenFingerprint. Pass an expected existing profile fingerprint/checked timestamp if needed. Apply the same rule to main and fallback profiles, and add the interleaving regression above.

PR #128 is now merged. Please rebase #129 onto current main; #130 should then be restacked on the corrected #129 head.

@iceteaSA
iceteaSA force-pushed the feat/quota-header-harvest branch from dadd240 to b76cc95 Compare July 19, 2026 22:54
@iceteaSA

Copy link
Copy Markdown
Contributor Author

Fixed and rebased onto current main.

Profile-clear race — the delayed-clear now refuses to delete a profile that is already bound to expectedTokenFingerprint. A clear decided from a stale snapshot can no longer wipe a fresher profile another process hydrated for the same current token; the same guard is applied to both the main and fallback paths, inside the existing config→state lock (no reordering). Your exact 5-step interleaving is encoded as a regression for both paths (red before the guard: reloaded profile came back undefined; green after). Legitimate clears are unaffected — those only occur on token mismatch, which the guard still allows; same-token staleness is resolved by a fresh write, never a clear.

Rebased onto main (post-#128 merge). The rebase reconciled sidebar-state.ts/index.ts against the merged boot-routing changes; one harvest unit test that asserted the pre-#128 auto-preserve behavior at the bare setSidebarState API was ported to the relocated resolvePreservedRouting callback.

Gates green: typecheck · opencode 949 · core 58 · pi 53 · e2e 22 · lint clean. #130 will be restacked on this head next.

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.
@iceteaSA
iceteaSA force-pushed the feat/quota-header-harvest branch from b76cc95 to c47af80 Compare July 19, 2026 23:32
@ualtinok
ualtinok merged commit 48b6577 into cortexkit:main Jul 21, 2026
5 checks passed
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.

2 participants