diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7e476d11..370fdc70 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,7 +50,7 @@ 5. **URL rewrite** — `rewriteUrl()` adds `?beta=true` to `/v1/messages` and overrides base URL when `ANTHROPIC_BASE_URL` is set — `packages/opencode/src/transform.ts` 6. **Request body rewrite** — `rewriteRequestBody()` strips trailing assistant messages, normalizes Fable/Mythos thinking and Sonnet 5 adaptive thinking, injects billing header, sanitizes system prompt (removes OpenCode identity), prepends Claude Code identity, applies cache strategy (explicit/automatic/hybrid), adds fast mode, prefixes tool names with `mcp_`, creates `cch` over serialized body — `packages/opencode/src/transform.ts` 7. **Routing** — `shouldFallbackStatus()` checks if response should trigger fallback; `FallbackAccountManager` iterates accounts in ordered modes, while `StickySessionRouter` assigns cold sessions by reset-normalized spendable OAuth quota and weighted initial-prompt deficit, then persists hashed session affinity across processes/restarts. Sticky routes retain transient failures, hold confirmed 5h exhaustion when reset is within 15 minutes, and migrate for longer confirmed exhaustion/permanent account failure. All modes respect model-scoped quotas and killswitch thresholds (including per-model scoped thresholds). If all accounts fail the killswitch policy, a 429 block response is returned immediately; this block is classified as scoped-driven (matching a specific model's weekly limit) or account-level (5h/7d limits) with a model-specific or generic retry hint — `packages/core/src/routing.ts`, `packages/core/src/accounts.ts`, `packages/opencode/src/index.ts` -8. **Relay** — `sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back — `packages/core/src/relay.ts` +8. **Relay** — `sendViaRelay()` sends full or patched body to Cloudflare Worker, which streams Anthropic response back. HTTP relay responses and WebSocket `response_start` control messages deliver genuine upstream headers to the OpenCode account-bound quota harvester; synthetic optimistic response headers are never harvest input, and relay-to-direct fallback stays owned by the direct path — `packages/core/src/relay.ts`, `packages/opencode/src/index.ts` 9. **SSE stream and Fable recovery** — Response body is wrapped in `createStrippedStream()`, which reverses the tool name prefix and detects Anthropic `refusal` finishes. For selected Fable sessions, a refusal activates a session-local 10-response Opus 4.8 downgrade; every successful downgraded response triggers a zero-output Fable prewarm through `CacheKeepManager` using the OAuth account that served the filtered Fable request. The last successful Opus tail anchor is retained per session and OAuth account; if Fable later refuses after that anchor has moved outside Anthropic's 20-block lookback, the retry spends the system cache slot on an explicit old-Opus-to-current-tail bridge rather than rewriting the intervening Opus cache. Recovery transitions are written per session to the TUI sidebar state; when no matching TUI is connected, OpenCode Desktop receives immediate ignored/no-reply `promptAsync` notices for the switch to Opus and return to Fable. Each notice is assigned a message ID immediately before the active assistant message: Desktop displays it immediately by creation time, while OpenCode's run loop sees it as older than the active assistant and cannot mistake it for pending user work or create an extra provider response — `packages/opencode/src/transform.ts`, `packages/opencode/src/fable-fallback.ts`, `packages/opencode/src/prompt-context.ts`, `packages/opencode/src/index.ts` 10. **Sidebar update** — `writeSidebarState()` writes quota/routing/cache state plus bounded per-session Fable recovery status to a JSON file read by the TUI sidebar widget (separate process via RPC) — `packages/opencode/src/sidebar-state.ts` diff --git a/docs/parity-backlog.md b/docs/parity-backlog.md index ca47cfa8..20195207 100644 --- a/docs/parity-backlog.md +++ b/docs/parity-backlog.md @@ -337,6 +337,21 @@ production-only regression (env-override bypass) — first-hand DIFF review caug --- +## 8. Relay response eligibility for quota harvest — DONE + +OpenCode harvests genuine upstream headers from both relay transports without a quota side channel. +HTTP delivery is gated on `usedRelay`; WebSocket delivery comes from `response_start` before the +optimistic-response early return. Relay-to-direct fallback invokes only the direct-path harvester, +and synthetic optimistic headers remain ineligible. + +## 9. Pi quota-header harvest parity — FOLLOW-UP + +Pi uses the distinct `packages/pi/src/stream.ts` response path. Header harvest, served-account +attribution, sidecar persistence, and quota display parity remain out of scope for v1. Port the +OpenCode direct-path behavior without sharing request-path state implicitly, then gate Pi's own +streaming response headers and malformed-header handling. +Relay-header callback wiring is also OpenCode-only; Pi parity remains outstanding. + ## Implementation phase (operator directive) When implementation begins, create a **fresh parity branch off `upstream/main`** — NOT off `dev`, diff --git a/docs/quota-surfaces.md b/docs/quota-surfaces.md new file mode 100644 index 00000000..212e0d96 --- /dev/null +++ b/docs/quota-surfaces.md @@ -0,0 +1,201 @@ +# Anthropic quota surfaces + +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. + +| Surface | Transport | Freshness | Scoped per-model windows | Idle accounts | +| --- | --- | --- | --- | --- | +| Usage API (`GET /api/oauth/usage`) | active poll, per token | on demand | **yes** (`limits[]`) | **yes** — pollable without traffic | +| `anthropic-ratelimit-unified-*` headers | passive, on every `/v1/messages` response | every request | no | no — only accounts you send through | + +The plugin combines surface 1 background polling (`fetchOAuthQuotaSnapshot` + `QuotaManager`) with passive response-header harvest from surface 2 across direct and relay transports. + +--- + +## Surface 1 — usage API + +``` +GET https://api.anthropic.com/api/oauth/usage +authorization: Bearer +anthropic-beta: oauth-2025-04-20 +``` + +Consumed by: `fetchOAuthQuotaSnapshot()` (`packages/core/src/accounts.ts`), which maps it to `OAuthQuotaSnapshot` (`five_hour`/`seven_day`/`scoped[]` + `checkedAt`). + +### Top-level shape (observed 2026-07-16) + +```jsonc +{ + "five_hour": { /* window */ }, + "seven_day": { /* window */ }, + // legacy per-model window slots — null on both probed accounts: + "seven_day_oauth_apps": null, + "seven_day_opus": null, + "seven_day_sonnet": null, + "seven_day_cowork": null, + "seven_day_omelette": null, + // unreleased feature-flag slots (codenames) — null on both probed accounts: + "tangelo": null, + "iguana_necktie": null, + "omelette_promotional": null, + "nimbus_quill": null, + "cinder_cove": null, + "amber_ladder": null, + "extra_usage": { /* extra-usage credits block */ }, + "limits": [ /* unified limits array — the modern surface */ ], + "spend": { /* extra-usage spend detail */ }, + "member_dashboard_available": false +} +``` + +### Window object (`five_hour`, `seven_day`) + +| Field | Type | Notes | +| --- | --- | --- | +| `utilization` | int percent | integer only — no sub-percent precision | +| `resets_at` | ISO 8601 (µs precision, +00:00) | end of current window | +| `limit_dollars` / `used_dollars` / `remaining_dollars` | null on plan accounts | presumably populated for pay-as-you-go/org billing | + +### `limits[]` — the modern unified surface + +One entry per active limit class. Observed kinds: + +| `kind` | `group` | `scope` | Meaning | +| --- | --- | --- | --- | +| `session` | `session` | null | the 5h window | +| `weekly_all` | `weekly` | null | the 7d all-models window | +| `weekly_scoped` | `weekly` | `{ model: { id, display_name }, surface }` | per-model weekly carve-out (e.g. Fable promo) | + +Entry fields: + +| Field | Type | Notes | +| --- | --- | --- | +| `percent` | int | utilization | +| `severity` | `normal` \| `warning` \| … | observed `warning` at 77%; captured but not used for plugin tone | +| `resets_at` | ISO 8601 | per-limit reset | +| `scope.model.id` | string \| null | **null observed even for Fable** — only `display_name` present ("Fable"); this is why `scopedQuotaModelKey` normalizes display names | +| `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 | + +`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). + +### `extra_usage` + `spend` (extra-usage credits) + +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`). + +Money is `{ amount_minor, currency, exponent }` — e.g. `10035` minor / exponent 2 = $100.35. + +Consumed for `/claude-quota` and expanded TUI/sidebar credit display. Extra usage remains display-only and does not affect routing. + +### Response headers on the usage API itself + +Only `anthropic-organization-id` + `request-id` — the `ratelimit-unified` family does NOT appear on the usage endpoint, only on `/v1/messages`. + +--- + +## Surface 2 — `anthropic-ratelimit-unified-*` response headers + +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: + +| Header | main — personal Max 20x (3%/12%, overage disabled) | work-alt — Team seat, Max-5x tier (78%/40%, credits exhausted) | +| --- | --- | --- | +| `…-unified-status` | `allowed` | `allowed` | +| `…-unified-reset` | `1784252400` (epoch **seconds**) | `1784246400` | +| `…-unified-representative-claim` | `five_hour` | `five_hour` | +| `…-unified-5h-status` | `allowed` | `allowed` | +| `…-unified-5h-utilization` | `0.03` (**fraction**, not percent) | `0.78` | +| `…-unified-5h-reset` | `1784252400` | `1784246400` | +| `…-unified-7d-status` | `allowed` | `allowed` | +| `…-unified-7d-utilization` | `0.12` | `0.4` | +| `…-unified-7d-reset` | `1784502000` | `1784628000` | +| `…-unified-fallback` | — absent | `available` | +| `…-unified-fallback-percentage` | `0.5` | `0.5` | +| `…-unified-overage-status` | `rejected` | `rejected` | +| `…-unified-overage-disabled-reason` | `org_level_disabled` | `org_spend_cap_reached` | +| `…-unified-overage-utilization` | — absent | `1.0` | +| `…-unified-overage-surpassed-threshold` | — absent | `1.0` | +| `…-unified-overage-reset` | — absent | `1785542400` | + +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. + +| Header | Semantics | +| --- | --- | +| `…-status` | overall admit decision (`allowed`; presumably `rejected`/throttle states near limits) | +| `…-reset` | top-level reset = reset of the representative claim | +| `…-representative-claim` | which window is currently binding (`five_hour`/`seven_day`) — header analogue of `limits[].is_active` | +| `…-5h-*` / `…-7d-*` | per-window status / **fractional** utilization (0.03 = 3%) / epoch-seconds reset | +| `…-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 | +| `…-fallback-percentage` | the fallback trip point (0.5 on both accounts) — consistent with `fallback` appearing once utilization crosses it | +| `…-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) | +| `…-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 | + +### Differences vs the usage API + +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. +2. **Passive** — idle fallback accounts emit nothing; pre-visibility requires the poll. +3. **Coarser numbers** — fraction (2 decimals) vs integer percent; no severity, no dollars, no extra-usage detail beyond admit state. +4. **Free freshness** — every real request refreshes main's 5h/7d at zero API cost. + +### Comparison with OpenAI/Codex (`x-codex-*`) + +| | Anthropic | OpenAI/Codex | +| --- | --- | --- | +| Passive headers | `anthropic-ratelimit-unified-*` (5h/7d + overage) | `x-codex-primary/secondary-*` (5h/weekly) | +| Active poll endpoint | `GET /api/oauth/usage` (rich: scoped, severity, spend) | **none** — headers are the only quota surface | +| Scoped per-model windows | `limits[]` `weekly_scoped` | n/a | +| Representative/binding marker | `representative-claim` header + `is_active` (inferred) | `x-codex-…-over-…` style flags | + +openai-auth is push-based by necessity (QuotaManager fed via `setMain`/`setFallback`); anthropic-auth combines the usage poll with passive header pushes on direct and relayed requests. + +--- + +## Surface 3 — profile API (account kind) + +``` +GET https://api.anthropic.com/api/oauth/profile +authorization: Bearer +anthropic-beta: oauth-2025-04-20 +``` + +Identity + plan metadata; the only surface exposing account KIND. Observed key fields: + +| Field | main | work-alt | Notes | +| --- | --- | --- | --- | +| `account.has_claude_max` | `true` | `false` | personal-plan flag only — false for Team seats | +| `organization.organization_type` | `claude_max` | `claude_team` | the account-kind discriminator | +| `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 | +| `organization.seat_tier` | `null` | `team_tier_1` | Team seat level | +| `organization.has_extra_usage_enabled` | `false` | `true` | matches `extra_usage.is_enabled` in the usage API + `overage-*` headers | +| `organization.billing_type` | `stripe_subscription` | `stripe_subscription` | | +| `application.slug` | `claude-code` | `claude-code` | OAuth app identity | + +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. + +## Implemented behavior + +- Direct and relayed `/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. +- Header pushes merge into the last poll snapshot. They preserve poll-owned `scoped`, including meaningful empty `[]`, and `extraUsage` credit data. +- Poll `limits[].is_active` owns `bindingWindow` when present. The header `representative-claim` fills the marker only when the poll did not supply one. +- Money stays in integer minor units with an explicit currency exponent. Formatting happens at the display boundary. +- `fallback: available` becomes `fallbackAdvised`; it appears only in expanded quota views and does not change routing. +- Profile metadata is sidecar-persisted, uses a seven-day TTL, and is absent from the request path. +- Relay HTTP harvest reads the Worker's genuine upstream response headers only when the relay handled the request. WebSocket harvest reads the genuine `response_start` headers through a callback even when the caller already received an optimistic response with synthetic headers. Relay-to-direct fallback remains owned by the direct-path harvest, so one upstream response produces one push. + +## Gaps / opportunities + +- Pi has a separate streaming response path and does not harvest quota headers in v1. + +## Probe recipes + +```bash +# usage API (token from opencode auth.json for main, anthropic-auth-state.json for fallbacks) +curl -s https://api.anthropic.com/api/oauth/usage \ + -H "authorization: Bearer $TOKEN" -H "anthropic-beta: oauth-2025-04-20" | jq . + +# headers (one ~20-token haiku request) +curl -sD - -o /dev/null https://api.anthropic.com/v1/messages?beta=true \ + -H "authorization: Bearer $TOKEN" -H "anthropic-version: 2023-06-01" \ + -H "anthropic-beta: oauth-2025-04-20" -H "content-type: application/json" \ + -d '{"model":"claude-haiku-4-5","max_tokens":1,"system":"Reply with 1 when you receive 0.","messages":[{"role":"user","content":"0"}]}' \ + | grep -i anthropic-ratelimit +``` + +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). diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 89412112..60042922 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -11,6 +11,7 @@ import { DEFAULT_CACHE_1H_MODE, } from './constants.ts' import { type LogLevel, log, logger } from './logger.ts' +import { tokenFingerprint } from './token-fingerprint.ts' const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis) const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis) @@ -38,6 +39,7 @@ export type OAuthAccount = AccountBase & { lastRefreshError?: AccountOperationError lastQuotaRefreshError?: AccountOperationError quota?: OAuthQuotaSnapshot + profile?: OAuthAccountProfile } export type ApiKeyAccount = AccountBase & { @@ -114,10 +116,36 @@ export type AccountScopedQuotaWindow = AccountQuotaWindow & { modelName: string } +export type QuotaMoney = { + amountMinor: number + currency: string + exponent: number +} + +export type OAuthExtraUsageSnapshot = { + used: QuotaMoney + limit: QuotaMoney + utilizationPercent?: number + severity?: string + exhausted: boolean +} + +export type OAuthAccountProfile = { + tier: string + orgType: string + checkedAt: number + tokenFingerprint?: string +} + export type OAuthQuotaSnapshot = Partial< Record > & { scoped?: AccountScopedQuotaWindow[] + extraUsage?: OAuthExtraUsageSnapshot + bindingWindow?: string + bindingWindowSource?: 'poll' | 'headers' + fallbackAdvised?: boolean + source?: 'poll' | 'headers' // Top-level freshness stamp for the whole snapshot. mergeAccountRuntimeState // uses this when the snapshot has no per-window checkedAt (e.g. a windowless // empty-scoped snapshot) — without it, a windowless refresh gets read as @@ -145,6 +173,7 @@ export type AccountStorage = { main?: { type: 'opencode' provider: 'anthropic' + profile?: OAuthAccountProfile } routing?: { mode?: RoutingMode @@ -235,6 +264,7 @@ export type AccountRuntimeEntry = Partial< | 'lastRefreshError' | 'lastQuotaRefreshError' | 'quota' + | 'profile' > & Pick > @@ -242,6 +272,8 @@ export type AccountRuntimeEntry = Partial< export type AccountRuntimeState = { version: 1 main?: { + profile?: OAuthAccountProfile + profileToken?: string quota?: OAuthQuotaSnapshot quotaCheckedAt?: number quotaToken?: string @@ -255,6 +287,7 @@ export type AccountRuntimeState = { } export type AccountStateSaveScope = { + mainProfile?: boolean mainQuota?: boolean mainRefresh?: boolean accounts?: true | string[] @@ -270,6 +303,7 @@ type OAuthUsageLimit = { group?: string percent?: number resets_at?: string + is_active?: boolean scope?: { model?: { id?: string | null @@ -283,6 +317,20 @@ type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow limits?: OAuthUsageLimit[] + extra_usage?: { + is_enabled?: boolean + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + } | null + spend?: { + severity?: string | null + limit?: { + amount_minor?: number + currency?: string + exponent?: number + } | null + } | null } export type AccountManagerOptions = { @@ -400,6 +448,32 @@ function normalizeAccount(value: unknown): FallbackAccount | null { lastRefreshError: normalizeOperationError(value.lastRefreshError), lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError), quota: normalizeQuota(value.quota), + profile: normalizeOAuthAccountProfile(value.profile), + } +} + +function normalizeOAuthAccountProfile( + value: unknown, +): OAuthAccountProfile | undefined { + if (!isRecord(value)) return undefined + if ( + typeof value.tier !== 'string' || + !value.tier.trim() || + typeof value.orgType !== 'string' || + !value.orgType.trim() || + typeof value.checkedAt !== 'number' || + !Number.isFinite(value.checkedAt) + ) { + return undefined + } + return { + tier: value.tier.trim(), + orgType: value.orgType.trim(), + checkedAt: value.checkedAt, + ...(typeof value.tokenFingerprint === 'string' && + value.tokenFingerprint.trim() && { + tokenFingerprint: value.tokenFingerprint.trim(), + }), } } @@ -500,9 +574,63 @@ function normalizeQuota(value: unknown): OAuthAccount['quota'] { quota.scoped = scoped } + if (isRecord(value.extraUsage)) { + const used = normalizeQuotaMoney(value.extraUsage.used) + const limit = normalizeQuotaMoney(value.extraUsage.limit) + if (used && limit && typeof value.extraUsage.exhausted === 'boolean') { + quota.extraUsage = { + used, + limit, + ...(typeof value.extraUsage.utilizationPercent === 'number' && + Number.isFinite(value.extraUsage.utilizationPercent) && { + utilizationPercent: value.extraUsage.utilizationPercent, + }), + ...(typeof value.extraUsage.severity === 'string' && { + severity: value.extraUsage.severity, + }), + exhausted: value.extraUsage.exhausted, + } + } + } + + if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) { + quota.bindingWindow = value.bindingWindow.trim() + } + if ( + value.bindingWindowSource === 'poll' || + value.bindingWindowSource === 'headers' + ) { + quota.bindingWindowSource = value.bindingWindowSource + } + if (typeof value.fallbackAdvised === 'boolean') { + quota.fallbackAdvised = value.fallbackAdvised + } + if (value.source === 'poll' || value.source === 'headers') { + quota.source = value.source + } + return Object.keys(quota).length ? quota : undefined } +function normalizeQuotaMoney(value: unknown): QuotaMoney | undefined { + if (!isRecord(value)) return undefined + if ( + typeof value.amountMinor !== 'number' || + !Number.isFinite(value.amountMinor) || + typeof value.currency !== 'string' || + !value.currency.trim() || + typeof value.exponent !== 'number' || + !Number.isFinite(value.exponent) + ) { + return undefined + } + return { + amountMinor: value.amountMinor, + currency: value.currency.trim(), + exponent: value.exponent, + } +} + // Fresh empty storage shell — main OpenCode OAuth account, no fallback // accounts. Returns a new object each call so mutating callers don't alias. export function createEmptyStorage(): AccountStorage { @@ -517,7 +645,13 @@ function normalizeStorage(value: unknown): AccountStorage | null { if (!isRecord(value) || !Array.isArray(value.accounts)) return null return { version: 1, - main: { type: 'opencode', provider: 'anthropic' }, + main: { + type: 'opencode', + provider: 'anthropic', + profile: normalizeOAuthAccountProfile( + isRecord(value.main) ? value.main.profile : undefined, + ), + }, routing: isRecord(value.routing) ? value.routing : undefined, fallbackOn: Array.isArray(value.fallbackOn) ? value.fallbackOn.filter((status) => Number.isInteger(status)) @@ -641,6 +775,11 @@ function mergeConfigAndState( return { ...configValue, + main: { + type: 'opencode', + provider: 'anthropic', + profile: normalizeOAuthAccountProfile(mainState?.profile), + }, refresh: objectWithDefinedEntries({ ...refreshConfig, mainLastRefreshError: mainRefreshSource.lastRefreshError, @@ -709,6 +848,7 @@ function accountRuntimeState(account: FallbackAccount) { lastRefreshError: account.lastRefreshError, lastQuotaRefreshError: account.lastQuotaRefreshError, quota: account.quota, + profile: account.profile, }) } @@ -721,14 +861,78 @@ function quotaSnapshotCheckedAt(quota: OAuthQuotaSnapshot | undefined) { ) } +function quotaSourcePrecedence(quota: OAuthQuotaSnapshot | undefined) { + if (quota?.source === 'poll') return 2 + if (quota?.source === 'headers') return 1 + return 0 +} + +function mergeHeaderScopedQuota( + existing: OAuthQuotaSnapshot, + incoming: OAuthQuotaSnapshot, +) { + if (!('scoped' in existing)) return incoming.scoped + if (!Array.isArray(existing.scoped) || existing.scoped.length === 0) { + return existing.scoped + } + if (!Array.isArray(incoming.scoped) || incoming.scoped.length === 0) { + return existing.scoped + } + const merged = new Map(incoming.scoped.map((window) => [window.id, window])) + for (const window of existing.scoped) { + const candidate = merged.get(window.id) + if (!candidate || window.checkedAt >= candidate.checkedAt) { + merged.set(window.id, window) + } + } + return [...merged.values()] +} + +function mergeHeaderOwnedWindow( + existingSnapshot: OAuthQuotaSnapshot, + incomingSnapshot: OAuthQuotaSnapshot, + key: QuotaWindowName, +) { + const existing = existingSnapshot[key] + const incoming = incomingSnapshot[key] + if (!incoming) return existing + if (!existing) return incoming + if (incoming.checkedAt > existing.checkedAt) return incoming + if (incoming.checkedAt < existing.checkedAt) return existing + return quotaSourcePrecedence(existingSnapshot) > + quotaSourcePrecedence(incomingSnapshot) + ? existing + : incoming +} + +function mergeHeaderQuotaForPersistence( + existing: OAuthQuotaSnapshot | undefined, + incoming: OAuthQuotaSnapshot, +) { + if (!existing || incoming.source !== 'headers') return incoming + const preservePollBinding = existing.bindingWindowSource === 'poll' + return { + ...existing, + ...incoming, + five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'), + seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'), + scoped: mergeHeaderScopedQuota(existing, incoming), + extraUsage: existing.extraUsage ?? incoming.extraUsage, + bindingWindow: preservePollBinding + ? existing.bindingWindow + : (incoming.bindingWindow ?? existing.bindingWindow), + bindingWindowSource: preservePollBinding + ? 'poll' + : (incoming.bindingWindowSource ?? existing.bindingWindowSource), + } satisfies OAuthQuotaSnapshot +} + function mergeAccountRuntimeState( existing: unknown, incoming: AccountRuntimeEntry, ): AccountRuntimeEntry { if (!isRecord(existing)) return incoming const existingEntry = existing as AccountRuntimeEntry - const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota) - const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(incoming.quota) const tokenChanged = Boolean( (existingEntry.access && incoming.access && @@ -737,36 +941,63 @@ function mergeAccountRuntimeState( incoming.refresh && existingEntry.refresh !== incoming.refresh), ) + const mergesHeaderQuota = Boolean( + !tokenChanged && incoming.quota?.source === 'headers', + ) + const effectiveIncoming = + mergesHeaderQuota && incoming.quota + ? { + ...incoming, + quota: mergeHeaderQuotaForPersistence( + existingEntry.quota, + incoming.quota, + ), + } + : incoming + const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota) + const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota) + const existingQuotaWinsEqualTimestamp = Boolean( + existingQuotaCheckedAt === incomingQuotaCheckedAt && + quotaSourcePrecedence(existingEntry.quota) > + quotaSourcePrecedence(effectiveIncoming.quota), + ) - if (existingQuotaCheckedAt > incomingQuotaCheckedAt) { + if ( + !mergesHeaderQuota && + (existingQuotaCheckedAt > incomingQuotaCheckedAt || + existingQuotaWinsEqualTimestamp) + ) { const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0 - const incomingRefreshAt = incoming.lastRefreshedAt ?? 0 + const incomingRefreshAt = effectiveIncoming.lastRefreshedAt ?? 0 if (tokenChanged && incomingRefreshAt <= existingRefreshAt) { const merged: AccountRuntimeEntry = { ...existingEntry } if ( - typeof incoming.lastUsed === 'number' && + typeof effectiveIncoming.lastUsed === 'number' && (!(typeof existingEntry.lastUsed === 'number') || - incoming.lastUsed > existingEntry.lastUsed) + effectiveIncoming.lastUsed > existingEntry.lastUsed) ) { - merged.lastUsed = incoming.lastUsed + merged.lastUsed = effectiveIncoming.lastUsed } return merged } - const merged: AccountRuntimeEntry = { ...existingEntry, ...incoming } + const merged: AccountRuntimeEntry = { + ...existingEntry, + ...effectiveIncoming, + } if (tokenChanged) { - if ('quota' in incoming) { - merged.quota = existingEntry.quota - if ('lastQuotaRefreshError' in existingEntry) { - merged.lastQuotaRefreshError = existingEntry.lastQuotaRefreshError - } else { - delete merged.lastQuotaRefreshError - } + if (!('profile' in effectiveIncoming)) delete merged.profile + if (effectiveIncoming.quota?.source) { + merged.quota = effectiveIncoming.quota } else { delete merged.quota + } + if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in incoming)) delete merged.lastRefreshError + if (!('lastRefreshError' in effectiveIncoming)) { + delete merged.lastRefreshError + } return merged } @@ -776,11 +1007,18 @@ function mergeAccountRuntimeState( lastQuotaRefreshError: existingEntry.lastQuotaRefreshError, } } - const merged: AccountRuntimeEntry = { ...existingEntry, ...incoming } - if (!('lastQuotaRefreshError' in incoming)) { + const merged: AccountRuntimeEntry = { + ...existingEntry, + ...effectiveIncoming, + } + if (tokenChanged) { + if (!('profile' in effectiveIncoming)) delete merged.profile + if (!effectiveIncoming.quota?.source) delete merged.quota + } + if (!('lastQuotaRefreshError' in effectiveIncoming)) { delete merged.lastQuotaRefreshError } - if (!('lastRefreshError' in incoming)) { + if (!('lastRefreshError' in effectiveIncoming)) { delete merged.lastRefreshError } return merged @@ -807,7 +1045,7 @@ function configFromStorage(storage: AccountStorage): Record { return omitUndefinedTopLevel({ version: 1, - main: storage.main, + main: { type: 'opencode', provider: 'anthropic' }, routing: storage.routing, fallbackOn: storage.fallbackOn, refresh, @@ -910,27 +1148,54 @@ function mergeAccountsForSave( const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000 const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000 +const ACCOUNT_STATE_LOCK_TTL_MS = 10_000 +const ACCOUNT_STATE_LOCK_WAIT_MS = 12_000 -async function acquireAccountConfigWriteLock(path: string) { +async function acquireAccountWriteLock(input: { + path: string + name: string + ttlMs: number + waitMs: number + description: string +}) { + const { path, name, ttlMs, waitMs, description } = input await mkdir(dirname(path), { recursive: true }) - const deadline = Date.now() + ACCOUNT_CONFIG_LOCK_WAIT_MS + const deadline = Date.now() + waitMs while (true) { const lock = await acquireRefreshFileLock({ - name: 'config-write', - ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS, + name, + ttlMs, path, renew: true, }) if (lock) return lock if (Date.now() >= deadline) { - throw new Error( - 'Timed out waiting for the account configuration write lock', - ) + throw new Error(`Timed out waiting for the account ${description} lock`) } await new Promise((resolve) => setTimeout(resolve, 25)) } } +function acquireAccountConfigWriteLock(path: string) { + return acquireAccountWriteLock({ + path, + name: 'config-write', + ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS, + waitMs: ACCOUNT_CONFIG_LOCK_WAIT_MS, + description: 'configuration write', + }) +} + +function acquireAccountStateWriteLock(path: string) { + return acquireAccountWriteLock({ + path, + name: 'state-write', + ttlMs: ACCOUNT_STATE_LOCK_TTL_MS, + waitMs: ACCOUNT_STATE_LOCK_WAIT_MS, + description: 'state write', + }) +} + export function saveAccounts( storage: AccountStorage, path = getAccountStoragePath(), @@ -959,21 +1224,49 @@ async function saveAccountsLocked( const existing = await loadExistingTopLevelFields(path) const nextConfig = { ...existing, ...configFromStorage(nextStorage) } await writeJsonAtomic(path, nextConfig) - await saveAccountStateUnlocked(nextStorage, path, { - mainQuota: true, - mainRefresh: true, - accounts: true, - }) + // Config precedes state everywhere both locks are needed; reversing this + // order can deadlock profile mutations against full account saves. + const stateLock = await acquireAccountStateWriteLock(path) + try { + await saveAccountStateUnlocked(nextStorage, path, { + mainQuota: true, + mainRefresh: true, + accounts: true, + }) + } finally { + await stateLock.release() + } } finally { await lock.release() } } +function applyMainProfileStatePatch( + state: AccountRuntimeState, + storage: AccountStorage, +) { + state.main = state.main ?? {} + state.main.profile = storage.main?.profile +} + function applyMainQuotaStatePatch( state: AccountRuntimeState, storage: AccountStorage, ) { state.main = state.main ?? {} + const incomingQuota = storage.quota?.mainQuota + const sameToken = Boolean( + state.main.quotaToken && + storage.quota?.mainQuotaToken && + state.main.quotaToken === storage.quota.mainQuotaToken, + ) + const effectiveIncomingQuota = + sameToken && incomingQuota?.source === 'headers' + ? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota) + : incomingQuota + const mergesHeaderQuota = Boolean( + sameToken && incomingQuota?.source === 'headers', + ) const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number' ? state.main.quotaCheckedAt @@ -981,11 +1274,21 @@ function applyMainQuotaStatePatch( const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number' ? storage.quota.mainQuotaCheckedAt - : quotaSnapshotCheckedAt(storage.quota?.mainQuota) - if (existingCheckedAt > incomingCheckedAt) return + : quotaSnapshotCheckedAt(effectiveIncomingQuota) + if ( + !mergesHeaderQuota && + (existingCheckedAt > incomingCheckedAt || + (existingCheckedAt === incomingCheckedAt && + quotaSourcePrecedence(state.main.quota) > + quotaSourcePrecedence(effectiveIncomingQuota))) + ) { + return + } - state.main.quota = storage.quota?.mainQuota - state.main.quotaCheckedAt = storage.quota?.mainQuotaCheckedAt + state.main.quota = effectiveIncomingQuota + state.main.quotaCheckedAt = mergesHeaderQuota + ? Math.max(existingCheckedAt, incomingCheckedAt) + : storage.quota?.mainQuotaCheckedAt state.main.quotaToken = storage.quota?.mainQuotaToken state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError } @@ -1015,15 +1318,124 @@ export function saveAccountState( storage: AccountStorage, path = getAccountStoragePath(), scope: AccountStateSaveScope = { + mainProfile: true, mainQuota: true, mainRefresh: true, accounts: true, }, ): Promise { const resolvedPath = path - return enqueueSave(() => - saveAccountStateUnlocked(storage, resolvedPath, scope), - ) + return enqueueSave(async () => { + const lock = await acquireAccountStateWriteLock(resolvedPath) + try { + await saveAccountStateUnlocked(storage, resolvedPath, scope) + } finally { + await lock.release() + } + }) +} + +export function saveOAuthProfileState( + input: { + accountId: 'main' | string + profile: OAuthAccountProfile | undefined + expectedTokenFingerprint: string + }, + path = getAccountStoragePath(), +): Promise { + const resolvedPath = path + return enqueueSave(async () => { + const configLock = await acquireAccountConfigWriteLock(resolvedPath) + try { + const stateLock = await acquireAccountStateWriteLock(resolvedPath) + try { + const current = await loadAccounts(resolvedPath) + const statePath = getAccountStatePath(resolvedPath) + const existing = (await readJsonIfPresent(statePath)).value + const next: AccountRuntimeState = isRecord(existing) + ? ({ ...existing, version: 1 } as AccountRuntimeState) + : { version: 1 } + const { accountId, expectedTokenFingerprint, profile } = input + if ( + profile?.tokenFingerprint && + profile.tokenFingerprint !== expectedTokenFingerprint + ) { + return false + } + + if (accountId === 'main') { + next.main = { ...(next.main ?? {}) } + const existingProfile = normalizeOAuthAccountProfile( + next.main.profile, + ) + const persistedToken = + next.main.profileToken ?? existingProfile?.tokenFingerprint + if ( + !profile && + existingProfile && + persistedToken === expectedTokenFingerprint + ) { + return false + } + if (profile) { + if (persistedToken && persistedToken !== expectedTokenFingerprint) { + return false + } + if ( + existingProfile && + existingProfile.checkedAt > profile.checkedAt + ) { + return false + } + } + next.main.profile = profile + next.main.profileToken = expectedTokenFingerprint + } else { + const account = current?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + if ( + !account?.access || + tokenFingerprint(account.access) !== expectedTokenFingerprint + ) { + return false + } + next.accounts = { + ...(isRecord(next.accounts) ? next.accounts : {}), + } + const existingEntry = isRecord(next.accounts[accountId]) + ? { ...next.accounts[accountId] } + : {} + const existingProfile = normalizeOAuthAccountProfile( + existingEntry.profile, + ) + if ( + !profile && + existingProfile?.tokenFingerprint === expectedTokenFingerprint + ) { + return false + } + if ( + profile && + existingProfile && + existingProfile.checkedAt > profile.checkedAt + ) { + return false + } + existingEntry.profile = profile + next.accounts[accountId] = existingEntry + } + + await writeJsonAtomic(statePath, pruneUndefined(next)) + return true + } finally { + await stateLock.release() + } + } finally { + await configLock.release() + } + }) } async function saveAccountStateUnlocked( @@ -1037,6 +1449,7 @@ async function saveAccountStateUnlocked( ? ({ ...existing, version: 1 } as AccountRuntimeState) : { version: 1 } + if (scope.mainProfile) applyMainProfileStatePatch(next, storage) if (scope.mainQuota) applyMainQuotaStatePatch(next, storage) if (scope.mainRefresh) applyMainRefreshStatePatch(next, storage) @@ -1998,23 +2411,39 @@ export function getQuotaNextRefreshAt( storage: AccountStorage | null, now: number, ) { - if (!quotaEnabled(storage)) return now + getQuotaCheckIntervalMs(storage) + const intervalMs = getQuotaCheckIntervalMs(storage) + if (!quotaEnabled(storage)) return now + intervalMs + + const windowFreshnessDeadline = Math.min( + ...[ + ...(['five_hour', 'seven_day'] as const).map( + (key) => quota?.[key]?.checkedAt, + ), + ...(quota?.scoped ?? []).map((window) => window.checkedAt), + ] + .filter((checkedAt): checkedAt is number => Number.isFinite(checkedAt)) + .map((checkedAt) => checkedAt + intervalMs), + ) + const capAtOldestWindow = (candidate: number) => + Number.isFinite(windowFreshnessDeadline) + ? Math.min(candidate, windowFreshnessDeadline) + : candidate const thresholds = getQuotaMinimumRemainingThresholds(storage) const blockedResetTimes: number[] = [] for (const key of ['five_hour', 'seven_day'] as const) { const window = quota?.[key] - if (!window) return now + getQuotaCheckIntervalMs(storage) + if (!window) return capAtOldestWindow(now + intervalMs) if (window.remainingPercent >= thresholds[key]) continue const resetTime = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN if (!Number.isFinite(resetTime) || resetTime <= now) { - return now + getQuotaCheckIntervalMs(storage) + return capAtOldestWindow(now + intervalMs) } blockedResetTimes.push(resetTime) } - if (!blockedResetTimes.length) return now + getQuotaCheckIntervalMs(storage) - return Math.min(...blockedResetTimes) + 60_000 + if (!blockedResetTimes.length) return capAtOldestWindow(now + intervalMs) + return capAtOldestWindow(Math.min(...blockedResetTimes) + 60_000) } function tokenNeedsRefresh( @@ -2168,6 +2597,63 @@ function mapScopedWeeklyLimits( return scoped } +function mapExtraUsage( + usage: OAuthUsageResponse, +): OAuthExtraUsageSnapshot | undefined { + if (usage.extra_usage?.is_enabled !== true) return undefined + const usedAmount = usage.extra_usage.used_credits + const limitAmount = usage.extra_usage.monthly_limit + if ( + typeof usedAmount !== 'number' || + !Number.isFinite(usedAmount) || + typeof limitAmount !== 'number' || + !Number.isFinite(limitAmount) + ) { + return undefined + } + const rawCurrency = usage.spend?.limit?.currency + const currency = rawCurrency == null ? 'USD' : nonEmptyString(rawCurrency) + const rawExponent = usage.spend?.limit?.exponent + const moneyExponent = rawExponent == null ? 2 : rawExponent + if ( + !currency || + !/^[A-Za-z]{3}$/.test(currency) || + !Number.isInteger(moneyExponent) || + moneyExponent < 0 || + moneyExponent > 20 + ) { + return undefined + } + return { + used: { amountMinor: usedAmount, currency, exponent: moneyExponent }, + limit: { amountMinor: limitAmount, currency, exponent: moneyExponent }, + ...(typeof usage.extra_usage.utilization === 'number' && + Number.isFinite(usage.extra_usage.utilization) && { + utilizationPercent: usage.extra_usage.utilization, + }), + ...(nonEmptyString(usage.spend?.severity) && { + severity: nonEmptyString(usage.spend?.severity), + }), + exhausted: usedAmount >= limitAmount, + } +} + +function mapBindingWindow(limits: OAuthUsageLimit[] | undefined) { + if (!Array.isArray(limits)) return undefined + const active = limits.find((limit) => limit?.is_active === true) + if (!active) return undefined + if (active.kind === 'session') return 'five_hour' + if (active.kind === 'weekly_all') return 'seven_day' + if (active.kind !== 'weekly_scoped' || active.group !== 'weekly') { + return undefined + } + const modelName = nonEmptyString(active.scope?.model?.display_name) + if (!modelName) return undefined + const identity = nonEmptyString(active.scope?.model?.id) ?? modelName + const slug = slugForQuotaIdentity(identity) + return slug ? `claude-weekly-scoped-${slug}` : undefined +} + function mapUsageWindow( window: OAuthUsageWindow | undefined, checkedAt: number, @@ -2214,10 +2700,17 @@ export async function fetchOAuthQuotaSnapshot(input: { const checkedAt = input.now?.() ?? Date.now() const usage = (await response.json()) as OAuthUsageResponse + const bindingWindow = mapBindingWindow(usage.limits) return { five_hour: mapUsageWindow(usage.five_hour, checkedAt), seven_day: mapUsageWindow(usage.seven_day, checkedAt), scoped: mapScopedWeeklyLimits(usage.limits, checkedAt), + extraUsage: mapExtraUsage(usage), + ...(bindingWindow && { + bindingWindow, + bindingWindowSource: 'poll' as const, + }), + source: 'poll', checkedAt, } satisfies OAuthQuotaSnapshot } @@ -2248,6 +2741,7 @@ export function upsertAccount( addedAt: storage.accounts[index]?.addedAt ?? account.addedAt, ...(account.type === 'oauth' && { quota: account.quota, + profile: account.profile, lastRefreshedAt: account.lastRefreshedAt, lastRefreshError: account.lastRefreshError, lastQuotaRefreshError: account.lastQuotaRefreshError, diff --git a/packages/core/src/commands/account.ts b/packages/core/src/commands/account.ts index 44abc195..a4aa0cc4 100644 --- a/packages/core/src/commands/account.ts +++ b/packages/core/src/commands/account.ts @@ -1,4 +1,5 @@ import type { AccountStorage, FallbackAccount } from '../accounts.ts' +import { formatOAuthAccountTier } from '../oauth-profile.ts' export const CLAUDE_ACCOUNT_COMMAND_NAME = 'claude-account' @@ -115,6 +116,7 @@ export interface AccountListItem { role: 'main' | 'fallback' enabled: boolean quotaPercent: number | null + tierLabel?: string } export function buildAccountList(storage: AccountStorage): AccountListItem[] { @@ -128,6 +130,7 @@ export function buildAccountList(storage: AccountStorage): AccountListItem[] { role: 'main', enabled: true, quotaPercent: fiveHour?.usedPercent ?? null, + tierLabel: formatOAuthAccountTier(storage.main?.profile), }) for (const account of storage.accounts) { @@ -140,6 +143,10 @@ export function buildAccountList(storage: AccountStorage): AccountListItem[] { role: 'fallback', enabled: account.enabled !== false, quotaPercent: fiveHourQuota?.usedPercent ?? null, + tierLabel: + account.type === 'oauth' + ? formatOAuthAccountTier(account.profile) + : undefined, }) } @@ -183,7 +190,8 @@ export function executeAccountCommand(input: { const pct = a.quotaPercent != null ? ` ${Math.round(a.quotaPercent)}%` : '' const status = !a.enabled ? ' (disabled)' : '' - lines.push(`- **${a.label}** [${a.role}]${status}${pct}`) + const tier = a.tierLabel ? ` · ${a.tierLabel}` : '' + lines.push(`- **${a.label}** [${a.role}]${tier}${status}${pct}`) } lines.push('', USAGE_TEXT) return { text: lines.join('\n') } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c3d731ee..e25acaa8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,8 +13,10 @@ export * from './killswitch.ts' export * from './logger.ts' export * from './logging.ts' export * from './models.ts' +export * from './oauth-profile.ts' export * from './pkce.ts' export * from './provider.ts' +export * from './quota-headers.ts' export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' diff --git a/packages/core/src/oauth-profile.ts b/packages/core/src/oauth-profile.ts new file mode 100644 index 00000000..e397d50d --- /dev/null +++ b/packages/core/src/oauth-profile.ts @@ -0,0 +1,76 @@ +import type { OAuthAccountProfile } from './accounts.ts' +import { tokenFingerprint } from './quota-manager.ts' + +const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile' + +export const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000 + +export async function fetchOAuthAccountProfile(input: { + accessToken: string + fetchImpl?: typeof fetch + now?: () => number + signal?: AbortSignal +}): Promise { + const response = await (input.fetchImpl ?? fetch)(PROFILE_URL, { + method: 'GET', + signal: input.signal, + headers: { + Authorization: `Bearer ${input.accessToken}`, + Accept: 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', + }, + }) + if (!response.ok) { + throw new Error(`Claude profile check failed: ${response.status}`) + } + const value = (await response.json()) as { + organization?: { + organization_type?: unknown + rate_limit_tier?: unknown + } + } + const tier = value.organization?.rate_limit_tier + const orgType = value.organization?.organization_type + if ( + typeof tier !== 'string' || + tier.trim() === '' || + typeof orgType !== 'string' || + orgType.trim() === '' + ) { + throw new Error('Claude profile response is missing account metadata') + } + return { + tier, + orgType, + checkedAt: input.now?.() ?? Date.now(), + tokenFingerprint: tokenFingerprint(input.accessToken), + } +} + +export function oauthProfileMatchesToken( + profile: OAuthAccountProfile | undefined, + accessToken: string, +) { + return profile?.tokenFingerprint === tokenFingerprint(accessToken) +} + +export function oauthProfileIsFresh( + profile: OAuthAccountProfile | undefined, + now = Date.now(), +) { + return Boolean( + profile && + Number.isFinite(profile.checkedAt) && + now >= profile.checkedAt && + now - profile.checkedAt < PROFILE_TTL_MS, + ) +} + +export function formatOAuthAccountTier( + profile: OAuthAccountProfile | undefined, +): string | undefined { + const match = profile?.tier.match(/^default_claude_max_(\d+)x$/) + if (!match) return undefined + const label = `Max ${match[1]}x` + return profile?.orgType === 'claude_team' ? `Team · ${label}` : label +} diff --git a/packages/core/src/quota-headers.ts b/packages/core/src/quota-headers.ts new file mode 100644 index 00000000..6fa678f6 --- /dev/null +++ b/packages/core/src/quota-headers.ts @@ -0,0 +1,95 @@ +import type { + AccountQuotaWindow, + OAuthQuotaSnapshot, + QuotaWindowName, +} from './accounts.ts' + +const PREFIX = 'anthropic-ratelimit-unified-' +const WINDOW_KEYS: Record = { + '5h': 'five_hour', + '7d': 'seven_day', +} + +export function isQuotaBearingHeaderFrame(headers: Headers): boolean { + return Object.keys(WINDOW_KEYS).some((suffix) => + Number.isFinite( + finiteHeaderNumber(headers, `${PREFIX}${suffix}-utilization`), + ), + ) +} + +function finiteHeaderNumber(headers: Headers, name: string) { + const value = headers.get(name) + if (value == null || value.trim() === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +function normalizeWindow( + headers: Headers, + suffix: string, + checkedAt: number, +): AccountQuotaWindow | undefined { + const utilization = finiteHeaderNumber( + headers, + `${PREFIX}${suffix}-utilization`, + ) + if (utilization == null) return undefined + const usedPercent = Math.min(100, Math.max(0, Math.round(utilization * 100))) + const resetSeconds = finiteHeaderNumber(headers, `${PREFIX}${suffix}-reset`) + const resetDate = + resetSeconds == null ? undefined : new Date(resetSeconds * 1000) + const resetsAt = + resetDate && Number.isFinite(resetDate.getTime()) + ? resetDate.toISOString() + : undefined + return { + usedPercent, + remainingPercent: 100 - usedPercent, + ...(resetsAt && { resetsAt }), + checkedAt, + } +} + +export function normalizeQuotaHeaders( + headers: Headers, + now = Date.now(), +): OAuthQuotaSnapshot { + const snapshot: OAuthQuotaSnapshot = { + fallbackAdvised: headers.get(`${PREFIX}fallback`) === 'available', + source: 'headers', + checkedAt: now, + } + for (const [suffix, key] of Object.entries(WINDOW_KEYS)) { + const window = normalizeWindow(headers, suffix, now) + if (window) snapshot[key] = window + } + const representativeClaim = headers.get(`${PREFIX}representative-claim`) + if (representativeClaim) { + snapshot.bindingWindow = representativeClaim + snapshot.bindingWindowSource = 'headers' + } + return snapshot +} + +export function mergeHeaderQuotaSnapshot( + existing: OAuthQuotaSnapshot | undefined, + incoming: OAuthQuotaSnapshot, +): OAuthQuotaSnapshot { + return { + ...existing, + ...incoming, + scoped: existing && 'scoped' in existing ? existing.scoped : undefined, + extraUsage: existing?.extraUsage, + bindingWindow: + existing?.bindingWindowSource === 'poll' + ? existing.bindingWindow + : (incoming.bindingWindow ?? existing?.bindingWindow), + bindingWindowSource: + existing?.bindingWindowSource === 'poll' + ? 'poll' + : (incoming.bindingWindowSource ?? existing?.bindingWindowSource), + // Source tracks 5h/7d freshness; preserved scoped and credit fields remain poll-owned. + source: 'headers', + } +} diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 9ee9da13..1249f804 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -6,8 +6,6 @@ * Handles deduplication, rate-limiting (429 backoff), and staleness. */ -import { createHash } from 'node:crypto' - import type { AccountOperationError, AccountStorage, @@ -26,19 +24,15 @@ import { isQuotaPolicyAuthError, quotaBackoffActive, } from './accounts.ts' +import { mergeHeaderQuotaSnapshot } from './quota-headers.ts' + +export { tokenFingerprint } from './token-fingerprint.ts' + +import { tokenFingerprint } from './token-fingerprint.ts' // Capture real setTimeout before tests can mock globalThis.setTimeout const nativeSetTimeout = globalThis.setTimeout -/** - * Stable, non-reversible fingerprint of an access token. Used to detect a - * main-account switch so a different account's persisted/cached quota is never - * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota. - */ -export function tokenFingerprint(token: string): string { - return createHash('sha256').update(token).digest('hex').slice(0, 16) -} - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -62,6 +56,37 @@ export type QuotaManagerOptions = { onApiError?: (error: AccountOperationError) => void } +function mergePollCompletionWithNewerHeaders( + current: OAuthQuotaSnapshot | undefined, + polled: OAuthQuotaSnapshot, +): OAuthQuotaSnapshot { + if (current?.source !== 'headers') return polled + const fiveHourIsNewer = Boolean( + current.five_hour && + current.five_hour.checkedAt > (polled.five_hour?.checkedAt ?? 0), + ) + const sevenDayIsNewer = Boolean( + current.seven_day && + current.seven_day.checkedAt > (polled.seven_day?.checkedAt ?? 0), + ) + if (!fiveHourIsNewer && !sevenDayIsNewer) return polled + + return mergeHeaderQuotaSnapshot(polled, { + ...(fiveHourIsNewer && { five_hour: current.five_hour }), + ...(sevenDayIsNewer && { seven_day: current.seven_day }), + fallbackAdvised: current.fallbackAdvised, + ...(current.bindingWindowSource === 'headers' && { + bindingWindow: current.bindingWindow, + bindingWindowSource: current.bindingWindowSource, + }), + source: 'headers', + checkedAt: Math.max( + fiveHourIsNewer ? (current.five_hour?.checkedAt ?? 0) : 0, + sevenDayIsNewer ? (current.seven_day?.checkedAt ?? 0) : 0, + ), + }) +} + // --------------------------------------------------------------------------- // Class // --------------------------------------------------------------------------- @@ -181,6 +206,44 @@ export class QuotaManager { } } + pushMainFromHeaders( + accessToken: string, + incoming: OAuthQuotaSnapshot, + ): QuotaEntry { + const checkedAt = incoming.checkedAt ?? this.now() + const accessTokenFp = tokenFingerprint(accessToken) + const quota = mergeHeaderQuotaSnapshot( + this.mainTokenFp === accessTokenFp ? this.main?.quota : undefined, + incoming, + ) + const entry = { + quota, + checkedAt, + refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt), + } + this.setMain(accessToken, entry) + return entry + } + + pushFallbackFromHeaders( + accountId: string, + accessToken: string, + incoming: OAuthQuotaSnapshot, + ): QuotaEntry { + const checkedAt = incoming.checkedAt ?? this.now() + const quota = mergeHeaderQuotaSnapshot( + this.getFallback(accountId, accessToken)?.quota, + incoming, + ) + const entry = { + quota, + checkedAt, + refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt), + } + this.setFallback(accountId, entry, accessToken) + return entry + } + // ========================================================================= // Refresh (async, deduplicated, rate-limited) // ========================================================================= @@ -495,20 +558,28 @@ export class QuotaManager { now: this.now, }) const now = this.now() + const completedQuota = mergePollCompletionWithNewerHeaders( + this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, + quota, + ) this.mainTokenFp = tokenFingerprint(accessToken) this.main = { - quota, - refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now), - checkedAt: now, + quota: completedQuota, + refreshAfter: getQuotaNextRefreshAt( + completedQuota, + this.storage, + now, + ), + checkedAt: completedQuota.checkedAt ?? now, } this.mainLastApiError = undefined this.onMainQuotaFetched?.( - quota, + completedQuota, now, this.mainTokenFp, fetchStartedAt, ) - return quota + return completedQuota } catch (error) { this._handleMainFetchError(error) throw error @@ -552,18 +623,26 @@ export class QuotaManager { now: this.now, }) const now = this.now() + const completedQuota = mergePollCompletionWithNewerHeaders( + this.getFallback(accountId, accessToken)?.quota, + quota, + ) this.setFallback( accountId, { - quota, - refreshAfter: now + getQuotaCheckIntervalMs(this.storage), - checkedAt: now, + quota: completedQuota, + refreshAfter: getQuotaNextRefreshAt( + completedQuota, + this.storage, + now, + ), + checkedAt: completedQuota.checkedAt ?? now, }, accessToken, ) this.fallbackApiErrors.delete(accountId) this.fallbackErrorTokenFps.delete(accountId) - return quota + return completedQuota } finally { await fileLock.release() } diff --git a/packages/core/src/quotas.ts b/packages/core/src/quotas.ts index 91798b7c..770c8636 100644 --- a/packages/core/src/quotas.ts +++ b/packages/core/src/quotas.ts @@ -4,9 +4,11 @@ import type { AccountStorage, OAuthAccount, OAuthQuotaSnapshot, + QuotaMoney, QuotaWindowName, } from './accounts.ts' import { isOAuthAccount } from './accounts.ts' +import { formatOAuthAccountTier } from './oauth-profile.ts' export const CLAUDE_QUOTAS_COMMAND_NAME = 'claude-quota' @@ -22,6 +24,7 @@ export type QuotaAccountSummary = { quota?: OAuthQuotaSnapshot lastRefreshedAt?: number error?: string + tierLabel?: string } function formatPercent(value: number) { @@ -65,13 +68,28 @@ function formatWindow( key: QuotaWindowName, window: AccountQuotaWindow | undefined, now: number, + bindingWindow?: string, ) { if (!window) return ` - ${WINDOW_LABELS[key]}: unknown` - return [ + const line = [ ` - ${WINDOW_LABELS[key]}: ${formatPercent(window.remainingPercent)} remaining`, ` (${formatPercent(window.usedPercent)} used`, `${formatReset(window.resetsAt, now)}, checked ${formatAge(window.checkedAt, now)})`, ].join('') + return bindingWindow === key ? `${line} •` : line +} + +export function formatQuotaMoney(money: QuotaMoney) { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: money.currency, + minimumFractionDigits: money.exponent, + maximumFractionDigits: money.exponent, + }).format(money.amountMinor / 10 ** money.exponent) + } catch { + return `${money.amountMinor} ${money.currency}` + } } function formatScopedWindow(window: AccountScopedQuotaWindow, now: number) { @@ -104,6 +122,9 @@ export function buildFallbackQuotaSummaries( role: 'fallback' as const, enabled: account.enabled !== false, quota: account.quota, + ...(formatOAuthAccountTier(account.profile) && { + tierLabel: formatOAuthAccountTier(account.profile), + }), lastRefreshedAt: account.lastRefreshedAt, ...(error && { error }), } @@ -131,6 +152,7 @@ export function buildClaudeQuotaSummary(input: { const role = account.role === 'main' ? 'main' : 'fallback' const disabled = account.enabled === false ? ' disabled' : '' lines.push(`### ${account.name} (${role}${disabled})`) + if (account.tierLabel) lines.push(` - Tier: ${account.tierLabel}`) if (account.lastRefreshedAt) { lines.push( ` - Last token refresh: ${formatAge(account.lastRefreshedAt, now)}`, @@ -139,10 +161,38 @@ export function buildClaudeQuotaSummary(input: { if (account.error) { lines.push(` - Error: ${account.error}`) } - lines.push(formatWindow('five_hour', account.quota?.five_hour, now)) - lines.push(formatWindow('seven_day', account.quota?.seven_day, now)) + lines.push( + formatWindow( + 'five_hour', + account.quota?.five_hour, + now, + account.quota?.bindingWindow, + ), + ) + lines.push( + formatWindow( + 'seven_day', + account.quota?.seven_day, + now, + account.quota?.bindingWindow, + ), + ) for (const window of account.quota?.scoped ?? []) { - lines.push(formatScopedWindow(window, now)) + const line = formatScopedWindow(window, now) + lines.push( + account.quota?.bindingWindow === window.id ? `${line} •` : line, + ) + } + const extraUsage = account.quota?.extraUsage + if (extraUsage) { + const used = formatQuotaMoney(extraUsage.used) + const limit = formatQuotaMoney(extraUsage.limit) + lines.push( + ` - credits ${used}/${limit}${extraUsage.exhausted ? ' · exhausted' : ''}`, + ) + } + if (account.quota?.fallbackAdvised === true) { + lines.push(' - → fallback advised') } lines.push('') } diff --git a/packages/core/src/relay.ts b/packages/core/src/relay.ts index 9d5c0b96..be4cee2e 100644 --- a/packages/core/src/relay.ts +++ b/packages/core/src/relay.ts @@ -497,6 +497,9 @@ type PendingWebSocketRequest = { streamByteCount: number retryAttempts: number retryingBeforeResponse: boolean + onResponseHeaders?: (headers: Headers) => void + stagedResponseHeaders?: Headers + responseHeadersDelivered: boolean } class PersistentRelaySession { @@ -535,13 +538,20 @@ class PersistentRelaySession { payload: RelayPayload, bodyText: string, optimisticResponse = false, + onResponseHeaders?: (headers: Headers) => void, ): Promise { this.touch() const enqueuedAt = perfNowMs() const start = this.queue .catch(() => {}) .then(() => - this.startQueued(payload, bodyText, enqueuedAt, optimisticResponse), + this.startQueued( + payload, + bodyText, + enqueuedAt, + optimisticResponse, + onResponseHeaders, + ), ) const result = start.then(async ({ response, getPayload }) => ({ response: await response, @@ -562,6 +572,7 @@ class PersistentRelaySession { bodyText: string, enqueuedAt: number, optimisticResponse: boolean, + onResponseHeaders?: (headers: Headers) => void, ) { const connectStart = perfNowMs() await this.ensureConnected() @@ -593,7 +604,12 @@ class PersistentRelaySession { ) let activePayload = requestPayload - const first = this.sendPayload(requestPayload, bodyText, optimisticResponse) + const first = this.sendPayload( + requestPayload, + bodyText, + optimisticResponse, + onResponseHeaders, + ) void first.done.catch(() => {}) let activeDone = first.done const response = first.response.catch((error) => { @@ -608,7 +624,12 @@ class PersistentRelaySession { fullSync.id = createRequestId() fullSync.revision = (this.serverState?.revision ?? 0) + 1 activePayload = fullSync - const retry = this.sendPayload(fullSync, bodyText, optimisticResponse) + const retry = this.sendPayload( + fullSync, + bodyText, + optimisticResponse, + onResponseHeaders, + ) void retry.done.catch(() => {}) activeDone = retry.done return retry.response @@ -711,6 +732,7 @@ class PersistentRelaySession { payload: RelayPayload, bodyText: string, optimisticResponse: boolean, + onResponseHeaders?: (headers: Headers) => void, ) { const socket = this.socket if (!socket || socket.readyState !== WebSocket.OPEN) { @@ -741,6 +763,9 @@ class PersistentRelaySession { streamByteCount: 0, retryAttempts: 0, retryingBeforeResponse: false, + onResponseHeaders, + stagedResponseHeaders: undefined, + responseHeadersDelivered: false, } this.pending = pending this.resetPendingTimeout(pending) @@ -805,6 +830,8 @@ class PersistentRelaySession { pending.accepted = false pending.acceptedAt = undefined pending.responseStartedAt = undefined + pending.stagedResponseHeaders = undefined + pending.responseHeadersDelivered = false pending.sentAt = perfNowMs() pending.retryingBeforeResponse = false @@ -830,11 +857,24 @@ class PersistentRelaySession { pending: PendingWebSocketRequest, chunk: Uint8Array, ) { + this.deliverResponseHeaders(pending) pending.streamChunkCount += 1 pending.streamByteCount += chunk.byteLength pending.streamController?.enqueue(chunk) } + private deliverResponseHeaders(pending: PendingWebSocketRequest) { + if ( + pending.responseHeadersDelivered || + pending.stagedResponseHeaders == null + ) + return + pending.responseHeadersDelivered = true + try { + pending.onResponseHeaders?.(pending.stagedResponseHeaders) + } catch {} + } + private handleBinaryChunk(data: unknown) { const pending = this.pending if (!pending?.responseStarted) return @@ -894,12 +934,15 @@ class PersistentRelaySession { return } if (message.type === 'response_start') { + if (pending.responseStartedAt != null) return const responseStartedAt = perfNowMs() pending.responseStartedAt = responseStartedAt relayLog( `perf websocket response_start session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(responseStartedAt - pending.sentAt)} upstreamMs=${pending.acceptedAt == null ? 'unknown' : formatMs(responseStartedAt - pending.acceptedAt)} status=${message.status}`, ) clearTimeout(pending.timeout) + if (message.headers) + pending.stagedResponseHeaders = new Headers(message.headers) this.resolvePendingResponse( pending, message.status, @@ -907,6 +950,7 @@ class PersistentRelaySession { message.headers, ) if (pending.optimisticResponse && message.status >= 400) { + this.deliverResponseHeaders(pending) pending.streamController?.enqueue( new TextEncoder().encode( `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_upstream_error', message: `Relay upstream returned HTTP ${message.status}` } })}\n\n`, @@ -933,6 +977,7 @@ class PersistentRelaySession { relayLog( `websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`, ) + this.deliverResponseHeaders(pending) pending.streamController?.enqueue( new TextEncoder().encode( `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`, @@ -972,6 +1017,7 @@ class PersistentRelaySession { ) clearTimeout(pending.timeout) if (!pending.streamDone) { + this.deliverResponseHeaders(pending) pending.streamDone = true pending.streamController?.close() } @@ -1046,6 +1092,13 @@ export async function sendViaRelay(options: { fallback: () => Promise affinity?: string | null optimisticResponse?: boolean + /** + * Observes genuine upstream headers once per delivered response. WebSocket + * retries before downstream bytes replace staged headers, so the callback + * receives only the final accepted response; duplicate response_start frames + * within an attempt are ignored. + */ + onResponseHeaders?: (headers: Headers) => void }): Promise { const { config, @@ -1056,6 +1109,7 @@ export async function sendViaRelay(options: { fallback, affinity: explicitAffinity, optimisticResponse, + onResponseHeaders, } = options if (!config || !isRelayableAnthropicRequest(input, body)) return fallback() @@ -1104,6 +1158,7 @@ export async function sendViaRelay(options: { payload, bodyText, optimisticResponse === true, + onResponseHeaders, ) } catch (error) { relayLog( @@ -1139,6 +1194,12 @@ export async function sendViaRelay(options: { if (!result.usedRelay) return result.response + if (result.transport === 'http') { + try { + onResponseHeaders?.(result.response.headers) + } catch {} + } + if (result.transport === 'http') { updateLocalRelayState( affinity, diff --git a/packages/core/src/tests/quota-surfaces.test.ts b/packages/core/src/tests/quota-surfaces.test.ts new file mode 100644 index 00000000..3f777bd2 --- /dev/null +++ b/packages/core/src/tests/quota-surfaces.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from 'bun:test' + +import { fetchOAuthQuotaSnapshot } from '../accounts.ts' +import { + isQuotaBearingHeaderFrame, + normalizeQuotaHeaders, +} from '../quota-headers.ts' + +const MAIN_USAGE_CAPTURE = { + five_hour: { utilization: 4 }, + seven_day: { utilization: 13 }, + limits: [ + { kind: 'session', group: 'session', percent: 4, is_active: false }, + { kind: 'weekly_all', group: 'weekly', percent: 13, is_active: false }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 15, + is_active: true, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + extra_usage: { is_enabled: false, monthly_limit: null, used_credits: null }, + spend: null, +} + +const TEAM_USAGE_CAPTURE = { + five_hour: { utilization: 77 }, + seven_day: { utilization: 40 }, + limits: [ + { kind: 'session', group: 'session', percent: 77, is_active: true }, + { kind: 'weekly_all', group: 'weekly', percent: 40, is_active: false }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 51, + is_active: false, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + extra_usage: { + is_enabled: true, + monthly_limit: 10000, + used_credits: 10035, + utilization: 100, + }, + spend: { + severity: 'critical', + limit: { amount_minor: 10000, currency: 'USD', exponent: 2 }, + can_purchase_credits: false, + }, +} + +const MAIN_HEADERS = new Headers({ + 'anthropic-ratelimit-unified-status': 'allowed', + 'anthropic-ratelimit-unified-reset': '1784252400', + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-5h-status': 'allowed', + 'anthropic-ratelimit-unified-5h-utilization': '0.03', + 'anthropic-ratelimit-unified-5h-reset': '1784252400', + 'anthropic-ratelimit-unified-7d-status': 'allowed', + 'anthropic-ratelimit-unified-7d-utilization': '0.12', + 'anthropic-ratelimit-unified-7d-reset': '1784502000', + 'anthropic-ratelimit-unified-fallback-percentage': '0.5', + 'anthropic-ratelimit-unified-overage-status': 'rejected', + 'anthropic-ratelimit-unified-overage-disabled-reason': 'org_level_disabled', +}) + +const TEAM_HEADERS = new Headers({ + 'anthropic-ratelimit-unified-status': 'allowed', + 'anthropic-ratelimit-unified-reset': '1784246400', + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-5h-status': 'allowed', + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-status': 'allowed', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + 'anthropic-ratelimit-unified-fallback': 'available', + 'anthropic-ratelimit-unified-fallback-percentage': '0.5', + 'anthropic-ratelimit-unified-overage-status': 'rejected', + 'anthropic-ratelimit-unified-overage-disabled-reason': + 'org_spend_cap_reached', + 'anthropic-ratelimit-unified-overage-utilization': '1.0', + 'anthropic-ratelimit-unified-overage-surpassed-threshold': '1.0', + 'anthropic-ratelimit-unified-overage-reset': '1785542400', +}) + +async function snapshotFor(capture: unknown) { + return fetchOAuthQuotaSnapshot({ + accessToken: 'test-token', + now: () => 1_700_000_000_000, + fetchImpl: (async () => Response.json(capture)) as unknown as typeof fetch, + }) +} + +describe('quota surface normalization', () => { + test('normalizes enabled exhausted extra usage from the Team capture', async () => { + const team = await snapshotFor(TEAM_USAGE_CAPTURE) + + expect(team.extraUsage?.used.amountMinor).toBe(10035) + expect(team.extraUsage?.limit.amountMinor).toBe(10000) + expect(team.extraUsage?.severity).toBe('critical') + expect(team.extraUsage?.exhausted).toBe(true) + expect(team.source).toBe('poll') + }) + + test('omits extraUsage when the personal capture says is_enabled false', async () => { + const main = await snapshotFor(MAIN_USAGE_CAPTURE) + + expect(main.extraUsage).toBeUndefined() + }) + + test('rejects extra usage with invalid currency or exponent metadata', async () => { + const invalidCurrency = await snapshotFor({ + ...TEAM_USAGE_CAPTURE, + spend: { + ...TEAM_USAGE_CAPTURE.spend, + limit: { amount_minor: 10000, currency: 'ZZZZ', exponent: 2 }, + }, + }) + const invalidExponent = await snapshotFor({ + ...TEAM_USAGE_CAPTURE, + spend: { + ...TEAM_USAGE_CAPTURE.spend, + limit: { amount_minor: 10000, currency: 'USD', exponent: 50 }, + }, + }) + + expect(invalidCurrency.extraUsage).toBeUndefined() + expect(invalidExponent.extraUsage).toBeUndefined() + }) + + test('maps the active session limit to bindingWindow five_hour', async () => { + const team = await snapshotFor(TEAM_USAGE_CAPTURE) + + expect(team.bindingWindow).toBe('five_hour') + expect(team.bindingWindowSource).toBe('poll') + }) + + test('maps an active scoped limit to its wire-derived identity without coercing it to seven_day', async () => { + const main = await snapshotFor(MAIN_USAGE_CAPTURE) + + expect(main.bindingWindow).toBe('claude-weekly-scoped-fable') + expect(main.bindingWindowSource).toBe('poll') + }) + + test('classifies a frame only when at least one unified utilization header exists', () => { + expect(isQuotaBearingHeaderFrame(MAIN_HEADERS)).toBe(true) + expect( + isQuotaBearingHeaderFrame( + new Headers({ 'anthropic-ratelimit-unified-status': 'allowed' }), + ), + ).toBe(false) + }) + + test('rejects overage-only utilization frames', () => { + expect( + isQuotaBearingHeaderFrame( + new Headers({ + 'anthropic-ratelimit-unified-overage-utilization': '0.8', + }), + ), + ).toBe(false) + }) + + test('normalizes the personal Max 20x capture with rounded percentages and ISO resets', () => { + const now = 1_700_000_000_000 + const personal = normalizeQuotaHeaders(MAIN_HEADERS, now) + + expect(personal).toMatchObject({ + five_hour: { usedPercent: 3, remainingPercent: 97, checkedAt: now }, + seven_day: { usedPercent: 12, remainingPercent: 88, checkedAt: now }, + bindingWindow: 'five_hour', + bindingWindowSource: 'headers', + source: 'headers', + checkedAt: now, + }) + expect(personal.five_hour?.resetsAt).toBe( + new Date(1784252400 * 1000).toISOString(), + ) + expect(personal.fallbackAdvised).toBe(false) + }) + + test('normalizes the Team Max-5x capture and fallback advisory', () => { + const team = normalizeQuotaHeaders(TEAM_HEADERS) + + expect(team.five_hour?.usedPercent).toBe(78) + expect(team.seven_day?.usedPercent).toBe(40) + expect(team.fallbackAdvised).toBe(true) + }) + + test('treats every non-core header as optional', () => { + const headers = new Headers({ + 'anthropic-ratelimit-unified-5h-utilization': '0.125', + }) + + expect(normalizeQuotaHeaders(headers).five_hour?.usedPercent).toBe(13) + }) + + test('rejects non-finite utilization and reset values without throwing', () => { + const headers = new Headers({ + 'anthropic-ratelimit-unified-5h-utilization': 'Infinity', + 'anthropic-ratelimit-unified-5h-reset': 'not-a-number', + 'anthropic-ratelimit-unified-7d-utilization': 'NaN', + }) + + expect(normalizeQuotaHeaders(headers)).toMatchObject({ + fallbackAdvised: false, + source: 'headers', + }) + expect(normalizeQuotaHeaders(headers).five_hour).toBeUndefined() + expect(normalizeQuotaHeaders(headers).seven_day).toBeUndefined() + expect(isQuotaBearingHeaderFrame(headers)).toBe(false) + }) + + test('keeps valid windows when reset values exceed the JavaScript date range', () => { + const snapshot = normalizeQuotaHeaders( + new Headers({ + 'anthropic-ratelimit-unified-5h-utilization': '0.2', + 'anthropic-ratelimit-unified-5h-reset': '1e308', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1e308', + }), + ) + + expect(snapshot.five_hour?.usedPercent).toBe(20) + expect(snapshot.seven_day?.usedPercent).toBe(40) + expect(snapshot.five_hour?.resetsAt).toBeUndefined() + expect(snapshot.seven_day?.resetsAt).toBeUndefined() + }) +}) diff --git a/packages/core/src/token-fingerprint.ts b/packages/core/src/token-fingerprint.ts new file mode 100644 index 00000000..f0d43a2d --- /dev/null +++ b/packages/core/src/token-fingerprint.ts @@ -0,0 +1,9 @@ +import { createHash } from 'node:crypto' + +/** + * Stable, non-reversible fingerprint used to bind persisted state to an access + * token without storing another copy of the credential. + */ +export function tokenFingerprint(token: string): string { + return createHash('sha256').update(token).digest('hex').slice(0, 16) +} diff --git a/packages/e2e-tests/src/mock-anthropic.ts b/packages/e2e-tests/src/mock-anthropic.ts index b9b2a642..ede97019 100644 --- a/packages/e2e-tests/src/mock-anthropic.ts +++ b/packages/e2e-tests/src/mock-anthropic.ts @@ -11,6 +11,7 @@ export type MockResponse = text: string delayMs?: number usage?: MockUsage + headers?: Record } | { type: 'tool_use' @@ -19,16 +20,19 @@ export type MockResponse = input: Record splitToolNameChunk?: boolean usage?: MockUsage + headers?: Record } | { type: 'refusal' usage?: MockUsage + headers?: Record } | { type: 'error' status: number errorType: string message: string + headers?: Record } export type CapturedAnthropicRequest = { @@ -146,7 +150,10 @@ export class MockAnthropicServer { }), { status: response.status, - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...response.headers, + }, }, ) } @@ -157,6 +164,7 @@ export class MockAnthropicServer { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive', + ...response.headers, }, }) } diff --git a/packages/e2e-tests/src/opencode-runner.ts b/packages/e2e-tests/src/opencode-runner.ts index aa7fe4c7..bc989679 100644 --- a/packages/e2e-tests/src/opencode-runner.ts +++ b/packages/e2e-tests/src/opencode-runner.ts @@ -404,6 +404,7 @@ export async function spawnOpencode( env.tempDir, 'opencode-anthropic-auth.log', ) + childEnv.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' childEnv.XDG_CONFIG_HOME = env.configDir childEnv.XDG_DATA_HOME = env.dataDir childEnv.XDG_CACHE_HOME = env.cacheDir diff --git a/packages/e2e-tests/tests/quota-header-relay.test.ts b/packages/e2e-tests/tests/quota-header-relay.test.ts new file mode 100644 index 00000000..cf12b3ff --- /dev/null +++ b/packages/e2e-tests/tests/quota-header-relay.test.ts @@ -0,0 +1,101 @@ +/// + +import { afterEach, describe, expect, it } from 'bun:test' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { E2EHarness } from '../src/harness.ts' + +let harness: E2EHarness | null = null + +type PersistedQuotaState = { + main?: { + quota?: { + source?: string + five_hour?: { usedPercent?: number } + seven_day?: { usedPercent?: number } + } + quotaToken?: string + } +} + +async function readQuotaState(harness: E2EHarness) { + const statePath = join( + harness.opencode.env.configDir, + 'anthropic-auth-state.json', + ) + return readFile(statePath, 'utf8') + .then((value) => JSON.parse(value) as PersistedQuotaState) + .catch(() => null) +} + +async function waitForHeaderQuotaState(harness: E2EHarness) { + let state: PersistedQuotaState | null = null + for (let attempt = 0; attempt < 100; attempt++) { + state = await readQuotaState(harness) + if (state?.main?.quota?.source === 'headers') return state + await Bun.sleep(50) + } + throw new Error( + `relay quota state did not persist: ${JSON.stringify(state)}\n--- stdout ---\n${harness.opencode.stdout()}\n--- stderr ---\n${harness.opencode.stderr()}`, + ) +} + +afterEach(async () => { + await harness?.dispose() + harness = null +}) + +describe('quota headers through relay', () => { + it('harvests quota headers from a websocket relay response', async () => { + harness = await E2EHarness.create({ relay: 'websocket' }) + harness.script([ + { + type: 'text', + text: 'relay response', + headers: { + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + 'anthropic-ratelimit-unified-fallback': 'available', + }, + }, + ]) + + const sessionId = await harness.createSession() + await harness.sendPrompt(sessionId, 'return the relay response') + await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1, { + label: 'relay request accepted', + }) + + const state = await waitForHeaderQuotaState(harness) + + expect(state?.main?.quota?.source).toBe('headers') + expect(state?.main?.quota?.five_hour?.usedPercent).toBe(78) + expect(state?.main?.quota?.seven_day?.usedPercent).toBe(40) + expect(state?.main?.quotaToken).toBe( + createHash('sha256') + .update('test-access-token') + .digest('hex') + .slice(0, 16), + ) + }, 90_000) + + it('leaves quota state untouched when websocket relay headers have no quota data', async () => { + harness = await E2EHarness.create({ relay: 'websocket' }) + harness.script([{ type: 'text', text: 'relay response without quota' }]) + + const sessionId = await harness.createSession() + await harness.sendPrompt(sessionId, 'return the relay response') + await harness.waitForSessionText(sessionId, 'relay response without quota') + await harness.waitFor(() => (harness?.relay?.acceptedRequests() ?? 0) >= 1, { + label: 'relay request accepted', + }) + + const state = await readQuotaState(harness) + expect(state?.main?.quota).toBeUndefined() + expect(state?.main?.quotaToken).toBeUndefined() + }, 90_000) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index afff4be3..dbe867b1 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -38,6 +38,8 @@ import { executeLoggingCommand, executeRoutingCommand, FallbackAccountManager, + fetchOAuthAccountProfile, + formatOAuthAccountTier, formatQuotaBackoffMessage, formatRefreshBackoffMessage, getAccountStoragePath, @@ -70,6 +72,7 @@ import { isKillswitchEnabled, isOAuthAccount, isPermanentRefreshError, + isQuotaBearingHeaderFrame, isValidApiBaseURL, KILLSWITCH_COMMAND_NAME, killswitchPassesPolicy, @@ -78,8 +81,11 @@ import { log, logger, mergeAnthropicBetas, + normalizeQuotaHeaders, type OAuthAccount, type OAuthQuotaSnapshot, + oauthProfileIsFresh, + oauthProfileMatchesToken, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, parseAccountCommandAction, parseCache1hCommandAction, @@ -89,6 +95,7 @@ import { parseLoggingCommandAction, parseRoutingCommandAction, type QuotaAccountSummary, + type QuotaEntry, QuotaManager, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, @@ -102,6 +109,7 @@ import { type StickyRouteCandidate, StickySessionRouter, saveAccountState, + saveOAuthProfileState, sendViaRelay, setAccountEnabledPersistent, setCache1hPersistentEnabled, @@ -123,6 +131,7 @@ import { stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, + tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import type { Plugin } from '@opencode-ai/plugin' import { @@ -692,6 +701,7 @@ function zeroModelCosts>( export const AnthropicAuthPlugin: Plugin = async (ctx) => { startEventLoopLagMonitor() const { client } = ctx + const profileFetch = globalThis.fetch const accountStoragePath = getAccountStoragePath() // -- OAuth add-flow pending state (Add account modal) -------------------- @@ -806,6 +816,242 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } }, }) + const profileHydrationAttempts = new Map< + string, + Promise> | undefined> + >() + + async function hydrateProfileOnce( + accountId: string, + accessToken: string, + signal?: AbortSignal, + ) { + if (signal?.aborted) return undefined + const attemptKey = `${accountId}:${tokenFingerprint(accessToken)}` + let attempt = profileHydrationAttempts.get(attemptKey) + if (!attempt) { + const fetchAttempt = fetchOAuthAccountProfile({ + accessToken, + fetchImpl: profileFetch, + signal, + }) + .catch((error) => { + logger.debug('quota', 'failed to hydrate account profile', { + account: accountId, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + }) + .finally(() => { + if (profileHydrationAttempts.get(attemptKey) === fetchAttempt) { + profileHydrationAttempts.delete(attemptKey) + } + }) + attempt = fetchAttempt + profileHydrationAttempts.set(attemptKey, attempt) + } + if (!signal) return attempt + return new Promise>((resolve) => { + let settled = false + const finish = (profile: Awaited) => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + resolve(profile) + } + const onAbort = () => finish(undefined) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + void attempt.then(finish) + }) + } + + async function persistProfileStateBestEffort(input: { + accountId: 'main' | string + accessToken: string + profile: OAuthAccount['profile'] + }): Promise { + try { + if (input.accountId === 'main') { + const currentAuth = await latestGetAuth?.() + if ( + currentAuth?.type !== 'oauth' || + currentAuth.access !== input.accessToken + ) { + return false + } + } + return await saveOAuthProfileState( + { + accountId: input.accountId, + profile: input.profile, + expectedTokenFingerprint: tokenFingerprint(input.accessToken), + }, + accountStoragePath, + ) + } catch (error) { + logger.debug('quota', 'failed to persist account profile', { + account: input.accountId, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } + } + + async function ensureProfilesForQuotaDisplay( + storage: AccountStorage, + mainAccessToken?: string, + signal?: AbortSignal, + ): Promise { + if (process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION === '1') { + return storage + } + const now = Date.now() + if ( + mainAccessToken && + storage.main?.profile && + !oauthProfileMatchesToken(storage.main.profile, mainAccessToken) + ) { + storage.main.profile = undefined + void persistProfileStateBestEffort({ + accountId: 'main', + accessToken: mainAccessToken, + profile: undefined, + }).catch(() => {}) + } + if (mainAccessToken && !oauthProfileIsFresh(storage.main?.profile, now)) { + const profile = await hydrateProfileOnce('main', mainAccessToken, signal) + if (profile) { + storage.main = { + type: 'opencode', + provider: 'anthropic', + profile, + } + void persistProfileStateBestEffort({ + accountId: 'main', + accessToken: mainAccessToken, + profile, + }).catch(() => {}) + } + } + + for (const account of storage.accounts) { + if (signal?.aborted) break + if (!isOAuthAccount(account) || !account.access) continue + const accessToken = account.access + if ( + account.profile && + !oauthProfileMatchesToken(account.profile, accessToken) + ) { + account.profile = undefined + void persistProfileStateBestEffort({ + accountId: account.id, + accessToken, + profile: undefined, + }).catch(() => {}) + } + if (oauthProfileIsFresh(account.profile, now)) continue + const profile = await hydrateProfileOnce(account.id, accessToken, signal) + if (profile) { + account.profile = profile + void persistProfileStateBestEffort({ + accountId: account.id, + accessToken, + profile, + }).catch(() => {}) + } + } + return storage + } + + const warnedQuotaNormalizeErrors = new Set() + + async function persistPushedQuota( + served: { accountId: 'main' | string; accessToken: string }, + entry: QuotaEntry, + ) { + const storage = + (await loadAccounts(accountStoragePath)) ?? createEmptyStorage() + if (served.accountId === 'main') { + let currentAccessToken: string | undefined + try { + const auth = await latestGetAuth?.() + if (auth?.type === 'oauth') currentAccessToken = auth.access + } catch {} + if (currentAccessToken !== served.accessToken) { + logger.trace('quota', 'skipped stale main response quota persistence') + return + } + storage.quota = storage.quota ?? {} + storage.quota.mainQuota = entry.quota + storage.quota.mainQuotaCheckedAt = entry.checkedAt + storage.quota.mainQuotaToken = tokenFingerprint(served.accessToken) + await saveAccountState(storage, accountStoragePath, { mainQuota: true }) + return + } + const account = storage.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === served.accountId && + isOAuthAccount(candidate) && + candidate.access === served.accessToken, + ) + if (!account) return + account.quota = entry.quota + await saveAccountState(storage, accountStoragePath, { + accounts: [served.accountId], + }) + } + + function logPersistFailure(error: unknown) { + logger.warn('quota', 'failed to persist harvested response quota', { + error: error instanceof Error ? error.message : String(error), + }) + } + + function warnQuotaNormalizeOnce(error: unknown) { + const name = error instanceof Error ? error.name : typeof error + const message = error instanceof Error ? error.message : String(error) + const shape = `${name}:${message}` + if (warnedQuotaNormalizeErrors.has(shape)) return + warnedQuotaNormalizeErrors.add(shape) + logger.warn('quota', 'failed to normalize response quota headers', { + error: message, + }) + } + + function harvestQuotaHeaders( + headers: Headers, + served: { accountId: 'main' | string; accessToken: string }, + ): void { + try { + if (!isQuotaBearingHeaderFrame(headers)) { + logger.trace('quota', 'skipped non-quota response headers', { + account: served.accountId, + }) + return + } + const incoming = normalizeQuotaHeaders(headers) + const entry = + served.accountId === 'main' + ? quotaManager.pushMainFromHeaders(served.accessToken, incoming) + : quotaManager.pushFallbackFromHeaders( + served.accountId, + served.accessToken, + incoming, + ) + void persistPushedQuota(served, entry).catch(logPersistFailure) + void refreshSidebarQuota().catch(() => {}) + logger.debug('quota', 'harvested response quota', { + account: served.accountId, + fiveHourPercent: entry.quota.five_hour?.usedPercent, + sevenDayPercent: entry.quota.seven_day?.usedPercent, + source: 'headers', + }) + } catch (error) { + warnQuotaNormalizeOnce(error) + } + } + const fallbackManager = new FallbackAccountManager({ quotaManager, onFallbackStorageChanged: () => { @@ -1023,6 +1269,47 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { mainAccessToken?: string mainRefreshToken?: string routingAuthoritative?: boolean + useHydratedProfiles?: boolean + } + + function mergeHydratedProfilesForSidebar( + latest: Awaited>, + hydrated: Awaited>, + mainAccessToken?: string, + ) { + if (!latest || !hydrated) return latest + const merged: AccountStorage = { + ...latest, + accounts: latest.accounts.map((account) => { + const hydratedAccount = hydrated.accounts.find( + (candidate) => candidate.id === account.id, + ) + if ( + !isOAuthAccount(account) || + !hydratedAccount || + !isOAuthAccount(hydratedAccount) || + !account.access || + hydratedAccount.access !== account.access + ) { + return account + } + return { ...account, profile: hydratedAccount.profile } + }), + } + const latestMainProfile = latest.main?.profile + const mainState = latest.main ?? hydrated.main + if ( + mainAccessToken && + mainState && + (!latestMainProfile || + oauthProfileMatchesToken(latestMainProfile, mainAccessToken)) + ) { + merged.main = { + ...mainState, + profile: hydrated.main?.profile, + } + } + return merged } function buildSidebarState( @@ -1040,6 +1327,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return { main: { quota: mainEntry?.quota ?? null, + tierLabel: formatOAuthAccountTier(storage?.main?.profile), quotaBackedOff: quotaManager.isBackedOff(), quotaBackoffUntil: lastApiError?.nextRetryAt, refreshBackedOff: mainRefreshError @@ -1059,6 +1347,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { .map((account) => ({ id: account.id, label: account.label, + tierLabel: formatOAuthAccountTier(account.profile), // Token-aware read: if a fallback account was re-logged with the same // id/label, an old in-memory quota snapshot must not be shown as the // new account's quota. @@ -1129,10 +1418,17 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { () => loadAccounts(accountStoragePath), { activeId: state.activeId, route: state.route }, ) + const displayStorage = options.useHydratedProfiles + ? mergeHydratedProfilesForSidebar( + preservedRouting.freshStorage, + storage, + options.mainAccessToken, + ) + : preservedRouting.freshStorage return { activeId: preservedRouting.activeId, route: preservedRouting.route, - state: buildSidebarState(preservedRouting.freshStorage, { + state: buildSidebarState(displayStorage, { ...options, activeId: preservedRouting.activeId, route: preservedRouting.route, @@ -1258,10 +1554,12 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function buildQuotaCommandSummary() { const accounts: QuotaAccountSummary[] = [] + let mainAccessToken: string | undefined if (latestGetAuth) { try { const auth = await latestGetAuth() if (auth.type === 'oauth' && auth.access) { + mainAccessToken = auth.access // /claude-quota is a manual action: force a real fetch instead of // returning the cache. refreshMain still respects 429 backoff — it // returns the last cached snapshot when the API is backed off. @@ -1297,8 +1595,19 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // per-account), saves refreshed snapshots, and clears stale errors. const { storage, errors } = await fallbackManager.refreshQuotaForAllAccounts({ force: true }) + const displayStorage = await ensureProfilesForQuotaDisplay( + storage ?? createEmptyStorage(), + mainAccessToken, + AbortSignal.timeout(3_000), + ) + const mainSummary = accounts.find((account) => account.role === 'main') + if (mainSummary) { + mainSummary.tierLabel = formatOAuthAccountTier( + displayStorage.main?.profile, + ) + } const errorMap = new Map(errors.map((e) => [e.accountId, e.message])) - accounts.push(...buildFallbackQuotaSummaries(storage, errorMap)) + accounts.push(...buildFallbackQuotaSummaries(displayStorage, errorMap)) if (!latestGetAuth) { accounts.unshift({ @@ -1683,7 +1992,21 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } // -- existing flows ---------------------------------------------------- - const storage = await loadAccounts(accountStoragePath) + let storage = await loadAccounts(accountStoragePath) + if (action.type === 'status' && storage) { + let mainAccessToken: string | undefined + if (latestGetAuth) { + try { + const auth = await latestGetAuth() + if (auth.type === 'oauth') mainAccessToken = auth.access + } catch {} + } + storage = await ensureProfilesForQuotaDisplay( + storage, + mainAccessToken, + AbortSignal.timeout(3_000), + ) + } const result = executeAccountCommand({ argumentsText, storage: storage ?? { version: 1, accounts: [] }, @@ -2524,6 +2847,26 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { mainRefreshToken: auth.refresh, routingAuthoritative: false, }) + if ( + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION !== + '1' + ) { + void ensureProfilesForQuotaDisplay( + initialStorage ?? createEmptyStorage(), + auth.access, + ) + .then((displayStorage) => { + writeSidebarState(displayStorage, { + activeId: 'main', + route: 'main', + mainAccessToken: auth.access, + mainRefreshToken: auth.refresh, + routingAuthoritative: false, + useHydratedProfiles: true, + }) + }) + .catch(() => {}) + } function isReplayableRequest( input: string | URL | Request, @@ -2957,7 +3300,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } } + let usedDirectFetch = false const directFetch = async () => { + usedDirectFetch = true try { const response = await fetch(rewritten.input, { ...init, @@ -2998,6 +3343,10 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } const relayConfig = getRelayConfig(await getRequestStorage()) + const served = { + accountId: oauthAccountId, + accessToken, + } const sendStart = nowMs() const response = await sendViaRelay({ config: relayConfig, @@ -3008,6 +3357,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { fallback: directFetch, affinity: relayAffinity, optimisticResponse: relayConfig?.transport === 'websocket', + onResponseHeaders: (headers) => + harvestQuotaHeaders(headers, served), }) trace?.mark('send_headers_received', { route, @@ -3017,6 +3368,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { totalSendWithAccessMs: roundMs(nowMs() - start), }) + if (usedDirectFetch) harvestQuotaHeaders(response.headers, served) return response } @@ -3057,6 +3409,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { function mainQuotaEntryIsFreshExhausted(accessToken?: string) { if (!accessToken) return false const entry = quotaManager.getMain(accessToken) + // A genuine response header is live routing evidence like a 429, but + // it gets no exemption from the shared freshness and token gates. return Boolean( entry && entry.refreshAfter > Date.now() && diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 09d82e1e..171f9dea 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -15,6 +15,15 @@ export interface AccountQuota { five_hour?: QuotaWindow seven_day?: QuotaWindow scoped?: ScopedQuotaWindow[] + extraUsage?: { + used: { amountMinor: number; currency: string; exponent: number } + limit: { amountMinor: number; currency: string; exponent: number } + utilizationPercent?: number + severity?: string + exhausted: boolean + } + bindingWindow?: string + fallbackAdvised?: boolean } export interface SidebarAccountState { @@ -25,6 +34,7 @@ export interface SidebarAccountState { // True when the account's refresh token is permanently dead (400 // invalid_grant) and it needs a re-login — distinct from a transient backoff. needsReauth: boolean + tierLabel?: string } export interface FableRecoverySidebarState { @@ -37,6 +47,7 @@ export interface FableRecoverySidebarState { export interface SidebarState { main: { quota: AccountQuota | null + tierLabel?: string quotaBackedOff?: boolean quotaBackoffUntil?: number refreshBackedOff?: boolean @@ -147,9 +158,53 @@ function normalizeAccountQuota(value: unknown): AccountQuota | null { quota.scoped = scoped } + if (isRecord(value.extraUsage)) { + const used = normalizeQuotaMoney(value.extraUsage.used) + const limit = normalizeQuotaMoney(value.extraUsage.limit) + if (used && limit && typeof value.extraUsage.exhausted === 'boolean') { + quota.extraUsage = { + used, + limit, + ...(typeof value.extraUsage.utilizationPercent === 'number' && + Number.isFinite(value.extraUsage.utilizationPercent) && { + utilizationPercent: value.extraUsage.utilizationPercent, + }), + ...(typeof value.extraUsage.severity === 'string' && { + severity: value.extraUsage.severity, + }), + exhausted: value.extraUsage.exhausted, + } + } + } + if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) { + quota.bindingWindow = value.bindingWindow.trim() + } + if (typeof value.fallbackAdvised === 'boolean') { + quota.fallbackAdvised = value.fallbackAdvised + } + return Object.keys(quota).length ? quota : null } +function normalizeQuotaMoney(value: unknown) { + if (!isRecord(value)) return undefined + if ( + !Number.isInteger(value.amountMinor) || + typeof value.currency !== 'string' || + !/^[A-Za-z]{3}$/.test(value.currency.trim()) || + !Number.isInteger(value.exponent) || + (value.exponent as number) < 0 || + (value.exponent as number) > 20 + ) { + return undefined + } + return { + amountMinor: value.amountMinor as number, + currency: value.currency.trim(), + exponent: value.exponent as number, + } +} + export function normalizeSidebarState(raw: unknown): SidebarState { if (!isRecord(raw)) return { ...DEFAULT_SIDEBAR_STATE } @@ -157,6 +212,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState { if (isRecord(raw.main)) { const m = raw.main main.quota = normalizeAccountQuota(m.quota) + if (typeof m.tierLabel === 'string' && m.tierLabel.trim()) { + main.tierLabel = m.tierLabel.trim() + } if (typeof m.quotaBackedOff === 'boolean') main.quotaBackedOff = m.quotaBackedOff if (typeof m.quotaBackoffUntil === 'number') @@ -178,6 +236,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState { enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false, needsReauth: typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false, + tierLabel: + typeof entry.tierLabel === 'string' && entry.tierLabel.trim() + ? entry.tierLabel.trim() + : undefined, })) : [] @@ -445,7 +507,9 @@ async function writeSidebarStateAtomic( } } -async function readSidebarState(stateFile: string): Promise { +export async function getSidebarState( + stateFile = getSidebarStateFile(), +): Promise { try { const raw = await readFile(stateFile, 'utf8') return normalizeSidebarState(JSON.parse(raw)) @@ -454,10 +518,6 @@ async function readSidebarState(stateFile: string): Promise { } } -export async function getSidebarState(): Promise { - return readSidebarState(getSidebarStateFile()) -} - export interface SidebarStateWriteOptions { routingAuthoritative?: boolean resolvePreservedRouting?: (current: SidebarState) => @@ -508,7 +568,7 @@ export async function setSidebarState( | 'lock-lost-after-rename' try { if (options.routingAuthoritative === false) { - const current = await readSidebarState(stateFile) + const current = await getSidebarState(stateFile) const preservedRouting = await options.resolvePreservedRouting?.(current) if (preservedRouting) { @@ -525,7 +585,7 @@ export async function setSidebarState( } await sidebarStateWriteTestHooks?.afterMergeRead?.(stateFile) } else if (repairingPostRenameLoss) { - const current = await readSidebarState(stateFile) + const current = await getSidebarState(stateFile) // A successor owns its fresh account/quota and recovery snapshots; // this routing frame may only republish its decision and shared UI state. stateToWrite = { diff --git a/packages/opencode/src/tests/account-command.test.ts b/packages/opencode/src/tests/account-command.test.ts index 8e4e951f..d68843fd 100644 --- a/packages/opencode/src/tests/account-command.test.ts +++ b/packages/opencode/src/tests/account-command.test.ts @@ -200,6 +200,47 @@ describe('buildAccountList', () => { const list = buildAccountList(storage) expect(list[1]!.label).toBe('abc') }) + + test('buildAccountList adds tierLabel only when profile exists', () => { + const storage = baseStorage() + storage.main = { + ...storage.main!, + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 100, + }, + } + Object.assign(storage.accounts[0]!, { + profile: { + tier: 'default_claude_max_5x', + orgType: 'claude_team', + checkedAt: 100, + }, + }) + + const list = buildAccountList(storage) + + expect(list[0]!.tierLabel).toBe('Max 20x') + expect(list[1]!.tierLabel).toBe('Team · Max 5x') + expect(list[2]!.tierLabel).toBeUndefined() + }) + + test('account modal includes optional tier label', () => { + const storage = baseStorage() + storage.main = { + ...storage.main!, + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 100, + }, + } + + const result = executeAccountCommand({ argumentsText: '', storage }) + + expect(result.text).toContain('Max 20x') + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 804bc669..3a8811b0 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -19,7 +19,9 @@ import { buildRefreshOperationError, ClaudeOAuthRefreshError, FallbackAccountManager, + fetchOAuthAccountProfile, fetchOAuthQuotaSnapshot, + formatOAuthAccountTier, getAccountStatePath, getCache1hPersistentMode, getLogLevel, @@ -33,7 +35,10 @@ import { killswitchPassesPolicy, loadAccounts, type OAuthAccount, + type OAuthAccountProfile, type OAuthQuotaSnapshot, + oauthProfileIsFresh, + PROFILE_TTL_MS, QuotaManager, quotaSnapshotModelScopeIsExhausted, quotaSnapshotPassesModelScope, @@ -44,6 +49,7 @@ import { reorderAccountsPersistent, saveAccountState, saveAccounts, + saveOAuthProfileState, setAccountEnabled, setAccountEnabledPersistent, setCache1hPersistentEnabled, @@ -56,6 +62,7 @@ import { setLogLevel, setLogLevelPersistent, shouldFallbackStatus, + tokenFingerprint, upsertAccount, } from '@cortexkit/anthropic-auth-core' @@ -149,6 +156,332 @@ afterEach(async () => { mock.restore() }) +describe('OAuth account profiles', () => { + const mainCapture = { + account: { has_claude_max: true }, + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + } + const teamCapture = { + account: { has_claude_max: false }, + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + seat_tier: 'team_tier_1', + }, + } + + async function fetchProfile(capture: unknown) { + return fetchOAuthAccountProfile({ + accessToken: 'token', + now: () => 1234, + fetchImpl: (async () => + Response.json(capture)) as unknown as typeof fetch, + }) + } + + test('fetchOAuthAccountProfile normalizes the personal Max 20x capture', async () => { + const profile = await fetchProfile(mainCapture) + + expect(profile).toEqual({ + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 1234, + tokenFingerprint: tokenFingerprint('token'), + }) + expect(formatOAuthAccountTier(profile)).toBe('Max 20x') + }) + + test('fetchOAuthAccountProfile normalizes the Team Max-5x capture', async () => { + const profile = await fetchProfile(teamCapture) + + expect(profile).toEqual({ + tier: 'default_claude_max_5x', + orgType: 'claude_team', + checkedAt: 1234, + tokenFingerprint: tokenFingerprint('token'), + }) + expect(formatOAuthAccountTier(profile)).toBe('Team · Max 5x') + }) + + test('fetchOAuthAccountProfile rejects blank account metadata', async () => { + await expect( + fetchProfile({ + organization: { + organization_type: ' ', + rate_limit_tier: '', + }, + }), + ).rejects.toThrow('missing account metadata') + }) + + test('profile freshness expires at seven days', () => { + const profile: OAuthAccountProfile = { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 100, + } + + expect( + oauthProfileIsFresh(profile, profile.checkedAt + PROFILE_TTL_MS - 1), + ).toBe(true) + expect( + oauthProfileIsFresh(profile, profile.checkedAt + PROFILE_TTL_MS), + ).toBe(false) + }) + + test('dedicated main and scoped fallback profiles survive sidecar load', async () => { + const storage = baseStorage() + const mainProfile: OAuthAccountProfile = { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 100, + } + const fallbackProfile: OAuthAccountProfile = { + tier: 'default_claude_max_5x', + orgType: 'claude_team', + checkedAt: 200, + } + storage.main = { ...storage.main!, profile: mainProfile } + storage.accounts.push({ + id: 'work', + type: 'oauth', + refresh: 'refresh', + profile: fallbackProfile, + }) + + await saveAccounts(storage, accountPath) + await saveAccountState(storage, accountPath, { mainProfile: true }) + const loaded = await loadAccounts(accountPath) + + expect(loaded?.main?.profile).toEqual(mainProfile) + expect(expectOAuthAccount(loaded?.accounts[0]).profile).toEqual( + fallbackProfile, + ) + }) + + test('profile runtime fields are omitted from anthropic-auth.json', async () => { + const storage = baseStorage() + storage.main = { + ...storage.main!, + profile: { tier: 'tier', orgType: 'org', checkedAt: 100 }, + } + storage.accounts.push({ + id: 'work', + type: 'oauth', + refresh: 'refresh', + profile: { tier: 'tier', orgType: 'org', checkedAt: 100 }, + }) + + await saveAccounts(storage, accountPath) + const config = JSON.parse(await readFile(accountPath, 'utf8')) + + expect(config.main.profile).toBeUndefined() + expect(config.accounts[0].profile).toBeUndefined() + }) + + test('re-login does not retain a previous account profile', () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work', + type: 'oauth', + refresh: 'old', + profile: { tier: 'old', orgType: 'old', checkedAt: 100 }, + }) + + upsertAccount(storage, { id: 'work', type: 'oauth', refresh: 'new' }) + + expect(expectOAuthAccount(storage.accounts[0]).profile).toBeUndefined() + }) + + test('runtime merge clears a fallback profile when OAuth credentials rotate', async () => { + const initial = baseStorage() + initial.accounts.push({ + id: 'work', + type: 'oauth', + access: 'old-access', + refresh: 'old-refresh', + profile: { + tier: 'old-tier', + orgType: 'claude_team', + checkedAt: 100, + tokenFingerprint: tokenFingerprint('old-access'), + }, + }) + await saveAccounts(initial, accountPath) + + const rotated = baseStorage() + rotated.accounts.push({ + id: 'work', + type: 'oauth', + access: 'new-access', + refresh: 'new-refresh', + }) + await saveAccounts(rotated, accountPath) + + expect( + expectOAuthAccount((await loadAccounts(accountPath))?.accounts[0]) + .profile, + ).toBeUndefined() + }) + + test('runtime merge keeps a fallback profile when OAuth credentials match', async () => { + const profile: OAuthAccountProfile = { + tier: 'same-tier', + orgType: 'claude_team', + checkedAt: 100, + tokenFingerprint: tokenFingerprint('same-access'), + } + const initial = baseStorage() + initial.accounts.push({ + id: 'work', + type: 'oauth', + access: 'same-access', + refresh: 'same-refresh', + profile, + }) + await saveAccounts(initial, accountPath) + + const sameCredentials = baseStorage() + sameCredentials.accounts.push({ + id: 'work', + type: 'oauth', + access: 'same-access', + refresh: 'same-refresh', + }) + await saveAccounts(sameCredentials, accountPath) + + expect( + expectOAuthAccount((await loadAccounts(accountPath))?.accounts[0]) + .profile, + ).toEqual(profile) + }) + + test('delayed main profile clear preserves a newer profile for the same token', async () => { + const accessToken = 'main-access' + const expectedTokenFingerprint = tokenFingerprint(accessToken) + const staleProfile: OAuthAccountProfile = { + tier: 'old-tier', + orgType: 'old-org', + checkedAt: 100, + tokenFingerprint: tokenFingerprint('old-main-access'), + } + const freshProfile: OAuthAccountProfile = { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 200, + tokenFingerprint: expectedTokenFingerprint, + } + const storage = baseStorage() + storage.main = { ...storage.main!, profile: staleProfile } + await saveAccounts(storage, accountPath) + await saveAccountState(storage, accountPath, { mainProfile: true }) + + const processAView = await loadAccounts(accountPath) + expect(processAView?.main?.profile).toEqual(staleProfile) + expect( + await saveOAuthProfileState( + { + accountId: 'main', + profile: undefined, + expectedTokenFingerprint, + }, + accountPath, + ), + ).toBe(true) + expect( + await saveOAuthProfileState( + { + accountId: 'main', + profile: freshProfile, + expectedTokenFingerprint, + }, + accountPath, + ), + ).toBe(true) + + const clearAccepted = await saveOAuthProfileState( + { + accountId: 'main', + profile: undefined, + expectedTokenFingerprint, + }, + accountPath, + ) + expect((await loadAccounts(accountPath))?.main?.profile).toEqual( + freshProfile, + ) + expect(clearAccepted).toBe(false) + }) + + test('delayed fallback profile clear preserves a newer profile for the same token', async () => { + const accessToken = 'fallback-access' + const expectedTokenFingerprint = tokenFingerprint(accessToken) + const staleProfile: OAuthAccountProfile = { + tier: 'old-tier', + orgType: 'old-org', + checkedAt: 100, + tokenFingerprint: tokenFingerprint('old-fallback-access'), + } + const freshProfile: OAuthAccountProfile = { + tier: 'default_claude_max_5x', + orgType: 'claude_team', + checkedAt: 200, + tokenFingerprint: expectedTokenFingerprint, + } + const storage = baseStorage() + storage.accounts.push({ + id: 'work', + type: 'oauth', + access: accessToken, + refresh: 'refresh', + profile: staleProfile, + }) + await saveAccounts(storage, accountPath) + + const processAView = await loadAccounts(accountPath) + expect(expectOAuthAccount(processAView?.accounts[0]).profile).toEqual( + staleProfile, + ) + expect( + await saveOAuthProfileState( + { + accountId: 'work', + profile: undefined, + expectedTokenFingerprint, + }, + accountPath, + ), + ).toBe(true) + expect( + await saveOAuthProfileState( + { + accountId: 'work', + profile: freshProfile, + expectedTokenFingerprint, + }, + accountPath, + ), + ).toBe(true) + + const clearAccepted = await saveOAuthProfileState( + { + accountId: 'work', + profile: undefined, + expectedTokenFingerprint, + }, + accountPath, + ) + expect( + expectOAuthAccount((await loadAccounts(accountPath))?.accounts[0]) + .profile, + ).toEqual(freshProfile) + expect(clearAccepted).toBe(false) + }) +}) + describe('isCostZeroingEnabled', () => { test('defaults to enabled when costZeroing is absent', () => { expect(isCostZeroingEnabled(baseStorage())).toBe(true) @@ -424,6 +757,33 @@ describe('account storage', () => { expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(22) }) + test('generic saves preserve a newer persisted main profile', async () => { + const staleStorage = baseStorage() + await saveAccounts(staleStorage) + + const hydratedStorage = await loadAccounts() + expect(hydratedStorage).not.toBeNull() + ;(hydratedStorage as AccountStorage).main = { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: 500, + tokenFingerprint: tokenFingerprint('main-access'), + }, + } + await saveAccountState(hydratedStorage as AccountStorage, accountPath, { + mainProfile: true, + }) + + await saveAccounts(staleStorage) + + expect((await loadAccounts())?.main?.profile).toEqual( + hydratedStorage?.main?.profile, + ) + }) + test('runtime state saves do not overwrite newer quota snapshots', async () => { const storage = baseStorage() storage.quota = { @@ -502,7 +862,387 @@ describe('account storage', () => { expect(account.lastQuotaRefreshError).toBeUndefined() }) - test('runtime state saves can update refreshed tokens without downgrading quota', async () => { + test('equal-time stale saves do not overwrite a harvested header snapshot', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 999, + quota: { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: 500, + }, + }, + }) + await saveAccounts(storage) + const harvestedView = await loadAccounts() + const staleMarkUsedView = await loadAccounts() + expect(harvestedView).not.toBeNull() + expect(staleMarkUsedView).not.toBeNull() + const harvested = expectOAuthAccount(harvestedView?.accounts[0]) + harvested.quota = { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 500, + }, + source: 'headers', + checkedAt: 500, + } + expectOAuthAccount(staleMarkUsedView?.accounts[0]).lastUsed = 501 + + await saveAccountState(harvestedView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + await saveAccountState(staleMarkUsedView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + + const loaded = await loadAccounts() + const account = expectOAuthAccount(loaded?.accounts[0]) + expect(account.quota?.source).toBe('headers') + expect(account.quota?.five_hour?.usedPercent).toBe(78) + expect(account.lastUsed).toBe(501) + }) + + test('equal-time poll replaces headers and keeps scoped quota', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 999, + quota: { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 500, + }, + source: 'headers', + checkedAt: 500, + }, + }) + await saveAccounts(storage) + const pollView = await loadAccounts() + const pollAccount = expectOAuthAccount(pollView?.accounts[0]) + pollAccount.quota = { + five_hour: { + usedPercent: 79, + remainingPercent: 21, + checkedAt: 500, + }, + scoped: [ + { + id: 'weekly-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 500, + }, + ], + source: 'poll', + checkedAt: 500, + } + + await saveAccountState(pollView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + + const loaded = await loadAccounts() + const account = expectOAuthAccount(loaded?.accounts[0]) + expect(account.quota?.source).toBe('poll') + expect(account.quota?.scoped?.[0]?.id).toBe('weekly-fable') + }) + + test('equal-time header snapshot can replace another header snapshot', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 999, + quota: { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 500, + }, + source: 'headers', + checkedAt: 500, + }, + }) + await saveAccounts(storage) + const nextHeaderView = await loadAccounts() + const nextHeader = expectOAuthAccount(nextHeaderView?.accounts[0]) + nextHeader.quota = { + five_hour: { + usedPercent: 80, + remainingPercent: 20, + checkedAt: 500, + }, + source: 'headers', + checkedAt: 500, + } + + await saveAccountState(nextHeaderView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + + const loaded = await loadAccounts() + expect( + expectOAuthAccount(loaded?.accounts[0]).quota?.five_hour?.usedPercent, + ).toBe(80) + }) + + test('main equal-time poll beats headers and source-less saves', async () => { + const storage = baseStorage() + storage.quota = { + ...storage.quota, + mainQuota: { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 500, + }, + source: 'headers', + checkedAt: 500, + }, + mainQuotaCheckedAt: 500, + mainQuotaToken: 'token', + } + await saveAccounts(storage) + const pollView = await loadAccounts() + const sourceLessView = await loadAccounts() + expect(pollView).not.toBeNull() + expect(sourceLessView).not.toBeNull() + ;(pollView as AccountStorage).quota!.mainQuota = { + five_hour: { + usedPercent: 79, + remainingPercent: 21, + checkedAt: 500, + }, + scoped: [ + { + id: 'weekly-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 500, + }, + ], + source: 'poll', + checkedAt: 500, + } + ;(sourceLessView as AccountStorage).quota!.mainQuota = { + five_hour: { + usedPercent: 1, + remainingPercent: 99, + checkedAt: 500, + }, + checkedAt: 500, + } + + await saveAccountState(pollView as AccountStorage, accountPath, { + mainQuota: true, + }) + await saveAccountState(sourceLessView as AccountStorage, accountPath, { + mainQuota: true, + }) + + const loaded = await loadAccounts() + expect(loaded?.quota?.mainQuota?.source).toBe('poll') + expect(loaded?.quota?.mainQuota?.scoped?.[0]?.id).toBe('weekly-fable') + }) + + test('fallback header persistence preserves newer poll-owned fields from disk', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'access', + refresh: 'refresh', + expires: 999, + quota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 600, + }, + scoped: [ + { + id: 'weekly-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 900, + }, + ], + extraUsage: { + used: { amountMinor: 500, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 1000, currency: 'USD', exponent: 2 }, + exhausted: false, + }, + bindingWindow: 'weekly-fable', + bindingWindowSource: 'poll', + source: 'poll', + checkedAt: 600, + }, + }) + await saveAccounts(storage) + const staleHeaderView = await loadAccounts() + const account = expectOAuthAccount(staleHeaderView?.accounts[0]) + account.quota = { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 700, + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: 700, + }, + scoped: [], + fallbackAdvised: true, + bindingWindow: 'five_hour', + bindingWindowSource: 'headers', + source: 'headers', + checkedAt: 700, + } + + await saveAccountState(staleHeaderView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + + const loaded = await loadAccounts() + const merged = expectOAuthAccount(loaded?.accounts[0]).quota + expect(merged?.five_hour?.usedPercent).toBe(78) + expect(merged?.seven_day?.usedPercent).toBe(40) + expect(merged?.scoped?.[0]?.id).toBe('weekly-fable') + expect(merged?.extraUsage?.used.amountMinor).toBe(500) + expect(merged?.bindingWindow).toBe('weekly-fable') + expect(merged?.bindingWindowSource).toBe('poll') + }) + + test('main header persistence preserves newer poll-owned fields from disk', async () => { + const storage = baseStorage() + storage.quota = { + ...storage.quota, + mainQuota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 600, + }, + scoped: [ + { + id: 'weekly-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 40, + remainingPercent: 60, + checkedAt: 900, + }, + ], + extraUsage: { + used: { amountMinor: 500, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 1000, currency: 'USD', exponent: 2 }, + exhausted: false, + }, + bindingWindow: 'weekly-fable', + bindingWindowSource: 'poll', + source: 'poll', + checkedAt: 600, + }, + mainQuotaToken: 'same-token', + } + await saveAccounts(storage) + const staleHeaderView = await loadAccounts() + ;(staleHeaderView as AccountStorage).quota!.mainQuota = { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 700, + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: 700, + }, + scoped: [], + source: 'headers', + checkedAt: 700, + } + ;(staleHeaderView as AccountStorage).quota!.mainQuotaCheckedAt = 700 + + await saveAccountState(staleHeaderView as AccountStorage, accountPath, { + mainQuota: true, + }) + + const loaded = await loadAccounts() + expect(loaded?.quota?.mainQuota?.five_hour?.usedPercent).toBe(78) + expect(loaded?.quota?.mainQuota?.scoped?.[0]?.id).toBe('weekly-fable') + expect(loaded?.quota?.mainQuota?.extraUsage?.used.amountMinor).toBe(500) + expect(loaded?.quota?.mainQuota?.bindingWindow).toBe('weekly-fable') + }) + + test('fallback token rotation never rebinds the previous token quota', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'fallback-1', + type: 'oauth', + access: 'old-access', + refresh: 'old-refresh', + expires: 1_000, + lastRefreshedAt: 100, + quota: { + five_hour: { + usedPercent: 80, + remainingPercent: 20, + checkedAt: 500, + }, + source: 'poll', + checkedAt: 500, + }, + }) + await saveAccounts(storage) + const rotatedView = await loadAccounts() + const rotated = expectOAuthAccount(rotatedView?.accounts[0]) + rotated.access = 'new-access' + rotated.refresh = 'new-refresh' + rotated.lastRefreshedAt = 600 + rotated.quota = { + five_hour: { + usedPercent: 5, + remainingPercent: 95, + checkedAt: 100, + }, + source: 'headers', + checkedAt: 100, + } + expect(rotated.access).not.toBe('old-access') + + await saveAccountState(rotatedView as AccountStorage, accountPath, { + accounts: ['fallback-1'], + }) + + const loaded = await loadAccounts() + const account = expectOAuthAccount(loaded?.accounts[0]) + expect(account.access).toBe('new-access') + expect(account.quota?.five_hour?.usedPercent).toBe(5) + expect(account.quota?.source).toBe('headers') + }) + + test('runtime token updates drop source-less quota passthrough', async () => { const storage = baseStorage() storage.accounts.push({ id: 'fallback-1', @@ -537,6 +1277,10 @@ describe('account storage', () => { }, }, } + expect( + expectOAuthAccount((refreshedRuntimeView as AccountStorage).accounts[0]) + .access, + ).not.toBe('old-access') await saveAccountState( refreshedRuntimeView as AccountStorage, @@ -551,7 +1295,7 @@ describe('account storage', () => { expect(account.access).toBe('new-access') expect(account.refresh).toBe('new-refresh') expect(account.lastRefreshedAt).toBe(600) - expect(account.quota?.five_hour?.usedPercent).toBe(20) + expect(account.quota).toBeUndefined() }) test('malformed config file throws a clear error', async () => { diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index c9d31da7..84b33a4f 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { buildKillswitchThresholdSeed } from '../tui/command-dialogs' +import { + buildAccountDialogOption, + buildKillswitchThresholdSeed, +} from '../tui/command-dialogs' describe('buildKillswitchThresholdSeed', () => { test('preserves scoped killswitch thresholds in the TUI edit seed', () => { @@ -24,3 +27,22 @@ describe('buildKillswitchThresholdSeed', () => { ).toBe('main:5,10,0 umut:5,10,0') }) }) + +describe('buildAccountDialogOption', () => { + test('threads the tier label into the account row detail', () => { + expect( + buildAccountDialogOption({ + id: 'work', + label: 'Work', + role: 'fallback', + enabled: true, + quotaPercent: 22, + tierLabel: 'Team · Max 5x', + }), + ).toEqual({ + title: 'Work [fallback] 22%', + value: 'work', + description: 'Team · Max 5x', + }) + }) +}) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index c9424fda..997ae513 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -1,20 +1,26 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' -import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { + __setLogTestSink, type AccountStorage, + acquireRefreshFileLock, buildRefreshOperationError, ClaudeOAuthRefreshError, getAccountStatePath, hashRefreshToken, + type LogTestRecord, loadAccounts, + type OAuthAccount, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, + PROFILE_TTL_MS, resetCache1hState, resetDumpState, resetFastModeState, saveAccountState, saveAccounts, + setLogLevel, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' @@ -139,6 +145,11 @@ async function useTempAccountFile(storage: AccountStorage) { 'cachekeep-registry', ) await saveAccounts(storage) + if (storage.main?.profile) { + await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { + mainProfile: true, + }) + } } function restoreProcessTestFiles() { @@ -170,6 +181,18 @@ async function waitForSidebarState( throw new Error(`Sidebar state did not match: ${JSON.stringify(state)}`) } +async function waitForAccountStorage( + predicate: (storage: Awaited>) => boolean, +) { + for (let attempt = 0; attempt < 100; attempt++) { + const storage = await loadAccounts() + if (predicate(storage)) return storage + await Bun.sleep(10) + } + const storage = await loadAccounts() + throw new Error(`Account storage did not match: ${JSON.stringify(storage)}`) +} + async function seedSidebarRouting( activeId: string, route: string, @@ -608,6 +631,7 @@ describe('auth.loader', () => { resetNotificationsForTest() __setInitialSidebarRoutingTestHooks(null) __setSidebarStateWriteTestHooks(null) + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' await useTempAccountFile(createFallbackStorage({ accounts: [] })) }) @@ -621,6 +645,7 @@ describe('auth.loader', () => { resetNotificationsForTest() __setInitialSidebarRoutingTestHooks(null) __setSidebarStateWriteTestHooks(null) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION await drainSidebarWrites() restoreProcessTestFiles() if (tempConfigDir) { @@ -3495,245 +3520,1465 @@ describe('auth.loader', () => { expect(text).toContain('1w: 50% remaining') }) - test('persistent claudeFast setting makes fetch wrapper request fast mode', async () => { - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - claudeFast: { enabled: true }, - }), - ) - - let capturedHeaders: Headers | undefined - let capturedBody: string | undefined - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, + test('/claude-quota bounds stalled profile hydration without hiding quota output', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + let profileSignal: AbortSignal | undefined + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/profile')) { + profileSignal = init?.signal ?? undefined + return new Promise((_resolve, reject) => { + profileSignal?.addEventListener( + 'abort', + () => reject(profileSignal?.reason), + { once: true }, + ) + }) + } + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 50 }, }), - { status: 200 }, - ), - ) - } - capturedHeaders = init?.headers - capturedBody = init?.body - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin() - const result = await plugin.auth.loader( + ) + } + return Promise.resolve(new Response('ok')) + }, + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'token', - refresh: 'refresh', + access: 'main-access', + refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ - model: 'claude-opus-4-7', - messages: [{ role: 'user', content: 'hello' }], + const startedAt = performance.now() + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', }), - }) - - expect(capturedHeaders?.get('anthropic-beta')).toContain( - 'fast-mode-2026-02-01', - ) - expect(JSON.parse(capturedBody!).speed).toBe('fast') - }) + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - test('persistent claudeFast setting skips unsupported models', async () => { - await useTempAccountFile( - createFallbackStorage({ - accounts: [], - claudeFast: { enabled: true }, - }), - ) + expect(performance.now() - startedAt).toBeLessThan(4_000) + expect(profileSignal?.aborted).toBe(true) + const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text as string + expect(text).toContain('## Claude Quotas') + expect(text).toContain('5h: 75% remaining') + expect(text).not.toContain('Max 20x') + }, 5_000) - let capturedHeaders: Headers | undefined - let capturedBody: string | undefined - globalThis.fetch = mock((input: any, init: any) => { + test('/claude-quota renders hydrated profile without waiting for profile persistence', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { const url = extractUrl(input) + if (url.includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } if (url.includes('/api/oauth/usage')) { return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 50 }, + }), ) } - capturedHeaders = init?.headers - capturedBody = init?.body - return Promise.resolve(new Response(null, { status: 200 })) + return Promise.resolve(new Response('ok')) }) as unknown as typeof fetch - - const plugin = await getPlugin() - const result = await plugin.auth.loader( + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'token', - refresh: 'refresh', + access: 'main-access', + refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - await result.fetch(MESSAGES_URL, { - method: 'POST', - body: JSON.stringify({ - model: 'claude-sonnet-4-5', - messages: [{ role: 'user', content: 'hello' }], - }), + const configLock = await acquireRefreshFileLock({ + name: 'config-write', + path: process.env.OPENCODE_ANTHROPIC_AUTH_FILE, + ttlMs: 10_000, + renew: true, }) + expect(configLock).not.toBeNull() + if (!configLock) throw new Error('expected account config lock') - expect(capturedHeaders?.get('anthropic-beta')).not.toContain( - 'fast-mode-2026-02-01', - ) - expect(JSON.parse(capturedBody!).speed).toBeUndefined() - }) - - test('/claude-cache on makes fetch wrapper set ttl on existing cache controls', async () => { - await useTempAccountFile(createFallbackStorage({ accounts: [] })) - let capturedBody: string | undefined - const mockClient = createMockClient() - - globalThis.fetch = mock((input: any, init: any) => { - const url = extractUrl(input) - if (url.includes('/api/oauth/usage')) { - return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), - ) - } - capturedBody = init?.body - return Promise.resolve(new Response(null, { status: 200 })) - }) as unknown as typeof fetch - - const plugin = await getPlugin(mockClient) - await expect( + const command = expectHandledCommandResponse( plugin['command.execute.before']({ - command: 'claude-cache', - arguments: 'on', + command: 'claude-quota', + arguments: '', sessionID: 'session-1', }), - ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') - - const result = await plugin.auth.loader( - () => - Promise.resolve({ - type: 'oauth', - access: 'my-access-token', - refresh: 'refresh', - expires: Date.now() + 100000, - }), - { models: {} }, ) + try { + const rendered = await Promise.race([ + command.then(() => true), + Bun.sleep(100).then(() => false), + ]) + + expect(rendered).toBe(true) + expect(profileCalls).toBe(1) + const text = (mockClient.session.promptAsync as any).mock.calls.at( + -1, + )?.[0]?.body.parts[0]?.text as string + expect(text).toContain('Max 20x') + expect((await loadAccounts())?.main?.profile).toBeUndefined() + } finally { + await configLock.release() + await command + } - await result.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - body: JSON.stringify({ - system: [ - { - type: 'text', - text: 'Cached block', - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [{ role: 'user', content: 'hello world test message' }], - }), - }) - - const parsedBody = JSON.parse(capturedBody!) - expect(parsedBody.system[2].cache_control).toEqual({ - type: 'ephemeral', - ttl: '1h', - }) + const persistedStorage = await waitForAccountStorage( + (storage) => storage?.main?.profile?.tier === 'default_claude_max_20x', + ) + expect(persistedStorage?.main?.profile?.tier).toBe('default_claude_max_20x') }) - test('persistent claudeCache setting does not apply to subagent requests with parent session header', async () => { - await useTempAccountFile( - createFallbackStorage({ accounts: [], claudeCache: { enabled: true } }), - ) - let capturedBody: string | undefined - let capturedHeaders: Headers | undefined + test('profile fetch runs once per account per boot and persists the result', async () => { + await useTempAccountFile(createFallbackStorage()) const mockClient = createMockClient() - + const profileCalls: string[] = [] globalThis.fetch = mock( (input: string | URL | Request, init?: RequestInit) => { const url = extractUrl(input) + const auth = new Headers(init?.headers).get('authorization') ?? '' + if (url.includes('/api/oauth/profile')) { + profileCalls.push(auth) + return Promise.resolve( + Response.json({ + organization: { + organization_type: auth.includes('fallback') + ? 'claude_team' + : 'claude_max', + rate_limit_tier: auth.includes('fallback') + ? 'default_claude_max_5x' + : 'default_claude_max_20x', + }, + }), + ) + } if (url.includes('/api/oauth/usage')) { return Promise.resolve( - new Response( - JSON.stringify({ - five_hour: { utilization: 0 }, - seven_day: { utilization: 0 }, - }), - { status: 200 }, - ), + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), ) } - capturedBody = String(init?.body) - capturedHeaders = new Headers(init?.headers) - return Promise.resolve(new Response(null, { status: 200 })) + return Promise.resolve(new Response('ok')) }, ) as unknown as typeof fetch - const plugin = await getPlugin(mockClient) - const result = await plugin.auth.loader( + await plugin.auth.loader( () => Promise.resolve({ type: 'oauth', - access: 'my-access-token', - refresh: 'refresh', + access: 'main-access', + refresh: 'main-refresh', expires: Date.now() + 100000, }), { models: {} }, ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION - await result.fetch('https://api.anthropic.com/v1/messages', { - method: 'POST', - headers: { 'x-parent-session-id': 'parent-session' }, - body: JSON.stringify({ - system: [ - { - type: 'text', - text: 'Cached block', - cache_control: { type: 'ephemeral' }, - }, - ], - messages: [{ role: 'user', content: 'hello world test message' }], - }), - }) + for (let call = 0; call < 2; call++) { + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + if (call === 0) { + await waitForAccountStorage( + (storage) => + storage?.main?.profile?.tier === 'default_claude_max_20x' && + (storage.accounts[0] as OAuthAccount | undefined)?.profile?.tier === + 'default_claude_max_5x', + ) + } + } - const parsedBody = JSON.parse(capturedBody!) - expect(parsedBody.system[2].cache_control).toEqual({ type: 'ephemeral' }) - expect(capturedHeaders?.has('x-parent-session-id')).toBe(false) + expect(profileCalls).toEqual([ + 'Bearer main-access', + 'Bearer fallback-access', + ]) + const loaded = await loadAccounts() + expect(loaded?.main?.profile?.tier).toBe('default_claude_max_20x') + expect((loaded?.accounts[0] as any)?.profile?.tier).toBe( + 'default_claude_max_5x', + ) + const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text as string + expect(text).toContain('Max 20x') + expect(text).toContain('Max 5x') }) - test('persistent hybrid claudeCache mode rewrites cache controls for main session requests', async () => { + test('fresh profile under seven days skips fetch', async () => { await useTempAccountFile( createFallbackStorage({ accounts: [], - claudeCache: { enabled: true, mode: 'hybrid' }, + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + tokenFingerprint: tokenFingerprint('main-access'), + }, + }, }), ) - let capturedBody: string | undefined + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin(createMockClient()) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + expect(profileCalls).toBe(0) + }) + + test('main token rotation clears a stale bound profile before display', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + tokenFingerprint: tokenFingerprint('old-access'), + }, + }, + }), + ) + const mockClient = createMockClient() + globalThis.fetch = mock((input: string | URL | Request) => + Promise.resolve( + extractUrl(input).includes('/api/oauth/profile') + ? new Response('failed', { status: 500 }) + : new Response('ok'), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'new-access', + refresh: 'new-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + + await expect( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text + + expect(text).not.toContain('Max 20x') + const clearedStorage = await waitForAccountStorage( + (storage) => storage?.main?.profile === undefined, + ) + expect(clearedStorage?.main?.profile).toBeUndefined() + }) + + test('same main token keeps a fresh bound profile without refetching', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + tokenFingerprint: tokenFingerprint('main-access'), + }, + }, + }), + ) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin(createMockClient()) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + + await expect( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + expect(profileCalls).toBe(0) + expect((await loadAccounts())?.main?.profile?.tier).toBe( + 'default_claude_max_20x', + ) + }) + + test('boot profile hydration publishes tier labels to the sidebar', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + const state = await waitForSidebarState( + (value) => value.main.tierLabel === 'Max 20x', + ) + + expect(state.main.tierLabel).toBe('Max 20x') + expect(profileCalls).toBe(1) + }) + + test('late fallback profile hydration cannot restore rotated credentials', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile( + createFallbackStorage({ + quota: { enabled: false }, + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + tokenFingerprint: tokenFingerprint('main-access'), + }, + }, + accounts: [ + { + id: 'fb', + type: 'oauth', + access: 'old-access', + refresh: 'old-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + lastRefreshedAt: 100, + }, + ], + }), + ) + let resolveProfile!: (response: Response) => void + let markProfileStarted!: () => void + const profileStarted = new Promise((resolve) => { + markProfileStarted = resolve + }) + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + markProfileStarted() + return new Promise((resolve) => { + resolveProfile = resolve + }) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await profileStarted + await drainSidebarWrites() + const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated + + const rotated = await loadAccounts() + const fallback = rotated?.accounts[0] + if (!rotated || fallback?.type !== 'oauth') { + throw new Error('expected fallback OAuth account') + } + fallback.access = 'new-access' + fallback.refresh = 'new-refresh' + fallback.lastRefreshedAt = 200 + await saveAccounts(rotated) + await Bun.sleep(2) + resolveProfile( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + await waitForSidebarState( + (state) => state.lastUpdated > initialSidebarUpdatedAt, + ) + + const reloaded = await loadAccounts() + const reloadedFallback = reloaded?.accounts[0] + expect(reloadedFallback).toMatchObject({ + access: 'new-access', + refresh: 'new-refresh', + lastRefreshedAt: 200, + }) + if (reloadedFallback?.type !== 'oauth') { + throw new Error('expected reloaded fallback OAuth account') + } + expect(reloadedFallback.profile).toBeUndefined() + }) + + test('late main profile hydration cannot replace a rotated-token profile', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let liveAccess = 'old-main-access' + let resolveProfile!: (response: Response) => void + let markProfileStarted!: () => void + const profileStarted = new Promise((resolve) => { + markProfileStarted = resolve + }) + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + markProfileStarted() + return new Promise((resolve) => { + resolveProfile = resolve + }) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: liveAccess, + refresh: `refresh-${liveAccess}`, + expires: Date.now() + 100_000, + }), + { models: {} }, + ) + await profileStarted + await drainSidebarWrites() + const initialSidebarUpdatedAt = (await getSidebarState()).lastUpdated + + liveAccess = 'new-main-access' + const rotated = await loadAccounts() + if (!rotated) throw new Error('expected account storage') + rotated.main = { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'default_claude_max_20x', + orgType: 'claude_max', + checkedAt: Date.now(), + tokenFingerprint: tokenFingerprint(liveAccess), + }, + } + await saveAccountState(rotated, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { + mainProfile: true, + }) + await Bun.sleep(2) + resolveProfile( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + await waitForSidebarState( + (state) => state.lastUpdated > initialSidebarUpdatedAt, + ) + + expect((await loadAccounts())?.main?.profile).toMatchObject({ + tier: 'default_claude_max_20x', + tokenFingerprint: tokenFingerprint('new-main-access'), + }) + }) + + test('delayed boot hydration preserves a live fallback sidebar route', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile( + createFallbackStorage({ routing: { mode: 'fallback-first' } }), + ) + let resolveMainProfile!: (response: Response) => void + let markMainProfileStarted!: () => void + const mainProfileStarted = new Promise((resolve) => { + markMainProfileStarted = resolve + }) + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + const authorization = new Headers(init?.headers).get('authorization') + if ( + url.includes('/api/oauth/profile') && + authorization === 'Bearer main-access' + ) { + markMainProfileStarted() + return new Promise((resolve) => { + resolveMainProfile = resolve + }) + } + if (url.includes('/api/oauth/profile')) { + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }, + ) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await mainProfileStarted + + await result.fetch(MESSAGES_URL, EMPTY_POST) + await waitForSidebarState( + (state) => + state.activeId === 'fallback-1' && state.route === 'fallback-first', + ) + resolveMainProfile( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + + const hydratedState = await waitForSidebarState( + (state) => state.main.tierLabel === 'Max 20x', + ) + expect(hydratedState).toMatchObject({ + activeId: 'fallback-1', + route: 'fallback-first', + }) + }) + + test('profile hydration keeps its plugin-scoped fetch across test turnover', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile(createFallbackStorage()) + let resolveMainProfile!: (response: Response) => void + let markMainProfileStarted!: () => void + const mainProfileStarted = new Promise((resolve) => { + markMainProfileStarted = resolve + }) + const firstFetchCalls: string[] = [] + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const authorization = new Headers( + input instanceof Request ? input.headers : init?.headers, + ).get('authorization') + firstFetchCalls.push(authorization ?? '') + if (authorization === 'Bearer main-access') { + markMainProfileStarted() + return new Promise((resolve) => { + resolveMainProfile = resolve + }) + } + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_team', + rate_limit_tier: 'default_claude_max_5x', + }, + }), + ) + }, + ) as unknown as typeof fetch + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await mainProfileStarted + + const nextTestFetch = mock(() => Promise.resolve(new Response('ok'))) + globalThis.fetch = nextTestFetch as unknown as typeof fetch + resolveMainProfile( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + await waitForSidebarState( + (state) => state.fallbacks[0]?.tierLabel === 'Team · Max 5x', + ) + + expect(firstFetchCalls).toEqual([ + 'Bearer main-access', + 'Bearer fallback-access', + ]) + expect(nextTestFetch).not.toHaveBeenCalled() + }) + + test('mock-environment opt-out prevents boot profile network calls', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await Bun.sleep(20) + + expect(profileCalls).toBe(0) + }) + + test('mock-environment opt-out prevents command profile network calls', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin(createMockClient()) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + expect(profileCalls).toBe(0) + }) + + test('boot hydration publishes storage reloaded after the profile await', async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let resolveProfile!: (response: Response) => void + let markProfileStarted!: () => void + const profileStarted = new Promise((resolve) => { + markProfileStarted = resolve + }) + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + markProfileStarted() + return new Promise((resolve) => { + resolveProfile = resolve + }) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await profileStarted + + const storage = await loadAccounts() + if (!storage?.quota) throw new Error('expected quota storage') + const backoff = { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: Date.now(), + nextRetryAt: Date.now() + 60_000, + retryCount: 1, + } + storage.quota.mainLastQuotaApiError = backoff + await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { + mainQuota: true, + }) + resolveProfile( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + + const state = await waitForSidebarState( + (value) => value.main.tierLabel === 'Max 20x', + ) + expect(state.main.quotaBackedOff).toBe(true) + expect((await loadAccounts())?.quota?.mainLastQuotaApiError).toEqual( + backoff, + ) + }) + + test('token rotation hydrates the new profile once and restores its tier label', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + const profileCalls: string[] = [] + let liveAccess = 'token-a' + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + const authorization = new Headers(init?.headers).get('authorization') + profileCalls.push(authorization ?? '') + const firstToken = authorization?.includes('token-a') + return Promise.resolve( + Response.json({ + organization: { + organization_type: firstToken ? 'claude_team' : 'claude_max', + rate_limit_tier: firstToken + ? 'default_claude_max_5x' + : 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }, + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: liveAccess, + refresh: `refresh-${liveAccess}`, + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const showAccounts = async () => { + await expect( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + return (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text as string + } + + expect(await showAccounts()).toContain('Team · Max 5x') + await waitForAccountStorage( + (storage) => + storage?.main?.profile?.tokenFingerprint === + tokenFingerprint('token-a'), + ) + liveAccess = 'token-b' + expect(await showAccounts()).toContain('Max 20x') + await waitForAccountStorage( + (storage) => + storage?.main?.profile?.tokenFingerprint === + tokenFingerprint('token-b'), + ) + expect(await showAccounts()).toContain('Max 20x') + + expect(profileCalls).toEqual(['Bearer token-a', 'Bearer token-b']) + expect((await loadAccounts())?.main?.profile?.tokenFingerprint).toBe( + tokenFingerprint('token-b'), + ) + }) + + test('expired profile TTL triggers a fresh hydration in the same process', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + const originalDateNow = Date.now + let now = 1_000_000 + let profileCalls = 0 + Date.now = () => now + try { + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: now + PROFILE_TTL_MS * 3, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const showAccounts = async () => { + await expect( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + } + + await showAccounts() + await waitForAccountStorage( + (storage) => storage?.main?.profile?.checkedAt === now, + ) + now += PROFILE_TTL_MS + 1 + await showAccounts() + + expect(profileCalls).toBe(2) + const refreshedStorage = await waitForAccountStorage( + (storage) => storage?.main?.profile?.checkedAt === now, + ) + expect(refreshedStorage?.main?.profile?.checkedAt).toBe(now) + } finally { + Date.now = originalDateNow + } + }) + + test('completed profile hydrations do not block later token generations', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + let profileCalls = 0 + let liveAccess = 'token-0' + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: liveAccess, + refresh: `refresh-${liveAccess}`, + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const showAccounts = async () => { + await expect( + plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + } + + for (let generation = 0; generation < 66; generation++) { + liveAccess = `token-${generation}` + await showAccounts() + } + liveAccess = 'token-0' + await showAccounts() + + expect(profileCalls).toBe(67) + }) + + test('stale profile refreshes on display', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + main: { + type: 'opencode', + provider: 'anthropic', + profile: { + tier: 'old', + orgType: 'claude_max', + checkedAt: Date.now() - 8 * 24 * 60 * 60 * 1000, + }, + }, + }), + ) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) { + profileCalls++ + return Promise.resolve( + Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }), + ) + } + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ) + }) as unknown as typeof fetch + const plugin = await getPlugin(createMockClient()) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + expect(profileCalls).toBe(1) + const refreshedStorage = await waitForAccountStorage( + (storage) => storage?.main?.profile?.tier === 'default_claude_max_20x', + ) + expect(refreshedStorage?.main?.profile?.tier).toBe('default_claude_max_20x') + }) + + test('profile fetch failure is silent and label is omitted', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + globalThis.fetch = mock((input: string | URL | Request) => + Promise.resolve( + extractUrl(input).includes('/api/oauth/profile') + ? new Response('failed', { status: 500 }) + : Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 20 }, + }), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + + try { + await expect( + plugin['command.execute.before']({ + command: 'claude-quota', + arguments: '', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + } finally { + __setLogTestSink(null) + setLogLevel('info') + } + const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text + + expect(text).not.toContain('Max 20x') + expect( + records.filter( + (record) => + record.level === 'debug' && + record.channel === 'quota' && + record.message === 'failed to hydrate account profile' && + record.payload?.account === 'main', + ), + ).toHaveLength(1) + }) + + test('profile persistence failure does not block account display', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + const mockClient = createMockClient() + globalThis.fetch = mock((input: string | URL | Request) => + Promise.resolve( + extractUrl(input).includes('/api/oauth/profile') + ? Response.json({ + organization: { + organization_type: 'claude_max', + rate_limit_tier: 'default_claude_max_20x', + }, + }) + : new Response('ok'), + ), + ) as unknown as typeof fetch + const plugin = await getPlugin(mockClient) + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION + const statePath = getAccountStatePath( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE, + ) + const stateDir = dirname(statePath) + await chmod(stateDir, 0o555) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + + let commandError: unknown + try { + await plugin['command.execute.before']({ + command: 'claude-account', + arguments: '', + sessionID: 'session-1', + }) + } catch (error) { + commandError = error + } finally { + await chmod(stateDir, 0o755) + __setLogTestSink(null) + setLogLevel('info') + } + const text = (mockClient.session.promptAsync as any).mock.calls.at(-1)?.[0] + ?.body.parts[0]?.text as string + + expect(commandError).toBeInstanceOf(Error) + expect((commandError as Error).message).toContain( + '__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__', + ) + expect(text).toContain('Max 20x') + expect( + records.filter( + (record) => + record.level === 'debug' && + record.channel === 'quota' && + record.message === 'failed to persist account profile', + ), + ).toHaveLength(1) + }) + + test('ordinary model request never calls the profile endpoint', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let profileCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/profile')) profileCalls++ + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(profileCalls).toBe(0) + }) + + test('persistent claudeFast setting makes fetch wrapper request fast mode', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeFast: { enabled: true }, + }), + ) + + let capturedHeaders: Headers | undefined + let capturedBody: string | undefined + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedHeaders = init?.headers + capturedBody = init?.body + return Promise.resolve(new Response(null, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-4-7', + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + expect(capturedHeaders?.get('anthropic-beta')).toContain( + 'fast-mode-2026-02-01', + ) + expect(JSON.parse(capturedBody!).speed).toBe('fast') + }) + + test('persistent claudeFast setting skips unsupported models', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeFast: { enabled: true }, + }), + ) + + let capturedHeaders: Headers | undefined + let capturedBody: string | undefined + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedHeaders = init?.headers + capturedBody = init?.body + return Promise.resolve(new Response(null, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + expect(capturedHeaders?.get('anthropic-beta')).not.toContain( + 'fast-mode-2026-02-01', + ) + expect(JSON.parse(capturedBody!).speed).toBeUndefined() + }) + + test('/claude-cache on makes fetch wrapper set ttl on existing cache controls', async () => { + await useTempAccountFile(createFallbackStorage({ accounts: [] })) + let capturedBody: string | undefined + const mockClient = createMockClient() + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedBody = init?.body + return Promise.resolve(new Response(null, { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(mockClient) + await expect( + plugin['command.execute.before']({ + command: 'claude-cache', + arguments: 'on', + sessionID: 'session-1', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + system: [ + { + type: 'text', + text: 'Cached block', + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: 'hello world test message' }], + }), + }) + + const parsedBody = JSON.parse(capturedBody!) + expect(parsedBody.system[2].cache_control).toEqual({ + type: 'ephemeral', + ttl: '1h', + }) + }) + + test('persistent claudeCache setting does not apply to subagent requests with parent session header', async () => { + await useTempAccountFile( + createFallbackStorage({ accounts: [], claudeCache: { enabled: true } }), + ) + let capturedBody: string | undefined + let capturedHeaders: Headers | undefined + const mockClient = createMockClient() + + globalThis.fetch = mock( + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedBody = String(init?.body) + capturedHeaders = new Headers(init?.headers) + return Promise.resolve(new Response(null, { status: 200 })) + }, + ) as unknown as typeof fetch + + const plugin = await getPlugin(mockClient) + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'my-access-token', + refresh: 'refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'x-parent-session-id': 'parent-session' }, + body: JSON.stringify({ + system: [ + { + type: 'text', + text: 'Cached block', + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [{ role: 'user', content: 'hello world test message' }], + }), + }) + + const parsedBody = JSON.parse(capturedBody!) + expect(parsedBody.system[2].cache_control).toEqual({ type: 'ephemeral' }) + expect(capturedHeaders?.has('x-parent-session-id')).toBe(false) + }) + + test('persistent hybrid claudeCache mode rewrites cache controls for main session requests', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + claudeCache: { enabled: true, mode: 'hybrid' }, + }), + ) + let capturedBody: string | undefined const mockClient = createMockClient() globalThis.fetch = mock( @@ -6468,6 +7713,758 @@ describe('auth.loader', () => { ) expect(state.fallbacks[0]?.id).toBe('fallback-1') }) + + describe('quota header harvest', () => { + const quotaHeaders = { + 'anthropic-ratelimit-unified-representative-claim': 'five_hour', + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + } + + const harvestStorage = ( + accounts: AccountStorage['accounts'] = [], + overrides: Partial = {}, + ) => + createFallbackStorage({ + accounts, + quota: { enabled: false }, + ...overrides, + }) + + function installRelayWebSocket(responseHeaders: Record) { + const originalWebSocket = globalThis.WebSocket + + class RelayWebSocket extends EventTarget { + binaryType = 'arraybuffer' + + constructor() { + super() + queueMicrotask(() => { + this.dispatchEvent(new Event('open')) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'ready', + state: null, + }), + }), + ) + }) + } + + send(data: string) { + const payload = JSON.parse(data) + queueMicrotask(() => { + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'accepted', + id: payload.id, + hash: payload.next_hash, + revision: payload.revision, + }), + }), + ) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'response_start', + id: payload.id, + status: 200, + headers: responseHeaders, + }), + }), + ) + this.dispatchEvent( + new MessageEvent('message', { + data: Buffer.from('event: message_stop\n\n'), + }), + ) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'done', + id: payload.id, + }), + }), + ) + }) + } + + close() { + this.dispatchEvent(new Event('close')) + } + } + + globalThis.WebSocket = RelayWebSocket as unknown as typeof WebSocket + return () => { + globalThis.WebSocket = originalWebSocket + } + } + + async function loadFetch( + getAccessToken: () => string = () => 'main-access', + ) { + const plugin = await getPlugin() + return plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth' as const, + access: getAccessToken(), + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + } + + async function waitForState(predicate: (state: any) => boolean) { + let lastState: unknown + for (let attempt = 0; attempt < 200; attempt++) { + try { + const state = JSON.parse( + await readFile( + getAccountStatePath(process.env.OPENCODE_ANTHROPIC_AUTH_FILE), + 'utf8', + ), + ) + lastState = state + if (predicate(state)) return state + } catch {} + await Bun.sleep(10) + } + throw new Error( + `quota state did not persist: ${JSON.stringify(lastState)}`, + ) + } + + test('main 200 response pushes unified headers before returning the response', async () => { + await useTempAccountFile(harvestStorage()) + globalThis.fetch = mock(() => + Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + ) as unknown as typeof fetch + const result = await loadFetch() + + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(await response.text()).toBe('main-ok') + const state = await waitForState( + (value) => value.main?.quota?.source === 'headers', + ) + expect(state.main.quota.five_hour.usedPercent).toBe(78) + expect(state.main.quotaToken).toBe(tokenFingerprint('main-access')) + }) + + test('fresh header exhaustion licenses API-key fallback on the next request', async () => { + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'kie-opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + quota: { enabled: false }, + }), + ) + const authorizations: Array = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + const authorization = new Headers(init?.headers).get('authorization') + authorizations.push(authorization) + return Promise.resolve( + new Response('ok', { + headers: + authorization === 'Bearer main-access' + ? { + ...quotaHeaders, + 'anthropic-ratelimit-unified-5h-utilization': '1', + } + : undefined, + }), + ) + }) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(authorizations).toEqual(['Bearer main-access', 'Bearer kie-key']) + }) + + test('stale header exhaustion does not license API-key fallback', async () => { + const checkedAt = Date.now() - 60 * 60 * 1000 + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + accounts: [ + { + id: 'kie-opus', + type: 'api', + apiKey: 'kie-key', + baseURL: 'https://api.kie.ai/claude', + authHeader: 'authorization-bearer', + }, + ], + quota: { + enabled: false, + mainQuota: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + checkedAt, + }, + source: 'headers', + checkedAt, + }, + mainQuotaCheckedAt: checkedAt, + mainQuotaToken: tokenFingerprint('main-access'), + }, + }), + ) + const authorizations: Array = [] + globalThis.fetch = mock((_input: unknown, init?: RequestInit) => { + authorizations.push(new Headers(init?.headers).get('authorization')) + return Promise.resolve(new Response('ok')) + }) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(authorizations).toEqual(['Bearer main-access']) + }) + test('websocket relay response_start pushes unified headers for the served account', async () => { + await useTempAccountFile( + harvestStorage([], { + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'websocket', + }, + }), + ) + const restoreWebSocket = installRelayWebSocket(quotaHeaders) + const result = await loadFetch() + + try { + const response = await result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + headers: { 'x-session-affinity': 'quota-relay-websocket' }, + }) + expect(response.headers.get('x-cortexkit-relay-optimistic')).toBe( + 'true', + ) + await response.text() + } finally { + restoreWebSocket() + } + + const state = await waitForState( + (value) => value.main?.quota?.source === 'headers', + ) + expect(state.main.quota.five_hour.usedPercent).toBe(78) + expect(state.main.quotaToken).toBe(tokenFingerprint('main-access')) + }) + + test('relay fallback to direct harvests quota headers exactly once', async () => { + await useTempAccountFile( + harvestStorage([], { + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'http', + }, + }), + ) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + globalThis.fetch = mock((input: string | URL | Request) => { + const url = extractUrl(input) + if (url === 'https://relay.example.test') { + return Promise.resolve( + new Response('relay unavailable', { status: 503 }), + ) + } + return Promise.resolve( + new Response('direct', { headers: quotaHeaders }), + ) + }) as unknown as typeof fetch + const result = await loadFetch() + setLogLevel('debug') + + try { + const response = await result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + headers: { 'x-session-affinity': 'quota-relay-direct-fallback' }, + }) + expect(await response.text()).toBe('direct') + await waitForState((value) => value.main?.quota?.source === 'headers') + expect( + records.filter( + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + ), + ).toHaveLength(1) + } finally { + __setLogTestSink(null) + setLogLevel('info') + } + }) + + test('websocket optimistic response headers without quota data do not persist', async () => { + await useTempAccountFile( + harvestStorage([], { + relay: { + enabled: true, + url: 'https://relay.example.test', + token: 'relay-token', + fallbackToDirect: true, + transport: 'websocket', + }, + }), + ) + const restoreWebSocket = installRelayWebSocket({ + 'content-type': 'text/event-stream', + }) + const result = await loadFetch() + + try { + const response = await result.fetch(MESSAGES_URL, { + ...EMPTY_POST, + headers: { 'x-session-affinity': 'quota-relay-synthetic-only' }, + }) + expect(response.headers.get('x-cortexkit-relay-optimistic')).toBe( + 'true', + ) + await response.text() + } finally { + restoreWebSocket() + } + + await Bun.sleep(30) + expect((await loadAccounts())?.quota?.mainQuota?.source).toBeUndefined() + }) + + test('main header push skips persistence after access-token rotation', async () => { + let liveAccessToken = 'old-main-access' + const existingQuota = { + five_hour: { + usedPercent: 11, + remainingPercent: 89, + checkedAt: 1, + }, + source: 'poll' as const, + checkedAt: 1, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainQuota: existingQuota, + mainQuotaCheckedAt: 1, + mainQuotaToken: tokenFingerprint('new-main-access'), + }, + }), + ) + let resolveResponse: ((response: Response) => void) | undefined + let markRequestStarted: (() => void) | undefined + const requestStarted = new Promise((resolve) => { + markRequestStarted = resolve + }) + const requestAuthorizations: Array = [] + globalThis.fetch = mock( + (_input: string | URL | Request, init?: RequestInit) => + new Promise((resolve) => { + requestAuthorizations.push( + new Headers(init?.headers).get('authorization'), + ) + resolveResponse = resolve + markRequestStarted?.() + }), + ) as unknown as typeof fetch + const result = await loadFetch(() => liveAccessToken) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + setLogLevel('debug') + + const responsePromise = result.fetch(MESSAGES_URL, EMPTY_POST) + await requestStarted + liveAccessToken = 'new-main-access' + resolveResponse?.(new Response('main-ok', { headers: quotaHeaders })) + await responsePromise + await Bun.sleep(100) + const rawState = JSON.parse( + await readFile( + getAccountStatePath(process.env.OPENCODE_ANTHROPIC_AUTH_FILE), + 'utf8', + ), + ) + const reloaded = await loadAccounts() + + expect( + records.some( + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + ), + ).toBe(true) + expect(requestAuthorizations[0]).toBe('Bearer old-main-access') + expect(rawState.main.quota).toEqual(existingQuota) + expect(rawState.main.quotaToken).toBe(tokenFingerprint('new-main-access')) + expect(reloaded?.quota?.mainQuota).toEqual(existingQuota) + expect(reloaded?.quota?.mainQuotaToken).toBe( + tokenFingerprint('new-main-access'), + ) + __setLogTestSink(null) + setLogLevel('info') + }) + + test('main header push preserves persisted poll backoff across reload', async () => { + const pollBackoff = { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: Date.now(), + nextRetryAt: Date.now() + 60_000, + retryCount: 1, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainLastQuotaApiError: pollBackoff, + }, + }), + ) + globalThis.fetch = mock(() => + Promise.resolve(new Response('main-ok', { headers: quotaHeaders })), + ) as unknown as typeof fetch + const result = await loadFetch() + + expect(await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text()).toBe( + 'main-ok', + ) + const state = await waitForState( + (value) => value.main?.quota?.source === 'headers', + ) + const reloaded = await loadAccounts() + + expect(state.main.lastQuotaApiError).toEqual(pollBackoff) + expect(state.main.quota.five_hour.usedPercent).toBe(78) + expect(reloaded?.quota?.mainLastQuotaApiError).toEqual(pollBackoff) + expect(reloaded?.quota?.mainQuota?.source).toBe('headers') + }) + + test('primary adapter harvests one response frame and makes no corroborating usage request', async () => { + await useTempAccountFile(harvestStorage()) + let messageCalls = 0 + let usageCalls = 0 + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + globalThis.fetch = mock((input: string | URL | Request) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) usageCalls++ + if (url.includes('/v1/messages')) messageCalls++ + return Promise.resolve(new Response('ok', { headers: quotaHeaders })) + }) as unknown as typeof fetch + const result = await loadFetch() + setLogLevel('debug') + + await result.fetch(MESSAGES_URL, EMPTY_POST) + const state = await waitForState( + (value) => value.main?.quota?.source === 'headers', + ) + + expect(messageCalls).toBe(1) + expect(usageCalls).toBe(0) + expect( + records.filter( + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + ), + ).toHaveLength(1) + expect(state.main.quota.source).toBe('headers') + __setLogTestSink(null) + setLogLevel('info') + }) + + test('fallback-served response updates that fallback and not main', async () => { + await useTempAccountFile(harvestStorage(createFallbackStorage().accounts)) + let messages = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ + return Promise.resolve( + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), + ) + }) as unknown as typeof fetch + const result = await loadFetch() + + expect(await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text()).toBe( + 'fallback-ok', + ) + const state = await waitForState( + (value) => value.accounts?.['fallback-1']?.quota?.source === 'headers', + ) + expect(state.main?.quota?.source).not.toBe('headers') + }) + + test('fallback header push preserves persisted poll backoff across reload', async () => { + const pollBackoff = { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: Date.now(), + nextRetryAt: Date.now() + 60_000, + retryCount: 1, + } + const fallback = createFallbackStorage().accounts[0] + if (fallback?.type !== 'oauth') { + throw new Error('expected OAuth fallback fixture') + } + await useTempAccountFile( + harvestStorage([{ ...fallback, lastQuotaRefreshError: pollBackoff }]), + ) + let messages = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ + return Promise.resolve( + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), + ) + }) as unknown as typeof fetch + const result = await loadFetch() + + expect(await (await result.fetch(MESSAGES_URL, EMPTY_POST)).text()).toBe( + 'fallback-ok', + ) + const state = await waitForState( + (value) => value.accounts?.['fallback-1']?.quota?.source === 'headers', + ) + const reloaded = await loadAccounts() + const reloadedFallback = reloaded?.accounts.find( + (account): account is OAuthAccount => + account.id === 'fallback-1' && account.type === 'oauth', + ) + + expect(state.accounts['fallback-1'].lastQuotaRefreshError).toEqual( + pollBackoff, + ) + expect(state.accounts['fallback-1'].quota.five_hour.usedPercent).toBe(78) + expect(reloadedFallback?.lastQuotaRefreshError).toEqual(pollBackoff) + expect(reloadedFallback?.quota?.source).toBe('headers') + }) + + test('sidebar state reflects header-pushed freshness and served fallback attribution', async () => { + await useTempAccountFile(harvestStorage(createFallbackStorage().accounts)) + let messages = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + Response.json({ + five_hour: { utilization: 10 }, + seven_day: { utilization: 10 }, + }), + ) + } + messages++ + return Promise.resolve( + messages === 1 + ? new Response('limited', { status: 429 }) + : new Response('fallback-ok', { headers: quotaHeaders }), + ) + }) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + const state = await waitForSidebarState( + (value) => + value.activeId === 'fallback-1' && + value.fallbacks[0]?.quota?.five_hour?.usedPercent === 78, + ) + + expect(state.main.quota?.five_hour?.usedPercent).not.toBe(78) + expect(state.fallbacks[0]?.id).toBe('fallback-1') + expect(state.lastUpdated).toBeGreaterThan(0) + }) + + test('non-quota response does not push or persist quota', async () => { + await useTempAccountFile(harvestStorage()) + globalThis.fetch = mock(() => + Promise.resolve(new Response('ok')), + ) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + await Bun.sleep(30) + + expect((await loadAccounts())?.quota?.mainQuota?.source).toBeUndefined() + }) + + test('non-finite utilization headers leave stored quota untouched', async () => { + const existingQuota = { + five_hour: { + usedPercent: 11, + remainingPercent: 89, + checkedAt: 1, + }, + fallbackAdvised: true, + source: 'poll' as const, + checkedAt: 1, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: false, + mainQuota: existingQuota, + mainQuotaCheckedAt: 1, + mainQuotaToken: tokenFingerprint('main-access'), + }, + }), + ) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('ok', { + headers: { + 'anthropic-ratelimit-unified-5h-utilization': 'garbage', + 'anthropic-ratelimit-unified-7d-utilization': 'NaN', + }, + }), + ), + ) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + await Bun.sleep(30) + + expect((await loadAccounts())?.quota?.mainQuota).toEqual(existingQuota) + }) + + test('malformed quota headers never reject or replace the original response', async () => { + await useTempAccountFile(harvestStorage()) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('original', { + status: 202, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.5', + 'anthropic-ratelimit-unified-5h-reset': '1e308', + }, + }), + ), + ) as unknown as typeof fetch + const result = await loadFetch() + + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(202) + expect(await response.text()).toBe('original') + }) + + test('header push persists source headers and refreshes sidebar checkedAt without a usage poll', async () => { + await useTempAccountFile(harvestStorage()) + let usageCalls = 0 + globalThis.fetch = mock((input: string | URL | Request) => { + if (extractUrl(input).includes('/api/oauth/usage')) usageCalls++ + return Promise.resolve(new Response('ok', { headers: quotaHeaders })) + }) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + const state = await waitForSidebarState( + (value) => value.main.quota?.five_hour?.usedPercent === 78, + ) + + expect(state.main.quota?.five_hour?.usedPercent).toBe(78) + expect(state.lastUpdated).toBeGreaterThan(0) + expect(usageCalls).toBe(0) + }) + + test('successful harvest emits one quota debug record without raw headers', async () => { + await useTempAccountFile(harvestStorage()) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + globalThis.fetch = mock(() => + Promise.resolve(new Response('ok', { headers: quotaHeaders })), + ) as unknown as typeof fetch + const result = await loadFetch() + setLogLevel('debug') + + await result.fetch(MESSAGES_URL, EMPTY_POST) + + const harvested = records.filter( + (record) => + record.channel === 'quota' && + record.message === 'harvested response quota', + ) + expect(harvested).toHaveLength(1) + expect(JSON.stringify(harvested[0])).not.toContain('anthropic-ratelimit') + __setLogTestSink(null) + setLogLevel('info') + }) + + test('repeated out-of-range resets do not warn and restore log state', async () => { + await useTempAccountFile(harvestStorage()) + const records: LogTestRecord[] = [] + __setLogTestSink((record) => records.push(record)) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('ok', { + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.5', + 'anthropic-ratelimit-unified-5h-reset': '1e308', + }, + }), + ), + ) as unknown as typeof fetch + const result = await loadFetch() + + await result.fetch(MESSAGES_URL, EMPTY_POST) + await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect( + records.filter( + (record) => + record.channel === 'quota' && + record.message === 'failed to normalize response quota headers', + ), + ).toHaveLength(0) + __setLogTestSink(null) + setLogLevel('info') + }) + }) }) describe('killswitch fetch gate', () => { @@ -6476,6 +8473,7 @@ describe('killswitch fetch gate', () => { beforeEach(() => { globalThis.fetch = originalFetch + process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION = '1' // Prevent the plugin's background quota-refresh interval from leaking a // real timer that fires during later tests (test-isolation flake). globalThis.setInterval = mock( @@ -6486,6 +8484,7 @@ describe('killswitch fetch gate', () => { afterEach(() => { globalThis.fetch = originalFetch globalThis.setInterval = originalSetInterval + delete process.env.OPENCODE_ANTHROPIC_AUTH_DISABLE_PROFILE_HYDRATION }) const oauthLoader = () => diff --git a/packages/opencode/src/tests/plugin-exports.test.ts b/packages/opencode/src/tests/plugin-exports.test.ts new file mode 100644 index 00000000..b7b2bd6b --- /dev/null +++ b/packages/opencode/src/tests/plugin-exports.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from 'bun:test' + +describe('plugin module exports', () => { + test('does not expose a state-mutating test hook to the OpenCode loader', async () => { + const pluginModule = await import('../index') + + expect('__setBootProfileHydrationForTest' in pluginModule).toBe(false) + }) +}) diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index 2447fba2..a9808cef 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { acquireRefreshFileLock, + type OAuthQuotaSnapshot, QuotaManager, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' @@ -867,4 +868,290 @@ describe('QuotaManager', () => { ) }) }) + + describe('header pushes', () => { + function headerSnapshot(checkedAt = now): OAuthQuotaSnapshot { + return { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt, + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt, + }, + bindingWindow: 'five_hour', + bindingWindowSource: 'headers', + fallbackAdvised: true, + source: 'headers', + checkedAt, + } + } + + test('header push always replaces main five_hour and seven_day regardless of cached checkedAt', () => { + const qm = createQM() + qm.setMain('token', { + quota: { + five_hour: { + usedPercent: 1, + remainingPercent: 99, + checkedAt: now + 10_000, + }, + seven_day: { + usedPercent: 2, + remainingPercent: 98, + checkedAt: now + 10_000, + }, + checkedAt: now + 10_000, + }, + checkedAt: now + 10_000, + refreshAfter: now + 20_000, + }) + + const pushed = qm.pushMainFromHeaders('token', headerSnapshot()) + + expect(pushed.quota.five_hour?.usedPercent).toBe(78) + expect(pushed.quota.seven_day?.usedPercent).toBe(40) + }) + + test('header push preserves a non-empty scoped poll snapshot', () => { + const qm = createQM() + const scoped = [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 15, + remainingPercent: 85, + checkedAt: 100, + }, + ] + qm.setMain('token', { + quota: { scoped, checkedAt: 100, source: 'poll' }, + checkedAt: 100, + refreshAfter: 200, + }) + + expect( + qm.pushMainFromHeaders('token', headerSnapshot()).quota.scoped, + ).toEqual(scoped) + }) + + test('header push preserves present empty scoped array', () => { + const qm = createQM() + qm.setMain('token', { + quota: { scoped: [], checkedAt: 100, source: 'poll' }, + checkedAt: 100, + refreshAfter: 200, + }) + + const pushed = qm.pushMainFromHeaders('token', headerSnapshot()) + + expect('scoped' in pushed.quota).toBe(true) + expect(pushed.quota.scoped).toEqual([]) + }) + + test('header push preserves extraUsage and poll bindingWindow', () => { + const qm = createQM() + const extraUsage = { + used: { amountMinor: 10035, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 10000, currency: 'USD', exponent: 2 }, + exhausted: true, + } + qm.setMain('token', { + quota: { + extraUsage, + bindingWindow: 'claude-weekly-scoped-fable', + bindingWindowSource: 'poll', + checkedAt: 100, + source: 'poll', + }, + checkedAt: 100, + refreshAfter: 200, + }) + + const pushed = qm.pushMainFromHeaders('token', headerSnapshot()) + + expect(pushed.quota.extraUsage).toEqual(extraUsage) + expect(pushed.quota.bindingWindow).toBe('claude-weekly-scoped-fable') + expect(pushed.quota.bindingWindowSource).toBe('poll') + }) + + test('poll completion preserves newer header windows while adding scoped limits', async () => { + let resolvePoll!: (response: Response) => void + let markPollStarted!: () => void + const pollStarted = new Promise((resolve) => { + markPollStarted = resolve + }) + const fetchMock = mock( + () => + new Promise((resolve) => { + resolvePoll = resolve + markPollStarted() + }), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + const refresh = qm.refreshMain('token') + + await pollStarted + qm.pushMainFromHeaders('token', headerSnapshot(now + 1_000)) + resolvePoll( + Response.json({ + five_hour: { utilization: 25 }, + seven_day: { utilization: 50 }, + limits: [ + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 15, + scope: { model: { id: null, display_name: 'Fable' } }, + }, + ], + }), + ) + await refresh + + const quota = qm.getMain('token')?.quota + expect(quota?.five_hour?.usedPercent).toBe(78) + expect(quota?.seven_day?.usedPercent).toBe(40) + expect(quota?.scoped?.[0]).toMatchObject({ + modelName: 'Fable', + usedPercent: 15, + }) + }) + + test('fallback header push updates only the named account', () => { + const qm = createQM() + qm.setFallback('other', { + quota: { checkedAt: 100 }, + checkedAt: 100, + refreshAfter: 200, + }) + + qm.pushFallbackFromHeaders('target', 'target-token', headerSnapshot()) + + expect(qm.getFallback('target')?.quota.five_hour?.usedPercent).toBe(78) + expect(qm.getFallback('other')?.quota.checkedAt).toBe(100) + }) + + test('header push binds main and fallback entries to the supplied token fingerprint', () => { + const qm = createQM() + + qm.pushMainFromHeaders('main-token', headerSnapshot()) + qm.pushFallbackFromHeaders('fallback', 'fallback-token', headerSnapshot()) + + expect(qm.getMain('different-token')).toBeNull() + expect(qm.getFallback('fallback', 'different-token')).toBeNull() + }) + + test('header checkedAt moves refreshAfter through getQuotaNextRefreshAt', () => { + const qm = createQM() + const pushed = qm.pushMainFromHeaders( + 'token', + headerSnapshot(now + 5_000), + ) + + expect(pushed.checkedAt).toBe(now + 5_000) + expect(pushed.refreshAfter).toBeGreaterThan(pushed.checkedAt) + }) + + test('partial header push stays due at the oldest preserved window staleness point', () => { + const intervalMs = 5 * 60_000 + const qm = new QuotaManager({ + storage: { version: 1, accounts: [], quota: { enabled: true } }, + now: () => now, + }) + qm.setMain('token', { + quota: { + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: now - intervalMs + 1_000, + }, + source: 'poll', + checkedAt: now - intervalMs + 1_000, + }, + checkedAt: now - intervalMs + 1_000, + refreshAfter: now + 1_000, + }) + + const pushed = qm.pushMainFromHeaders('token', { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: now, + }, + source: 'headers', + checkedAt: now, + }) + + expect(pushed.quota.seven_day?.checkedAt).toBe(now - intervalMs + 1_000) + expect(pushed.refreshAfter).toBe(now + 1_000) + }) + + test('header pushes stay due at the oldest preserved scoped window staleness point', () => { + const intervalMs = 5 * 60_000 + const qm = new QuotaManager({ + storage: { version: 1, accounts: [], quota: { enabled: true } }, + now: () => now, + }) + qm.setMain('token', { + quota: { + scoped: [ + { + id: 'claude-weekly-scoped-fable', + title: 'Fable only', + modelName: 'Fable', + usedPercent: 70, + remainingPercent: 30, + checkedAt: now - intervalMs + 1_000, + }, + ], + source: 'poll', + checkedAt: now - intervalMs + 1_000, + }, + checkedAt: now - intervalMs + 1_000, + refreshAfter: now + 1_000, + }) + + const pushed = qm.pushMainFromHeaders('token', headerSnapshot()) + + expect(pushed.quota.scoped?.[0]?.checkedAt).toBe(now - intervalMs + 1_000) + expect(pushed.refreshAfter).toBe(now + 1_000) + }) + + test('legacy unbound main quota is not merged into a newly bound header push', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainQuota: { + scoped: [ + { + id: 'legacy-scope', + title: 'Legacy scope', + modelName: 'Legacy', + usedPercent: 90, + remainingPercent: 10, + checkedAt: now - 1_000, + }, + ], + source: 'poll', + checkedAt: now - 1_000, + }, + mainQuotaCheckedAt: now - 1_000, + }, + }, + now: () => now, + }) + + const pushed = qm.pushMainFromHeaders('new-token', headerSnapshot()) + + expect(pushed.quota.scoped).toBeUndefined() + expect(pushed.quota.five_hour?.usedPercent).toBe(78) + }) + }) }) diff --git a/packages/opencode/src/tests/quotas.test.ts b/packages/opencode/src/tests/quotas.test.ts index 9e5fedb2..359bd985 100644 --- a/packages/opencode/src/tests/quotas.test.ts +++ b/packages/opencode/src/tests/quotas.test.ts @@ -9,6 +9,95 @@ import { } from '@cortexkit/anthropic-auth-core' describe('quota summaries', () => { + test('malformed money metadata falls back without throwing', () => { + const summary = buildClaudeQuotaSummary({ + accounts: [ + { + name: 'malformed', + role: 'main', + quota: { + extraUsage: { + used: { amountMinor: 10035, currency: 'ZZZZ', exponent: 50 }, + limit: { amountMinor: 10000, currency: 'ZZZZ', exponent: 50 }, + exhausted: false, + }, + }, + }, + ], + }) + + expect(summary).toContain('credits 10035 ZZZZ/10000 ZZZZ') + }) + + test('claude quota shows Team tier exhausted credits binding marker and fallback advice', () => { + const summary = buildClaudeQuotaSummary({ + accounts: [ + { + name: 'work', + role: 'fallback', + tierLabel: 'Team · Max 5x', + quota: { + five_hour: { + usedPercent: 78, + remainingPercent: 22, + checkedAt: 100, + }, + seven_day: { + usedPercent: 40, + remainingPercent: 60, + checkedAt: 100, + }, + extraUsage: { + used: { amountMinor: 10035, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 10000, currency: 'USD', exponent: 2 }, + exhausted: true, + }, + bindingWindow: 'five_hour', + bindingWindowSource: 'poll', + fallbackAdvised: true, + }, + }, + ], + now: 100, + }) + + expect(summary).toContain('Team · Max 5x') + expect(summary).toContain('credits $100.35/$100.00 · exhausted') + expect(summary).toContain('5h:') + expect(summary).toContain('•') + expect(summary).toContain('→ fallback advised') + }) + + test('claude quota omits credits and advice for disabled personal capture', () => { + const summary = buildClaudeQuotaSummary({ + accounts: [ + { + name: 'personal', + role: 'main', + tierLabel: 'Max 20x', + quota: { + five_hour: { + usedPercent: 3, + remainingPercent: 97, + checkedAt: 100, + }, + seven_day: { + usedPercent: 12, + remainingPercent: 88, + checkedAt: 100, + }, + fallbackAdvised: false, + }, + }, + ], + now: 100, + }) + + expect(summary).toContain('Max 20x') + expect(summary).not.toContain('credits') + expect(summary).not.toContain('fallback advised') + }) + test('formats main and fallback quota windows', () => { const now = Date.parse('2026-04-28T12:00:00.000Z') const summary = buildClaudeQuotaSummary({ diff --git a/packages/opencode/src/tests/relay-worker-miniflare.test.ts b/packages/opencode/src/tests/relay-worker-miniflare.test.ts index aac32c31..c41a9bc0 100644 --- a/packages/opencode/src/tests/relay-worker-miniflare.test.ts +++ b/packages/opencode/src/tests/relay-worker-miniflare.test.ts @@ -13,6 +13,7 @@ type ControlMessage = { id?: string status?: number message?: string + headers?: Record state?: { hash: string; revision: number } | null hash?: string revision?: number @@ -35,7 +36,14 @@ function startUpstream() { bodies.push(await request.text()) return new Response('upstream-ok', { status: 200, - headers: { 'content-type': 'text/plain' }, + headers: { + 'content-type': 'text/plain', + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + 'anthropic-ratelimit-unified-5h-reset': '1784246400', + 'anthropic-ratelimit-unified-7d-utilization': '0.4', + 'anthropic-ratelimit-unified-7d-reset': '1784628000', + 'anthropic-ratelimit-unified-fallback': 'available', + }, }) }, }) @@ -142,6 +150,43 @@ function sendPayload(socket: WebSocket, payload: Record) { } describe('relay Worker under Miniflare', () => { + test('HTTP relay forwards upstream unified quota headers', async () => { + const upstream = startUpstream() + const mf = await startWorker() + + try { + const response = await sendViaRelay({ + config: { + enabled: true, + url: (await mf.ready).toString(), + token: RELAY_TOKEN, + fallbackToDirect: false, + transport: 'http', + }, + input: upstream.url, + init: { method: 'POST' }, + headers: new Headers({ + authorization: 'Bearer test-token', + 'x-session-affinity': 'miniflare-http-session', + }), + body: '{}', + fallback: async () => new Response('direct'), + }) + + expect( + response.headers.get('anthropic-ratelimit-unified-5h-utilization'), + ).toBe('0.78') + expect(response.headers.get('anthropic-ratelimit-unified-fallback')).toBe( + 'available', + ) + expect(await response.text()).toBe('upstream-ok') + expect(upstream.bodies).toEqual(['{}']) + } finally { + await mf.dispose() + upstream.server.stop(true) + } + }, 30_000) + test('client websocket transport reaches Miniflare Worker with byte-exact patch reconstruction', async () => { const upstream = startUpstream() const mf = await startWorker() @@ -173,6 +218,12 @@ describe('relay Worker under Miniflare', () => { fallback: async () => new Response('direct'), }) expect(await first.text()).toBe('upstream-ok') + expect( + first.headers.get('anthropic-ratelimit-unified-5h-utilization'), + ).toBe('0.78') + expect(first.headers.get('anthropic-ratelimit-unified-fallback')).toBe( + 'available', + ) const second = await sendViaRelay({ config: relayConfig, @@ -214,6 +265,16 @@ describe('relay Worker under Miniflare', () => { revision: 1, body: firstBody, }) + const responseStart = await workerSocket.waitForControl( + (message) => + message.type === 'response_start' && message.id === 'req_full_sync', + ) + expect( + responseStart.headers?.['anthropic-ratelimit-unified-5h-utilization'], + ).toBe('0.78') + expect( + responseStart.headers?.['anthropic-ratelimit-unified-fallback'], + ).toBe('available') await workerSocket.waitForControl( (message) => message.type === 'done' && message.id === 'req_full_sync', ) diff --git a/packages/opencode/src/tests/relay.test.ts b/packages/opencode/src/tests/relay.test.ts index a021a461..7676de89 100644 --- a/packages/opencode/src/tests/relay.test.ts +++ b/packages/opencode/src/tests/relay.test.ts @@ -139,6 +139,73 @@ describe('relay client', () => { ) }) + test('delivers genuine HTTP relay response headers to the caller', async () => { + const originalFetch = globalThis.fetch + const receivedHeaders: Headers[] = [] + globalThis.fetch = mock( + async () => + new Response('relay', { + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + }, + }), + ) as unknown as typeof fetch + try { + const response = await sendViaRelay({ + config, + input: 'https://api.anthropic.com/v1/messages?beta=true', + init: { method: 'POST' }, + headers: headers('session-relay-http-headers'), + body: 'body', + fallback: async () => new Response('direct'), + onResponseHeaders: (value) => { + receivedHeaders.push(value) + throw new Error('observer failure') + }, + }) + + expect(await response.text()).toBe('relay') + } finally { + globalThis.fetch = originalFetch + } + + expect(receivedHeaders).toHaveLength(1) + expect( + receivedHeaders[0]?.get('anthropic-ratelimit-unified-5h-utilization'), + ).toBe('0.78') + }) + + test('does not deliver headers when HTTP relay falls back to direct', async () => { + const originalFetch = globalThis.fetch + const receivedHeaders: Headers[] = [] + globalThis.fetch = mock( + async () => new Response('relay unavailable', { status: 503 }), + ) as unknown as typeof fetch + try { + const response = await sendViaRelay({ + config, + input: 'https://api.anthropic.com/v1/messages?beta=true', + init: { method: 'POST' }, + headers: headers('session-relay-http-direct-fallback'), + body: 'body', + fallback: async () => + new Response('direct', { + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.42', + }, + }), + onResponseHeaders: (value) => receivedHeaders.push(value), + }) + + expect(await response.text()).toBe('direct') + } finally { + globalThis.fetch = originalFetch + } + + expect(receivedHeaders).toHaveLength(0) + }) + test('dumps final body and redacted relay payload when enabled', async () => { const originalFetch = globalThis.fetch const originalDumpDir = process.env.OPENCODE_ANTHROPIC_AUTH_DUMP_DIR @@ -256,6 +323,7 @@ describe('relay client', () => { test('falls back to direct fetch on relay transport failure', async () => { const originalFetch = globalThis.fetch + const receivedHeaders: Headers[] = [] globalThis.fetch = mock(async () => { throw new Error('relay offline') }) as unknown as typeof fetch @@ -267,11 +335,13 @@ describe('relay client', () => { headers: headers('session-relay-c'), body: 'body', fallback: async () => new Response('direct'), + onResponseHeaders: (value) => receivedHeaders.push(value), }) expect(await response.text()).toBe('direct') } finally { globalThis.fetch = originalFetch } + expect(receivedHeaders).toHaveLength(0) }) test('can stream a response over websocket transport', async () => { @@ -370,8 +440,9 @@ describe('relay client', () => { expect(sentPayloads[0]).toMatchObject({ mode: 'full_sync' }) }) - test('websocket optimistic response resolves immediately after local send', async () => { + test('websocket optimistic response resolves immediately and delivers headers once for duplicate response_start', async () => { const originalWebSocket = globalThis.WebSocket + const receivedHeaders: Headers[] = [] let socket: OptimisticWebSocket | undefined let payloadId = '' let acceptedSent = false @@ -412,6 +483,7 @@ describe('relay client', () => { body: 'body', fallback: async () => new Response('direct'), optimisticResponse: true, + onResponseHeaders: (value) => receivedHeaders.push(value), }) expect(response.status).toBe(200) expect(response.headers.get('x-cortexkit-relay-optimistic')).toBe('true') @@ -437,6 +509,22 @@ describe('relay client', () => { type: 'response_start', id: payloadId, status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + }, + }), + }), + ) + socket?.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'response_start', + id: payloadId, + status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.78', + }, }), }), ) @@ -451,14 +539,86 @@ describe('relay client', () => { }), ) expect(await textPromise).toBe('event: message_stop\n\n') + expect(receivedHeaders).toHaveLength(1) + expect( + receivedHeaders[0]?.get('anthropic-ratelimit-unified-5h-utilization'), + ).toBe('0.78') + } finally { + globalThis.WebSocket = originalWebSocket + } + }) + + test('does not deliver headers for websocket errors before response_start', async () => { + const originalWebSocket = globalThis.WebSocket + const originalFetch = globalThis.fetch + const receivedHeaders: Headers[] = [] + + class ErrorWebSocket extends EventTarget { + binaryType = 'arraybuffer' + + constructor() { + super() + queueMicrotask(() => { + this.dispatchEvent(new Event('open')) + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ protocol: 2, type: 'ready', state: null }), + }), + ) + }) + } + + send(data: string) { + const payload = JSON.parse(data) + queueMicrotask(() => { + this.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ + protocol: 2, + type: 'error', + id: payload.id, + status: 502, + message: 'upstream failed', + }), + }), + ) + }) + } + + close() { + this.dispatchEvent(new Event('close')) + } + } + + globalThis.WebSocket = ErrorWebSocket as unknown as typeof WebSocket + globalThis.fetch = mock(async () => { + throw new Error('http relay unavailable') + }) as unknown as typeof fetch + try { + await expect( + sendViaRelay({ + config: { ...websocketConfig, fallbackToDirect: false }, + input: 'https://api.anthropic.com/v1/messages?beta=true', + init: { method: 'POST' }, + headers: headers('session-relay-ws-error'), + body: 'body', + fallback: async () => new Response('direct'), + optimisticResponse: false, + onResponseHeaders: (value) => receivedHeaders.push(value), + }), + ).rejects.toThrow('http relay unavailable') } finally { globalThis.WebSocket = originalWebSocket + globalThis.fetch = originalFetch } + + expect(receivedHeaders).toHaveLength(0) }) test('websocket optimistic response reconnects and retries when socket closes before upstream response', async () => { const originalWebSocket = globalThis.WebSocket const sentPayloads: Array<{ id: string; mode: string }> = [] + const receivedHeaders: Headers[] = [] let socketCount = 0 class ClosingBeforeResponseWebSocket extends EventTarget { @@ -505,6 +665,9 @@ describe('relay client', () => { type: 'response_start', id: payload.id, status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': '0.64', + }, }), }), ) @@ -541,6 +704,7 @@ describe('relay client', () => { body: 'body', fallback: async () => new Response('direct'), optimisticResponse: true, + onResponseHeaders: (value) => receivedHeaders.push(value), }) expect(response.headers.get('x-cortexkit-relay-optimistic')).toBe('true') expect(await response.text()).toBe('event: message_stop\n\n') @@ -554,11 +718,16 @@ describe('relay client', () => { 'full_sync', ]) expect(sentPayloads[1]?.id).not.toBe(sentPayloads[0]?.id) + expect(receivedHeaders).toHaveLength(1) + expect( + receivedHeaders[0]?.get('anthropic-ratelimit-unified-5h-utilization'), + ).toBe('0.64') }) test('websocket optimistic response retries when socket closes after response_start before stream bytes', async () => { const originalWebSocket = globalThis.WebSocket const sentPayloads: Array<{ id: string; mode: string }> = [] + const receivedHeaders: Headers[] = [] let socketCount = 0 class ClosingBeforeStreamBytesWebSocket extends EventTarget { @@ -601,6 +770,10 @@ describe('relay client', () => { type: 'response_start', id: payload.id, status: 200, + headers: { + 'anthropic-ratelimit-unified-5h-utilization': + this.socketNumber === 1 ? '0.91' : '0.42', + }, }), }), ) @@ -643,6 +816,7 @@ describe('relay client', () => { body: 'body', fallback: async () => new Response('direct'), optimisticResponse: true, + onResponseHeaders: (value) => receivedHeaders.push(value), }) expect(response.headers.get('x-cortexkit-relay-optimistic')).toBe('true') expect(await response.text()).toBe('event: message_stop\n\n') @@ -656,6 +830,11 @@ describe('relay client', () => { 'full_sync', ]) expect(sentPayloads[1]?.id).not.toBe(sentPayloads[0]?.id) + expect( + receivedHeaders.map((value) => + value.get('anthropic-ratelimit-unified-5h-utilization'), + ), + ).toEqual(['0.42']) }) test('websocket optimistic response reports stream close after bytes without retry', async () => { diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 6d341463..6deb7b20 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -225,6 +225,22 @@ describe('getFableRecoverySummary', () => { }) describe('getCollapsedQuotaSummary', () => { + test('collapsed quota summary ignores credits binding marker and fallback advice', () => { + expect( + getCollapsedQuotaSummary({ + five_hour: { usedPercent: 78, remainingPercent: 22 }, + seven_day: { usedPercent: 40, remainingPercent: 60 }, + extraUsage: { + used: { amountMinor: 10035, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 10000, currency: 'USD', exponent: 2 }, + exhausted: true, + }, + bindingWindow: 'five_hour', + fallbackAdvised: true, + }).text, + ).toBe('5h: 78% 7d: 40%') + }) + test('formats both active-account quota windows', () => { expect(getCollapsedQuotaSummary(quota(13)).text).toBe('5h: 13% 7d: 13%') }) @@ -305,6 +321,102 @@ describe('getCollapsedQuotaSummary', () => { }) describe('normalizeSidebarState', () => { + test('normalizes valid optional quota metadata and tier labels', () => { + const normalized = normalizeSidebarState({ + main: { + tierLabel: 'Max 20x', + quota: { + extraUsage: { + used: { amountMinor: 10035, currency: 'USD', exponent: 2 }, + limit: { amountMinor: 10000, currency: 'USD', exponent: 2 }, + severity: 'critical', + exhausted: true, + }, + bindingWindow: 'five_hour', + fallbackAdvised: true, + }, + }, + fallbacks: [ + { + id: 'work', + tierLabel: 'Team · Max 5x', + quota: null, + enabled: true, + needsReauth: false, + }, + ], + }) + + expect(normalized.main.tierLabel).toBe('Max 20x') + expect(normalized.main.quota?.extraUsage?.used.amountMinor).toBe(10035) + expect(normalized.main.quota?.bindingWindow).toBe('five_hour') + expect(normalized.main.quota?.fallbackAdvised).toBe(true) + expect(normalized.fallbacks[0]?.tierLabel).toBe('Team · Max 5x') + }) + + test('drops malformed extraUsage bindingWindow fallbackAdvised and tierLabel independently', () => { + const normalized = normalizeSidebarState({ + main: { + tierLabel: 42, + quota: { + five_hour: { usedPercent: 78, remainingPercent: 22 }, + extraUsage: { + used: { amountMinor: 1.5, currency: '', exponent: 2 }, + limit: { amountMinor: 100, currency: 'USD', exponent: 2 }, + exhausted: 'yes', + }, + bindingWindow: 42, + fallbackAdvised: 'yes', + }, + }, + fallbacks: [], + }) + + expect(normalized.main.quota?.five_hour?.usedPercent).toBe(78) + expect(normalized.main.quota?.extraUsage).toBeUndefined() + expect(normalized.main.quota?.bindingWindow).toBeUndefined() + expect(normalized.main.quota?.fallbackAdvised).toBeUndefined() + expect(normalized.main.tierLabel).toBeUndefined() + }) + + test('drops invalid money metadata while preserving valid quota windows', () => { + const normalized = normalizeSidebarState({ + main: { + quota: { + five_hour: { usedPercent: 78, remainingPercent: 22 }, + seven_day: { usedPercent: 40, remainingPercent: 60 }, + extraUsage: { + used: { amountMinor: 10035, currency: 'ZZZZ', exponent: 50 }, + limit: { amountMinor: 10000, currency: 'ZZZZ', exponent: 50 }, + exhausted: false, + }, + }, + }, + fallbacks: [], + }) + + expect(normalized.main.quota?.five_hour?.usedPercent).toBe(78) + expect(normalized.main.quota?.seven_day?.usedPercent).toBe(40) + expect(normalized.main.quota?.extraUsage).toBeUndefined() + }) + + test('preserves empty scoped array with optional metadata present', () => { + const normalized = normalizeSidebarState({ + main: { + quota: { + scoped: [], + bindingWindow: 'five_hour', + fallbackAdvised: false, + }, + }, + fallbacks: [], + }) + + expect(normalized.main.quota?.scoped).toEqual([]) + expect(normalized.main.quota?.bindingWindow).toBe('five_hour') + expect(normalized.main.quota?.fallbackAdvised).toBe(false) + }) + test('preserves valid scoped quota windows and drops malformed ones', () => { const normalized = normalizeSidebarState({ main: { @@ -807,6 +919,33 @@ describe('getSidebarState malformed file round-trip', () => { const state = await getSidebarState() expect(state).toEqual(valid) }) + + test('non-authoritative writes preserve the latest routing decision', async () => { + process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE = testFile + await mkdir(testDir, { recursive: true }) + const fallback = make({ activeId: 'fallback-1', route: 'fallback' }) + const delayedHydration = make({ + activeId: 'main', + route: 'main', + main: { quota: null, tierLabel: 'Max 20x' }, + }) + + const routingWrite = setSidebarState(fallback, testFile) + const hydrationWrite = setSidebarState(delayedHydration, testFile, { + routingAuthoritative: false, + resolvePreservedRouting: (current) => ({ + activeId: current.activeId, + route: current.route, + }), + }) + await Promise.all([routingWrite, hydrationWrite]) + + expect(await getSidebarState(testFile)).toMatchObject({ + activeId: 'fallback-1', + route: 'fallback', + main: { tierLabel: 'Max 20x' }, + }) + }) }) describe('setSidebarState cross-process writes', () => { diff --git a/packages/opencode/src/tests/tui-money.test.ts b/packages/opencode/src/tests/tui-money.test.ts new file mode 100644 index 00000000..1d8409a7 --- /dev/null +++ b/packages/opencode/src/tests/tui-money.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test' +import { formatQuotaMoney as formatCoreQuotaMoney } from '@cortexkit/anthropic-auth-core' + +import { formatQuotaMoney } from '../tui' + +describe('formatQuotaMoney', () => { + test('core and TUI surfaces use identical currency formatting', () => { + const money = { amountMinor: 10035, currency: 'USD', exponent: 2 } + + expect(formatQuotaMoney(money)).toBe(formatCoreQuotaMoney(money)) + expect(formatQuotaMoney(money)).toBe('$100.35') + }) + + test('falls back for malformed currency and exponent metadata', () => { + const money = { amountMinor: 10035, currency: 'ZZZZ', exponent: 50 } + + expect(formatQuotaMoney(money)).toBe(formatCoreQuotaMoney(money)) + expect(formatQuotaMoney(money)).toBe('10035 ZZZZ') + }) +}) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index b393415e..561885e4 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import { formatQuotaMoney } from '@cortexkit/anthropic-auth-core' import type { TuiPlugin, TuiPluginApi, @@ -237,6 +238,7 @@ function QuotaRow(props: { label: string window: { usedPercent: number; resetsAt?: string } | undefined pacing: QuotaPacing | null + binding?: boolean }) { const used = () => props.window?.usedPercent ?? 0 const reset = () => formatResetIn(props.window?.resetsAt) @@ -275,7 +277,7 @@ function QuotaRow(props: { - {` ${String(Math.round(used())).padStart(3)}%`} + {` ${String(Math.round(used())).padStart(3)}%${props.binding ? ' •' : ''}`} @@ -310,6 +312,7 @@ function AccountBlock(props: { active: boolean pacingEnabled: boolean needsReauth?: boolean + tierLabel?: string marginTop?: number }) { const statusWord = () => @@ -329,7 +332,7 @@ function AccountBlock(props: { - {props.name} + {`${props.name}${props.tierLabel ? ` · ${props.tierLabel}` : ''}`} {statusWord()} @@ -345,6 +348,7 @@ function AccountBlock(props: { label='5h' window={props.quota?.five_hour} pacing={pacingFor(props.quota?.five_hour, FIVE_HOUR_MS)} + binding={props.quota?.bindingWindow === 'five_hour'} /> {(window) => ( @@ -361,14 +366,37 @@ function AccountBlock(props: { label={formatScopedQuotaLabel(window.title)} window={window} pacing={pacingFor(window, SEVEN_DAY_MS)} + binding={props.quota?.bindingWindow === window.id} /> )} + + {(extraUsage: () => NonNullable) => ( + + {`credits ${formatQuotaMoney(extraUsage().used)}/${formatQuotaMoney(extraUsage().limit)}${extraUsage().exhausted ? ' · exhausted' : ''}`} + + )} + + + {'→ fallback advised'} + ) } +export { formatQuotaMoney } + // --- Quota dialog content --------------------------------------------------- // Renders the same rich visualization as the sidebar (account blocks with @@ -410,6 +438,7 @@ function QuotaDialogContent(props: { quota={state().main?.quota} active={state().activeId === 'main'} pacingEnabled={prefs().sections.pacing} + tierLabel={state().main?.tierLabel} /> @@ -421,6 +450,7 @@ function QuotaDialogContent(props: { quota={fb.quota} active={state().activeId === fb.id} pacingEnabled={prefs().sections.pacing} + tierLabel={fb.tierLabel} needsReauth={fb.needsReauth} marginTop={1} /> @@ -724,6 +754,7 @@ function QuotaSidebar(props: { quota={state().main?.quota} active={state().activeId === 'main'} pacingEnabled={prefs().sections.pacing} + tierLabel={state().main?.tierLabel} /> @@ -735,6 +766,7 @@ function QuotaSidebar(props: { quota={fb.quota} active={state().activeId === fb.id} pacingEnabled={prefs().sections.pacing} + tierLabel={fb.tierLabel} needsReauth={fb.needsReauth} marginTop={1} /> diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index d169c95f..22448c9d 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -32,6 +32,26 @@ export function buildKillswitchThresholdSeed( return seedParts.join(' ') } +export function buildAccountDialogOption(account: { + id: string + label: string + role: string + enabled: boolean + quotaPercent: number | null + tierLabel?: string +}) { + const pct = + account.quotaPercent != null + ? ` ${Math.round(account.quotaPercent)}%` + : ' \u2013%' + const status = !account.enabled ? ' (disabled)' : '' + return { + title: `${account.label} [${account.role}]${status}${pct}`, + value: account.id, + ...(account.tierLabel && { description: account.tierLabel }), + } +} + function showText(api: TuiPluginApi, text: string) { api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => ( @@ -253,6 +273,7 @@ export function openCommandDialog( role: string enabled: boolean quotaPercent: number | null + tierLabel?: string }>) ?? [] const updateAccounts = (r: { @@ -278,17 +299,7 @@ export function openCommandDialog( value: '__add__', description: 'Add an API key or OAuth fallback account', }, - ...accounts.map((a) => { - const pct = - a.quotaPercent != null - ? ` ${Math.round(a.quotaPercent)}%` - : ' \u2013%' - const status = !a.enabled ? ' (disabled)' : '' - return { - title: `${a.label} [${a.role}]${status}${pct}`, - value: a.id, - } - }), + ...accounts.map(buildAccountDialogOption), ] api.ui.dialog.setSize('xlarge') api.ui.dialog.replace(() => (