diff --git a/docs/parity-backlog.md b/docs/parity-backlog.md index ca47cfa8..c2fafdb0 100644 --- a/docs/parity-backlog.md +++ b/docs/parity-backlog.md @@ -337,6 +337,24 @@ production-only regression (env-override bypass) — first-hand DIFF review caug --- +## 8. Relay response eligibility for quota harvest — FOLLOW-UP + +The corrected Miniflare gate confirms both relay transports preserve +`anthropic-ratelimit-unified-*`: the HTTP Worker copies `upstream.headers` into its response and +WebSocket sends them in `response_start`. The earlier HTTP-negative test omitted +`x-session-affinity`, so `sendViaRelay` returned its direct fallback without reaching the Worker. +Quota harvest remains direct-only at the client `usedRelay` guard because WebSocket response +headers are transport-reconstructed and are not yet canonical harvest evidence. Resolve that +eligibility/synthetic-header question at the client guard before enabling relay harvest. Do not add +a quota side channel. + +## 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. + ## 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..6953deec --- /dev/null +++ b/docs/quota-surfaces.md @@ -0,0 +1,202 @@ +# 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 direct-path harvest from surface 2. Relay responses remain gated from harvest. + +--- + +## 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 now combines the usage poll with passive header pushes on direct 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 `/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 transport is direct-only for harvest in v1. Both the HTTP Worker (`upstream.headers` copied into its response) and WebSocket `response_start` preserve unified headers, but relay responses remain gated because transport-reconstructed WebSocket headers are not yet treated as canonical harvest evidence. See the relay parity item in `docs/parity-backlog.md`. + +## Gaps / opportunities + +- Relay-side harvest requires a client eligibility decision and synthetic-header safety gate at the `usedRelay` guard, not an HTTP Worker passthrough fix. +- 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/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..c3e1e0d4 --- /dev/null +++ b/packages/e2e-tests/tests/quota-header-relay.test.ts @@ -0,0 +1,50 @@ +/// + +import { afterEach, describe, expect, it } from 'bun:test' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { E2EHarness } from '../src/harness.ts' + +let harness: E2EHarness | null = null + +afterEach(async () => { + await harness?.dispose() + harness = null +}) + +describe('quota headers through relay', () => { + it('keeps quota harvest direct-only when relay transport is configured', 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 statePath = join( + harness.opencode.env.configDir, + 'anthropic-auth-state.json', + ) + const state = await readFile(statePath, 'utf8') + .then((value) => JSON.parse(value)) + .catch(() => null) + + expect(state?.main?.quota?.source).not.toBe('headers') + expect(state?.main?.quota?.five_hour?.usedPercent).not.toBe(78) + }, 90_000) +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index afff4be3..1fb970b5 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( + response: Response, + served: { accountId: 'main' | string; accessToken: string }, + ): void { + try { + if (!isQuotaBearingHeaderFrame(response.headers)) { + logger.trace('quota', 'skipped non-quota response headers', { + account: served.accountId, + }) + return + } + const incoming = normalizeQuotaHeaders(response.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, @@ -3017,6 +3362,17 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { totalSendWithAccessMs: roundMs(nowMs() - start), }) + if (!relayConfig || usedDirectFetch) { + harvestQuotaHeaders(response, { + accountId: oauthAccountId, + accessToken, + }) + } else { + logger.trace('quota', 'skipped relay response quota headers', { + account: oauthAccountId, + transport: relayConfig.transport, + }) + } return response } @@ -3057,6 +3413,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..d4e81f9a 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,563 @@ 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'] = []) => + createFallbackStorage({ + accounts, + quota: { enabled: false }, + }) + + 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('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 +8278,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 +8289,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..72b1fd38 100644 --- a/packages/opencode/src/tests/relay-worker-miniflare.test.ts +++ b/packages/opencode/src/tests/relay-worker-miniflare.test.ts @@ -35,7 +35,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 +149,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 +217,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, 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(() => (