From 7cf6b4e77a7a2e695319183386343ab6dbeca378 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:48:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20/claude-prime=20=E2=80=94=20start?= =?UTF-8?q?=205h=20quota=20windows=20right=20after=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 24 +- docs/quota-surfaces.md | 2 +- packages/core/src/accounts.ts | 464 +++- packages/core/src/auth.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/models.ts | 18 + packages/core/src/prime.ts | 1091 ++++++++ packages/core/src/quota-manager.ts | 48 +- packages/core/src/tests/killswitch.test.ts | 25 + packages/core/src/tests/prime-manager.test.ts | 302 +++ .../core/src/tests/prime-usage-race.test.ts | 87 + packages/core/src/tests/prime.test.ts | 2346 +++++++++++++++++ packages/opencode/README.md | 23 +- packages/opencode/src/cli.ts | 1 + packages/opencode/src/index.ts | 404 ++- .../opencode/src/prime-manager-registry.ts | 54 + packages/opencode/src/rpc/protocol.ts | 1 + packages/opencode/src/sidebar-state.ts | 130 + packages/opencode/src/tests/accounts.test.ts | 396 +++ .../src/tests/add-account-flows.test.ts | 18 + packages/opencode/src/tests/auth.test.ts | 2 + packages/opencode/src/tests/cli.test.ts | 3 + .../src/tests/command-dialogs.test.ts | 75 + packages/opencode/src/tests/index.test.ts | 1979 +++++++++++++- packages/opencode/src/tests/info-logs.test.ts | 51 + .../src/tests/prime-manager-registry.test.ts | 230 ++ .../opencode/src/tests/quota-manager.test.ts | 74 + .../opencode/src/tests/sidebar-state.test.ts | 123 + packages/opencode/src/tui.tsx | 28 + packages/opencode/src/tui/command-dialogs.tsx | 112 + packages/pi/README.md | 16 +- packages/pi/src/commands.ts | 25 + packages/pi/src/tests/commands.test.ts | 134 +- 33 files changed, 8244 insertions(+), 46 deletions(-) create mode 100644 packages/core/src/prime.ts create mode 100644 packages/core/src/tests/prime-manager.test.ts create mode 100644 packages/core/src/tests/prime-usage-race.test.ts create mode 100644 packages/core/src/tests/prime.test.ts create mode 100644 packages/opencode/src/prime-manager-registry.ts create mode 100644 packages/opencode/src/tests/prime-manager-registry.test.ts diff --git a/README.md b/README.md index d23810f1..2e3e515e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Quota sidebar widget | OpenCode TUI plugin via `tui.json` | Not available | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | @@ -31,6 +31,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum. - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. +- **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 content-filter recovery (OpenCode)**: when Fable or Opus 5 ends a session response with Anthropic's `refusal` content-filter reason, transparently retry that source-model family with Opus 4.8 for 10 successful model responses. Recovery state and pending cache warms remain independent when a session switches between Fable and Opus 5. After each downgraded response, a zero-output prewarm advances the source model's prompt cache using the OAuth account that served the filtered request. The latest Opus cache boundary is retained so a later refusal can bridge back even after more than 20 content blocks. The TUI sidebar and OpenCode Desktop report both transitions while preserving the selected model. @@ -49,7 +50,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - Support fallback Claude accounts stored in a local per-agent sidecar file. - Keep fallback OAuth tokens fresh in the background. - Apply quota thresholds before routing to main or fallback accounts. -- Add `/claude-cache`, `/claude-cachekeep`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. +- Add `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. - Optionally relay large requests through a Cloudflare Worker owned by the user. ## Install @@ -334,6 +335,7 @@ In the OpenCode TUI, the `/claude-*` commands open interactive modal dialogs ins - `/claude-dump` — toggle request dump capture on or off. - `/claude-cache` — select the 1-hour cache mode (off, explicit, automatic, or hybrid). - `/claude-cachekeep` — select `always`, enter a cache keepalive window (`HH-HH`), or turn it `off`. +- `/claude-prime` — turn quota-window priming on or off, or view its status and usage. - `/claude-killswitch` — enable or disable the killswitch, or edit per-account `5h,1w,scoped` thresholds. Applying a change in a modal persists it through the same configuration the slash arguments use, so the modal and the typed command (`/claude-routing fallback-first`, etc.) are equivalent. Outside the OpenCode TUI (OpenCode desktop or headless), the commands print their text summary as before; Pi is unaffected. @@ -471,6 +473,24 @@ Request bodies, headers, and tokens remain in memory. A lease-backed file under Pre-warm requests preserve explicit cache anchors but remove response-only fields that Anthropic rejects with `max_tokens: 0`, such as streaming, enabled thinking, structured output format, and forced/any tool choice. The feature works only while OpenCode or Pi is running and the machine is awake, and cache writes are still billed when the cache entry is no longer warm. +## Claude quota window priming + +Quota-window priming is off by default. OpenCode controls it with: + +```text +/claude-prime +/claude-prime on +/claude-prime off +``` + +When enabled, the plugin watches each OAuth account's 5-hour quota reset. About one minute after a confirmed reset, it sends one minimal `claude-haiku-4-5` request so the next window starts without waiting for a normal prompt. Usage is measured from response accounting. + +Scheduled fires happen only after the quota window has reset. If an idle account has no cached reset time, the plugin sends one bootstrap request to establish its first observed window. Atomic temporary-file claims ensure that multiple OpenCode processes sharing the same account config send only one request per account and reset. + +Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refresh rotations preserve the main account's lineage, while a host credential replacement creates a new lineage. An existing main lineage without a refresh-token binding attaches to the current credential on its first check without changing identity. On upgrade, an existing fallback account receives an identity during its first prime check; that one-time marker change can send one extra request in the current window. + +Pi exposes `/claude-prime` as a status-only command. Its `on` and `off` arguments are ignored; enable or disable priming from OpenCode. + ## Claude fast mode Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: diff --git a/docs/quota-surfaces.md b/docs/quota-surfaces.md index 212e0d96..51d17935 100644 --- a/docs/quota-surfaces.md +++ b/docs/quota-surfaces.md @@ -190,7 +190,7 @@ Also returned: account/org uuids, email, subscription status/created, `enabled_p 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) +# headers (one minimal Haiku request; usage is measured from response accounting) 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" \ diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 5acc25e6..4d16e003 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -32,6 +32,7 @@ export type AccountBase = { export type OAuthAccount = AccountBase & { type: 'oauth' + authLineageId?: string access?: string refresh: string expires?: number @@ -40,6 +41,11 @@ export type OAuthAccount = AccountBase & { lastQuotaRefreshError?: AccountOperationError quota?: OAuthQuotaSnapshot profile?: OAuthAccountProfile + /** + * Per-fallback cumulative prime counters. Lives in the runtime-state file + * (scoped under `accounts[id].prime`) and never in `anthropic-auth.json`. + */ + prime?: PrimeUsageCounters } export type ApiKeyAccount = AccountBase & { @@ -154,6 +160,30 @@ export type OAuthQuotaSnapshot = Partial< checkedAt?: number } +export type PrimeUsageCounters = { + count: number + inputTokens: number + outputTokens: number + since: number +} + +export type PrimeUsageDelta = { + inputTokens?: number + outputTokens?: number +} + +export type PrimeRuntimeState = { + enabled?: boolean + mainAuthLineageId?: string + mainAuthLineageRefreshTokenFingerprint?: string + /** + * Main account prime counters. Persisted only on the main side of the + * runtime-state file — `configFromStorage()` never writes them to the config + * file so they cannot leak into `anthropic-auth.json`. + */ + main?: PrimeUsageCounters +} + export type RoutingMode = 'main-first' | 'fallback-first' | 'sticky-balanced' export type KillswitchThresholds = Partial< @@ -231,6 +261,13 @@ export type AccountStorage = { endHour?: number subagents?: boolean } + /** + * Opt-in flag and runtime metadata for `/claude-prime`. The `enabled` flag + * belongs on the config side; counters and main lineage bindings live in the + * state file and must never appear in `anthropic-auth.json`. See + * `configFromStorage()` for the write-side filter. + */ + prime?: PrimeRuntimeState relay?: { enabled?: boolean url?: string @@ -257,6 +294,7 @@ export type AccountRuntimeEntry = Partial< Pick< OAuthAccount, | 'access' + | 'authLineageId' | 'refresh' | 'expires' | 'lastUsed' @@ -265,6 +303,7 @@ export type AccountRuntimeEntry = Partial< | 'lastQuotaRefreshError' | 'quota' | 'profile' + | 'prime' > & Pick > @@ -282,6 +321,9 @@ export type AccountRuntimeState = { refreshLeaseId?: string refreshLeaseUntil?: number refreshLeaseTokenHash?: string + prime?: PrimeUsageCounters + primeAuthLineageId?: string + primeAuthLineageRefreshTokenFingerprint?: string } accounts?: Record } @@ -290,6 +332,7 @@ export type AccountStateSaveScope = { mainProfile?: boolean mainQuota?: boolean mainRefresh?: boolean + mainPrime?: boolean accounts?: true | string[] } @@ -438,6 +481,10 @@ function normalizeAccount(value: unknown): FallbackAccount | null { return { ...normalizeAccountBase(value), type: 'oauth', + authLineageId: + typeof value.authLineageId === 'string' && value.authLineageId.trim() + ? value.authLineageId + : undefined, access: typeof value.access === 'string' ? value.access : undefined, refresh: value.refresh, expires: typeof value.expires === 'number' ? value.expires : undefined, @@ -449,6 +496,7 @@ function normalizeAccount(value: unknown): FallbackAccount | null { lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError), quota: normalizeQuota(value.quota), profile: normalizeOAuthAccountProfile(value.profile), + prime: normalizePrimeUsageCounters(value.prime), } } @@ -524,6 +572,31 @@ function normalizeQuotaWindow(value: unknown): AccountQuotaWindow | undefined { } } +function normalizePrimeUsageCounters( + value: unknown, +): PrimeUsageCounters | undefined { + if (!isRecord(value)) return undefined + const count = Number(value.count) + const inputTokens = Number(value.inputTokens) + const outputTokens = Number(value.outputTokens) + const since = Number(value.since) + if ( + ![count, inputTokens, outputTokens, since].every(Number.isFinite) || + count < 0 || + inputTokens < 0 || + outputTokens < 0 || + since < 0 + ) { + return undefined + } + return { + count: Math.floor(count), + inputTokens: Math.floor(inputTokens), + outputTokens: Math.floor(outputTokens), + since: Math.floor(since), + } +} + function normalizeQuota(value: unknown): OAuthAccount['quota'] { if (!isRecord(value)) return undefined const quota: OAuthAccount['quota'] = {} @@ -666,6 +739,39 @@ function normalizeStorage(value: unknown): AccountStorage | null { relay: isRecord(value.relay) ? value.relay : undefined, logging: isRecord(value.logging) ? value.logging : undefined, killswitch: isRecord(value.killswitch) ? value.killswitch : undefined, + prime: (() => { + if (!isRecord(value.prime)) return undefined + const enabled = + typeof value.prime.enabled === 'boolean' + ? value.prime.enabled + : undefined + const main = normalizePrimeUsageCounters(value.prime.main) + const mainAuthLineageId = + typeof value.prime.mainAuthLineageId === 'string' && + value.prime.mainAuthLineageId.trim() + ? value.prime.mainAuthLineageId + : undefined + const mainAuthLineageRefreshTokenFingerprint = + typeof value.prime.mainAuthLineageRefreshTokenFingerprint === + 'string' && value.prime.mainAuthLineageRefreshTokenFingerprint.trim() + ? value.prime.mainAuthLineageRefreshTokenFingerprint + : undefined + if ( + enabled === undefined && + !main && + !mainAuthLineageId && + !mainAuthLineageRefreshTokenFingerprint + ) + return undefined + return { + ...(enabled !== undefined && { enabled }), + ...(main && { main }), + ...(mainAuthLineageId && { mainAuthLineageId }), + ...(mainAuthLineageRefreshTokenFingerprint && { + mainAuthLineageRefreshTokenFingerprint, + }), + } + })(), accounts: value.accounts .map(normalizeAccount) .filter((account): account is FallbackAccount => account != null), @@ -794,6 +900,44 @@ function mergeConfigAndState( mainQuotaToken: mainQuotaSource.quotaToken, mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError, }), + // Carry the main-side prime counters from the state file back into the + // merged storage so a subsequent read sees the cumulative counters. The + // `enabled` flag stays sourced from the config side; main-side counters + // live exclusively on the state file. + prime: (() => { + const configPrime = isRecord(configValue.prime) + ? configValue.prime + : undefined + const mainCounters = normalizePrimeUsageCounters(mainState?.prime) + const mainAuthLineageId = + typeof mainState?.primeAuthLineageId === 'string' && + mainState.primeAuthLineageId.trim() + ? mainState.primeAuthLineageId + : undefined + const mainAuthLineageRefreshTokenFingerprint = + typeof mainState?.primeAuthLineageRefreshTokenFingerprint === + 'string' && mainState.primeAuthLineageRefreshTokenFingerprint.trim() + ? mainState.primeAuthLineageRefreshTokenFingerprint + : undefined + if ( + !configPrime && + !mainCounters && + !mainAuthLineageId && + !mainAuthLineageRefreshTokenFingerprint + ) + return undefined + return { + ...(configPrime && + typeof configPrime.enabled === 'boolean' && { + enabled: configPrime.enabled, + }), + ...(mainCounters && { main: mainCounters }), + ...(mainAuthLineageId && { mainAuthLineageId }), + ...(mainAuthLineageRefreshTokenFingerprint && { + mainAuthLineageRefreshTokenFingerprint, + }), + } + })(), accounts, } } @@ -840,6 +984,7 @@ function accountRuntimeState(account: FallbackAccount) { }) } return objectWithDefinedEntries({ + authLineageId: account.authLineageId, access: account.access, refresh: account.refresh, expires: account.expires, @@ -849,6 +994,7 @@ function accountRuntimeState(account: FallbackAccount) { lastQuotaRefreshError: account.lastQuotaRefreshError, quota: account.quota, profile: account.profile, + prime: account.prime, }) } @@ -927,6 +1073,13 @@ function mergeHeaderQuotaForPersistence( } satisfies OAuthQuotaSnapshot } +/** Returns the latest timestamp carried by a quota snapshot. */ +export function primeQuotaSnapshotCheckedAt( + quota: OAuthQuotaSnapshot | undefined, +): number { + return quotaSnapshotCheckedAt(quota) +} + function mergeAccountRuntimeState( existing: unknown, incoming: AccountRuntimeEntry, @@ -1058,6 +1211,14 @@ function configFromStorage(storage: AccountStorage): Record { cacheKeep: storage.cacheKeep, relay: storage.relay, killswitch: storage.killswitch, + prime: (() => { + // Config side carries ONLY the `enabled` flag — runtime counters and + // lineage bindings stay on the state file. Write `enabled` whenever it was explicitly set so a + // toggle off persists `{ enabled: false }` and is visible to a stale + // reader that only inspects the config. + if (typeof storage.prime?.enabled !== 'boolean') return undefined + return { enabled: storage.prime.enabled } + })(), accounts: storage.accounts.map(accountConfig), }) } @@ -1304,6 +1465,35 @@ function applyMainRefreshStatePatch( state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash } +function applyMainPrimeStatePatch( + state: AccountRuntimeState, + storage: AccountStorage, +) { + const incoming = storage.prime?.main + const incomingAuthLineageId = storage.prime?.mainAuthLineageId + const incomingAuthLineageRefreshTokenFingerprint = + storage.prime?.mainAuthLineageRefreshTokenFingerprint + if ( + !incoming && + !incomingAuthLineageId && + !incomingAuthLineageRefreshTokenFingerprint + ) + return + state.main = state.main ?? {} + // Last-writer-wins on prime counters — the only writer is the prime manager + // itself, monotonically accumulating per success, so there is no race for an + // older write to overwrite a newer one within this process. Across processes + // the cross-process claim marker (#1247) keeps the fire exclusive. + if (incoming) state.main.prime = incoming + if (incomingAuthLineageId) { + state.main.primeAuthLineageId = incomingAuthLineageId + } + if (incomingAuthLineageRefreshTokenFingerprint) { + state.main.primeAuthLineageRefreshTokenFingerprint = + incomingAuthLineageRefreshTokenFingerprint + } +} + function pruneUndefined(value: unknown): unknown { if (Array.isArray(value)) return value.map(pruneUndefined) if (!isRecord(value)) return value @@ -1452,6 +1642,7 @@ async function saveAccountStateUnlocked( if (scope.mainProfile) applyMainProfileStatePatch(next, storage) if (scope.mainQuota) applyMainQuotaStatePatch(next, storage) if (scope.mainRefresh) applyMainRefreshStatePatch(next, storage) + if (scope.mainPrime) applyMainPrimeStatePatch(next, storage) if (scope.accounts) { const ids = scope.accounts === true ? null : new Set(scope.accounts) @@ -1861,6 +2052,245 @@ export async function setCacheKeepSubagentsEnabled( return storage } +export function isPrimePersistentlyEnabled(storage: AccountStorage | null) { + return storage?.prime?.enabled === true +} + +export async function setPrimePersistentEnabled( + enabled: boolean, + path = getAccountStoragePath(), +) { + const storage = (await loadAccounts(path)) ?? createEmptyStorage() + storage.prime = { + ...(storage.prime ?? {}), + enabled, + } + await saveAccounts(storage, path) + return storage +} + +/** + * Return the stable prime marker identity for an OAuth account, seeding it in + * runtime state when older storage has no lineage yet. Main lookups return + * undefined when the live refresh token is unavailable and no lineage exists. + */ +export async function getOrCreatePrimeAuthLineageId( + accountId: 'main' | string, + path = getAccountStoragePath(), + mainRefreshToken?: string, +): Promise { + return enqueueSave(async () => { + const configLock = await acquireAccountConfigWriteLock(path) + try { + const stateLock = await acquireAccountStateWriteLock(path) + try { + const storage = (await loadAccounts(path)) ?? createEmptyStorage() + if (accountId === 'main') { + const existing = storage.prime?.mainAuthLineageId + const observedFingerprint = mainRefreshToken + ? tokenFingerprint(mainRefreshToken) + : undefined + const boundFingerprint = + storage.prime?.mainAuthLineageRefreshTokenFingerprint + if (existing) { + if ( + !observedFingerprint || + boundFingerprint === observedFingerprint + ) + return existing + + if ( + boundFingerprint && + storage.refresh?.mainRefreshLeaseUntil && + storage.refresh.mainRefreshLeaseUntil > Date.now() + ) { + // The owner binds before publishing its rotated credential. An + // active lease makes either side of that handoff ambiguous, so + // suppress this tick instead of classifying it as a re-login. + return existing + } + + if (!boundFingerprint) { + // Legacy lineages predate refresh-token binding. The first + // observation attaches the credential without changing markers. + storage.prime = { + ...(storage.prime ?? {}), + mainAuthLineageRefreshTokenFingerprint: observedFingerprint, + } + await saveAccountStateUnlocked(storage, path, { + mainPrime: true, + }) + return existing + } + } + + // Without the live host credential there is no safe way to bind a + // new identity. Skip this tick rather than creating an unbound + // namespace that could churn or suppress another account. + if (!observedFingerprint) return undefined + + // A bound mismatch that did not pass through the owned refresh path + // is a host credential replacement and needs a fresh marker identity. + const authLineageId = randomUUID() + storage.prime = { + ...(storage.prime ?? {}), + mainAuthLineageId: authLineageId, + ...(observedFingerprint && { + mainAuthLineageRefreshTokenFingerprint: observedFingerprint, + }), + } + await saveAccountStateUnlocked(storage, path, { mainPrime: true }) + return authLineageId + } + + const account = storage.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && isOAuthAccount(candidate), + ) + if (!account) { + throw new Error( + `getOrCreatePrimeAuthLineageId: OAuth account "${accountId}" not found`, + ) + } + if (account.authLineageId) return account.authLineageId + + // Upgrading changes the marker namespace once, so an already-primed + // window may fire again; persisting the seed prevents every later token + // rotation from repeating that migration cost. + const authLineageId = randomUUID() + account.authLineageId = authLineageId + await saveAccountStateUnlocked(storage, path, { + accounts: [accountId], + }) + return authLineageId + } finally { + await stateLock.release() + } + } finally { + await configLock.release() + } + }) +} + +/** Advance a main lineage across a refresh performed by this plugin. */ +export async function continueMainPrimeAuthLineageAfterRefresh( + input: { + previousRefreshToken: string + currentRefreshToken: string + }, + path = getAccountStoragePath(), +): Promise { + return enqueueSave(async () => { + const configLock = await acquireAccountConfigWriteLock(path) + try { + const stateLock = await acquireAccountStateWriteLock(path) + try { + const storage = (await loadAccounts(path)) ?? createEmptyStorage() + const authLineageId = storage.prime?.mainAuthLineageId + if (!authLineageId) return + + const previousFingerprint = tokenFingerprint(input.previousRefreshToken) + const currentFingerprint = tokenFingerprint(input.currentRefreshToken) + const boundFingerprint = + storage.prime?.mainAuthLineageRefreshTokenFingerprint + if ( + boundFingerprint && + boundFingerprint !== previousFingerprint && + boundFingerprint !== currentFingerprint + ) { + // Another process observed a host replacement after this refresh + // started; the stale rotation must not rebind that newer lineage. + return + } + + storage.prime = { + ...(storage.prime ?? {}), + mainAuthLineageRefreshTokenFingerprint: currentFingerprint, + } + await saveAccountStateUnlocked(storage, path, { mainPrime: true }) + } finally { + await stateLock.release() + } + } finally { + await configLock.release() + } + }) +} + +/** + * Atomically increment an account's cumulative prime counters and persist via + * the scoped runtime-state path. The `main` account lives at state.main.prime; + * every other account lives at state.accounts[id].prime. Config-side writes + * are intentionally NOT triggered so prime counters cannot leak into + * `anthropic-auth.json`. Callers should not depend on this function to mutate + * the caller's storage object. + */ +export async function incrementPrimeUsagePersistent( + accountId: 'main' | string, + usage: PrimeUsageDelta, + path = getAccountStoragePath(), + now = Date.now(), +): Promise { + const inputTokens = Number.isFinite(usage?.inputTokens) + ? Math.max(0, Math.floor(usage.inputTokens as number)) + : 0 + const outputTokens = Number.isFinite(usage?.outputTokens) + ? Math.max(0, Math.floor(usage.outputTokens as number)) + : 0 + + return enqueueSave(async () => { + // The config-write lock is the repository-wide outer lock for config and + // runtime-state RMW operations; taking it before the state write preserves + // saveAccountsLocked's config → state ordering across processes. + const lock = await acquireAccountConfigWriteLock(path) + try { + const stateLock = await acquireAccountStateWriteLock(path) + try { + const storage = (await loadAccounts(path)) ?? createEmptyStorage() + + if (accountId === 'main') { + const existing = storage.prime?.main + const next: PrimeUsageCounters = { + count: (existing?.count ?? 0) + 1, + inputTokens: (existing?.inputTokens ?? 0) + inputTokens, + outputTokens: (existing?.outputTokens ?? 0) + outputTokens, + since: existing?.since ?? Math.floor(now), + } + storage.prime = { ...(storage.prime ?? {}), main: next } + await saveAccountStateUnlocked(storage, path, { mainPrime: true }) + return next + } + + const index = storage.accounts.findIndex( + (account) => account.id === accountId, + ) + if (index < 0) { + throw new Error( + `incrementPrimeUsagePersistent: account "${accountId}" not found`, + ) + } + const account = storage.accounts[index] as OAuthAccount + const existing = account.prime + const next: PrimeUsageCounters = { + count: (existing?.count ?? 0) + 1, + inputTokens: (existing?.inputTokens ?? 0) + inputTokens, + outputTokens: (existing?.outputTokens ?? 0) + outputTokens, + since: existing?.since ?? Math.floor(now), + } + storage.accounts[index] = { ...account, prime: next } + await saveAccountStateUnlocked(storage, path, { + accounts: [accountId], + }) + return next + } finally { + await stateLock.release() + } + } finally { + await lock.release() + } + }) +} + function getFallbackStatuses(storage: AccountStorage | null) { return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON } @@ -2144,12 +2574,16 @@ function failClosedOnUnknownQuota(storage: AccountStorage | null) { ) } +function normalizeScopedQuotaModel(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, '') +} + function scopedQuotaModelKey(model: unknown): string | null { if (typeof model !== 'string') return null - const normalized = model.toLowerCase() + const normalized = normalizeScopedQuotaModel(model) if (normalized.includes('fable')) return 'fable' if (normalized.includes('mythos')) return 'mythos' - return null + return normalized } export function getScopedQuotaWindowForModel( @@ -2161,8 +2595,8 @@ export function getScopedQuotaWindowForModel( return quota?.scoped?.find((window) => { const haystack = [window.modelId, window.modelName, window.title] .filter((value): value is string => typeof value === 'string') + .map(normalizeScopedQuotaModel) .join(' ') - .toLowerCase() return haystack.includes(key) }) } @@ -2943,7 +3377,7 @@ export class FallbackAccountManager { stale && !quotaBackoffActive(next.lastQuotaRefreshError, this.now()) ) { - next = await this.refreshAccountQuota(next, storage) + next = (await this.refreshAccountQuota(next, storage)).account changed = true } // Single source of truth: evaluate quota policy from the unified @@ -3315,11 +3749,14 @@ export class FallbackAccountManager { }) const refreshed = await refreshClaudeOAuthToken({ refreshToken, + authLineageId: sourceAccount.authLineageId, fetchImpl: this.fetchImpl, now: this.now, }) sourceAccount.access = refreshed.access sourceAccount.refresh = refreshed.refresh + sourceAccount.authLineageId = + refreshed.authLineageId ?? sourceAccount.authLineageId sourceAccount.expires = refreshed.expires sourceAccount.lastRefreshedAt = refreshed.expires - refreshed.expiresIn * 1000 @@ -3348,15 +3785,18 @@ export class FallbackAccountManager { // to a direct fetch only when no QuotaManager is wired (e.g. in isolation). const fetchSnapshot = (accessToken: string) => this.quotaManager - ? this.quotaManager.refreshFallback(target.id, accessToken) + ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken) : fetchOAuthQuotaSnapshot({ accessToken, fetchImpl: this.fetchImpl, now: this.now, - }) + }).then((quota) => ({ quota, fetched: true })) const fetchStartedAt = this.now() + let fetched = false try { - target.quota = await fetchSnapshot(target.access) + const result = await fetchSnapshot(target.access) + target.quota = result.quota + fetched = result.fetched } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.includes('Claude quota check failed: 401')) throw error @@ -3365,7 +3805,9 @@ export class FallbackAccountManager { }) if (!target.access) throw error // 401 does not arm QuotaManager backoff, so this retry proceeds. - target.quota = await fetchSnapshot(target.access) + const result = await fetchSnapshot(target.access) + target.quota = result.quota + fetched = result.fetched } const latestStorage = await this.load() @@ -3380,7 +3822,7 @@ export class FallbackAccountManager { ) { this.seedFallbackQuota(latestAccount, latestStorage) updateStoredAccount(storage, latestAccount) - return latestAccount + return { account: latestAccount, fetched: false } } if ( latestStorage && @@ -3391,7 +3833,7 @@ export class FallbackAccountManager { ) { this.seedFallbackQuota(latestAccount, latestStorage) updateStoredAccount(storage, latestAccount) - return latestAccount + return { account: latestAccount, fetched } } target.lastQuotaRefreshError = undefined @@ -3411,6 +3853,6 @@ export class FallbackAccountManager { target.access, ) } - return target + return { account: target, fetched } } } diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 38823e9f..4dc793a2 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -56,6 +56,7 @@ export type ClaudeOAuthRefreshResult = { refresh: string expires: number expiresIn: number + authLineageId?: string } function isTransientNetworkError(error: unknown) { @@ -72,6 +73,7 @@ function isTransientNetworkError(error: unknown) { export async function refreshClaudeOAuthToken(input: { refreshToken: string + authLineageId?: string fetchImpl?: typeof fetch now?: () => number maxRetries?: number @@ -128,6 +130,7 @@ export async function refreshClaudeOAuthToken(input: { refresh: json.refresh_token ?? input.refreshToken, expires: refreshedAt + json.expires_in * 1000, expiresIn: json.expires_in, + authLineageId: input.authLineageId, } } catch (error) { if (error instanceof ClaudeOAuthRefreshError) throw error diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e25acaa8..80b11538 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './logging.ts' export * from './models.ts' export * from './oauth-profile.ts' export * from './pkce.ts' +export * from './prime.ts' export * from './provider.ts' export * from './quota-headers.ts' export * from './quota-manager.ts' diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 06e7dbcd..756bfd57 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -1,6 +1,24 @@ export const CLAUDE_FABLE_5_MODEL_ID = 'claude-fable-5' export const CLAUDE_MYTHOS_5_MODEL_ID = 'claude-mythos-5' +/** + * Haiku 4.5 model identifier used by `/claude-prime` to start each OAuth + * account's five-hour quota window with a minimal request. Usage is measured + * from response accounting. Kept distinct from the Fable/Mythos pricing block + * above so per-million-token cost estimation does not conflate families. + */ +export const CLAUDE_HAIKU_4_5_MODEL_ID = 'claude-haiku-4-5' + +/** + * Per-million-token USD pricing for Haiku 4.5. Used to project the cumulative + * cost of prime requests from persisted usage counters — never persisted on + * disk; derive at display time so future pricing revisions land in one place. + */ +export const CLAUDE_HAIKU_4_5_PRICING = { + input: 1, + output: 5, +} as const + export const CLAUDE_FABLE_MYTHOS_5_MODEL_IDS = [ CLAUDE_FABLE_5_MODEL_ID, CLAUDE_MYTHOS_5_MODEL_ID, diff --git a/packages/core/src/prime.ts b/packages/core/src/prime.ts new file mode 100644 index 00000000..3947e8e9 --- /dev/null +++ b/packages/core/src/prime.ts @@ -0,0 +1,1091 @@ +import { createHash } from 'node:crypto' +import { realpathSync } from 'node:fs' +import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { + type AccountQuotaWindow, + type AccountStorage, + type FallbackAccount, + isOAuthAccount, + isPermanentRefreshError, + killswitchPassesPolicy, + type OAuthQuotaSnapshot, + type PrimeUsageCounters, +} from './accounts.ts' +import { logger } from './logger.ts' +import { + CLAUDE_HAIKU_4_5_MODEL_ID, + CLAUDE_HAIKU_4_5_PRICING, +} from './models.ts' + +// Re-export pricing for downstream packages that import it through core's +// public surface (the test suite verifies exact per-million-token values). +export { CLAUDE_HAIKU_4_5_PRICING } + +/** + * `/claude-prime` — opt-in 5h quota window priming. Default OFF. + * + * Every OAuth account (main + enabled fallbacks) gets one minimal request + * fired ~60s after its `five_hour.resetsAt`, exactly once across concurrent + * OpenCode processes. Catch-up on boot; redundancy guard via fresh quota + * check; cross-process atomic marker claim via `writeFile(..., flag: 'wx')`. + */ + +export const CLAUDE_PRIME_COMMAND_NAME = 'claude-prime' + +export const PRIME_TICK_MS = 60_000 +export const PRIME_DUE_OFFSET_MS = 60_000 +export const PRIME_MARKER_MAX_AGE_MS = 6 * 60 * 60_000 +export const PRIME_POST_FIRE_REFRESH_MS = 90_000 +/** Minimum interval between forced quota checks for an unchanged reset epoch. */ +export const PRIME_CHECK_THROTTLE_MS = 5 * 60_000 + +export type PrimeCommandAction = + | { type: 'status' } + | { type: 'enable' } + | { type: 'disable' } + | { type: 'usage' } + +export type PrimeAccountStatus = { + id: string + label: string + nextDueAt?: number + lastPrimedAt?: number | null + lastResult?: 'ok' | 'error' + usage?: import('./accounts.ts').PrimeUsageCounters + estimatedCostUsd?: number +} + +export type PrimeSendResult = + | { + ok: true + status: number + ms: number + usage?: { inputTokens?: number; outputTokens?: number } + } + | { + ok: false + status?: number + ms?: number + error: string + // Discriminates the failure kind so the manager can emit the + // spec Logging table's two distinct warn events: `prime token + // refresh failed` (warn · prime · { account, error }) for + // token-refresh failures, `prime fire failed` (warn · prime · + // { account, status?, error }) for HTTP / fetch / identity + // failures during the request itself. + reason?: 'token-refresh' | 'send' + } + +/** + * Pure command parser — accepts `''` / `on` / `off` and treats anything else + * as a usage error. Mirrors `parseFastModeCommandAction` shape so all + * `claude-*` commands stay uniform. + */ +export function parsePrimeCommandAction(input: string): PrimeCommandAction { + const normalized = input.trim().split(/\s+/).filter(Boolean) + if (normalized.length === 0) return { type: 'status' } + if (normalized.length === 1 && normalized[0] === 'on') + return { type: 'enable' } + if (normalized.length === 1 && normalized[0] === 'off') + return { type: 'disable' } + return { type: 'usage' } +} + +/** + * Per-million-token USD cost projection. Locale-independent math; the display + * layer is responsible for formatting. Returns 0 for absent counters so + * pre-fire accounts still get a clean number in the sidebar/dialog. + */ +export function estimatePrimeCostUsd( + usage?: import('./accounts.ts').PrimeUsageCounters, +): number { + if (!usage) return 0 + return ( + (usage.inputTokens * CLAUDE_HAIKU_4_5_PRICING.input + + usage.outputTokens * CLAUDE_HAIKU_4_5_PRICING.output) / + 1_000_000 + ) +} + +function accountLabel( + id: string, + storage: AccountStorage | null, + fallbackAccount?: { label?: string }, +): string { + if (fallbackAccount?.label?.trim()) return fallbackAccount.label + if (id === 'main') return 'main' + // Stale-storage fallback: the caller passed a non-empty storage so prefer + // the first matching account label. + const found = storage?.accounts?.find((a) => a.id === id) + if (found?.label?.trim()) return found.label + return id +} + +/** + * Project per-account prime status. Enumerates a synthetic `main` plus each + * enabled OAuth fallback. `nextDueAt` is the stored five-hour reset plus + * `PRIME_DUE_OFFSET_MS`; null when no reset is known. Manager-transient + * `lastPrimedAt`/`lastResult` overlay the persisted counters so the dialog and + * sidebar can show "just attempted" without waiting for the next save. + */ +export function buildPrimeAccountStatuses( + storage: AccountStorage | null, + options?: { + now?: number + transient?: ReadonlyMap< + string, + { lastPrimedAt?: number; lastResult?: 'ok' | 'error' } + > + }, +): PrimeAccountStatus[] { + const transient = options?.transient + const mainStoredQuota = storage?.quota?.mainQuota + const mainUsage = storage?.prime?.main + const mainStatus: PrimeAccountStatus = { + id: 'main', + label: 'main', + nextDueAt: primeNextDueAt(mainStoredQuota?.five_hour), + lastPrimedAt: transient?.get('main')?.lastPrimedAt ?? null, + lastResult: transient?.get('main')?.lastResult, + usage: mainUsage, + estimatedCostUsd: estimatePrimeCostUsd(mainUsage), + } + + const fallbacks: PrimeAccountStatus[] = (storage?.accounts ?? []) + .filter(isOAuthAccount) + .filter((account) => account.id !== 'main') + .filter((account) => account.enabled !== false) + .map((account) => ({ + id: account.id, + label: accountLabel(account.id, storage, account), + nextDueAt: primeNextDueAt(account.quota?.five_hour), + lastPrimedAt: transient?.get(account.id)?.lastPrimedAt ?? null, + lastResult: transient?.get(account.id)?.lastResult, + usage: account.prime, + estimatedCostUsd: estimatePrimeCostUsd(account.prime), + })) + + return [mainStatus, ...fallbacks] +} + +function primeNextDueAt(window?: AccountQuotaWindow): number | undefined { + if (!window?.resetsAt) return undefined + const ms = Date.parse(window.resetsAt) + if (!Number.isFinite(ms)) return undefined + return ms + PRIME_DUE_OFFSET_MS +} + +/** + * Pure multi-line status summary shared by the OpenCode dialog, the sidebar + * expanded row, and the Pi display. Title is stable text the dialog uses to + * anchor and the orchestrator uses to assert RED. + */ +export function buildPrimeStatusSummary(input: { + enabled: boolean + accounts: PrimeAccountStatus[] +}): string { + const header = input.enabled + ? '## Claude Prime Status' + : '## Claude Prime Disabled' + const lines: string[] = [header, ''] + lines.push(`Status: ${input.enabled ? 'enabled' : 'disabled'}`) + lines.push('') + lines.push('Accounts:') + for (const account of input.accounts) { + const usage = account.usage + const count = usage?.count ?? 0 + const due = formatDue(account.nextDueAt) + const primed = formatPrimed(account.lastPrimedAt, account.lastResult) + const segments: string[] = [`${account.label}`] + if (due) segments.push(`next prime ${due}`) + if (primed) segments.push(primed) + if (count > 0) { + segments.push( + `${count} ${count === 1 ? 'prime' : 'primes'} \u2248 $${formatUsd( + account.estimatedCostUsd ?? 0, + )}`, + ) + } + lines.push(`- ${segments.join(' · ')}`) + } + lines.push('') + lines.push('Usage: `/claude-prime on`, `/claude-prime off`, or status.') + return lines.join('\n') +} + +function formatDue(nextDueAt: number | null | undefined): string { + if (typeof nextDueAt !== 'number') return '' + return new Date(nextDueAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatPrimed( + lastPrimedAt: number | null | undefined, + lastResult: 'ok' | 'error' | undefined, +): string { + if (typeof lastPrimedAt !== 'number') return '' + const time = new Date(lastPrimedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + if (lastResult === 'error') return `primed ${time} err` + return `primed ${time} \u2713` +} + +function formatUsd(value: number): string { + if (value === 0) return '0' + if (value < 0.0001) return value.toExponential(2) + return value.toFixed(Math.min(6, Math.max(0, 4))) +} + +/** + * Pure executor — returns markdown text and an optional `updated` envelope. + * Effects (persistent toggle, manager start/stop) are the caller's job; Pi + * uses this same function in display-only mode. + */ +export function executePrimeCommand(input: { + argumentsText: string + enabled: boolean + accounts: PrimeAccountStatus[] +}): { text: string; updated?: { enabled: boolean } } { + const action = parsePrimeCommandAction(input.argumentsText) + + if (action.type === 'status') { + return { + text: buildPrimeStatusSummary({ + enabled: input.enabled, + accounts: input.accounts, + }), + } + } + + if (action.type === 'enable') { + return { + text: buildPrimeStatusSummary({ + enabled: true, + accounts: input.accounts, + }), + updated: { enabled: true }, + } + } + + if (action.type === 'disable') { + return { + text: buildPrimeStatusSummary({ + enabled: false, + accounts: input.accounts, + }), + updated: { enabled: false }, + } + } + + return { + text: [ + '## Claude Prime Usage', + '', + 'Usage: `/claude-prime`, `/claude-prime on`, or `/claude-prime off`.', + '', + buildPrimeStatusSummary({ + enabled: input.enabled, + accounts: input.accounts, + }), + ].join('\n'), + } +} + +/** + * Which gate of the eligibility check failed. The manager logs the human + * reason string on every ineligible account (including API-key and + * disabled), per the spec's Logging table. + */ +export type PrimeEligibilityReason = + | 'not-oauth' + | 'disabled' + | 'needs-reauth' + | 'killswitch' + +export type PrimeEligibilityResult = + | { eligible: true } + | { eligible: false; reason: PrimeEligibilityReason } + +const ELIGIBILITY_REASON_LABEL: Record = { + 'not-oauth': 'API-key account', + disabled: 'disabled', + 'needs-reauth': 'needs re-login', + killswitch: 'killswitch', +} + +/** + * Eligibility check shared by the manager tick and the dialog preview. + * Returns a discriminated result so the manager can log a specific reason + * per the spec's Logging table. Quota policy is evaluated authoritatively on + * the fresh snapshot immediately before claim. + */ +export function primeIsEligible(input: { + storage: AccountStorage | null + accountId: 'main' | string + isOAuth: boolean + isEnabled: boolean + hasPermanentRefreshError: boolean + quota?: OAuthQuotaSnapshot +}): PrimeEligibilityResult { + if (!input.isOAuth) return { eligible: false, reason: 'not-oauth' } + if (!input.isEnabled) return { eligible: false, reason: 'disabled' } + if (input.hasPermanentRefreshError) + return { eligible: false, reason: 'needs-reauth' } + return { eligible: true } +} + +export function primeEligibilityReasonLabel( + reason: PrimeEligibilityReason, +): string { + return ELIGIBILITY_REASON_LABEL[reason] +} + +export const PRIME_REQUEST_BODY = { + model: CLAUDE_HAIKU_4_5_MODEL_ID, + max_tokens: 1, + system: 'Reply with 1 when you receive 0.', + messages: [{ role: 'user', content: '0' }], +} as const + +export function buildPrimeRequestBody(): { + model: string + max_tokens: number + system: string + messages: Array<{ role: 'user'; content: string }> +} { + // Copy to keep callers from accidentally mutating the canonical frozen body. + return JSON.parse(JSON.stringify(PRIME_REQUEST_BODY)) +} + +// -- PrimeManager ----------------------------------------------------------- + +/** + * Refresh result from a fresh-check. `fresh=false` means the adapter + * returned a cached snapshot (e.g. quota API in 429-backoff, or another + * process holds the quota file lock). PrimeManager treats a stale + * result as "do not claim, retry next tick" so a cached past + * `resetsAt` can never cause a duplicate prime against an + * already-started window. + */ +export type PrimeRefreshResult = { + quota: OAuthQuotaSnapshot + fresh: boolean +} + +type AccountEvaluation = { + id: 'main' | string + label: string + isOAuth: boolean + isEnabled: boolean + hasPermanentRefreshError: boolean + storedQuota?: OAuthQuotaSnapshot + storedFiveHour?: AccountQuotaWindow +} + +function defaultMarkerDir(): string { + return join(tmpdir(), 'opencode-anthropic-auth', 'prime') +} + +export function primeStorageFingerprint(storagePath: string): string { + const absolutePath = resolve(storagePath) + let canonicalPath: string + try { + canonicalPath = realpathSync(absolutePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + canonicalPath = absolutePath + } + return createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12) +} + +export function primeMarkerNamespaceDir( + markerDir: string, + storagePath: string, +): string { + // Legacy root markers are intentionally not migrated: an upgrade may send + // one extra Haiku request per account, whose usage is measured from response + // accounting, then the namespaced claim self-heals. + return join(markerDir, primeStorageFingerprint(storagePath)) +} + +export function primeAccountMarkerDir( + markerDir: string, + storagePath: string, + accountFingerprint: string, +): string { + if (!/^[a-f0-9]{12,}$/i.test(accountFingerprint)) { + throw new Error('Prime account fingerprint must be a hexadecimal digest') + } + return join( + primeMarkerNamespaceDir(markerDir, storagePath), + accountFingerprint.toLowerCase().slice(0, 12), + ) +} + +export function primeMarkerPath( + markerDir: string, + accountId: 'main' | string, + resetsAtEpochMs: number, +): string { + return join(markerDir, `${encodeURIComponent(accountId)}-${resetsAtEpochMs}`) +} + +function primeBootstrapMarkerPath( + markerDir: string, + accountId: 'main' | string, +): string { + return join(markerDir, `${encodeURIComponent(accountId)}-bootstrap`) +} + +function primeAccountLabel( + id: 'main' | string, + fallback?: FallbackAccount, +): string { + if (id === 'main') return 'main' + if (fallback && 'label' in fallback && fallback.label?.trim()) { + return fallback.label + } + return id +} + +/** + * Build the list of accounts the manager should evaluate EVERY tick. + * Includes disabled and API-key accounts so the eligibility log fires + * for them too (the spec Logging table mandates the debug log for + * every non-eligible case so operators can troubleshoot). Filter for + * the actual fire decision happens via `primeIsEligible`. + */ +function evaluateAccounts(storage: AccountStorage): AccountEvaluation[] { + const evaluations: AccountEvaluation[] = [] + const mainRefreshError = storage.refresh?.mainLastRefreshError + evaluations.push({ + id: 'main', + label: 'main', + isOAuth: true, + isEnabled: true, + hasPermanentRefreshError: isPermanentRefreshError(mainRefreshError), + storedQuota: storage.quota?.mainQuota, + storedFiveHour: storage.quota?.mainQuota?.five_hour, + }) + for (const account of storage.accounts ?? []) { + if (account.id === 'main') { + logger.debug('prime', 'ineligible', { + account: primeAccountLabel(account.id, account), + reason: 'reserved-id', + }) + continue + } + if (!isOAuthAccount(account)) { + // API-key accounts are surfaced here so the eligibility-skip debug + // log fires once per tick and an operator can confirm the + // account won't prime. The check itself is in `primeIsEligible`. + evaluations.push({ + id: account.id, + label: primeAccountLabel(account.id, account), + isOAuth: false, + isEnabled: account.enabled !== false, + hasPermanentRefreshError: false, + }) + continue + } + evaluations.push({ + id: account.id, + label: primeAccountLabel(account.id, account), + isOAuth: true, + isEnabled: account.enabled !== false, + hasPermanentRefreshError: isPermanentRefreshError( + account.lastRefreshError, + ), + storedQuota: account.quota, + storedFiveHour: account.quota?.five_hour, + }) + } + return evaluations +} + +function storedResetMs(window?: AccountQuotaWindow): number | undefined { + if (!window?.resetsAt) return undefined + const ms = Date.parse(window.resetsAt) + return Number.isFinite(ms) ? ms : undefined +} + +/** + * Scheduler for `/claude-prime`. Mirrors `CacheKeepManager` lifecycle (unref'd + * 60s interval, idempotent start/stop, swallowed tick errors) but adds the + * cross-process atomic marker claim before firing — exactly one OpenCode + * process fires per reset epoch even with N concurrent instances. + * + * Dependencies are injected so the manager can be exercised deterministically + * without filesystem, network, or live OAuth state. + */ +export type PrimeManagerOptions = { + loadStorage: () => Promise + /** Returns a non-secret digest, or undefined when identity is unavailable. */ + getAccountFingerprint: ( + accountId: 'main' | string, + ) => Promise + refreshQuota: (accountId: 'main' | string) => Promise + sendPrime: (accountId: 'main' | string) => Promise + recordSuccess: ( + accountId: 'main' | string, + usage: { inputTokens?: number; outputTokens?: number }, + ) => Promise + now?: () => number + markerDir?: string + storagePath: string +} + +export class PrimeManager { + private timer: ReturnType | null = null + private postFireRefreshTimers = new Set>() + private lastForcedCheck = new Map< + string, + { at: number; resetEpoch: number | undefined } + >() + // Set true by `stop()` so a tick already in flight can short-circuit + // before claim/send, preventing a stale in-flight cycle from firing + // after `/claude-prime off` or after a newer plugin invocation has + // replaced this instance (M3b). + private stopped = false + // The registry replaces this dependency bundle on plugin reload so the + // singleton manager cannot retain closures from an obsolete plugin context. + public options: PrimeManagerOptions + // Per-account transient state from the last ATTEMPT — overlays persisted + // counters in `stats()` so the sidebar/dialog can show "just attempted" + // without waiting for the next save. NOT persisted. + // + // Crucially, fresh-check skips do NOT touch this map. Only an actual + // claim+fire attempt mutates it. See `runForAccount` for the invariant + // (M4): skip is not the same as a successful prime. + private transient = new Map< + string, + { lastPrimedAt: number; lastResult: 'ok' | 'error' } + >() + // Latest cumulative counters returned by recordSuccess. Overlays the + // persisted counters in stats() before the next load persists them. + private counters = new Map<'main' | string, PrimeUsageCounters>() + // Per-account "window active" observation from the most recent fresh + // check (M4). Surfaced in the status as the rendered "— window active" + // text; independent of lastPrimedAt/lastResult. + private windowActive = new Map<'main' | string, number>() + + constructor(options: PrimeManagerOptions) { + this.options = options + } + + updateOptions(options: PrimeManagerOptions): void { + this.options = options + } + + start(): void { + if (this.timer) return + this.stopped = false + logger.trace('prime', 'manager started') + this.timer = setInterval(() => { + void this.tick().catch((error) => { + logger.warn('prime', 'tick failed', { + error: error instanceof Error ? error.message : String(error), + }) + }) + }, PRIME_TICK_MS) + if (typeof this.timer === 'object' && 'unref' in this.timer) { + ;(this.timer as { unref?: () => void }).unref?.() + } + } + + stop(): void { + const hadTimer = this.timer !== null + const hadPostFireRefresh = this.postFireRefreshTimers.size > 0 + this.stopped = true + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + for (const timer of this.postFireRefreshTimers) clearTimeout(timer) + this.postFireRefreshTimers.clear() + this.lastForcedCheck.clear() + if (!hadTimer && !hadPostFireRefresh) return + logger.trace('prime', 'manager stopped') + } + + isStopped(): boolean { + return this.stopped + } + + async tick(): Promise { + const now = this.options.now?.() ?? Date.now() + logger.trace('prime', 'tick start', { now }) + await this.sweepMarkers(now) + if (this.stopped) { + logger.trace('prime', 'tick: stopped, aborting') + return + } + // Re-load persisted state at the start of every tick. The previous + // read may pre-date an `/claude-prime off` from another process or + // a settings edit (M3b). + const storage = await this.options.loadStorage() + if (!storage) { + logger.trace('prime', 'tick: no storage, skipping') + return + } + if (storage.prime?.enabled !== true) { + logger.trace('prime', 'tick: feature disabled, skipping') + return + } + const evaluations = evaluateAccounts(storage) + await Promise.all( + evaluations.map((evaluation) => + this.runForAccount(evaluation, storage, now), + ), + ) + logger.trace('prime', 'tick end') + } + + /** + * Project cumulative per-account status for the sidebar and dialog. Machine + * values only — formatting belongs in the TUI/sidebar/Pi layer. + */ + stats( + storage?: AccountStorage | null, + nowArg?: number, + ): PrimeAccountStatus[] { + const baseStatuses = buildPrimeAccountStatuses(storage ?? null, { + now: nowArg, + transient: this.transient, + }) + if (this.counters.size === 0) return baseStatuses + return baseStatuses.map((status) => { + const overlay = this.counters.get(status.id) + if (!overlay) return status + return { + ...status, + usage: overlay, + estimatedCostUsd: estimatePrimeCostUsd(overlay), + } + }) + } + + /** True if the most recent fresh-check observed the window as already running. */ + isWindowActive(accountId: 'main' | string): boolean { + return this.windowActive.has(accountId) + } + + private async runForAccount( + evaluation: AccountEvaluation, + storage: AccountStorage, + now: number, + ): Promise { + try { + if (this.stopped) return + + const eligibility = primeIsEligible({ + storage, + accountId: evaluation.id, + isOAuth: evaluation.isOAuth, + isEnabled: evaluation.isEnabled, + hasPermanentRefreshError: evaluation.hasPermanentRefreshError, + quota: evaluation.storedQuota, + }) + if (!eligibility.eligible) { + // Per spec Logging table: every stored-state ineligible case (disabled / + // needs-reauth / API-key) emits a debug log carrying + // the account's display label and the specific reason. `trace` + // would hide this from operators troubleshooting "why isn't prime + // firing for my X account". + logger.debug('prime', 'ineligible', { + account: evaluation.label, + reason: primeEligibilityReasonLabel(eligibility.reason), + }) + return + } + + const storedResetEpoch = storedResetMs(evaluation.storedFiveHour) + if ( + storedResetEpoch !== undefined && + now < storedResetEpoch + PRIME_DUE_OFFSET_MS + ) { + // "Not due" is a routine tick outcome, not an operator-visible + // diagnostic. Per the spec's `trace` row for tick evaluation. + logger.trace('prime', 'not due', { account: evaluation.label }) + return + } + + const accountFingerprint = await this.options.getAccountFingerprint( + evaluation.id, + ) + if (!accountFingerprint) { + logger.debug('prime', 'credential identity unavailable', { + account: evaluation.label, + }) + return + } + const claimIdentity = `${evaluation.id}:${accountFingerprint + .toLowerCase() + .slice(0, 12)}` + const markerDir = this.markerDir(accountFingerprint) + const bootstrapMarkerPath = primeBootstrapMarkerPath( + markerDir, + evaluation.id, + ) + + const expectedMarkerPath = + storedResetEpoch === undefined + ? bootstrapMarkerPath + : primeMarkerPath(markerDir, evaluation.id, storedResetEpoch) + try { + await stat(expectedMarkerPath) + // A completed claim makes another forced poll redundant; the normal + // background refresh will persist the reset when the usage API catches up. + return + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if ( + storedResetEpoch === undefined && + (await this.hasAnyClaimMarker(evaluation.id, accountFingerprint)) + ) { + // The bootstrap sentinel is replaced only after an epoch claim exists. + // A stale process must observe one side of that transition. + return + } + if (this.stopped) return + + // Binding the throttle to the observed epoch preserves the immediate + // check when a newly passed reset replaces the previous window. + const lastForcedCheck = this.lastForcedCheck.get(claimIdentity) + if ( + lastForcedCheck !== undefined && + lastForcedCheck.resetEpoch === storedResetEpoch && + now < lastForcedCheck.at + PRIME_CHECK_THROTTLE_MS + ) { + return + } + this.lastForcedCheck.set(claimIdentity, { + at: now, + resetEpoch: storedResetEpoch, + }) + + let refreshed: PrimeRefreshResult + try { + refreshed = await this.options.refreshQuota(evaluation.id) + } catch (error) { + // Quota fresh-check failure: skip this tick without consuming a claim. + logger.warn('prime', 'refresh failed', { + account: evaluation.label, + error: error instanceof Error ? error.message : String(error), + }) + return + } + // Re-check stop + persist opt-in after every await boundary so + // an `/claude-prime off` (or a newer plugin instance's stop()) + // aborts the cycle BEFORE claim or send (M3b). + if (this.stopped) return + const reloaded = await this.options.loadStorage() + if (reloaded?.prime?.enabled !== true) return + + const fresh = refreshed.quota + // Stale fresh-check: the adapter returned a cached snapshot (quota + // API 429-backoff, another process owns the file lock, or the + // account was just refreshed by someone else). Skip without + // claiming — a cached past `resetsAt` would otherwise let us + // prime against an already-started window. Retry next tick. + if (!refreshed.fresh) { + logger.debug('prime', 'stale fresh-check', { + account: evaluation.label, + }) + return + } + + if ( + !killswitchPassesPolicy( + fresh, + reloaded, + evaluation.id, + CLAUDE_HAIKU_4_5_MODEL_ID, + ) + ) { + logger.debug('prime', 'ineligible', { + account: evaluation.label, + reason: primeEligibilityReasonLabel('killswitch'), + }) + return + } + + const freshResetEpoch = storedResetMs(fresh?.five_hour) + if (freshResetEpoch !== undefined && freshResetEpoch > now) { + // Window already started by a real request. Track the active- + // window observation separately (M4) so the status renderer can + // show "— window active" without conflating it with a successful + // prime. Do NOT touch lastPrimedAt / lastResult. + logger.debug('prime', 'window active', { + account: evaluation.label, + resetsAt: fresh.five_hour?.resetsAt, + }) + this.windowActive.set(evaluation.id, now) + return + } + this.windowActive.delete(evaluation.id) + + const markerEpoch = freshResetEpoch ?? storedResetEpoch + const markerPath = + markerEpoch === undefined + ? await this.tryClaimBootstrap(evaluation.id, accountFingerprint) + : await this.tryClaim(evaluation.id, accountFingerprint, markerEpoch) + if (!markerPath) { + logger.debug('prime', 'claim lost', { + account: evaluation.label, + marker: `${evaluation.id}-${markerEpoch ?? 'bootstrap'}`, + }) + return + } + + const postClaimStorage = await this.options.loadStorage() + if (this.stopped || postClaimStorage?.prime?.enabled !== true) { + await rm(markerPath, { force: true }) + return + } + + if (markerEpoch !== undefined) { + // Create the epoch claim before releasing the bootstrap sentinel so + // concurrent processes never observe an unclaimed transition gap. + await rm(bootstrapMarkerPath, { force: true }) + if (this.stopped) { + await rm(markerPath, { force: true }) + return + } + } + + await this.fire(evaluation, now, postClaimStorage) + } catch (error) { + logger.warn('prime', 'account evaluation failed', { + account: evaluation.label, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + private async tryClaim( + accountId: 'main' | string, + accountFingerprint: string, + resetsAtEpochMs: number, + ): Promise { + const dir = this.markerDir(accountFingerprint) + const markerPath = primeMarkerPath(dir, accountId, resetsAtEpochMs) + return this.tryClaimPath(dir, markerPath) + } + + private async tryClaimBootstrap( + accountId: 'main' | string, + accountFingerprint: string, + ): Promise { + const dir = this.markerDir(accountFingerprint) + const markerPath = primeBootstrapMarkerPath(dir, accountId) + return this.tryClaimPath(dir, markerPath) + } + + private async tryClaimPath( + dir: string, + markerPath: string, + ): Promise { + try { + await mkdir(dir, { recursive: true }) + } catch { + // Directory-create failures are propagated by the writeFile attempt below. + } + try { + await writeFile(markerPath, '', { encoding: 'utf8', flag: 'wx' }) + return markerPath + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') return null + throw error + } + } + + private async hasAnyClaimMarker( + accountId: 'main' | string, + accountFingerprint: string, + ): Promise { + const dir = this.markerDir(accountFingerprint) + const prefix = `${encodeURIComponent(accountId)}-` + try { + return (await readdir(dir)).some((entry) => entry.startsWith(prefix)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } + } + + private async fire( + evaluation: AccountEvaluation, + now: number, + _storage: AccountStorage, + ): Promise { + let result: PrimeSendResult + try { + result = await this.options.sendPrime(evaluation.id) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.warn('prime', 'prime fire threw', { + account: evaluation.label, + error: message, + }) + this.transient.set(evaluation.id, { + lastPrimedAt: now, + lastResult: 'error', + }) + return + } + if (this.stopped) return + + if (!result.ok) { + // Spec Logging table: two distinct warn events. + // - `prime token refresh failed` — token-refresh failure + // before the request fires (reason: 'token-refresh'). + // - `prime fire failed` — HTTP error / fetch throw / + // identity failure during the request (reason: 'send'). + if (result.reason === 'token-refresh') { + logger.warn('prime', 'prime token refresh failed', { + account: evaluation.label, + error: result.error, + }) + } else { + logger.warn('prime', 'prime fire failed', { + account: evaluation.label, + status: result.status, + error: result.error, + }) + } + this.transient.set(evaluation.id, { + lastPrimedAt: now, + lastResult: 'error', + }) + return + } + + const usage = { + inputTokens: result.usage?.inputTokens, + outputTokens: result.usage?.outputTokens, + } + try { + const counters = await this.options.recordSuccess(evaluation.id, usage) + if (this.stopped) { + // If stopped mid-fire, roll back the transient so a later + // `stats()` does not report a phantom success. + this.transient.delete(evaluation.id) + this.counters.delete(evaluation.id) + return + } + this.counters.set(evaluation.id, counters) + this.transient.set(evaluation.id, { + lastPrimedAt: now, + lastResult: 'ok', + }) + logger.info('prime', 'prime fired', { + account: evaluation.label, + status: result.status, + ms: result.ms, + usage: { + inputTokens: counters.inputTokens, + outputTokens: counters.outputTokens, + }, + }) + this.schedulePostFireRefresh(evaluation) + } catch (error) { + // Persisting counters failed; surface the error but keep the marker so + // the cycle counts as attempted. Operator can retry next reset epoch. + logger.warn('prime', 'recordSuccess failed', { + account: evaluation.label, + error: error instanceof Error ? error.message : String(error), + }) + this.transient.set(evaluation.id, { + lastPrimedAt: now, + lastResult: 'error', + }) + } + } + + private schedulePostFireRefresh(evaluation: AccountEvaluation): void { + // Unified fire-response headers can arm the window instantly on newer + // trees. This branch predates header harvesting, so a delayed poll captures + // the reset after the usage API's observed propagation lag. + const timer = setTimeout(() => { + this.postFireRefreshTimers.delete(timer) + if (this.stopped) return + void (async () => { + const storage = await this.options.loadStorage() + if (this.stopped || storage?.prime?.enabled !== true) return + await this.options.refreshQuota(evaluation.id) + })().catch((error) => { + logger.debug('prime', 'post-fire refresh failed', { + account: evaluation.label, + error: error instanceof Error ? error.message : String(error), + }) + }) + }, PRIME_POST_FIRE_REFRESH_MS) + this.postFireRefreshTimers.add(timer) + if (typeof timer === 'object' && 'unref' in timer) { + ;(timer as { unref?: () => void }).unref?.() + } + } + + private async sweepMarkers(now: number): Promise { + const dir = this.storageMarkerDir() + const swept = await this.sweepMarkerDirectory(dir, now) + if (swept > 0) { + logger.trace('prime', 'swept stale markers', { swept }) + } + } + + private async sweepMarkerDirectory( + dir: string, + now: number, + ): Promise { + let entries: string[] + try { + entries = await readdir(dir) + } catch { + return 0 + } + let swept = 0 + for (const entry of entries) { + try { + const path = join(dir, entry) + const s = await stat(path) + if (s.isDirectory()) { + swept += await this.sweepMarkerDirectory(path, now) + continue + } + if (now - s.mtimeMs > PRIME_MARKER_MAX_AGE_MS) { + await rm(path, { force: true }) + swept += 1 + } + } catch { + // Markers can vanish between readdir and stat; skip silently. + } + } + return swept + } + + private storageMarkerDir(): string { + return primeMarkerNamespaceDir( + this.options.markerDir ?? defaultMarkerDir(), + this.options.storagePath, + ) + } + + private markerDir(accountFingerprint: string): string { + return primeAccountMarkerDir( + this.options.markerDir ?? defaultMarkerDir(), + this.options.storagePath, + accountFingerprint, + ) + } +} diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts index 1249f804..58e08676 100644 --- a/packages/core/src/quota-manager.ts +++ b/packages/core/src/quota-manager.ts @@ -43,6 +43,12 @@ export type QuotaEntry = { checkedAt: number // when snapshot was fetched } +export type QuotaRefreshResult = { + quota: OAuthQuotaSnapshot + /** False when backoff or a cross-process lock served the existing cache. */ + fetched: boolean +} + export type QuotaManagerOptions = { storage: AccountStorage | null fetchImpl?: typeof fetch @@ -102,9 +108,9 @@ export class QuotaManager { private fallbackTokenFps = new Map() // --- Inflight deduplication --- - private inflightMain: Promise | null = null + private inflightMain: Promise | null = null private inflightMainFp: string | null = null - private inflightFallbacks = new Map>() + private inflightFallbacks = new Map>() // --- Rate-limiting (scoped per route so a fallback 429 never backs off the // main account or vice versa) --- @@ -249,6 +255,12 @@ export class QuotaManager { // ========================================================================= async refreshMain(accessToken: string): Promise { + return (await this.refreshMainWithMetadata(accessToken)).quota + } + + async refreshMainWithMetadata( + accessToken: string, + ): Promise { // If the main account/token changed, invalidate the cache (including a // persisted seed) BEFORE the backoff short-circuit so a different account's // stale quota is never returned while the quota API is backed off. @@ -264,7 +276,7 @@ export class QuotaManager { // Rate-limit — if API recently 429'd, return stale or throw if (this.isBackedOff()) { - if (this.main) return this.main.quota + if (this.main) return { quota: this.main.quota, fetched: false } throw new Error('Quota API rate-limited — try again later') } @@ -277,6 +289,14 @@ export class QuotaManager { accountId: string, accessToken: string, ): Promise { + return (await this.refreshFallbackWithMetadata(accountId, accessToken)) + .quota + } + + async refreshFallbackWithMetadata( + accountId: string, + accessToken: string, + ): Promise { // Deduplicate per account+token so a same-label re-login never joins a // quota probe that was started with the previous credentials. const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken) @@ -286,7 +306,7 @@ export class QuotaManager { // Rate-limit — scoped to THIS fallback account only if (this.isFallbackBackedOff(accountId, accessToken)) { const cached = this.getFallback(accountId, accessToken) - if (cached) return cached.quota + if (cached) return { quota: cached.quota, fetched: false } throw new Error('Quota API rate-limited — try again later') } @@ -531,14 +551,14 @@ export class QuotaManager { return queued } - private async _fetchMain(accessToken: string): Promise { + private async _fetchMain(accessToken: string): Promise { const thisFetchFp = tokenFingerprint(accessToken) return this._enqueueApiFetch(async () => { try { // Re-check backoff inside gate — may have been set by // a preceding queued call while we waited if (this.isBackedOff()) { - if (this.main) return this.main.quota + if (this.main) return { quota: this.main.quota, fetched: false } throw new Error('Quota API rate-limited — try again later') } const fileLock = await acquireRefreshFileLock({ @@ -547,7 +567,9 @@ export class QuotaManager { }) if (!fileLock) { const cached = this.main - if (cached && this.now() < cached.refreshAfter) return cached.quota + if (cached && this.now() < cached.refreshAfter) { + return { quota: cached.quota, fetched: false } + } throw new Error('Quota refresh is already in progress') } try { @@ -579,7 +601,7 @@ export class QuotaManager { this.mainTokenFp, fetchStartedAt, ) - return completedQuota + return { quota: completedQuota, fetched: true } } catch (error) { this._handleMainFetchError(error) throw error @@ -598,13 +620,13 @@ export class QuotaManager { private async _fetchFallback( accountId: string, accessToken: string, - ): Promise { + ): Promise { return this._enqueueApiFetch(async () => { try { // Re-check backoff inside gate — scoped to this fallback account if (this.isFallbackBackedOff(accountId, accessToken)) { const cached = this.getFallback(accountId, accessToken) - if (cached) return cached.quota + if (cached) return { quota: cached.quota, fetched: false } throw new Error('Quota API rate-limited — try again later') } const fileLock = await acquireRefreshFileLock({ @@ -613,7 +635,9 @@ export class QuotaManager { }) if (!fileLock) { const cached = this.getFallback(accountId, accessToken) - if (cached && this.now() < cached.refreshAfter) return cached.quota + if (cached && this.now() < cached.refreshAfter) { + return { quota: cached.quota, fetched: false } + } throw new Error('Quota refresh is already in progress') } try { @@ -642,7 +666,7 @@ export class QuotaManager { ) this.fallbackApiErrors.delete(accountId) this.fallbackErrorTokenFps.delete(accountId) - return completedQuota + return { quota: completedQuota, fetched: true } } finally { await fileLock.release() } diff --git a/packages/core/src/tests/killswitch.test.ts b/packages/core/src/tests/killswitch.test.ts index b522d113..19c51dd1 100644 --- a/packages/core/src/tests/killswitch.test.ts +++ b/packages/core/src/tests/killswitch.test.ts @@ -341,6 +341,31 @@ describe('killswitchPassesPolicy — scoped model dimension', () => { ).toBe(true) }) + test('matches a Haiku scoped window by display name when modelId is absent', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10, scoped: 0 }, + } + const quota: OAuthQuotaSnapshot = { + ...healthy5h7d(), + scoped: [ + { + usedPercent: 100, + remainingPercent: 0, + checkedAt: Date.now(), + id: 'claude-weekly-scoped-haiku', + title: 'Claude Haiku 4.5', + modelName: 'Claude Haiku 4.5', + }, + ], + } + + expect( + killswitchPassesPolicy(quota, storage, undefined, 'claude-haiku-4-5'), + ).toBe(false) + }) + test('killswitch disabled → scoped check never runs', () => { const storage = baseStorage() // killswitch NOT enabled; even with a present scoped window and a model diff --git a/packages/core/src/tests/prime-manager.test.ts b/packages/core/src/tests/prime-manager.test.ts new file mode 100644 index 00000000..25f23125 --- /dev/null +++ b/packages/core/src/tests/prime-manager.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + mkdtemp, + readdir, + rm, + symlink, + utimes, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import type { AccountStorage, OAuthQuotaSnapshot } from '../accounts.ts' +import { + buildPrimeRequestBody, + PRIME_DUE_OFFSET_MS, + PRIME_MARKER_MAX_AGE_MS, + PrimeManager, + primeMarkerNamespaceDir, + primeStorageFingerprint, +} from '../prime.ts' + +const tempDirs: string[] = [] +const managers: PrimeManager[] = [] + +afterEach(async () => { + for (const manager of managers.splice(0)) manager.stop() + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ) +}) + +function storage(resetsAt?: number): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + fallbackOn: [401, 403, 429], + accounts: [], + prime: { enabled: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + ...(resetsAt === undefined + ? {} + : { + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(resetsAt).toISOString(), + checkedAt: 1, + }, + }, + }), + }, + } +} + +async function tempMarkerRoot(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'prime-manager-test-')) + tempDirs.push(dir) + return dir +} + +function manager(input: { + storagePath: string + markerDir: string + now: () => number + loadStorage: () => Promise + refreshQuota: () => Promise + sendPrime: () => Promise + accountFingerprint?: () => Promise +}): PrimeManager { + const instance = new PrimeManager({ + storagePath: input.storagePath, + markerDir: input.markerDir, + now: input.now, + loadStorage: input.loadStorage, + getAccountFingerprint: + input.accountFingerprint ?? (async () => '0123456789abcdef'), + refreshQuota: async () => ({ + quota: await input.refreshQuota(), + fresh: true, + }), + sendPrime: async () => { + await input.sendPrime() + return { ok: true, status: 200, ms: 1 } + }, + recordSuccess: async () => ({ + count: 1, + inputTokens: 0, + outputTokens: 0, + since: input.now(), + }), + } as ConstructorParameters[0]) + managers.push(instance) + return instance +} + +describe('PrimeManager storage namespaces', () => { + test('fingerprints resolved paths without exposing the path', () => { + const relative = join('.', 'config', 'accounts.json') + const fingerprint = primeStorageFingerprint(relative) + + expect(fingerprint).toBe(primeStorageFingerprint(resolve(relative))) + expect(fingerprint).toMatch(/^[a-f0-9]{12}$/) + expect(fingerprint).not.toContain('config') + }) + + test('symlink and real storage paths share a fingerprint', async () => { + const directory = await tempMarkerRoot() + const realPath = join(directory, 'accounts.json') + const symlinkPath = join(directory, 'linked-accounts.json') + await writeFile(realPath, '{}', 'utf8') + await symlink(realPath, symlinkPath) + + expect(primeStorageFingerprint(symlinkPath)).toBe( + primeStorageFingerprint(realPath), + ) + }) + + test('different storage paths prime the same account and reset independently', async () => { + const markerDir = await tempMarkerRoot() + const reset = Date.now() - PRIME_DUE_OFFSET_MS + let fires = 0 + const make = (storagePath: string) => + manager({ + storagePath, + markerDir, + now: () => reset + PRIME_DUE_OFFSET_MS, + loadStorage: async () => storage(reset), + refreshQuota: async () => storage(reset).quota!.mainQuota!, + sendPrime: async () => { + fires += 1 + }, + }) + + await make('/config/a/accounts.json').tick() + await make('/config/b/accounts.json').tick() + + expect(fires).toBe(2) + }) + + test('the same storage path still deduplicates simulated processes', async () => { + const markerDir = await tempMarkerRoot() + const reset = Date.now() - PRIME_DUE_OFFSET_MS + let fires = 0 + const make = () => + manager({ + storagePath: '/config/shared/accounts.json', + markerDir, + now: () => reset + PRIME_DUE_OFFSET_MS, + loadStorage: async () => storage(reset), + refreshQuota: async () => storage(reset).quota!.mainQuota!, + sendPrime: async () => { + fires += 1 + }, + }) + + await Promise.all([make().tick(), make().tick()]) + + expect(fires).toBe(1) + }) + + test('a main OAuth credential switch primes independently and sweeps the old identity', async () => { + const markerDir = await tempMarkerRoot() + const storagePath = join(markerDir, 'accounts.json') + const reset = Date.now() - PRIME_DUE_OFFSET_MS + let accountFingerprint = 'aaaaaaaaaaaaaaaa' + let fires = 0 + const mgr = manager({ + storagePath, + markerDir, + now: () => reset + PRIME_DUE_OFFSET_MS, + loadStorage: async () => storage(reset), + refreshQuota: async () => storage(reset).quota!.mainQuota!, + accountFingerprint: async () => accountFingerprint, + sendPrime: async () => { + fires += 1 + }, + }) + + await mgr.tick() + accountFingerprint = 'bbbbbbbbbbbbbbbb' + await mgr.tick() + + expect(fires).toBe(2) + const namespaceDir = primeMarkerNamespaceDir(markerDir, storagePath) + const oldIdentityDir = join(namespaceDir, 'aaaaaaaaaaaa') + const newIdentityDir = join(namespaceDir, 'bbbbbbbbbbbb') + expect(await readdir(oldIdentityDir)).toEqual([`main-${reset}`]) + expect(await readdir(newIdentityDir)).toEqual([`main-${reset}`]) + + const oldMarker = join(oldIdentityDir, `main-${reset}`) + const staleAt = new Date( + reset + PRIME_DUE_OFFSET_MS - PRIME_MARKER_MAX_AGE_MS - 1, + ) + await utimes(oldMarker, staleAt, staleAt) + await mgr.tick() + + expect(await readdir(oldIdentityDir)).toEqual([]) + expect(await readdir(newIdentityDir)).toEqual([`main-${reset}`]) + }) + + test('bootstrap sentinels are independent after a main OAuth credential switch', async () => { + const markerDir = await tempMarkerRoot() + const storagePath = join(markerDir, 'accounts.json') + let accountFingerprint = 'aaaaaaaaaaaaaaaa' + let fires = 0 + const mgr = manager({ + storagePath, + markerDir, + now: () => Date.now(), + loadStorage: async () => storage(), + refreshQuota: async () => ({}), + accountFingerprint: async () => accountFingerprint, + sendPrime: async () => { + fires += 1 + }, + }) + + await mgr.tick() + accountFingerprint = 'bbbbbbbbbbbbbbbb' + await mgr.tick() + + expect(fires).toBe(2) + const namespaceDir = primeMarkerNamespaceDir(markerDir, storagePath) + expect(await readdir(join(namespaceDir, 'aaaaaaaaaaaa'))).toEqual([ + 'main-bootstrap', + ]) + expect(await readdir(join(namespaceDir, 'bbbbbbbbbbbb'))).toEqual([ + 'main-bootstrap', + ]) + }) + + test('bootstrap sentinels do not cross storage namespaces', async () => { + const markerDir = await tempMarkerRoot() + let fires = 0 + const make = (storagePath: string) => + manager({ + storagePath, + markerDir, + now: () => Date.now(), + loadStorage: async () => storage(), + refreshQuota: async () => ({}), + sendPrime: async () => { + fires += 1 + }, + }) + + await make('/config/a/accounts.json').tick() + await make('/config/b/accounts.json').tick() + + expect(fires).toBe(2) + }) +}) + +test('PrimeManager walks two complete reset cycles without firing between windows', async () => { + const markerDir = await tempMarkerRoot() + const firstReset = Date.UTC(2026, 0, 1, 5) + const secondReset = Date.UTC(2026, 0, 1, 10) + let now = firstReset - 1 + let currentStorage = storage(firstReset) + let refreshedReset = firstReset + const requestBodies: unknown[] = [] + const mgr = manager({ + storagePath: '/config/cycle/accounts.json', + markerDir, + now: () => now, + loadStorage: async () => currentStorage, + refreshQuota: async () => storage(refreshedReset).quota!.mainQuota!, + sendPrime: async () => { + requestBodies.push(buildPrimeRequestBody()) + }, + }) + + await mgr.tick() + expect(requestBodies).toHaveLength(0) + + now = firstReset + PRIME_DUE_OFFSET_MS + await mgr.tick() + expect(requestBodies).toEqual([ + { + model: 'claude-haiku-4-5', + max_tokens: 1, + system: 'Reply with 1 when you receive 0.', + messages: [{ role: 'user', content: '0' }], + }, + ]) + + currentStorage = storage(secondReset) + refreshedReset = secondReset + now = secondReset - 1 + await mgr.tick() + expect(requestBodies).toHaveLength(1) + + now = secondReset + PRIME_DUE_OFFSET_MS + await mgr.tick() + expect(requestBodies).toHaveLength(2) +}) diff --git a/packages/core/src/tests/prime-usage-race.test.ts b/packages/core/src/tests/prime-usage-race.test.ts new file mode 100644 index 00000000..e90674ee --- /dev/null +++ b/packages/core/src/tests/prime-usage-race.test.ts @@ -0,0 +1,87 @@ +import { afterEach, expect, test } from 'bun:test' +import { exists, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { createEmptyStorage, loadAccounts, saveAccounts } from '../accounts.ts' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ) +}) + +async function waitForFiles(paths: string[]): Promise { + const deadline = Date.now() + 10_000 + while ( + !(await Promise.all(paths.map((path) => exists(path)))).every(Boolean) + ) { + if (Date.now() >= deadline) + throw new Error('child increment barrier timed out') + await Bun.sleep(5) + } +} + +test('concurrent process increments preserve every prime usage delta', async () => { + const directory = await mkdtemp(join(tmpdir(), 'prime-usage-race-')) + tempDirs.push(directory) + const storagePath = join(directory, 'anthropic-auth.json') + await saveAccounts(createEmptyStorage(), storagePath) + + const accountsModule = pathToFileURL( + join(dirname(fileURLToPath(import.meta.url)), '..', 'accounts.ts'), + ).href + const workerPath = join(directory, 'increment-worker.ts') + await writeFile( + workerPath, + ` +import { exists, writeFile } from 'node:fs/promises' +import { incrementPrimeUsagePersistent } from ${JSON.stringify(accountsModule)} + +const [storagePath, barrierDir, workerId] = process.argv.slice(2) +for (let iteration = 0; iteration < 20; iteration += 1) { + await writeFile(barrierDir + '/ready-' + workerId + '-' + iteration, '') + const go = barrierDir + '/go-' + iteration + while (!(await exists(go))) await Bun.sleep(1) + await incrementPrimeUsagePersistent('main', { inputTokens: 20, outputTokens: 1 }, storagePath) +} +`, + 'utf8', + ) + + const children = ['a', 'b'].map((workerId) => + Bun.spawn( + [process.execPath, workerPath, storagePath, directory, workerId], + { + cwd: directory, + stdout: 'pipe', + stderr: 'pipe', + }, + ), + ) + for (let iteration = 0; iteration < 20; iteration += 1) { + await waitForFiles( + children.map((_, index) => + join(directory, `ready-${index === 0 ? 'a' : 'b'}-${iteration}`), + ), + ) + await writeFile(join(directory, `go-${iteration}`), '', 'utf8') + } + const exitCodes = await Promise.all(children.map((child) => child.exited)) + if (exitCodes.some((code) => code !== 0)) { + const errors = await Promise.all( + children.map((child) => new Response(child.stderr).text()), + ) + throw new Error(`increment workers failed: ${errors.join('\n')}`) + } + + const persisted = await loadAccounts(storagePath) + expect(persisted?.prime?.main).toMatchObject({ + count: 40, + inputTokens: 800, + outputTokens: 40, + }) +}, 20_000) diff --git a/packages/core/src/tests/prime.test.ts b/packages/core/src/tests/prime.test.ts new file mode 100644 index 00000000..d3ce0d55 --- /dev/null +++ b/packages/core/src/tests/prime.test.ts @@ -0,0 +1,2346 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + mkdir, + mkdtemp, + readdir, + rm, + utimes, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { + __setLogTestSink, + type AccountStorage, + type LogTestRecord, + type OAuthAccount, + type OAuthQuotaSnapshot, + setLogLevel, +} from '@cortexkit/anthropic-auth-core' +// Source-side logger import — installed as a second sink so PrimeManager logs +// (which are emitted through the in-package logger instance loaded via the +// relative `./logger.ts` path) reach the test capture array. The dist logger +// instance is the package-alias target used by other tests; both must be +// wired to keep sink assertions consistent across runs. +import { + __setLogTestSink as __setLogTestSinkSource, + getLogLevel, + setLogLevel as setLogLevelSource, +} from '../logger.ts' +import { CLAUDE_HAIKU_4_5_MODEL_ID } from '../models.ts' +import type { PrimeRefreshResult } from '../prime.ts' +import { + buildPrimeAccountStatuses, + buildPrimeStatusSummary, + CLAUDE_HAIKU_4_5_PRICING, + estimatePrimeCostUsd, + executePrimeCommand, + PRIME_CHECK_THROTTLE_MS, + PRIME_DUE_OFFSET_MS, + PRIME_POST_FIRE_REFRESH_MS, + type PrimeAccountStatus, + PrimeManager, + type PrimeSendResult, + type PrimeUsageCounters, + parsePrimeCommandAction, + primeAccountMarkerDir, + primeIsEligible, + primeMarkerPath, +} from '../prime.ts' + +const STORAGE_TS = 1_721_111_111_000 +const DUE_TS = STORAGE_TS - 60_000 +const ISO_DUE = new Date(DUE_TS).toISOString() + +function storage(): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [ + { + id: 'work-alt', + type: 'oauth', + refresh: 'r', + enabled: true, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: ISO_DUE, + checkedAt: STORAGE_TS, + }, + }, + prime: { + count: 1, + inputTokens: 20, + outputTokens: 1, + since: 1_721_111_112_000, + }, + }, + ], + } +} + +describe('parsePrimeCommandAction', () => { + test('empty → status', () => { + expect(parsePrimeCommandAction('')).toEqual({ type: 'status' }) + }) + test('on → enable', () => { + expect(parsePrimeCommandAction('on')).toEqual({ type: 'enable' }) + }) + test('off → disable', () => { + expect(parsePrimeCommandAction('off')).toEqual({ type: 'disable' }) + }) + test('anything else → usage', () => { + expect(parsePrimeCommandAction('hello')).toEqual({ type: 'usage' }) + expect(parsePrimeCommandAction('on extra')).toEqual({ type: 'usage' }) + }) +}) + +describe('estimatePrimeCostUsd', () => { + test('zero usage is zero', () => { + expect( + estimatePrimeCostUsd({ + count: 0, + inputTokens: 0, + outputTokens: 0, + since: 0, + }), + ).toBe(0) + }) + test('derives cost from recorded response usage', () => { + expect( + estimatePrimeCostUsd({ + count: 1, + inputTokens: 137, + outputTokens: 2, + since: 1, + }), + ).toBeCloseTo( + (137 * CLAUDE_HAIKU_4_5_PRICING.input + + 2 * CLAUDE_HAIKU_4_5_PRICING.output) / + 1_000_000, + 9, + ) + }) + test('undefined usage → 0', () => { + expect(estimatePrimeCostUsd(undefined)).toBe(0) + }) +}) + +describe('buildPrimeAccountStatuses', () => { + test('synthetic main + enabled fallback; nextDueAt derived from resetsAt', () => { + const statuses = buildPrimeAccountStatuses(storage(), { now: STORAGE_TS }) + expect(statuses).toHaveLength(2) + const main = statuses.find((s) => s.id === 'main') + expect(main?.label).toBe('main') + expect(main?.usage).toBeUndefined() + expect(main?.estimatedCostUsd).toBe(0) + const fallback = statuses.find((s) => s.id === 'work-alt') + expect(fallback?.label).toBe('work-alt') + expect(fallback?.nextDueAt).toBe(DUE_TS + 60_000) + expect(fallback?.usage?.count).toBe(1) + expect(fallback?.estimatedCostUsd).toBeCloseTo( + (20 * CLAUDE_HAIKU_4_5_PRICING.input + + 1 * CLAUDE_HAIKU_4_5_PRICING.output) / + 1_000_000, + 9, + ) + }) + + test('omits disabled and api-key fallbacks', () => { + const s = storage() + s.accounts.push({ id: 'apikey-1', type: 'api', baseURL: 'https://x.y' }) + s.accounts.push({ + id: 'disabled-fb', + type: 'oauth', + refresh: 'r', + enabled: false, + }) + const statuses = buildPrimeAccountStatuses(s, { now: STORAGE_TS }) + expect(statuses.map((x) => x.id)).toEqual(['main', 'work-alt']) + }) + + test('manager transient overlay overrides runtime state', () => { + const transient = new Map< + string, + { lastPrimedAt?: number; lastResult?: 'ok' | 'error' } + >() + transient.set('work-alt', { lastPrimedAt: 999, lastResult: 'error' }) + const statuses = buildPrimeAccountStatuses(storage(), { + now: STORAGE_TS, + transient, + }) + const fb = statuses.find((s) => s.id === 'work-alt') as PrimeAccountStatus + expect(fb.lastPrimedAt).toBe(999) + expect(fb.lastResult).toBe('error') + }) + + test('undefined nextDueAt when no resetsAt', () => { + const s = storage() + s.accounts = [] + const statuses = buildPrimeAccountStatuses(s, { now: STORAGE_TS }) + const main = statuses.find((s) => s.id === 'main') as PrimeAccountStatus + expect(main.nextDueAt).toBeUndefined() + }) +}) + +describe('buildPrimeStatusSummary', () => { + test('shows status, accounts, counts, cost', () => { + const accounts: PrimeAccountStatus[] = [ + { + id: 'main', + label: 'main', + nextDueAt: null, + usage: { + count: 12, + inputTokens: 240, + outputTokens: 12, + since: 1, + }, + estimatedCostUsd: + (240 * CLAUDE_HAIKU_4_5_PRICING.input + + 12 * CLAUDE_HAIKU_4_5_PRICING.output) / + 1_000_000, + }, + { + id: 'work-alt', + label: 'work-alt', + nextDueAt: DUE_TS + 60_000, + usage: { count: 0, inputTokens: 0, outputTokens: 0, since: 1 }, + estimatedCostUsd: 0, + }, + ] + const summary = buildPrimeStatusSummary({ enabled: true, accounts }) + expect(summary).toContain('## Claude Prime Status') + expect(summary).toContain('main · ') + expect(summary).toContain('work-alt · next prime') + expect(summary).toContain('12 primes') + expect(summary).toContain('\u2248 $') + }) +}) + +describe('executePrimeCommand', () => { + const accounts: PrimeAccountStatus[] = [ + { + id: 'main', + label: 'main', + nextDueAt: null, + usage: { count: 0, inputTokens: 0, outputTokens: 0, since: 1 }, + estimatedCostUsd: 0, + }, + ] + + test('status returns title without updated', () => { + const r = executePrimeCommand({ + argumentsText: '', + enabled: true, + accounts, + }) + expect(r.updated).toBeUndefined() + expect(r.text).toContain('## Claude Prime Status') + }) + + test('on returns updated { enabled: true }', () => { + const r = executePrimeCommand({ + argumentsText: 'on', + enabled: true, + accounts, + }) + expect(r.updated).toEqual({ enabled: true }) + }) + + test('off returns updated { enabled: false }', () => { + const r = executePrimeCommand({ + argumentsText: 'off', + enabled: true, + accounts, + }) + expect(r.updated).toEqual({ enabled: false }) + }) + + test('unknown args returns usage text', () => { + const r = executePrimeCommand({ + argumentsText: 'maybe', + enabled: false, + accounts, + }) + expect(r.updated).toBeUndefined() + expect(r.text).toContain('Usage') + }) + + test('does not persist or mutate anything', () => { + const r = executePrimeCommand({ + argumentsText: 'on', + enabled: false, + accounts, + }) + expect(typeof r.text).toBe('string') + expect(r.updated).toEqual({ enabled: true }) + }) +}) + +// -- PrimeManager ---------------------------------------------------------- + +interface SendCall { + accountId: 'main' | string + result: PrimeSendResult +} + +function makePrimeFixture(opts?: { + enabled?: boolean + mainQuota?: OAuthQuotaSnapshot + fallbackQuota?: OAuthQuotaSnapshot + fallbackEnabled?: boolean + fallbackType?: 'oauth' | 'api' + fallbackPermanent?: boolean + killswitch?: AccountStorage['killswitch'] +}) { + const accountId = 'work-alt' + const accounts: AccountStorage['accounts'] = [] + if (opts?.fallbackType !== undefined) { + if (opts.fallbackType === 'api') { + accounts.push({ + id: accountId, + type: 'api', + baseURL: 'https://example.com', + }) + } else { + accounts.push({ + id: accountId, + type: 'oauth', + refresh: 'r', + enabled: opts.fallbackEnabled !== false, + ...(opts.fallbackPermanent && { + lastRefreshError: { + message: 'invalid_grant', + checkedAt: 1, + status: 400, + permanent: true, + }, + }), + ...(opts.fallbackQuota && { quota: opts.fallbackQuota }), + } as OAuthAccount) + } + } + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + quota: opts?.mainQuota + ? { + enabled: true, + checkIntervalMinutes: 5, + mainQuota: opts.mainQuota, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp', + } + : undefined, + killswitch: opts?.killswitch, + prime: { enabled: true }, + accounts, + } + return { storage, accountId } +} + +interface Harness { + manager: PrimeManager + sendCalls: SendCall[] + refreshCalls: string[] + recordSuccessCalls: Array<{ + accountId: 'main' | string + usage: { inputTokens?: number; outputTokens?: number } + returnCounters: PrimeUsageCounters + }> + records: LogTestRecord[] + markerDir: string + cleanup: () => Promise +} + +async function makeHarness(opts: { + storage: AccountStorage + markerDir: string + now: number + quotaFresh: OAuthQuotaSnapshot + quotaFreshIsStale?: boolean + send?: (id: 'main' | string) => PrimeSendResult | Promise + recordSuccessReturn?: PrimeUsageCounters + refreshError?: Error + storagePath?: string +}): Promise { + const sendCalls: SendCall[] = [] + const refreshCalls: string[] = [] + const recordSuccessCalls: Harness['recordSuccessCalls'] = [] + + const storagePath = opts.storagePath ?? join(opts.markerDir, 'accounts.json') + const manager = new PrimeManager({ + storagePath, + loadStorage: async () => opts.storage, + getAccountFingerprint: async () => '0123456789abcdef', + refreshQuota: async (id) => { + refreshCalls.push(id) + if (opts.refreshError) throw opts.refreshError + const result: PrimeRefreshResult = { + quota: opts.quotaFresh, + fresh: opts.quotaFreshIsStale !== true, + } + return result + }, + sendPrime: async (id) => { + const result = opts.send + ? await opts.send(id) + : { + ok: true, + status: 200, + ms: 1, + usage: { inputTokens: 20, outputTokens: 1 }, + } + sendCalls.push({ accountId: id, result }) + return result + }, + recordSuccess: async (accountId, usage) => { + const counters = opts.recordSuccessReturn ?? { + count: 1, + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + since: opts.now, + } + recordSuccessCalls.push({ accountId, usage, returnCounters: counters }) + return counters + }, + now: () => opts.now, + markerDir: opts.markerDir, + }) + + return { + manager, + sendCalls, + refreshCalls, + recordSuccessCalls, + records: [], + markerDir: primeAccountMarkerDir( + opts.markerDir, + storagePath, + '0123456789abcdef', + ), + cleanup: async () => { + manager.stop() + }, + } +} + +let markerRoot: string +let capturedPrimeSink: LogTestRecord[] = [] + +beforeEach(async () => { + markerRoot = await mkdtemp(join(tmpdir(), 'prime-manager-test-')) + capturedPrimeSink = [] + const sink = (r: LogTestRecord) => { + capturedPrimeSink.push(r) + } + __setLogTestSink(sink) + __setLogTestSinkSource(sink) +}) + +afterEach(async () => { + __setLogTestSink(null) + __setLogTestSinkSource(null) + if (markerRoot) { + await rm(markerRoot, { recursive: true, force: true }).catch(() => {}) + } +}) + +describe('PrimeManager — due boundary', () => { + test('resetsAt + 59s is not yet due', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(1_000_000).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 1_000_000 + 59_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + ...(fixture.storage.quota?.mainQuota?.five_hour as { + usedPercent: number + remainingPercent: number + resetsAt: string + checkedAt: number + }), + }, + }, + }) + await h.manager.tick() + expect(h.refreshCalls).toEqual([]) + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) + + test('resetsAt + 61s is due and fires', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(1_000_000).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 1_000_000 + 61_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(1_000_000 - 1).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls.map((c) => c.accountId)).toEqual(['main']) + expect(h.recordSuccessCalls).toHaveLength(1) + await h.cleanup() + }) +}) + +describe('PrimeManager — bootstrap', () => { + test('missing stored reset with an inactive fresh window fires using the stable bootstrap marker', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: undefined, + checkedAt: 1, + }, + }, + }) + const now = 7_234_567 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: null as unknown as string, + checkedAt: 2, + }, + }, + }) + + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls.map((call) => call.accountId)).toEqual(['main']) + expect(await readdir(h.markerDir)).toEqual(['main-bootstrap']) + await h.cleanup() + }) + + test('missing stored reset with a future fresh reset records an active window without firing', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const now = 7_234_567 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now + 60_000).toISOString(), + checkedAt: 2, + }, + }, + }) + + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls).toEqual([]) + expect(h.manager.isWindowActive('main')).toBe(true) + expect(await readdir(markerRoot)).toEqual([]) + await h.cleanup() + }) + + test('killswitch-blocked bootstrap checks quota at most once per five minutes', async () => { + const fixture = makePrimeFixture({ + killswitch: { enabled: true, main: { five_hour: 5 } }, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const initialNow = 7_200_000 + let now = initialNow + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + resetsAt: null as unknown as string, + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + + await h.manager.tick() + now = initialNow + 60_000 + await h.manager.tick() + now = initialNow + 4 * 60_000 + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls).toEqual([]) + + now = initialNow + 5 * 60_000 + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main', 'main']) + await h.cleanup() + }) + + test('killswitch-blocked past reset checks quota at most once per five minutes', async () => { + const storedReset = 7_000_000 + const fixture = makePrimeFixture({ + killswitch: { enabled: true, main: { five_hour: 5 } }, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(storedReset).toISOString(), + checkedAt: 1, + }, + }, + }) + const initialNow = storedReset + 120_000 + let now = initialNow + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + resetsAt: new Date(storedReset).toISOString(), + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + + await h.manager.tick() + now = initialNow + 60_000 + await h.manager.tick() + now = initialNow + 4 * 60_000 + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls).toEqual([]) + + now = initialNow + 5 * 60_000 + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main', 'main']) + await h.cleanup() + }) + + test('a newly passed stored reset bypasses a recent forced-check throttle', async () => { + const initialReset = 9_800_000 + const initialNow = 10_000_000 + const fixture = makePrimeFixture({ + killswitch: { enabled: true, main: { five_hour: 5 } }, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(initialReset).toISOString(), + checkedAt: 1, + }, + }, + }) + let now = initialNow + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 100, + remainingPercent: 0, + resetsAt: new Date(initialReset).toISOString(), + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + + await h.manager.tick() + now += 60_000 + await h.manager.tick() + expect(h.refreshCalls).toEqual(['main']) + + const newlyPassedReset = initialNow + fixture.storage.quota!.mainQuota!.five_hour!.resetsAt = new Date( + newlyPassedReset, + ).toISOString() + now += 60_000 + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main', 'main']) + await h.cleanup() + }) + + test('an existing bootstrap marker suppresses a fresh-check in another manager', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const now = 7_234_567 + const fresh: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 2, + }, + } + const firing = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + await firing.manager.tick() + expect(firing.sendCalls).toHaveLength(1) + + const observing = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + await observing.manager.tick() + await observing.manager.tick() + + expect(observing.refreshCalls).toEqual([]) + expect(observing.sendCalls).toEqual([]) + await firing.cleanup() + await observing.cleanup() + }) + + test('observing a reset does not expose a claim gap to a stale manager', async () => { + const staleFixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const observedFixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const now = 7_234_567 + const fresh: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 2, + }, + } + const observing = await makeHarness({ + storage: observedFixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + + await observing.manager.tick() + expect(observing.sendCalls).toHaveLength(1) + observedFixture.storage.quota!.mainQuota!.five_hour!.resetsAt = new Date( + now + 60_000, + ).toISOString() + await observing.manager.tick() + + const stale = await makeHarness({ + storage: staleFixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + await stale.manager.tick() + + expect(stale.refreshCalls).toEqual([]) + expect(stale.sendCalls).toEqual([]) + expect(await readdir(observing.markerDir)).toEqual(['main-bootstrap']) + await observing.cleanup() + await stale.cleanup() + }) + + test('persistent reset unavailability produces one bootstrap fire across hours', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + let now = 14_400_000 - 30_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + + await h.manager.tick() + now += 5 * 60_000 + await h.manager.tick() + now += 60 * 60_000 + await h.manager.tick() + now += 60 * 60_000 + await h.manager.tick() + + expect(h.sendCalls).toHaveLength(1) + expect(h.refreshCalls).toEqual(['main']) + expect(await readdir(h.markerDir)).toEqual(['main-bootstrap']) + await h.cleanup() + }) + + test('a real reset replaces the bootstrap marker only after its epoch claim', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + let now = 10_000_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: null as unknown as string, + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + + await h.manager.tick() + expect(h.sendCalls).toHaveLength(1) + expect(await readdir(h.markerDir)).toEqual(['main-bootstrap']) + + const observedReset = 11_000_000 + fixture.storage.quota!.mainQuota!.five_hour!.resetsAt = new Date( + observedReset, + ).toISOString() + await h.manager.tick() + expect(await readdir(h.markerDir)).toEqual(['main-bootstrap']) + + now = observedReset + PRIME_DUE_OFFSET_MS + 1 + await h.manager.tick() + + expect(h.sendCalls).toHaveLength(2) + expect(await readdir(h.markerDir)).toEqual([`main-${observedReset}`]) + await h.cleanup() + }) +}) + +describe('PrimeManager — fresh-check', () => { + test('future resetsAt → skip + no claim', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + resetsAt: new Date(now + 5 * 60_000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect((await readdir(markerRoot)).length).toBe(0) + await h.cleanup() + }) + + test('absent five_hour fires (inactive-window shape 1)', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { scoped: [] }, + }) + await h.manager.tick() + expect(h.sendCalls).toHaveLength(1) + expect((await readdir(markerRoot)).length).toBe(1) + await h.cleanup() + }) + + test('past resetsAt fires (inactive-window shape 2)', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toHaveLength(1) + await h.cleanup() + }) +}) + +describe('PrimeManager — claim atomicity', () => { + test('sanitizes the account id into one marker path component', () => { + const markerPath = primeMarkerPath(markerRoot, '../escape', 123) + const resolvedRoot = `${resolve(markerRoot)}${sep}` + expect(resolve(markerPath).startsWith(resolvedRoot)).toBe(true) + expect(markerPath).toContain('..%2Fescape-123') + }) + + test('two managers, same marker dir + reset epoch → exactly one fires', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const fresh: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + } + const a = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + const b = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + await Promise.all([a.manager.tick(), b.manager.tick()]) + const total = a.sendCalls.length + b.sendCalls.length + expect(total).toBe(1) + expect((await readdir(markerRoot)).length).toBe(1) + await a.cleanup() + await b.cleanup() + }) + + test('two bootstrap managers fire exactly once via the stable marker', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }) + const now = 7_234_567 + const fresh: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 2, + }, + } + const a = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: fresh, + }) + const b = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: now + 1_000, + quotaFresh: fresh, + }) + + await Promise.all([a.manager.tick(), b.manager.tick()]) + + expect(a.sendCalls.length + b.sendCalls.length).toBe(1) + expect(await readdir(a.markerDir)).toEqual(['main-bootstrap']) + await a.cleanup() + await b.cleanup() + }) +}) + +describe('PrimeManager — eligibility', () => { + test('stored Haiku-scoped exhaustion remains eligible for an authoritative refresh', () => { + const storage: AccountStorage = { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + accounts: [], + killswitch: { + enabled: true, + main: { five_hour: 5, seven_day: 10, scoped: 0 }, + }, + } + + expect( + primeIsEligible({ + storage, + accountId: 'main', + isOAuth: true, + isEnabled: true, + hasPermanentRefreshError: false, + quota: { + five_hour: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: 1, + }, + seven_day: { + usedPercent: 30, + remainingPercent: 70, + checkedAt: 1, + }, + scoped: [ + { + id: 'haiku-weekly', + title: 'Haiku only', + modelId: CLAUDE_HAIKU_4_5_MODEL_ID, + modelName: 'Claude Haiku 4.5', + usedPercent: 100, + remainingPercent: 0, + checkedAt: 1, + }, + ], + }, + }), + ).toEqual({ eligible: true }) + }) + + test('disabled fallback is skipped', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(10_000_000).toISOString(), + checkedAt: 1, + }, + }, + fallbackType: 'oauth', + fallbackEnabled: false, + fallbackQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + expect(fixture.storage.accounts).toHaveLength(1) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) + + test('reserved fallback id main is skipped instead of routing as synthetic main', async () => { + const previousLevel = getLogLevel() + setLogLevel('debug') + setLogLevelSource('debug') + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(10_000_000).toISOString(), + checkedAt: 1, + }, + }, + }) + fixture.storage.accounts.push({ + id: 'main', + type: 'oauth', + refresh: 'r', + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 2, + }, + }, + }) + + await h.manager.tick() + + expect(h.refreshCalls).toEqual([]) + expect(h.sendCalls).toEqual([]) + expect( + capturedPrimeSink.some( + (record) => + record.channel === 'prime' && + record.message === 'ineligible' && + record.payload?.reason === 'reserved-id', + ), + ).toBe(true) + setLogLevel(previousLevel) + setLogLevelSource(previousLevel) + await h.cleanup() + }) + + test('api-key fallback is skipped', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(10_000_000).toISOString(), + checkedAt: 1, + }, + }, + fallbackType: 'api', + }) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: 1_000_000, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(0).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) + + test('permanent refresh error → skipped', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(10_000_000).toISOString(), + checkedAt: 1, + }, + }, + fallbackPermanent: true, + fallbackQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) + + test('killswitched account is skipped (no modelId)', async () => { + const fixture = makePrimeFixture({ + killswitch: { enabled: true, main: { five_hour: 5 } }, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) +}) + +describe('PrimeManager — main + fallbacks evaluated independently', () => { + test('due fallback fires even when main is not due', async () => { + const mainFresh = new Date(1_000_000).toISOString() + const fbFresh = new Date(500).toISOString() + const fixture = makePrimeFixture({ + fallbackType: 'oauth', + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: mainFresh, + checkedAt: 1, + }, + }, + fallbackQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: fbFresh, + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls.map((c) => c.accountId)).toEqual(['work-alt']) + await h.cleanup() + }) +}) + +describe('PrimeManager — catch-up', () => { + test('overdue reset fires on first explicit tick', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 10 * 60_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls.map((c) => c.accountId)).toEqual(['main']) + expect(h.recordSuccessCalls).toHaveLength(1) + await h.cleanup() + }) +}) + +describe('PrimeManager — refresh failure', () => { + test('refresh failure consumes no marker; retry resumes after the throttle window', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + let now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { five_hour: undefined }, + refreshError: new Error('boom'), + }) + h.manager.options.now = () => now + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect((await readdir(markerRoot)).length).toBe(0) + h.refreshCalls.length = 0 + const refreshed: OAuthQuotaSnapshot = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + } + h.manager.options.refreshQuota = async () => ({ + quota: refreshed, + fresh: true, + }) + now += PRIME_CHECK_THROTTLE_MS + await h.manager.tick() + expect(h.sendCalls).toHaveLength(1) + await h.cleanup() + }) +}) + +describe('PrimeManager — send failure', () => { + test('send failure: marker kept, lastResult=error, no retry this cycle', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + send: () => ({ ok: false, error: 'boom', status: 500 }), + }) + await h.manager.tick() + expect(h.sendCalls).toHaveLength(1) + expect(h.recordSuccessCalls).toHaveLength(0) + expect((await readdir(markerRoot)).length).toBe(1) + const stats = h.manager.stats() + expect(stats[0]?.lastResult).toBe('error') + h.sendCalls.length = 0 + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + await h.cleanup() + }) +}) + +describe('PrimeManager — recordSuccess', () => { + test('successful send calls recordSuccess once; stats reflect cumulative counters', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + recordSuccessReturn: { + count: 12, + inputTokens: 240, + outputTokens: 12, + since: 1, + }, + }) + await h.manager.tick() + expect(h.recordSuccessCalls).toHaveLength(1) + expect(h.recordSuccessCalls[0]?.accountId).toBe('main') + const stats = h.manager.stats() + expect(stats[0]?.usage?.count).toBe(12) + expect(stats[0]?.lastResult).toBe('ok') + expect(stats[0]?.estimatedCostUsd).toBeCloseTo( + (240 * CLAUDE_HAIKU_4_5_PRICING.input + + 12 * CLAUDE_HAIKU_4_5_PRICING.output) / + 1_000_000, + 9, + ) + await h.cleanup() + }) + + test('successful fire schedules one quota refresh after 90 seconds', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + let scheduled: (() => void) | undefined + let scheduledDelay: number | undefined + globalThis.setTimeout = ((handler: () => void, delay?: number) => { + scheduled = handler + scheduledDelay = delay + return 42 + }) as typeof setTimeout + globalThis.clearTimeout = (() => {}) as typeof clearTimeout + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1_000).toISOString(), + checkedAt: 2, + }, + }, + }) + + try { + await h.manager.tick() + expect(h.refreshCalls).toEqual(['main']) + expect(scheduledDelay).toBe(PRIME_POST_FIRE_REFRESH_MS) + + scheduled?.() + await Promise.resolve() + + expect(h.refreshCalls).toEqual(['main', 'main']) + await h.cleanup() + } finally { + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } + }) + + test('post-fire refresh skips when another process disables prime', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + let scheduled: (() => void) | undefined + globalThis.setTimeout = ((handler: () => void) => { + scheduled = handler + return 42 + }) as typeof setTimeout + globalThis.clearTimeout = (() => {}) as typeof clearTimeout + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1_000).toISOString(), + checkedAt: 2, + }, + }, + }) + + try { + await h.manager.tick() + expect(h.refreshCalls).toEqual(['main']) + fixture.storage.prime = { enabled: false } + + scheduled?.() + await Promise.resolve() + await Promise.resolve() + + expect(h.refreshCalls).toEqual(['main']) + await h.cleanup() + } finally { + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } + }) +}) + +describe('PrimeManager — marker sweep', () => { + test('markers older than six hours are removed; fresh markers survive', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(1_000_100_000).toISOString(), + checkedAt: 1, + }, + }, + }) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: 1_000_000_000, + quotaFresh: {}, + }) + await mkdir(h.markerDir, { recursive: true }) + const oldMarker = join(h.markerDir, 'main-100000') + await writeFile(oldMarker, '', 'utf8') + const oldBootstrapMarker = join(h.markerDir, 'main-bootstrap') + await writeFile(oldBootstrapMarker, '', 'utf8') + const oldTime = new Date(0) + await utimes(oldMarker, oldTime, oldTime) + await utimes(oldBootstrapMarker, oldTime, oldTime) + + const freshMarker = join(h.markerDir, 'main-200000') + await writeFile(freshMarker, '', 'utf8') + + await h.manager.tick() + const remaining = (await readdir(h.markerDir)).sort() + expect(remaining).toContain('main-200000') + expect(remaining).not.toContain('main-100000') + expect(remaining).not.toContain('main-bootstrap') + await h.cleanup() + }) +}) + +describe('PrimeManager — lifecycle', () => { + test('start is idempotent; stop is idempotent', async () => { + const fixture = makePrimeFixture({}) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: 1, + quotaFresh: {}, + }) + h.manager.start() + h.manager.start() + h.manager.start() + h.manager.stop() + h.manager.stop() + await h.cleanup() + }) + + test('a tick already in flight aborts before claim when stop() is called', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + // Block refreshQuota until we call stop() so the manager is mid-tick. + let releaseRefresh: (() => void) | undefined + const refreshBlocked = new Promise((resolve) => { + releaseRefresh = resolve + }) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + // Override the refresh adapter to block on the gate. + h.manager.options.refreshQuota = async () => { + await refreshBlocked + return { + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + fresh: true, + } + } + const tickPromise = h.manager.tick() + // Let the tick reach the refresh await. + await Bun.sleep(10) + h.manager.stop() + releaseRefresh!() + await tickPromise + // No claim should have been written because the post-refresh stop + // check aborted the cycle. + const remaining = await readdir(markerRoot) + expect(remaining).toEqual([]) + await h.cleanup() + }) + + test('stop cancels the pending post-fire quota refresh', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + let scheduled: (() => void) | undefined + const cleared: unknown[] = [] + globalThis.setTimeout = ((handler: () => void) => { + scheduled = handler + return 42 + }) as typeof setTimeout + globalThis.clearTimeout = ((timer: unknown) => { + cleared.push(timer) + }) as typeof clearTimeout + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1_000).toISOString(), + checkedAt: 2, + }, + }, + }) + + try { + await h.manager.tick() + h.manager.stop() + scheduled?.() + await Promise.resolve() + + expect(cleared).toEqual([42]) + expect(h.refreshCalls).toEqual(['main']) + } finally { + await h.cleanup() + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + } + }) +}) + +describe('PrimeManager — opt-in toggle', () => { + test('a tick after `prime.enabled=false` does not fire even if due', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + // Disable the feature in stored state. + fixture.storage.prime = { enabled: false } + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect((await readdir(markerRoot)).length).toBe(0) + await h.cleanup() + }) + + test('a tick that re-reads storage after refresh sees a fresh opt-in toggle', async () => { + // Fixture is initially enabled, but the loadStorage adapter flips to + // disabled after the refresh await — simulating an `/claude-prime off` + // racing with an in-flight tick. The post-refresh opt-in check must + // abort the cycle before claim. + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + let storageDisabled = false + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + }) + h.manager.options.loadStorage = async () => { + if (storageDisabled) { + return { ...fixture.storage, prime: { enabled: false } } + } + return fixture.storage + } + h.manager.options.refreshQuota = async () => { + storageDisabled = true + return { + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + fresh: true, + } + } + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect((await readdir(markerRoot)).length).toBe(0) + await h.cleanup() + }) +}) + +describe('PrimeManager — fresh-check staleness', () => { + test('a stale fresh-check (quota API in 429-backoff) skips without claiming', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + // Stored quota has a past resetsAt (so due). The fresh adapter + // returns a CACHED past resetsAt — i.e. the quota API was in 429 + // backoff and the adapter just returned the cached value with + // `fresh: false`. Without the staleness guard a stale past resetsAt + // would let the manager claim+fire against an already-started + // window. + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + quotaFreshIsStale: true, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect((await readdir(markerRoot)).length).toBe(0) + // `prime fired` must NOT appear — the skip is a fresh-check skip, not + // a successful prime. + const fired = capturedPrimeSink.filter( + (r) => r.channel === 'prime' && r.message === 'prime fired', + ) + expect(fired).toHaveLength(0) + await h.cleanup() + }) +}) + +describe('PrimeManager — fresh killswitch policy', () => { + const scopedQuota = (remainingPercent: number): OAuthQuotaSnapshot => ({ + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + scoped: [ + { + id: 'haiku-weekly', + title: 'Claude Haiku 4.5', + modelId: CLAUDE_HAIKU_4_5_MODEL_ID, + modelName: 'Claude Haiku 4.5', + usedPercent: 100 - remainingPercent, + remainingPercent, + checkedAt: 1, + }, + ], + }) + + test('stored healthy but fresh Haiku-exhausted skips without claiming', async () => { + const fixture = makePrimeFixture({ + mainQuota: scopedQuota(100), + killswitch: { + enabled: true, + main: { five_hour: 5, seven_day: 10, scoped: 0 }, + }, + }) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: 500 + 120_000, + quotaFresh: scopedQuota(0), + }) + + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls).toEqual([]) + expect(await readdir(markerRoot)).toEqual([]) + await h.cleanup() + }) + + test('stored Haiku-exhausted but fresh healthy after reset fires', async () => { + const fixture = makePrimeFixture({ + mainQuota: scopedQuota(0), + killswitch: { + enabled: true, + main: { five_hour: 5, seven_day: 10, scoped: 0 }, + }, + }) + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now: 500 + 120_000, + quotaFresh: scopedQuota(100), + }) + + await h.manager.tick() + + expect(h.refreshCalls).toEqual(['main']) + expect(h.sendCalls.map((call) => call.accountId)).toEqual(['main']) + await h.cleanup() + }) +}) + +describe('PrimeManager — post-claim abort', () => { + test('releases its marker and retries after the throttle window', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + let now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 2, + }, + }, + }) + h.manager.options.now = () => now + let loadCount = 0 + h.manager.options.loadStorage = async () => { + loadCount += 1 + if (loadCount === 3) { + return { ...fixture.storage, prime: { enabled: false } } + } + return fixture.storage + } + + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + expect(await readdir(h.markerDir)).toEqual([]) + + now += PRIME_CHECK_THROTTLE_MS + await h.manager.tick() + expect(h.sendCalls.map((call) => call.accountId)).toEqual(['main']) + await h.cleanup() + }) +}) + +describe('PrimeManager — fresh-check skip does not record as primed', () => { + test('window-active observation does not touch lastPrimedAt/lastResult', async () => { + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + // Fresh quota has a future resetsAt (window already running). + quotaFresh: { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + resetsAt: new Date(now + 5 * 60_000).toISOString(), + checkedAt: 1, + }, + }, + }) + await h.manager.tick() + expect(h.sendCalls).toEqual([]) + const stats = h.manager.stats(fixture.storage, now) + const mainStatus = stats.find((s) => s.id === 'main') as + | { lastPrimedAt?: number | null; lastResult?: 'ok' | 'error' } + | undefined + // lastPrimedAt / lastResult must NOT be set on a fresh-check skip. + // Otherwise the status would render "primed HH:MM ✓" for a request + // that never fired. + expect(mainStatus?.lastPrimedAt == null).toBe(true) + expect(mainStatus?.lastResult).toBeUndefined() + // `isWindowActive` exposes the observation separately so the + // status / sidebar can render "— window active" without conflating + // it with a successful prime. + expect(h.manager.isWindowActive('main')).toBe(true) + await h.cleanup() + }) +}) + +describe('PrimeManager — logging', () => { + test('token-refresh failure logs `prime token refresh failed` (warn) — distinct from the generic `prime fire failed`', async () => { + const previousLevel = getLogLevel() + setLogLevel('warn') + setLogLevelSource('warn') + // Only work-alt is due, so the assertion remains unambiguous: exactly one + // `prime token refresh failed` record for the single fire path. + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(10_000_000).toISOString(), + checkedAt: 1, + }, + }, + }) + fixture.storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + send: () => ({ + ok: false, + reason: 'token-refresh', + error: 'main refresh failed', + }), + }) + await h.manager.tick() + + const tokenRefreshWarns = capturedPrimeSink.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime token refresh failed', + ) + expect(tokenRefreshWarns).toHaveLength(1) + const payload = tokenRefreshWarns[0]?.payload as + | { account?: string; error?: string } + | undefined + expect(payload?.account).toBe('work-alt') + expect(payload?.error).toBe('main refresh failed') + + // The generic `prime fire failed` must NOT fire for a token-refresh + // failure — the two events are distinct in the spec Logging table. + const fireFailedWarns = capturedPrimeSink.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime fire failed', + ) + expect(fireFailedWarns).toHaveLength(0) + await h.cleanup() + setLogLevel(previousLevel) + setLogLevelSource(previousLevel) + }) + + test('fire success emits info prime record; failure emits warn; skips emit debug/trace; payloads exclude secrets', async () => { + const previousLevel = getLogLevel() + setLogLevel('trace') + setLogLevelSource('trace') + const fixture = makePrimeFixture({ + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + fixture.storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(500).toISOString(), + checkedAt: 1, + }, + }, + }) + const futureReset = 500 + 5 * 60_000 + // A second fallback whose reset is in the future — eligible, but not yet + // due, so the manager emits a trace skip record (covers the trace branch + // of the canonical log table). + fixture.storage.accounts.push({ + id: 'work-future', + type: 'oauth', + refresh: 'r', + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(futureReset).toISOString(), + checkedAt: 1, + }, + }, + }) + // An API-key account — surfaces the `not-oauth` debug eligibility skip. + fixture.storage.accounts.push({ + id: 'apikey-acc', + type: 'api', + baseURL: 'https://example.com', + }) + const now = 500 + 120_000 + const h = await makeHarness({ + storage: fixture.storage, + markerDir: markerRoot, + now, + quotaFresh: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now - 1000).toISOString(), + checkedAt: 1, + }, + }, + send: (id) => { + if (id === 'work-alt') { + return { ok: false, error: 'fire_failure', status: 500 } + } + return { + ok: true, + status: 200, + ms: 1, + usage: { inputTokens: 20, outputTokens: 1 }, + } + }, + }) + await h.manager.tick() + const prime = (m: string) => + capturedPrimeSink.filter((r) => r.channel === 'prime' && r.message === m) + const pick = ( + records: LogTestRecord[], + account: string, + ): Record | undefined => { + for (const r of records) { + const accountValue = (r.payload as { account?: unknown })?.account + if (accountValue === account) { + return r.payload as Record + } + } + return undefined + } + + // -- main: fire success → info, label, status, ms, usage (nested object) -- + const fired = prime('prime fired') + expect(fired).toHaveLength(1) + const mainFire = pick(fired, 'main') as + | { + account: string + status: number + ms: number + usage: { inputTokens: number; outputTokens: number } + } + | undefined + expect(mainFire).toBeDefined() + expect(mainFire?.status).toBe(200) + expect(typeof mainFire?.ms).toBe('number') + expect(mainFire?.usage).toBeDefined() + expect(mainFire?.usage?.inputTokens).toBe(20) + expect(mainFire?.usage?.outputTokens).toBe(1) + + // -- work-alt: fire failed → warn, label, status, error -- + const failed = prime('prime fire failed') + expect(failed).toHaveLength(1) + const fbFail = pick(failed, 'work-alt') as + | { account: string; status?: number; error: string } + | undefined + expect(fbFail).toBeDefined() + expect(fbFail?.status).toBe(500) + expect(fbFail?.error).toBe('fire_failure') + + // -- apikey-acc: eligibility skip → debug, label, reason -- + const ineligible = prime('ineligible') + expect(ineligible.length).toBeGreaterThanOrEqual(1) + const apiSkip = pick(ineligible, 'apikey-acc') as + | { account: string; reason: string } + | undefined + expect(apiSkip).toBeDefined() + expect(apiSkip?.reason).toBe('API-key account') + + // -- work-future: not due → trace, label -- + const notDue = prime('not due') + expect(notDue).toHaveLength(1) + const futureSkip = pick(notDue, 'work-future') as + | { account: string } + | undefined + expect(futureSkip).toBeDefined() + + // -- Lifecycle: manager started → trace, manager stopped → trace -- + h.manager.start() + expect( + capturedPrimeSink.some( + (r) => r.channel === 'prime' && r.message === 'manager started', + ), + ).toBe(true) + h.manager.stop() + expect( + capturedPrimeSink.some( + (r) => r.channel === 'prime' && r.message === 'manager stopped', + ), + ).toBe(true) + + // No secret strings in any captured record. + const serialized = JSON.stringify(capturedPrimeSink) + expect(serialized).not.toContain('sk-ant-') + expect(serialized).not.toContain('Bearer ') + expect(serialized).not.toContain('eyJ') + setLogLevel(previousLevel) + setLogLevelSource(previousLevel) + await h.cleanup() + }) +}) diff --git a/packages/opencode/README.md b/packages/opencode/README.md index d58862ee..a03176da 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -20,7 +20,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Provider integration point | OpenCode plugin fetch/request transform | Pi `registerProvider("anthropic")` provider override | | Sidecar config | `~/.config/opencode/anthropic-auth.json` | `~/.pi/agent/anthropic-auth.json` | | Runtime state | `~/.config/opencode/anthropic-auth-state.json` | next to the Pi sidecar as `anthropic-auth-state.json` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/claude-prime` (status only), `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | | Fallback accounts, quota routing, killswitch, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | ## What CortexKit adds over the original plugin @@ -30,6 +30,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Quota-aware routing**: skip main or fallback accounts when their 5-hour or 7-day Claude quota falls below your configured minimum. - **Persistent Claude cache controls**: manage Anthropic 1-hour prompt caching from `/claude-cache` with explicit, automatic, or hybrid modes. - **Cache keepalive**: use `/claude-cachekeep always` or `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. +- **Quota window priming**: opt in with `/claude-prime on` to start each 5-hour quota window about one minute after it resets instead of waiting for the next normal prompt. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Adaptive reasoning visibility**: request summarized adaptive thinking for Claude Fable 5, Mythos 5, and Opus 5. OpenCode receives native `low`, `medium`, `high`, `xhigh`, and `max` Opus 5 effort variants rather than legacy manual-thinking budgets. - **Fable/Opus 5 content-filter recovery**: when Fable or Opus 5 ends a session response with Anthropic's `refusal` content-filter reason, transparently retry that source-model family with Opus 4.8 for 10 successful model responses. Recovery state and pending cache warms remain independent when a session switches between Fable and Opus 5. After each downgraded response, a zero-output prewarm advances the source model's prompt cache using the OAuth account that served the filtered request. The latest Opus cache boundary is retained so a later refusal can bridge back even after more than 20 content blocks. The TUI sidebar and OpenCode Desktop report both transitions while preserving the selected model. @@ -47,7 +48,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - Support fallback Claude accounts stored in a local per-agent sidecar file. - Keep fallback OAuth tokens fresh in the background. - Apply quota thresholds before routing to main or fallback accounts. -- Add `/claude-cache`, `/claude-cachekeep`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. +- Add `/claude-cache`, `/claude-cachekeep`, `/claude-prime`, `/claude-fast`, `/claude-quota`, and `/claude-dump` commands. - Optionally relay large requests through a Cloudflare Worker owned by the user. ## Install @@ -377,6 +378,24 @@ Request bodies, headers, and tokens remain in memory. A lease-backed file under Pre-warm requests preserve explicit cache anchors but remove response-only fields that Anthropic rejects with `max_tokens: 0`, such as streaming, enabled thinking, structured output format, and forced/any tool choice. The feature works only while OpenCode or Pi is running and the machine is awake, and cache writes are still billed when the cache entry is no longer warm. +## Claude quota window priming + +Quota-window priming is off by default. OpenCode controls it with: + +```text +/claude-prime +/claude-prime on +/claude-prime off +``` + +When enabled, the plugin watches each OAuth account's 5-hour quota reset. About one minute after a confirmed reset, it sends one minimal `claude-haiku-4-5` request so the next window starts without waiting for a normal prompt. Usage is measured from response accounting. + +Scheduled fires happen only after the quota window has reset. If an idle account has no cached reset time, the plugin sends one bootstrap request to establish its first observed window. Atomic temporary-file claims ensure that multiple OpenCode processes sharing the same account config send only one request per account and reset. + +Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refresh rotations preserve the main account's lineage, while a host credential replacement creates a new lineage. An existing main lineage without a refresh-token binding attaches to the current credential on its first check without changing identity. On upgrade, an existing fallback account receives an identity during its first prime check; that one-time marker change can send one extra request in the current window. + +Pi exposes `/claude-prime` as a status-only command. Its `on` and `off` arguments are ignored; enable or disable priming from OpenCode. + ## Claude fast mode Both OpenCode and Pi packages can persistently request Anthropic fast mode for supported Opus models: diff --git a/packages/opencode/src/cli.ts b/packages/opencode/src/cli.ts index acdd7c73..8a3e06dd 100644 --- a/packages/opencode/src/cli.ts +++ b/packages/opencode/src/cli.ts @@ -324,6 +324,7 @@ export async function login(labelArg?: string, deps: LoginDeps = {}) { id: label || crypto.randomUUID(), label: label || undefined, type: 'oauth', + authLineageId: crypto.randomUUID(), access: result.access, refresh: result.refresh, expires: result.expires, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 53300e08..fc0225f4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -8,6 +8,7 @@ import { buildAccountList, buildClaudeQuotaSummary, buildFallbackQuotaSummaries, + buildPrimeRequestBody, buildRefreshOperationError, CACHE_1H_COMMAND_NAME, CACHE_KEEP_EXTENDED_TTL_BETA, @@ -22,9 +23,12 @@ import { CLAUDE_FABLE_MYTHOS_5_PRICING, CLAUDE_FABLE_MYTHOS_5_RELEASE_DATE, CLAUDE_FAST_COMMAND_NAME, + CLAUDE_HAIKU_4_5_MODEL_ID, CLAUDE_LOGGING_COMMAND_NAME, + CLAUDE_PRIME_COMMAND_NAME, CLAUDE_QUOTAS_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, + continueMainPrimeAuthLineageAfterRefresh, createEmptyStorage, decideStickyQuotaFailure, dumpDirectRequest, @@ -36,6 +40,7 @@ import { executeFastModeCommand, executeKillswitchCommand, executeLoggingCommand, + executePrimeCommand, executeRoutingCommand, FallbackAccountManager, fetchOAuthAccountProfile, @@ -49,6 +54,7 @@ import { getDefaultCacheKeepRegistryDirectory, getKillswitchConfig, getKillswitchThresholdsForAccount, + getOrCreatePrimeAuthLineageId, getPersistedLogLevel, getPersistedMainQuota, getQuotaNextRefreshAt, @@ -57,6 +63,7 @@ import { getScopedQuotaWindowForModel, getStickyRoutingStatePath, hashRefreshToken, + incrementPrimeUsagePersistent, isApiKeyAccount, isCache1hEnabled, isCache1hPersistentlyEnabled, @@ -73,6 +80,7 @@ import { isKillswitchEnabled, isOAuthAccount, isPermanentRefreshError, + isPrimePersistentlyEnabled, isQuotaBearingHeaderFrame, isValidApiBaseURL, KILLSWITCH_COMMAND_NAME, @@ -88,13 +96,19 @@ import { oauthProfileIsFresh, oauthProfileMatchesToken, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, + PrimeManager, + type PrimeManagerOptions, + type PrimeRefreshResult, + type PrimeSendResult, parseAccountCommandAction, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, parseLoggingCommandAction, + parsePrimeCommandAction, parseRoutingCommandAction, + primeQuotaSnapshotCheckedAt, type QuotaAccountSummary, type QuotaEntry, QuotaManager, @@ -127,6 +141,7 @@ import { setKillswitchPersistent, setLogLevel, setLogLevelPersistent, + setPrimePersistentEnabled, setRoutingMode, shouldFallbackStatus, stickyQuotaSnapshotIsFresh, @@ -140,6 +155,7 @@ import { type FableFallbackPlan, type FableStandbyCacheAnchor, } from './fable-fallback.ts' +import { adoptPrimeManager } from './prime-manager-registry.ts' import { resolvePromptContext } from './prompt-context.ts' import { drainNotifications, @@ -178,6 +194,7 @@ const HTTP_SERVER_RESPONSE_TYPE_ID = '~effect/http/HttpServerResponse' const HTTP_COOKIES_TYPE_ID = '~effect/http/Cookies' const HTTP_BODY_TYPE_ID = '~effect/http/HttpBody' const ERROR_REPORTER_IGNORE = '~effect/ErrorReporter/ignore' +const PRIME_MESSAGES_URL = 'https://api.anthropic.com/v1/messages' const MAIN_AUTH_REFRESH_TICK_MS = 60_000 const MAIN_AUTH_REFRESH_TICK_JITTER_MS = 60_000 const CONCURRENT_MAIN_REFRESH_WAIT_MS = 5_000 @@ -749,6 +766,13 @@ function zeroModelCosts>( ) as T } +export function primeQuotaSnapshotIsFreshSince( + quota: OAuthQuotaSnapshot | undefined, + refreshStartedAt: number, +): boolean { + return primeQuotaSnapshotCheckedAt(quota) > refreshStartedAt +} + export const AnthropicAuthPlugin: Plugin = async (ctx) => { startEventLoopLagMonitor() const { client } = ctx @@ -1197,6 +1221,247 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const recoveryWarmChains = new Map>() + // -- /claude-prime manager: direct OAuth request to start each account's + // five-hour quota window ~60s after reset, exactly once across concurrent + // OpenCode processes via atomic marker claim. In-process manager mirrors the + // cacheKeep singleton shape; cross-process exclusivity is the marker's job. + + // Obtain a CURRENT main access token via the existing refresh path + // (M2). If the cached auth has no access or the token is expired, refresh + // first; only fail when the refresh path itself fails. Returns the live + // token so the fresh-check and the fire path use the same value. + async function getCurrentMainAccessToken(): Promise { + if (!latestGetAuth) { + throw new Error('prime: main auth loader is not available') + } + const auth = await latestGetAuth() + if (auth.type !== 'oauth') { + throw new Error('prime: main account is not an OAuth account') + } + if (auth.access && (!auth.expires || auth.expires > Date.now())) { + return auth.access + } + if (!latestRefreshMainAccessToken) { + throw new Error('prime: main token refresh unavailable') + } + try { + return await latestRefreshMainAccessToken() + } catch (error) { + if (error instanceof Error) { + ;( + error as Error & { isPrimeTokenRefresh?: boolean } + ).isPrimeTokenRefresh = true + } + throw error + } + } + + async function refreshPrimeMainQuota(): Promise { + const accessToken = await getCurrentMainAccessToken() + const result = await quotaManager.refreshMainWithMetadata(accessToken) + return { quota: result.quota, fresh: result.fetched } + } + + async function refreshPrimeFallbackQuota( + accountId: string, + ): Promise { + const storage = await loadAccounts(accountStoragePath) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && + candidate.enabled !== false && + isOAuthAccount(candidate), + ) + if (!account || !storage) { + throw new Error(`prime: OAuth account ${accountId} is unavailable`) + } + // Reuse the same fallback-manager refresh path used by the + // background quota refresh — it persists the refreshed quota to the + // state file (M1 persist requirement) and is the SINGLE usage-API + // call per tick for fallback fresh-checks. The redundant + // `quotaManager.refreshFallback` call was collapsed (R2). + const refreshed = await fallbackManager.refreshAccountQuota( + account, + storage, + ) + await fallbackManager.save(storage, [accountId]) + if (!refreshed.account.access) { + throw new Error(`prime: OAuth account ${accountId} has no access token`) + } + return { + quota: refreshed.account.quota ?? {}, + fresh: refreshed.fetched, + } + } + + async function sendPrime( + accountId: 'main' | string, + ): Promise { + const start = performance.now() + let accessToken: string | undefined + let resolvedModel: string | undefined + try { + if (accountId === 'main') { + // Use the same refresh path the fresh-check uses, so a missing or + // expired access token is refreshed BEFORE the request — not used + // as-is. The previous early-return-on-!auth.access made the + // refresh arm unreachable (M2). + try { + accessToken = await getCurrentMainAccessToken() + } catch (error) { + const isTokenRefresh = + error instanceof Error && + (error as Error & { isPrimeTokenRefresh?: boolean }) + .isPrimeTokenRefresh === true + return { + ok: false, + ...(isTokenRefresh && { reason: 'token-refresh' as const }), + error: error instanceof Error ? error.message : String(error), + } + } + resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID + } else { + const storage = await loadAccounts(accountStoragePath) + const account = storage?.accounts.find( + (candidate): candidate is OAuthAccount => + candidate.id === accountId && + candidate.enabled !== false && + isOAuthAccount(candidate), + ) + if (!account || !storage) { + return { + ok: false, + error: `prime: OAuth account ${accountId} is unavailable`, + } + } + let current = account + try { + // R2: the fire path refreshes ONLY the token, not the quota. + // The fresh-check already performed the single usage-API + // call and persisted the result; the fire path reuses the + // in-memory quota via the headers / URL contract. This keeps + // the cycle at exactly one quota API call per account. + current = await fallbackManager.refreshAccount(account, storage) + } catch (error) { + return { + ok: false, + reason: 'token-refresh', + error: error instanceof Error ? error.message : String(error), + } + } + accessToken = current.access + resolvedModel = CLAUDE_HAIKU_4_5_MODEL_ID + } + + if (!accessToken) { + return { ok: false, error: 'prime: no access token available' } + } + + const primeBody = buildPrimeRequestBody() + const identity = await resolveClaudeCodeIdentity( + accessToken, + resolvedModel, + ) + const body = await rewriteRequestBody(JSON.stringify(primeBody), { + identity, + }) + const headers = new Headers({ + 'content-type': 'application/json', + }) + setOAuthHeaders(headers, accessToken, { + body: JSON.parse(body), + identity, + }) + headers.delete('content-length') + headers.delete('transfer-encoding') + // Route through rewriteUrl so the request inherits the canonical + // ?beta=true query param and ANTHROPIC_BASE_URL overrides like every + // other direct Anthropic call in this codebase. The URL is rendered + // back to a string here because the surrounding fetch wrapper and + // test mocks expect a string input. + const primeRequest = rewriteUrl(PRIME_MESSAGES_URL, { baseURL: '' }) + const primeUrl = + primeRequest.url?.toString() ?? primeRequest.input.toString() + const response = await fetch(primeUrl, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(30_000), + }) + const ms = Math.round(performance.now() - start) + if (!response.ok) { + const reason = + (await response.text().catch(() => '')) || `HTTP ${response.status}` + return { ok: false, status: response.status, ms, error: reason } + } + const data = (await response.json().catch(() => null)) as Record< + string, + unknown + > | null + const usageRaw = data?.usage as + | { input_tokens?: number; output_tokens?: number } + | undefined + const usage = + usageRaw && + (Number.isFinite(usageRaw.input_tokens) || + Number.isFinite(usageRaw.output_tokens)) + ? { + inputTokens: Number.isFinite(usageRaw.input_tokens) + ? usageRaw.input_tokens + : undefined, + outputTokens: Number.isFinite(usageRaw.output_tokens) + ? usageRaw.output_tokens + : undefined, + } + : undefined + return { ok: true, status: response.status, ms, ...(usage && { usage }) } + } catch (error) { + return { + ok: false, + ms: Math.round(performance.now() - start), + error: error instanceof Error ? error.message : String(error), + } + } + } + + const primeManagerOptions: PrimeManagerOptions = { + storagePath: accountStoragePath, + loadStorage: () => loadAccounts(accountStoragePath), + getAccountFingerprint: async (accountId) => { + let mainRefreshToken: string | undefined + if (accountId === 'main') { + try { + const auth = await latestGetAuth?.() + if (auth?.type === 'oauth') mainRefreshToken = auth.refresh + } catch {} + } + const authLineageId = await getOrCreatePrimeAuthLineageId( + accountId, + accountStoragePath, + mainRefreshToken, + ) + return authLineageId ? tokenFingerprint(authLineageId) : undefined + }, + refreshQuota: async (accountId) => { + if (accountId === 'main') return refreshPrimeMainQuota() + return refreshPrimeFallbackQuota(accountId) + }, + sendPrime, + recordSuccess: (accountId, usage) => + incrementPrimeUsagePersistent(accountId, usage, accountStoragePath), + } + const primeManager: PrimeManager = adoptPrimeManager( + accountStoragePath, + () => new PrimeManager(primeManagerOptions), + { + slot: ctx.directory ?? 'default', + rebind: (manager) => manager.updateOptions(primeManagerOptions), + }, + ) + if (isPrimePersistentlyEnabled(initialStorage)) { + primeManager.start() + } + function warmRecoverySourceAfterOpus(context: FableRequestContext) { const sessionId = context.plan.sessionId const modelLabel = isClaudeOpus5Model(context.plan.requestedModel) @@ -1446,6 +1711,16 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { : undefined, trackedSessions: aggregateCacheKeepSessions.length, }, + // Prime section is omitted from the wire when the feature is disabled + // so the sidebar reads a clean `prime === undefined` and the expanded + // view renders nothing (declutter rule). Enabled writes include + // per-account status (next-due / last-primed / cumulative usage). + prime: isPrimePersistentlyEnabled(storage) + ? { + enabled: true, + accounts: primeManager.stats(storage), + } + : undefined, fableRecoveries: fableRecoveryNotices.size > 0 ? [...fableRecoveryNotices.values()] @@ -1681,7 +1956,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (latestGetAuth) { try { const auth = await latestGetAuth() - writeSidebarState(updatedStorage, { + await writeSidebarState(updatedStorage, { activeId: lastSidebarRouting.activeId, route: lastSidebarRouting.route, mainAccessToken: auth.access, @@ -1894,6 +2169,61 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeLoggingCommand({ argumentsText, level }) } + async function executePersistentPrimeCommand(argumentsText: string) { + const action = parsePrimeCommandAction(argumentsText) + const previous = isPrimePersistentlyEnabled( + await loadAccounts(accountStoragePath), + ) + let enabled = previous + if (action.type === 'enable') { + enabled = true + if (!previous) { + await setPrimePersistentEnabled(true, accountStoragePath) + primeManager.start() + logger.info('commands', 'prime changed', { enabled: true }) + } + } else if (action.type === 'disable') { + enabled = false + if (previous) { + await setPrimePersistentEnabled(false, accountStoragePath) + primeManager.stop() + logger.info('commands', 'prime changed', { enabled: false }) + } + } + + // Publish the new prime section to the sidebar file (M7) so the + // expanded Prime row appears on `on` and disappears on `off` without + // waiting for the next quota refresh path. The post-mutation storage + // IS authoritative; we re-read it so the section payload reflects + // the freshly persisted flag. + if (action.type === 'enable' || action.type === 'disable') { + const reloaded = await loadAccounts(accountStoragePath) + if (reloaded && latestGetAuth) { + try { + const cmdAuth = await latestGetAuth().catch(() => undefined) + await writeSidebarState(reloaded, { + activeId: lastSidebarRouting.activeId, + route: lastSidebarRouting.route, + mainAccessToken: cmdAuth?.access, + mainRefreshToken: cmdAuth?.refresh, + routingAuthoritative: false, + }) + } catch (error) { + logger.warn('sidebar', 'prime-toggle sidebar write failed', { + error: error instanceof Error ? error.message : String(error), + }) + } + } + } + + const storage = await loadAccounts(accountStoragePath) + return executePrimeCommand({ + argumentsText, + enabled, + accounts: primeManager.stats(storage), + }).text + } + async function executePersistentAccountCommand( argumentsText: string, sessionId?: string, @@ -2011,6 +2341,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const account: OAuthAccount = { id: randomUUID(), type: 'oauth' as const, + authLineageId: randomUUID(), label: action.label || undefined, access: result.access, refresh: result.refresh, @@ -2200,6 +2531,19 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const storage = await loadAccounts(accountStoragePath) return { command, text, knobs: { window: getCacheKeepWindow(storage) } } } + if (command === 'claude-prime') { + const text = await executePersistentPrimeCommand(args) + const storage = await loadAccounts(accountStoragePath) + const enabled = isPrimePersistentlyEnabled(storage) + return { + command, + text, + knobs: { + enabled, + accounts: primeManager.stats(storage), + }, + } + } const storage = await loadAccounts() const config = getKillswitchConfig(storage) const accountIds = (storage?.accounts ?? []) @@ -2383,6 +2727,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { description: 'Keep hybrid Claude cache warm always or during a local time window.', }, + [CLAUDE_PRIME_COMMAND_NAME]: { + template: CLAUDE_PRIME_COMMAND_NAME, + description: + "Start each OAuth account's five-hour quota window after reset.", + }, [CLAUDE_QUOTAS_COMMAND_NAME]: { template: CLAUDE_QUOTAS_COMMAND_NAME, @@ -2455,6 +2804,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { 'claude-account', 'claude-cache', 'claude-cachekeep', + 'claude-prime', 'claude-quota', 'claude-dump', 'claude-fast', @@ -2553,6 +2903,15 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { changed && (!latest.expires || latest.expires > Date.now()) ) { + if (previous.refresh && latest.refresh) { + await continueMainPrimeAuthLineageAfterRefresh( + { + previousRefreshToken: previous.refresh, + currentRefreshToken: latest.refresh, + }, + accountStoragePath, + ) + } log( '[refresh] opencode main oauth joined concurrent refresh', { @@ -2704,18 +3063,37 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { maxRetries: 0, }) - // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set - await (client as any).auth.set({ - path: { - id: 'anthropic', - }, - body: { - type: 'oauth', - refresh: refreshed.refresh, - access: refreshed.access, - expires: refreshed.expires, + await continueMainPrimeAuthLineageAfterRefresh( + { + previousRefreshToken: freshAuth.refresh, + currentRefreshToken: refreshed.refresh, }, - }) + accountStoragePath, + ) + + try { + // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set + await (client as any).auth.set({ + path: { + id: 'anthropic', + }, + body: { + type: 'oauth', + refresh: refreshed.refresh, + access: refreshed.access, + expires: refreshed.expires, + }, + }) + } catch (error) { + await continueMainPrimeAuthLineageAfterRefresh( + { + previousRefreshToken: refreshed.refresh, + currentRefreshToken: freshAuth.refresh, + }, + accountStoragePath, + ) + throw error + } await updateMainRefreshState((storage) => { if (!storage?.refresh) return @@ -4969,6 +5347,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, ], }, + __primeManager: primeManager, + __quotaManager: quotaManager, // biome-ignore lint/suspicious/noExplicitAny: Plugin type doesn't include undocumented auth/hooks } as any } diff --git a/packages/opencode/src/prime-manager-registry.ts b/packages/opencode/src/prime-manager-registry.ts new file mode 100644 index 00000000..37be9444 --- /dev/null +++ b/packages/opencode/src/prime-manager-registry.ts @@ -0,0 +1,54 @@ +import { + type PrimeManager, + primeStorageFingerprint, +} from '@cortexkit/anthropic-auth-core' + +type PrimeManagerEntry = { + manager: PrimeManager + slots: Set +} + +type PrimeManagerAdoption = { + slot: string + rebind: (manager: PrimeManager) => void +} + +// Each project slot owns one fingerprint at a time, while slots sharing a +// storage identity adopt one process-wide manager. Moving a slot releases its +// old owner so an unreferenced timer cannot survive a project reload. +const primeManagers = new Map() +const slotFingerprints = new Map() + +export function adoptPrimeManager( + storagePath: string, + create: () => PrimeManager, + adoption: PrimeManagerAdoption, +): PrimeManager { + const fingerprint = primeStorageFingerprint(storagePath) + const previousFingerprint = slotFingerprints.get(adoption.slot) + if (previousFingerprint && previousFingerprint !== fingerprint) { + const previous = primeManagers.get(previousFingerprint) + if (previous) { + previous.slots.delete(adoption.slot) + if (previous.slots.size === 0) { + previous.manager.stop() + primeManagers.delete(previousFingerprint) + } + } + } + + slotFingerprints.set(adoption.slot, fingerprint) + const existing = primeManagers.get(fingerprint) + if (existing) { + existing.slots.add(adoption.slot) + adoption.rebind(existing.manager) + return existing.manager + } + + const manager = create() + primeManagers.set(fingerprint, { + manager, + slots: new Set([adoption.slot]), + }) + return manager +} diff --git a/packages/opencode/src/rpc/protocol.ts b/packages/opencode/src/rpc/protocol.ts index 29ca1b30..d0b1b48b 100644 --- a/packages/opencode/src/rpc/protocol.ts +++ b/packages/opencode/src/rpc/protocol.ts @@ -2,6 +2,7 @@ export type CommandModalName = | 'claude-account' | 'claude-cache' | 'claude-cachekeep' + | 'claude-prime' | 'claude-quota' | 'claude-dump' | 'claude-fast' diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index f9d1107f..f52c687c 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -50,6 +50,16 @@ export interface FableRecoverySidebarState { requestedModelId?: string } +export interface PrimeSidebarAccountState { + id: string + label: string + nextDueAt?: number | null + lastPrimedAt?: number | null + lastResult?: 'ok' | 'error' + usage?: PrimeUsageCounters + estimatedCostUsd?: number +} + export interface SidebarState { main: { quota: AccountQuota | null @@ -69,6 +79,15 @@ export interface SidebarState { window?: string trackedSessions?: number } + /** + * Prime opt-in flag plus per-account status. Absent on the wire when + * the feature is disabled — `normalizeSidebarState` validates every + * account independently and drops any entry it cannot prove. + */ + prime?: { + enabled: boolean + accounts: PrimeSidebarAccountState[] + } fableRecoveries?: FableRecoverySidebarState[] lastUpdated: number } @@ -86,6 +105,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import type { PrimeUsageCounters } from '@cortexkit/anthropic-auth-core' import { logger } from '@cortexkit/anthropic-auth-core' const STATE_FILE_ENV = 'OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE' @@ -109,6 +129,77 @@ function isRecord(v: unknown): v is Record { return v !== null && typeof v === 'object' && !Array.isArray(v) } +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function normalizePrimeUsage(value: unknown): PrimeUsageCounters | undefined { + if (!isRecord(value)) return undefined + const count = Number(value.count) + const inputTokens = Number(value.inputTokens) + const outputTokens = Number(value.outputTokens) + const since = Number(value.since) + if ( + ![count, inputTokens, outputTokens, since].every(Number.isFinite) || + count < 0 || + inputTokens < 0 || + outputTokens < 0 || + since < 0 + ) { + return undefined + } + return { + count: Math.floor(count), + inputTokens: Math.floor(inputTokens), + outputTokens: Math.floor(outputTokens), + since: Math.floor(since), + } +} + +function normalizePrimeAccount( + value: unknown, +): PrimeSidebarAccountState | undefined { + if (!isRecord(value)) return undefined + if (typeof value.id !== 'string' || !value.id.trim()) return undefined + if (typeof value.label !== 'string' || !value.label.trim()) return undefined + const account: PrimeSidebarAccountState = { + id: value.id.trim(), + label: value.label.trim(), + } + if (value.nextDueAt === null) { + account.nextDueAt = null + } else if (isFiniteNumber(value.nextDueAt)) { + account.nextDueAt = value.nextDueAt + } + if (value.lastPrimedAt === null) { + account.lastPrimedAt = null + } else if (isFiniteNumber(value.lastPrimedAt)) { + account.lastPrimedAt = value.lastPrimedAt + } + if (value.lastResult === 'ok' || value.lastResult === 'error') { + account.lastResult = value.lastResult + } + const usage = normalizePrimeUsage(value.usage) + if (usage) account.usage = usage + if (isFiniteNumber(value.estimatedCostUsd)) { + account.estimatedCostUsd = value.estimatedCostUsd + } + return account +} + +function normalizePrimeSection( + value: unknown, +): SidebarState['prime'] | undefined { + if (!isRecord(value)) return undefined + if (typeof value.enabled !== 'boolean') return undefined + const accounts = Array.isArray(value.accounts) + ? value.accounts + .map(normalizePrimeAccount) + .filter((a): a is PrimeSidebarAccountState => a != null) + : [] + return { enabled: value.enabled, accounts } +} + function normalizeQuotaWindow(value: unknown): QuotaWindow | undefined { if (!isRecord(value)) return undefined const usedPercent = Number(value.usedPercent) @@ -314,6 +405,7 @@ export function normalizeSidebarState(raw: unknown): SidebarState { ? raw.fastMode : DEFAULT_SIDEBAR_STATE.fastMode, cacheKeep, + prime: normalizePrimeSection(raw.prime), fableRecoveries: fableRecoveries.length > 0 ? fableRecoveries : undefined, lastUpdated: typeof raw.lastUpdated === 'number' ? raw.lastUpdated : 0, } @@ -700,6 +792,44 @@ export function formatFallbackModelLabel(modelId: string | undefined): string { return modelId ?? 'Fable 5' } +export function formatPrimeTime(epochMs: number): string { + return new Date(epochMs).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) +} + +export function formatPrimeCost(value: number): string { + if (value === 0) return '0' + if (value < 0.0001) return value.toExponential(2) + return value.toFixed(Math.min(6, Math.max(0, 4))) +} + +export function formatPrimeAccountValue(account: PrimeSidebarAccountState): { + text: string + hasError: boolean +} { + if (account.lastResult === 'error') { + return { text: 'err', hasError: true } + } + if (account.nextDueAt && account.nextDueAt > Date.now()) { + return { text: formatPrimeTime(account.nextDueAt), hasError: false } + } + if (account.lastPrimedAt) { + return { + text: `primed ${formatPrimeTime(account.lastPrimedAt)} \u2713`, + hasError: false, + } + } + if (account.usage?.count) { + return { + text: `\u2713 ${account.usage.count} \u2248 $${formatPrimeCost(account.estimatedCostUsd ?? 0)}`, + hasError: false, + } + } + return { text: '\u2014', hasError: false } +} + export function getFableRecoverySummary( state: SidebarState, sessionId: string, diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 3a8811b0..7eb95ddd 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -25,12 +25,15 @@ import { getAccountStatePath, getCache1hPersistentMode, getLogLevel, + getOrCreatePrimeAuthLineageId, getPersistedLogLevel, getScopedQuotaWindowForModel, + incrementPrimeUsagePersistent, isCacheKeepSubagentsEnabled, isCostZeroingEnabled, isFastModePersistentlyEnabled, isPermanentRefreshError, + isPrimePersistentlyEnabled, type KillswitchThresholds, killswitchPassesPolicy, loadAccounts, @@ -61,6 +64,7 @@ import { setFastModePersistentEnabled, setLogLevel, setLogLevelPersistent, + setPrimePersistentEnabled, shouldFallbackStatus, tokenFingerprint, upsertAccount, @@ -4496,3 +4500,395 @@ describe('setLogLevelPersistent', () => { } }) }) + +// -- prime opt-in flag + scoped counters ------------------------------------ + +describe('isPrimePersistentlyEnabled', () => { + test('defaults to false when prime is absent', () => { + expect(isPrimePersistentlyEnabled(baseStorage())).toBe(false) + }) + + test('returns false when prime.enabled is not true', () => { + expect( + isPrimePersistentlyEnabled({ + ...baseStorage(), + prime: { enabled: false }, + }), + ).toBe(false) + }) + + test('returns true when prime.enabled is true', () => { + expect( + isPrimePersistentlyEnabled({ + ...baseStorage(), + prime: { enabled: true }, + }), + ).toBe(true) + }) + + test('survives save/load round-trip', async () => { + const _storage = baseStorage() + await setPrimePersistentEnabled(true, accountPath) + const loaded = await loadAccounts() + expect(isPrimePersistentlyEnabled(loaded)).toBe(true) + }) +}) + +describe('setPrimePersistentEnabled', () => { + test('writes only enabled to the config; runtime counters never enter the config', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + }) + await saveAccounts(storage) + await setPrimePersistentEnabled(true, accountPath) + + const rawConfig = JSON.parse(await readFile(accountPath, 'utf8')) + expect(rawConfig.prime).toEqual({ enabled: true }) + }) + + test('clears enabled when toggled off', async () => { + await setPrimePersistentEnabled(true, accountPath) + await setPrimePersistentEnabled(false, accountPath) + const loaded = await loadAccounts() + expect(isPrimePersistentlyEnabled(loaded)).toBe(false) + const rawConfig = JSON.parse(await readFile(accountPath, 'utf8')) + expect(rawConfig.prime).toEqual({ enabled: false }) + }) +}) + +describe('incrementPrimeUsagePersistent', () => { + test('initializes main counters on first success and accumulates tokens', async () => { + const storage = baseStorage() + await saveAccounts(storage) + + const counters = await incrementPrimeUsagePersistent( + 'main', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1_721_111_111_000, + ) + expect(counters).toEqual({ + count: 1, + inputTokens: 20, + outputTokens: 1, + since: 1_721_111_111_000, + }) + + const next = await incrementPrimeUsagePersistent( + 'main', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1_721_111_112_000, + ) + expect(next).toEqual({ + count: 2, + inputTokens: 40, + outputTokens: 2, + since: 1_721_111_111_000, + }) + + // Counters live in the runtime-state file, not the config file + const rawConfig = JSON.parse(await readFile(accountPath, 'utf8')) + expect(rawConfig.prime).toBeUndefined() + + const rawState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(rawState.main.prime).toEqual({ + count: 2, + inputTokens: 40, + outputTokens: 2, + since: 1_721_111_111_000, + }) + }) + + test('falls back to fallback account scoped counter', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + }) + await saveAccounts(storage) + + const counters = await incrementPrimeUsagePersistent( + 'work-alt', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1_721_111_112_000, + ) + expect(counters).toEqual({ + count: 1, + inputTokens: 20, + outputTokens: 1, + since: 1_721_111_112_000, + }) + + const rawState = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) + expect(rawState.accounts['work-alt'].prime).toEqual({ + count: 1, + inputTokens: 20, + outputTokens: 1, + since: 1_721_111_112_000, + }) + }) + + test('clamps missing or non-finite usage to zero', async () => { + const storage = baseStorage() + await saveAccounts(storage) + + const counters = await incrementPrimeUsagePersistent( + 'main', + { inputTokens: Number.NaN, outputTokens: Number.POSITIVE_INFINITY }, + accountPath, + 1, + ) + expect(counters.count).toBe(1) + expect(counters.inputTokens).toBe(0) + expect(counters.outputTokens).toBe(0) + }) + + test('a scoped fallback increment preserves an unrelated account state', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + access: 'a', + }) + storage.accounts.push({ + id: 'main-extra', + type: 'oauth', + refresh: 'r2', + access: 'b', + }) + await saveAccounts(storage) + + // Seed an unrelated runtime entry on the second account + await saveAccountState( + { + ...storage, + accounts: storage.accounts.map((acc) => + acc.id === 'main-extra' + ? ({ + ...acc, + access: 'pre-existing', + lastRefreshedAt: 999, + } as OAuthAccount) + : acc, + ), + }, + accountPath, + { accounts: ['main-extra'] }, + ) + + await incrementPrimeUsagePersistent( + 'work-alt', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1_721_111_112_000, + ) + + const loaded = await loadAccounts() + const workAlt = loaded?.accounts.find((acc) => acc.id === 'work-alt') as + | OAuthAccount + | undefined + const mainExtra = loaded?.accounts.find((acc) => acc.id === 'main-extra') as + | OAuthAccount + | undefined + expect(workAlt?.prime?.count).toBe(1) + expect(mainExtra?.access).toBe('pre-existing') + expect(mainExtra?.lastRefreshedAt).toBe(999) + }) + + test('a main prime increment preserves fallback state', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + access: 'a', + }) + await saveAccounts(storage) + + await incrementPrimeUsagePersistent( + 'work-alt', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1, + ) + + await incrementPrimeUsagePersistent( + 'main', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 2, + ) + + const loaded = await loadAccounts() + const workAlt = loaded?.accounts.find((acc) => acc.id === 'work-alt') as + | OAuthAccount + | undefined + expect(workAlt?.prime?.count).toBe(1) + expect(loaded?.prime?.main?.count).toBe(1) + }) + + test('malformed persisted prime counters are dropped on load', async () => { + // Write a state file with invalid counter shapes directly. The + // `normalizePrimeUsageCounters` validator must reject negative/NaN/ + // string-coerced fields, so loadAccounts returns `prime === undefined` + // for the offending section — a regression that silently accepted + // them would let the sidebar render NaN$ totals. + const storage = baseStorage() + storage.prime = { + enabled: true, + main: { count: -1, inputTokens: 0, outputTokens: 0, since: -5 }, + } + await saveAccounts(storage) + + const loaded = await loadAccounts() + expect(loaded?.prime?.enabled).toBe(true) + expect(loaded?.prime?.main).toBeUndefined() + + // NaN / string-coerced fields are also rejected. Cast through unknown + // so the test fixture can carry a malformed on-disk shape (the + // normalizer is what we're testing, not the input type). + const storage2 = baseStorage() + storage2.prime = { + enabled: true, + main: { + count: Number.NaN, + inputTokens: 0, + outputTokens: 0, + since: 0, + } as unknown as never, + } + await saveAccounts(storage2) + const loaded2 = await loadAccounts() + expect(loaded2?.prime?.main).toBeUndefined() + + // A fallback account with a malformed counter is dropped from the + // account's prime field, but the rest of the account survives. + const storage3 = baseStorage() + storage3.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'r', + access: 'a', + }) + await saveAccounts(storage3) + await incrementPrimeUsagePersistent( + 'work-alt', + { inputTokens: 20, outputTokens: 1 }, + accountPath, + 1, + ) + // Tamper the on-disk counter with a negative value. + const statePath = getAccountStatePath(accountPath) + const raw = JSON.parse(await readFile(statePath, 'utf8')) + raw.accounts['work-alt'].prime.count = -7 + await writeFile(statePath, JSON.stringify(raw), 'utf8') + const loaded3 = await loadAccounts() + const workAlt3 = loaded3?.accounts.find((a) => a.id === 'work-alt') as + | OAuthAccount + | undefined + expect(workAlt3?.prime).toBeUndefined() + expect(workAlt3?.access).toBe('a') + }) +}) + +describe('getOrCreatePrimeAuthLineageId', () => { + test('concurrent first lookups converge on one persisted fallback lineage', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'work-alt', + type: 'oauth', + refresh: 'rotating-refresh', + }) + await saveAccounts(storage) + + const [first, second] = await Promise.all([ + getOrCreatePrimeAuthLineageId('work-alt', accountPath), + getOrCreatePrimeAuthLineageId('work-alt', accountPath), + ]) + + expect(first).toBe(second) + expect(first).toMatch(/^[0-9a-f-]{36}$/) + const loaded = await loadAccounts(accountPath) + expect(expectOAuthAccount(loaded?.accounts[0]).authLineageId).toBe(first) + }) + + test('concurrent main replacement observations converge on one new lineage', async () => { + const original = await getOrCreatePrimeAuthLineageId( + 'main', + accountPath, + 'main-refresh-a', + ) + + const [first, second] = await Promise.all([ + getOrCreatePrimeAuthLineageId('main', accountPath, 'main-refresh-b'), + getOrCreatePrimeAuthLineageId('main', accountPath, 'main-refresh-b'), + ]) + + expect(first).toBe(second) + expect(first).not.toBe(original) + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.main.primeAuthLineageRefreshTokenFingerprint).toBe( + tokenFingerprint('main-refresh-b'), + ) + }) + + test('a legacy main lineage binds once without changing identity', async () => { + const legacy = 'legacy-main-lineage' + await writeFile( + getAccountStatePath(accountPath), + `${JSON.stringify({ + version: 1, + main: { primeAuthLineageId: legacy }, + })}\n`, + 'utf8', + ) + + const migrated = await getOrCreatePrimeAuthLineageId( + 'main', + accountPath, + 'main-refresh-a', + ) + + expect(migrated).toBe(legacy) + const rawState = JSON.parse( + await readFile(getAccountStatePath(accountPath), 'utf8'), + ) + expect(rawState.main.primeAuthLineageRefreshTokenFingerprint).toBe( + tokenFingerprint('main-refresh-a'), + ) + }) + + test('a missing main refresh token does not mint or persist a lineage', async () => { + const statePath = getAccountStatePath(accountPath) + + const first = await getOrCreatePrimeAuthLineageId('main', accountPath) + const second = await getOrCreatePrimeAuthLineageId('main', accountPath) + + expect(first).toBeUndefined() + expect(second).toBeUndefined() + await expect(stat(statePath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + test('a missing main refresh token returns persisted lineage without writing', async () => { + const statePath = getAccountStatePath(accountPath) + const persisted = `${JSON.stringify({ + version: 1, + main: { primeAuthLineageId: 'existing-main-lineage' }, + })}\n` + await writeFile(statePath, persisted, 'utf8') + + const lineage = await getOrCreatePrimeAuthLineageId('main', accountPath) + + expect(lineage).toBe('existing-main-lineage') + expect(await readFile(statePath, 'utf8')).toBe(persisted) + }) +}) diff --git a/packages/opencode/src/tests/add-account-flows.test.ts b/packages/opencode/src/tests/add-account-flows.test.ts index aa097cab..0aeebbb2 100644 --- a/packages/opencode/src/tests/add-account-flows.test.ts +++ b/packages/opencode/src/tests/add-account-flows.test.ts @@ -522,6 +522,24 @@ describe('add-oauth label threading', () => { // id is a UUID (no natural key for OAuth) expect(account.id).toMatch(/^[0-9a-f-]{36}$/) }) + + test('a fresh login at the same label gets a new auth lineage', async () => { + const first = await runOAuthAddWithLabel('work') + const firstAccount = first!.accounts[0]! + expect(firstAccount.type).toBe('oauth') + if (firstAccount.type !== 'oauth') throw new Error('expected OAuth account') + + const second = await runOAuthAddWithLabel('work') + const secondAccount = second!.accounts[0]! + expect(secondAccount.type).toBe('oauth') + if (secondAccount.type !== 'oauth') + throw new Error('expected OAuth account') + expect(secondAccount.authLineageId).not.toBe(firstAccount.authLineageId) + const { tokenFingerprint } = await import('@cortexkit/anthropic-auth-core') + expect(tokenFingerprint(secondAccount.authLineageId!)).not.toBe( + tokenFingerprint(firstAccount.authLineageId!), + ) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/tests/auth.test.ts b/packages/opencode/src/tests/auth.test.ts index 9c920c29..e9a489dc 100644 --- a/packages/opencode/src/tests/auth.test.ts +++ b/packages/opencode/src/tests/auth.test.ts @@ -165,6 +165,7 @@ describe('refreshClaudeOAuthToken', () => { const result = await refreshClaudeOAuthToken({ refreshToken: 'old-refresh', + authLineageId: 'stable-lineage', now: () => 1_000, fetchImpl: mock((input: string | URL | Request, init?: RequestInit) => { capturedUrl = String(input) @@ -200,6 +201,7 @@ describe('refreshClaudeOAuthToken', () => { refresh: 'old-refresh', expires: 3_601_000, expiresIn: 3600, + authLineageId: 'stable-lineage', }) }) diff --git a/packages/opencode/src/tests/cli.test.ts b/packages/opencode/src/tests/cli.test.ts index a02350e9..a9aaadea 100644 --- a/packages/opencode/src/tests/cli.test.ts +++ b/packages/opencode/src/tests/cli.test.ts @@ -195,6 +195,9 @@ describe('CLI login', () => { access: 'cli-access', refresh: 'cli-refresh', }) + expect(runtimeState.accounts['cli-label'].authLineageId).toMatch( + /^[0-9a-f-]{36}$/, + ) }) test('preserves an account committed while the interactive OAuth flow is open', async () => { diff --git a/packages/opencode/src/tests/command-dialogs.test.ts b/packages/opencode/src/tests/command-dialogs.test.ts index 84b33a4f..409d7e14 100644 --- a/packages/opencode/src/tests/command-dialogs.test.ts +++ b/packages/opencode/src/tests/command-dialogs.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test' +import type { PrimeAccountStatus } from '@cortexkit/anthropic-auth-core' import { buildAccountDialogOption, buildKillswitchThresholdSeed, + buildPrimeStatusRows, + handlePrimeStatusOption, + PRIME_DIALOG_OPTIONS, } from '../tui/command-dialogs' describe('buildKillswitchThresholdSeed', () => { @@ -46,3 +50,74 @@ describe('buildAccountDialogOption', () => { }) }) }) + +describe('buildPrimeStatusRows', () => { + const base = { + id: 'main', + label: 'main', + nextDueAt: undefined, + } as PrimeAccountStatus + + test('renders future-due, successful prime, and active-window rows', () => { + const futureDue = Date.now() + 60 * 60_000 + const past = Date.now() - 60_000 + const rows = buildPrimeStatusRows([ + { ...base, id: 'main', nextDueAt: futureDue }, + { + id: 'work-alt', + label: 'work-alt', + nextDueAt: undefined, + lastPrimedAt: past, + lastResult: 'ok', + usage: { count: 12, inputTokens: 240, outputTokens: 12, since: 1 }, + estimatedCostUsd: 0.00132, + }, + { + id: 'expired', + label: 'expired', + // active window: a past nextDueAt means the reset has happened but + // the window already started; no row says "primed" and no future + // prime is due. + nextDueAt: past, + }, + ]) + expect(rows.length).toBeGreaterThanOrEqual(4) + expect(rows[0]).toContain('main · next prime') + expect(rows.find((r) => r.includes('work-alt · primed'))).toBeDefined() + expect(rows.find((r) => r.includes('12 primes'))).toBeDefined() + expect(rows.find((r) => r.includes('— window active'))).toBeDefined() + }) + + test('error row uses "primed HH:MM err" notation', () => { + const rows = buildPrimeStatusRows([ + { + id: 'work-alt', + label: 'work-alt', + nextDueAt: undefined, + lastPrimedAt: Date.now() - 60_000, + lastResult: 'error', + }, + ]) + expect(rows[0]).toContain('primed') + expect(rows[0]).toContain('err') + }) +}) + +describe('openCommandDialog — claude-prime modal interaction (M6)', () => { + test('main view exposes 4 options in spec order: Enable / Disable / Status / Back', () => { + expect(PRIME_DIALOG_OPTIONS).toEqual([ + { title: 'Enable', value: 'on' }, + { title: 'Disable', value: 'off' }, + { title: 'Status', value: 'status' }, + { title: 'Back', value: 'back' }, + ]) + }) + + test('Status view has a working Back action that returns to the main view', () => { + let returned = false + handlePrimeStatusOption({ value: 'back' }, () => { + returned = true + }) + expect(returned).toBe(true) + }) +}) diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 1ed9af5a..809b4f39 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -6,9 +6,13 @@ import { __setLogTestSink, type AccountStorage, acquireRefreshFileLock, + buildPrimeRequestBody, buildRefreshOperationError, ClaudeOAuthRefreshError, + extractBillingHeaderCCH, getAccountStatePath, + getClaudeCodeIdentity, + getOrCreatePrimeAuthLineageId, hashRefreshToken, type LogTestRecord, loadAccounts, @@ -23,7 +27,7 @@ import { setLogLevel, tokenFingerprint, } from '@cortexkit/anthropic-auth-core' -import { AnthropicAuthPlugin } from '../index' +import { AnthropicAuthPlugin, primeQuotaSnapshotIsFreshSince } from '../index' import { drainNotifications, resetNotificationsForTest, @@ -37,6 +41,7 @@ import { resolveActiveAccount, setSidebarState, } from '../sidebar-state' +import { rewriteRequestBody } from '../transform.ts' /** Extract the URL string from a fetch input (string, URL, or Request). */ function extractUrl(input: string | URL | Request): string { @@ -45,6 +50,14 @@ function extractUrl(input: string | URL | Request): string { return input.url } +async function freshPrimeQuotaResponse( + body: unknown, + init: ResponseInit = { status: 200 }, +): Promise { + await Bun.sleep(2) + return new Response(JSON.stringify(body), init) +} + // Minimal mock of the OpenCode plugin client function createMockClient(messages?: unknown[]) { return { @@ -248,10 +261,14 @@ function fireConcurrentFetches(result: { fetch: typeof fetch }) { ) } -async function getPlugin(client?: ReturnType) { +async function getPlugin( + client?: ReturnType, + directory?: string, +) { return (await AnthropicAuthPlugin({ // @ts-expect-error: minimal mock for testing client: client ?? createMockClient(), + ...(directory && { directory }), })) as Promise } @@ -3141,6 +3158,7 @@ describe('auth.loader', () => { 'claude-account', 'claude-cache', 'claude-cachekeep', + 'claude-prime', 'claude-quota', 'claude-dump', 'claude-fast', @@ -3153,8 +3171,8 @@ describe('auth.loader', () => { } // The config hook must not register extra claude-* commands beyond the - // modalCommands set (drift in either direction is a bug). Exactly the 9 - // required names should be claude-* keys — no more, no less. (The foreign + // modalCommands set (drift in either direction is a bug). Exactly + // `required.length` names should be claude-* keys — no more, no less. (The foreign // 'other-plugin-cmd' is excluded from this count via the claude- prefix.) const claudeRegistered = registered.filter((name) => name.startsWith('claude-'), @@ -8944,3 +8962,1956 @@ describe('killswitch fetch gate', () => { expect(mainServed).toBe(false) }) }) + +// -- /claude-prime: direct OAuth sender + quota refresh + accounting ------ + +describe('claude-prime direct request', () => { + const originalFetch = globalThis.fetch + const originalSetInterval = globalThis.setInterval + + beforeEach(async () => { + globalThis.fetch = originalFetch + globalThis.setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval + // Marker dir is shared across processes; sweep leftovers so a prior + // suite's fire doesn't suppress the next suite's claim. + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setInterval = originalSetInterval + }) + + test('main prime fires a direct messages request with the documented body shape', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const primeCalls: Array<{ + url: string + init: RequestInit | undefined + }> = [] + let quotaCalls = 0 + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + primeCalls.push({ url, init }) + return Promise.resolve( + new Response( + JSON.stringify({ + id: 'msg-test', + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + quotaCalls += 1 + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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: {} }, + ) + + // Reach into the plugin's internal manager via the auth closure wiring + // already in place: the manager has been constructed with sendPrime wired + // to a closure that calls the auth loader. Trigger an explicit tick via + // the manager factory on `plugin`. + const mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + expect(mgr).toBeDefined() + await mgr!.tick() + + expect(primeCalls).toHaveLength(1) + // The prime request routes through rewriteUrl which appends + // ?beta=true to /v1/messages URLs (house convention for direct + // Anthropic calls). Assert the URL is the messages endpoint with + // the beta param rather than the bare path. + expect(primeCalls[0]?.url).toBe(`${MESSAGES_URL}?beta=true`) + const init = primeCalls[0]?.init + const bodyText = + typeof init?.body === 'string' + ? init.body + : init?.body instanceof Uint8Array + ? new TextDecoder().decode(init.body) + : '' + const body = JSON.parse(bodyText) + const canonicalBody = await rewriteRequestBody( + JSON.stringify(buildPrimeRequestBody()), + { identity: getClaudeCodeIdentity('main-access') }, + ) + expect(bodyText).toBe(canonicalBody) + expect(body.model).toBe('claude-haiku-4-5') + expect(body.max_tokens).toBe(1) + expect(body.messages).toEqual([{ role: 'user', content: '0' }]) + expect(JSON.stringify(body.system)).toContain( + 'Reply with 1 when you receive 0.', + ) + expect(extractBillingHeaderCCH(bodyText)).toMatch(/^[a-f0-9]{5}$/) + expect(body.stream).toBeUndefined() + expect(body.thinking).toBeUndefined() + expect(body.tools).toBeUndefined() + expect(bodyText).not.toContain('cache_control') + + // Quota fresh-check fired before the request + expect(quotaCalls).toBeGreaterThanOrEqual(1) + }) + + test('main prime uses main OAuth token + Anthropic identity headers', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + let observedAuth: string | undefined + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const headers = new Headers(init?.headers ?? {}) + observedAuth = headers.get('authorization') ?? undefined + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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 mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + await mgr!.tick() + + expect(observedAuth).toBeDefined() + expect(observedAuth).toContain('main-access') + }) + + test('main host credential replacement primes a new lineage in the same reset window', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + let sends = 0 + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + sends += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + const firstPlugin = await getPlugin() + await firstPlugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access-a', + refresh: 'main-refresh-a', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await ( + firstPlugin as unknown as { + __primeManager: { tick: () => Promise } + } + ).__primeManager.tick() + const firstState = JSON.parse( + await readFile(getAccountStatePath(), 'utf8'), + ) as { main?: { primeAuthLineageId?: string } } + const firstLineage = firstState.main?.primeAuthLineageId + + const secondPlugin = await getPlugin() + await secondPlugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access-b', + refresh: 'main-refresh-b', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + await ( + secondPlugin as unknown as { + __primeManager: { tick: () => Promise } + } + ).__primeManager.tick() + + expect(sends).toBe(2) + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) as { + main?: { primeAuthLineageId?: string } + } + expect(firstLineage).toMatch(/^[0-9a-f-]{36}$/) + expect(state.main?.primeAuthLineageId).toMatch(/^[0-9a-f-]{36}$/) + expect(state.main?.primeAuthLineageId).not.toBe(firstLineage) + }) + + test('main refresh through the plugin keeps the lineage and prime claim', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const intervalHandlers: Array<() => void> = [] + globalThis.setInterval = mock((handler: () => void) => { + intervalHandlers.push(handler) + return { unref() {} } + }) as unknown as typeof setInterval + let hostAuth = { + type: 'oauth', + access: 'main-access-a', + refresh: 'main-refresh-a', + expires: Date.now() + 5 * 60 * 60_000, + } + const mockClient = createMockClient() + let lineageObservedBeforePublish: string | undefined + let lineageObservedDuringPublish: string | undefined + ;(mockClient.auth as any).set = mock( + async (input: { + body: { + type: string + access: string + refresh: string + expires: number + } + }) => { + lineageObservedBeforePublish = await getOrCreatePrimeAuthLineageId( + 'main', + process.env.OPENCODE_ANTHROPIC_AUTH_FILE, + hostAuth.refresh, + ) + hostAuth = { ...input.body } + lineageObservedDuringPublish = await getOrCreatePrimeAuthLineageId( + 'main', + process.env.OPENCODE_ANTHROPIC_AUTH_FILE, + hostAuth.refresh, + ) + }, + ) + let sends = 0 + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/oauth/token')) { + return Promise.resolve( + new Response( + JSON.stringify({ + refresh_token: 'main-refresh-b', + access_token: 'main-access-b', + expires_in: 3600, + }), + { status: 200 }, + ), + ) + } + if (url.includes('/v1/messages')) { + sends += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin(mockClient) + await plugin.auth.loader(() => Promise.resolve(hostAuth), { models: {} }) + const manager = (plugin as any).__primeManager + await manager.tick() + const before = JSON.parse( + await readFile(getAccountStatePath(), 'utf8'), + ) as { + main?: { + primeAuthLineageId?: string + primeAuthLineageRefreshTokenFingerprint?: string + } + } + + hostAuth.expires = Date.now() - 1 + const mainRefreshHandler = intervalHandlers.at(-1) + expect(mainRefreshHandler).toBeDefined() + mainRefreshHandler!() + await waitForMockCall(mockClient.auth.set) + await manager.tick() + + const after = JSON.parse( + await readFile(getAccountStatePath(), 'utf8'), + ) as typeof before + expect(sends).toBe(1) + expect(lineageObservedBeforePublish).toBe(before.main?.primeAuthLineageId) + expect(lineageObservedDuringPublish).toBe(before.main?.primeAuthLineageId) + expect(after.main?.primeAuthLineageId).toBe(before.main?.primeAuthLineageId) + expect(after.main?.primeAuthLineageRefreshTokenFingerprint).toBe( + tokenFingerprint('main-refresh-b'), + ) + }) + + test('a main refresh lease adopter advances the lineage binding', async () => { + const lineage = 'main-lineage-a' + const refreshToken = 'main-refresh-a' + const storage = createFallbackStorage({ + accounts: [], + refresh: { + enabled: true, + refreshBeforeExpiryMinutes: 30, + mainRefreshLeaseId: 'other-process', + mainRefreshLeaseUntil: Date.now() + 60_000, + mainRefreshLeaseTokenHash: hashRefreshToken(refreshToken), + }, + prime: { + enabled: true, + mainAuthLineageId: lineage, + mainAuthLineageRefreshTokenFingerprint: tokenFingerprint(refreshToken), + }, + }) + await useTempAccountFile(storage) + await saveAccountState(storage, process.env.OPENCODE_ANTHROPIC_AUTH_FILE, { + mainRefresh: true, + mainPrime: true, + }) + + let hostAuth = { + type: 'oauth', + access: 'main-access-a', + refresh: refreshToken, + expires: Date.now() + 5 * 60 * 60_000, + } + let observedAuthorization: string | null = null + globalThis.fetch = mock((_input: any, init?: RequestInit) => { + observedAuthorization = new Headers(init?.headers).get('authorization') + return Promise.resolve( + new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(() => Promise.resolve(hostAuth), { + models: {}, + }) + hostAuth.expires = Date.now() - 1 + const request = result.fetch(MESSAGES_URL, EMPTY_POST) + await Bun.sleep(25) + hostAuth = { + type: 'oauth', + access: 'main-access-b', + refresh: 'main-refresh-b', + expires: Date.now() + 60 * 60_000, + } + const response = await request + + expect(response.status).toBe(200) + expect(String(observedAuthorization)).toContain('main-access-b') + const state = JSON.parse(await readFile(getAccountStatePath(), 'utf8')) as { + main?: { + primeAuthLineageId?: string + primeAuthLineageRefreshTokenFingerprint?: string + } + } + expect(state.main?.primeAuthLineageId).toBe(lineage) + expect(state.main?.primeAuthLineageRefreshTokenFingerprint).toBe( + tokenFingerprint('main-refresh-b'), + ) + }) + + test('fallback prime uses fallback OAuth token', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + // Marker dir is shared across tests; sweep any leftover marker for + // this reset epoch so a prior suite's fire doesn't suppress the + // claim and the fresh-check is forced through. + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'fb-access', + refresh: 'fb-refresh', + // expires must exceed the refresh-before-expiry window (4h default) + // so the token is NOT marked as needing refresh and the prime + // request flows through without the OAuth refresh fetch. + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + // Recent (1min) so the background refresh's + // `isFallbackStale` guard does NOT fire a competing + // refresh in the same millisecond — without this the + // prime fresh-check's baseline equals the fetched + // checkedAt and `fresh` is false. The check-interval + // gate is 5min (storage.quota.checkIntervalMinutes), + // so a 1min-old checkedAt is comfortably fresh. + checkedAt: Date.now() - 60 * 1000, + }, + }, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + prime: { enabled: true }, + }), + ) + + const calls: Array<{ url: string; auth?: string }> = [] + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + const headers = new Headers(init?.headers ?? {}) + calls.push({ url, auth: headers.get('authorization') ?? undefined }) + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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 mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + await mgr!.tick() + + const primeCall = calls.find( + (c) => + c.url === `${MESSAGES_URL}?beta=true` && c.auth?.includes('fb-access'), + ) + expect(primeCall).toBeDefined() + expect(primeCall?.auth).toContain('fb-access') + }) + + test('fallback refresh-token rotation keeps one prime claim per reset window', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-rotating', + type: 'oauth', + access: 'fb-access-a', + refresh: 'fb-refresh-a', + expires: Date.now() + 5 * 60 * 60 * 1000, + authLineageId: 'lineage-work', + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: Date.now() - 60 * 1000, + }, + }, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + prime: { enabled: true }, + }), + ) + + let sends = 0 + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + const authorization = new Headers(init?.headers).get('authorization') + if (authorization?.includes('fb-access-')) sends += 1 + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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 mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + + await mgr!.tick() + const rotated = await loadAccounts() + const account = rotated!.accounts.find( + (candidate) => candidate.id === 'work-rotating', + )! + if (account.type !== 'oauth') throw new Error('expected OAuth account') + account.access = 'fb-access-b' + account.refresh = 'fb-refresh-b' + await saveAccounts(rotated!) + await mgr!.tick() + + expect(sends).toBe(1) + }) + + test('send failure does not increment prime counters; no retry in same cycle', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + let messageCalls = 0 + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + messageCalls += 1 + return Promise.resolve(new Response('boom', { status: 500 })) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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 mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + await mgr!.tick() + await mgr!.tick() + + // Two ticks in the same reset cycle: marker claimed → second tick skips + expect(messageCalls).toBe(1) + + // No counter incremented (state file has no main.prime) + const statePath = getAccountStatePath( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, + ) + const raw = JSON.parse(await readFile(statePath, 'utf8')) + expect(raw.main?.prime).toBeUndefined() + }) + + test('successful send increments main prime counters', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(now - 1_000).toISOString(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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 mgr = ( + plugin as unknown as { + __primeManager?: { tick: () => Promise } + } + ).__primeManager + await mgr!.tick() + + const statePath = getAccountStatePath( + process.env.OPENCODE_ANTHROPIC_AUTH_FILE!, + ) + const raw = JSON.parse(await readFile(statePath, 'utf8')) + expect(raw.main.prime).toEqual({ + count: 1, + inputTokens: 20, + outputTokens: 1, + since: expect.any(Number), + }) + }) + + test('main prime refreshes a missing access token before firing (M2)', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + let authCallCount = 0 + const primeCalls: Array<{ url: string; init: RequestInit | undefined }> = [] + let _quotaCalls = 0 + globalThis.fetch = mock((input: any, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.url + const _headers = new Headers(init?.headers ?? {}) + if (url.includes('/v1/messages')) { + primeCalls.push({ url, init }) + return Promise.resolve( + new Response( + JSON.stringify({ + usage: { input_tokens: 20, output_tokens: 1 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + _quotaCalls += 1 + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + }, + }) + } + if (url.includes('/v1/oauth/token')) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: 'refreshed-main-access', + refresh_token: 'main-refresh', + expires_in: 3600, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + // First call: getAuth returns oauth WITHOUT access (triggers refresh). + // Second call: getAuth returns oauth WITH the refreshed access token. + await plugin.auth.loader( + () => { + authCallCount += 1 + if (authCallCount === 1) { + return Promise.resolve({ + type: 'oauth', + access: undefined, + refresh: 'main-refresh', + expires: undefined, + }) + } + return Promise.resolve({ + type: 'oauth', + access: 'refreshed-main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }) + }, + { models: {} }, + ) + const mgr = ( + plugin as unknown as { __primeManager?: { tick: () => Promise } } + ).__primeManager + expect(mgr).toBeDefined() + await mgr!.tick() + + // The prime request should have fired with the refreshed token. + expect(primeCalls).toHaveLength(1) + const init = primeCalls[0]?.init + const headers = new Headers(init?.headers ?? {}) + expect(headers.get('authorization')).toContain('refreshed-main-access') + }) + + test('plugin instances with the same storage path adopt one prime manager', async () => { + const fixture = createFallbackStorage({ + prime: { enabled: true }, + }) + await useTempAccountFile(fixture) + let intervalCalls = 0 + ;(globalThis as any).setInterval = mock(() => { + intervalCalls += 1 + return { unref() {} } as unknown as ReturnType + }) + const plugin1 = await getPlugin() + const mgr1 = (plugin1 as any).__primeManager + const firstLoadStorage = mgr1.options.loadStorage + expect(mgr1).toBeDefined() + expect(mgr1.isStopped?.()).toBeFalsy() + const intervalsAfterFirstPlugin = intervalCalls + const plugin2 = await getPlugin() + const mgr2 = (plugin2 as any).__primeManager + expect(mgr2).toBeDefined() + expect(mgr2).toBe(mgr1) + expect(mgr2.options.loadStorage).not.toBe(firstLoadStorage) + expect(mgr1.isStopped()).toBe(false) + expect(intervalCalls - intervalsAfterFirstPlugin).toBe(1) + }) + + test('plugin instances with different storage paths own independent prime managers', async () => { + const fixture = createFallbackStorage({ prime: { enabled: true } }) + await useTempAccountFile(fixture) + const plugin1 = await getPlugin(undefined, '/project/one') + const mgr1 = (plugin1 as any).__primeManager + + await useTempAccountFile(fixture) + const plugin2 = await getPlugin(undefined, '/project/two') + const mgr2 = (plugin2 as any).__primeManager + + expect(mgr2).not.toBe(mgr1) + expect(mgr1.isStopped()).toBe(false) + expect(mgr2.isStopped()).toBe(false) + }) +}) + +describe('claude-prime sidebar on toggle', () => { + let sidebarStateFile: string | undefined + + beforeEach(async () => { + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + }) + async function readSidebar(): Promise<{ + prime?: { enabled?: boolean; accounts?: unknown } + }> { + if (!sidebarStateFile) { + throw new Error('sidebar state file not configured') + } + const fs = await import('node:fs/promises') + try { + return JSON.parse(await fs.readFile(sidebarStateFile, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // Sidebar file has not been written yet — treat as empty + // (degenerate) state with no `prime` field. + return {} + } + throw error + } + } + + test('/claude-prime on publishes prime section to the sidebar (M7)', async () => { + const fixture = createFallbackStorage({ + prime: { enabled: false }, + }) + await useTempAccountFile(fixture) + sidebarStateFile = process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + ;(globalThis as any).setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }), + { models: {} }, + ) + // Baseline: prime disabled in storage → no prime section in sidebar. + const before = await readSidebar() + expect(before.prime).toBeUndefined() + + await expect( + plugin['command.execute.before']({ + command: 'claude-prime', + arguments: 'on', + sessionID: 'ses_test', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + // After on, sidebar has the prime section. + const afterOn = await readSidebar() + expect(afterOn.prime?.enabled).toBe(true) + expect(afterOn.prime?.accounts).toBeDefined() + }) + + test('/claude-prime off removes prime section from the sidebar (M7)', async () => { + const fixture = createFallbackStorage({ + prime: { enabled: true }, + }) + await useTempAccountFile(fixture) + sidebarStateFile = process.env.OPENCODE_ANTHROPIC_AUTH_SIDEBAR_STATE_FILE + ;(globalThis as any).setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }), + { models: {} }, + ) + // Prime a baseline sidebar write by issuing `/claude-prime on` (the + // mutation path publishes the sidebar section per M7). + await expect( + plugin['command.execute.before']({ + command: 'claude-prime', + arguments: 'on', + sessionID: 'ses_test', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + const baseline = await readSidebar() + expect(baseline.prime?.enabled).toBe(true) + + await expect( + plugin['command.execute.before']({ + command: 'claude-prime', + arguments: 'off', + sessionID: 'ses_test', + }), + ).rejects.toThrow('__OPENCODE_ANTHROPIC_AUTH_COMMAND_HANDLED__') + + // After off, prime section is removed. + const afterOff = await readSidebar() + expect(afterOff.prime).toBeUndefined() + }) +}) + +describe('claude-prime — snapshot-derived freshness (R1/R2)', () => { + // R1: only snapshots stamped during the current refresh call are fresh. + // R2: refreshPrimeFallbackQuota must make exactly ONE usage-API call + // (the refreshAccountQuota path), not two. The second quotaManager. + // refreshFallback call is redundant — its result is ignored. + + const originalFetch = globalThis.fetch + const originalSetInterval = globalThis.setInterval + + beforeEach(async () => { + globalThis.fetch = originalFetch + globalThis.setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval + // Marker dir is shared across processes; sweep leftovers so a prior + // suite's fire doesn't suppress the next suite's claim. + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setInterval = originalSetInterval + }) + + test('R1: a cached positive checkedAt before the refresh call is stale even with no baseline snapshot', () => { + const preCall = 10_000 + const cachedQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: preCall - 1, + }, + } + + expect(primeQuotaSnapshotIsFreshSince(cachedQuota, preCall)).toBe(false) + }) + + test('R1: a snapshot stamped at the refresh-call boundary is stale', () => { + const preCall = 10_000 + const fetchedQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: preCall, + }, + } + + expect(primeQuotaSnapshotIsFreshSince(fetchedQuota, preCall)).toBe(false) + }) + + test('R1: a snapshot stamped after the refresh-call boundary is fresh', () => { + const preCall = 10_000 + const fetchedQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: preCall + 1, + }, + } + + expect(primeQuotaSnapshotIsFreshSince(fetchedQuota, preCall)).toBe(true) + }) + + test('R1: the manager skips a quota result classified stale', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 10, + }, + }, + mainQuotaCheckedAt: 10, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const primeCalls: any[] = [] + const cachedQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 10, + }, + } + + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + primeCalls.push({ url }) + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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() + 3600_000, + }), + { models: {} }, + ) + const mgr = (plugin as any).__primeManager + mgr.options.refreshQuota = async () => ({ + quota: cachedQuota, + fresh: false, + }) + await mgr.tick() + + expect(primeCalls).toHaveLength(0) + }) + + test('R1: the manager fires after a quota result classified fresh', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 10, + }, + }, + mainQuotaCheckedAt: 10, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const primeCalls: any[] = [] + const freshQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: 100, + }, + } + + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + primeCalls.push({ url }) + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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() + 3600_000, + }), + { models: {} }, + ) + const mgr = (plugin as any).__primeManager + mgr.options.refreshQuota = async () => ({ + quota: freshQuota, + fresh: true, + }) + await mgr.tick() + + expect(primeCalls).toHaveLength(1) + }) + + test('R2: refreshPrimeFallbackQuota makes exactly one usage-API call per tick', async () => { + const now = Date.now() - 60_000 + const past = now - 120_000 + // Each test runs from a fresh temp dir but the prime marker dir is + // shared across processes. Sweep any leftover marker for this reset + // epoch so a prior suite's fire doesn't suppress the R2 claim AND + // doesn't trigger the manager's `refreshPrimeFallbackQuota` twice + // (once for the cached account, once for the due account). + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'fb-access', + refresh: 'fb-refresh', + // expires must exceed the 4h refresh-before-expiry window so + // the token is NOT marked as needing refresh (otherwise the + // refresh path would make a second fetch to /v1/oauth/token). + expires: Date.now() + 10 * 60 * 60 * 1000, + quota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + // Recent (1min) so the background refresh's + // `isFallbackStale` guard does NOT fire a competing + // refresh in the same millisecond — without this the + // prime fresh-check's baseline equals the fetched + // checkedAt and `fresh` is false. + checkedAt: Date.now() - 60 * 1000, + }, + }, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: 'main-access', + }, + prime: { enabled: true }, + }), + ) + + let usageCalls = 0 + let primeCalls = 0 + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + primeCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + usageCalls += 1 + return freshPrimeQuotaResponse( + { + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + checked_at: Date.now(), + }, + }, + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) 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() + 3600_000, + }), + { models: {} }, + ) + const mgr = (plugin as any).__primeManager + await mgr.tick() + + // Exactly ONE usage-API call per tick — the redundant second + // refreshFallback is collapsed into the single refreshAccountQuota + // path. + expect(usageCalls).toBe(1) + // The fire still happens against the (now-fresh) quota result. + expect(primeCalls).toBe(1) + }) + + test('R1: fallback 429 backoff classifies a re-stamped cached quota as stale', async () => { + const now = Date.now() + const past = now - 120_000 + const cachedQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(past).toISOString(), + checkedAt: now + 60_000, + }, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'fb-access', + refresh: 'fb-refresh', + expires: now + 10 * 60 * 60_000, + quota: cachedQuota, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(now + 5 * 60 * 60_000).toISOString(), + checkedAt: now, + }, + }, + mainQuotaCheckedAt: now, + mainQuotaToken: 'main-access', + }, + prime: { enabled: true }, + }), + ) + + let primeCalls = 0 + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/api/oauth/usage')) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + if (url.includes('/v1/messages')) { + primeCalls += 1 + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 20, output_tokens: 1 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: now + 3600_000, + }), + { models: {} }, + ) + const quotaManager = (plugin as any).__quotaManager + quotaManager.setFallback( + 'work-alt', + { quota: cachedQuota, refreshAfter: now, checkedAt: now + 60_000 }, + 'fb-access', + ) + await expect( + quotaManager.refreshFallback('work-alt', 'fb-access'), + ).rejects.toThrow('429') + + const mgr = (plugin as any).__primeManager + await mgr.tick() + + expect(primeCalls).toBe(0) + }) +}) + +describe('claude-prime — warn dedup (R3)', () => { + // R3: on a fresh-check-ok-but-fire-time-token-refresh-fails path, + // both the adapter-side `prime fire failed` warn (index.ts main + // catch) and the manager-side warn emit the same message. Only the + // manager should log — the adapter must surface the error to the + // manager as a non-ok result and not log itself. + + const originalFetch = globalThis.fetch + const originalSetInterval = globalThis.setInterval + + beforeEach(async () => { + globalThis.fetch = originalFetch + globalThis.setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval + // Marker dir is shared across processes; sweep leftovers so a prior + // suite's fire doesn't suppress the next suite's claim. + await rm(join(tmpdir(), 'opencode-anthropic-auth', 'prime'), { + recursive: true, + force: true, + }).catch(() => {}) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setInterval = originalSetInterval + }) + + test('R3: a fire-time main token refresh failure produces exactly one warn·prime·prime token refresh failed record (distinct from the generic fire-failed event)', async () => { + // Force a genuine token-refresh failure during the fire path. The + // getAuth returns no access token + a past expiry, so + // `getCurrentMainAccessToken` invokes `latestRefreshMainAccessToken`, + // which is mocked to throw. This is the ONLY way to exercise the + // `prime token refresh failed` event from the main path. + const records: any[] = [] + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + checked_at: Date.now(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() - 180_000).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const plugin = await getPlugin() + // The init loader calls getAuth once (call 1). Prime lineage + // reconciliation observes the refresh token (call 2), then the fresh-check + // calls getAuth (call 3) — return a valid token. The fire path calls getAuth + // (call 4) — return a no-access / past-expiry auth so the refresh path is + // exercised. The refresh function + // fetches the token endpoint which the mock returns 599 for — + // this is the genuine token-refresh failure. + let authCallCount = 0 + await plugin.auth.loader( + () => { + authCallCount += 1 + if (authCallCount <= 3) { + return Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }) + } + return Promise.resolve({ + type: 'oauth', + access: undefined, + refresh: 'main-refresh', + expires: Date.now() - 1000, + }) + }, + { models: {} }, + ) + + // Capture logs via the dist sink (prime.ts compiled to dist/prime.js + // imports dist/logger.js; the dist sink captures all events). + const { __setLogTestSink, setLogLevel } = await import( + '@cortexkit/anthropic-auth-core' + ) + const setDistSink = __setLogTestSink + setLogLevel('warn') + setDistSink((r: any) => records.push(r)) + + const mgr = (plugin as any).__primeManager + // The fresh-check succeeds because the main quota is already + // stored. The fire path's `getCurrentMainAccessToken` calls + // `latestRefreshMainAccessToken` (because the cached access + // is missing/expired) and that function throws. This is the + // genuine token-refresh failure path. + // + // NOTE: the loader captures `getAuth` in a closure. The + // refresh function is also captured. We can't easily replace + // it from outside, so we rely on the getAuth returning a + // no-access / past-expiry auth, which forces the refresh + // path. The refresh function is the loader's own + // `refreshMainAccessToken`, which internally calls the + // Anthropic refresh endpoint. The mock fetch returns 599 for + // everything except /v1/messages, so the token refresh + // endpoint fetch fails with a network error, and the refresh + // function throws — which IS a token-refresh failure. + records.length = 0 + await mgr.tick() + setDistSink(null) + setLogLevel('info') + + // The token-refresh path throws because the mock fetch returns + // 599 for the token endpoint. The adapter catches the throw + // and tags it as `reason: 'token-refresh'` (the + // `isPrimeTokenRefresh` flag is set by the refresh wrapper in + // `getCurrentMainAccessToken`). The manager emits the distinct + // `prime token refresh failed` event. + const tokenRefreshWarns = records.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime token refresh failed', + ) + expect(tokenRefreshWarns).toHaveLength(1) + const fireFailedWarns = records.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime fire failed', + ) + expect(fireFailedWarns).toHaveLength(0) + }) + + test('R3-precision: a fire-time main auth-unavailable (latestGetAuth null) failure logs the GENERIC `prime fire failed` (NOT `prime token refresh failed`)', async () => { + // The main `getCurrentMainAccessToken` throws BEFORE reaching the + // refresh path (auth loader is null). This is NOT a token-refresh + // failure — it is a lifecycle / availability failure — and the + // manager must log the generic `prime fire failed` event, not the + // distinct `prime token refresh failed` event. + const records: any[] = [] + globalThis.fetch = mock((input: any) => { + const url = typeof input === 'string' ? input : input.url + if (url.includes('/v1/messages')) { + return Promise.resolve( + new Response( + JSON.stringify({ usage: { input_tokens: 0, output_tokens: 0 } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ) + } + if (url.includes('/api/oauth/usage')) { + return freshPrimeQuotaResponse({ + five_hour: { + utilization: 0, + resets_at: new Date(Date.now() - 1_000).toISOString(), + checked_at: Date.now(), + }, + }) + } + return Promise.resolve(new Response('not-mocked', { status: 599 })) + }) as unknown as typeof fetch + + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() - 180_000).toISOString(), + checkedAt: 1, + }, + }, + mainQuotaCheckedAt: 1, + mainQuotaToken: 'fp-main', + }, + prime: { enabled: true }, + }), + ) + + const plugin = await getPlugin() + // Provide a getAuth so lineage reconciliation and the fresh-check succeed, + // but make the fire path's `latestGetAuth()` throw before any refresh is + // attempted. `getCurrentMainAccessToken` will throw from the + // `await latestGetAuth()` line, which is NOT a refresh failure. + let authCallCount = 0 + await plugin.auth.loader( + () => { + authCallCount += 1 + if (authCallCount >= 4) { + return Promise.reject( + new Error('prime: main auth loader is not available'), + ) + } + return Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }) + }, + { models: {} }, + ) + + const { __setLogTestSink, setLogLevel } = await import( + '@cortexkit/anthropic-auth-core' + ) + const setDistSink = __setLogTestSink + setLogLevel('warn') + setDistSink((r: any) => records.push(r)) + + const mgr = (plugin as any).__primeManager + records.length = 0 + await mgr.tick() + setDistSink(null) + setLogLevel('info') + + const tokenRefreshWarns = records.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime token refresh failed', + ) + expect(tokenRefreshWarns).toHaveLength(0) + const fireFailedWarns = records.filter( + (r) => + r.channel === 'prime' && + r.level === 'warn' && + r.message === 'prime fire failed', + ) + expect(fireFailedWarns).toHaveLength(1) + }) + + test('R3-precision: a fallback removed between fresh-check and fire logs `prime fire failed`', async () => { + const records: any[] = [] + const dueQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() - 180_000).toISOString(), + checkedAt: Date.now(), + }, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'fb-access', + refresh: 'fb-refresh', + expires: Date.now() + 10 * 60 * 60 * 1000, + quota: dueQuota, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: 'main-access', + }, + prime: { enabled: true }, + }), + ) + + const plugin = await getPlugin() + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 3600_000, + }), + { models: {} }, + ) + const mgr = (plugin as any).__primeManager + mgr.options.refreshQuota = async () => { + const storage = await loadAccounts() + if (!storage) throw new Error('missing test storage') + storage.accounts = [] + await saveAccounts(storage) + return { quota: dueQuota, fresh: true } + } + + const { __setLogTestSink, setLogLevel } = await import( + '@cortexkit/anthropic-auth-core' + ) + setLogLevel('warn') + __setLogTestSink((record: any) => records.push(record)) + await mgr.tick() + __setLogTestSink(null) + setLogLevel('info') + + expect( + records.filter( + (record) => record.message === 'prime token refresh failed', + ), + ).toHaveLength(0) + expect( + records.filter((record) => record.message === 'prime fire failed'), + ).toHaveLength(1) + }) + + test('R3: a fallback refreshAccount failure logs `prime token refresh failed`', async () => { + const records: any[] = [] + const dueQuota = { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() - 180_000).toISOString(), + checkedAt: Date.now(), + }, + } + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'work-alt', + type: 'oauth', + access: 'fb-access', + refresh: 'fb-refresh', + expires: Date.now() + 5 * 60 * 60_000, + quota: dueQuota, + }, + ], + quota: { + enabled: true, + checkIntervalMinutes: 5, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(Date.now() + 5 * 60 * 60_000).toISOString(), + checkedAt: Date.now(), + }, + }, + mainQuotaCheckedAt: Date.now(), + mainQuotaToken: 'main-access', + }, + prime: { enabled: true }, + }), + ) + globalThis.fetch = mock(() => + Promise.resolve( + new Response('{"error":"invalid_grant"}', { status: 400 }), + ), + ) 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() + 3600_000, + }), + { models: {} }, + ) + const storage = await loadAccounts() + const fallback = storage!.accounts.find( + (account) => account.id === 'work-alt', + )! + if (fallback.type !== 'oauth') throw new Error('expected OAuth account') + fallback.expires = Date.now() - 1_000 + await saveAccounts(storage!) + const mgr = (plugin as any).__primeManager + mgr.options.refreshQuota = async () => ({ quota: dueQuota, fresh: true }) + + const { __setLogTestSink, setLogLevel } = await import( + '@cortexkit/anthropic-auth-core' + ) + setLogLevel('warn') + __setLogTestSink((record: any) => records.push(record)) + await mgr.tick() + __setLogTestSink(null) + setLogLevel('info') + + expect( + records.filter( + (record) => record.message === 'prime token refresh failed', + ), + ).toHaveLength(1) + expect( + records.filter((record) => record.message === 'prime fire failed'), + ).toHaveLength(0) + }) +}) diff --git a/packages/opencode/src/tests/info-logs.test.ts b/packages/opencode/src/tests/info-logs.test.ts index 2962db98..a11b67db 100644 --- a/packages/opencode/src/tests/info-logs.test.ts +++ b/packages/opencode/src/tests/info-logs.test.ts @@ -382,4 +382,55 @@ describe('setting-change INFO logs', () => { } } }) + + // -- prime --------------------------------------------------------------- + + test('prime on emits info log and persists', async () => { + await useTempAccountFile(createFallbackStorage()) + const plugin = await getPlugin() + await executeCommand(plugin, 'claude-prime', 'on') + expect( + capturedRecords.filter((r) => r.channel === 'commands'), + ).toHaveLength(1) + const rec = findCommandsLog('prime changed') + expect(rec).toBeDefined() + expect(rec!.payload).toEqual({ enabled: true }) + const raw = await readConfigFile() + expect(raw.prime?.enabled).toBe(true) + }) + + test('prime off after on emits info log', async () => { + await useTempAccountFile(createFallbackStorage()) + const plugin = await getPlugin() + // Turn on first so off is a genuine change + await executeCommand(plugin, 'claude-prime', 'on') + capturedRecords = [] + await executeCommand(plugin, 'claude-prime', 'off') + expect( + capturedRecords.filter((r) => r.channel === 'commands'), + ).toHaveLength(1) + const rec = findCommandsLog('prime changed') + expect(rec).toBeDefined() + expect(rec!.payload).toEqual({ enabled: false }) + }) + + test('prime status emits no setting-change info log', async () => { + await useTempAccountFile(createFallbackStorage()) + const plugin = await getPlugin() + await executeCommand(plugin, 'claude-prime', '') + expect( + capturedRecords.filter((r) => r.channel === 'commands'), + ).toHaveLength(0) + }) + + test('repeated prime on emits only one setting-change log', async () => { + await useTempAccountFile(createFallbackStorage()) + const plugin = await getPlugin() + await executeCommand(plugin, 'claude-prime', 'on') + capturedRecords = [] + await executeCommand(plugin, 'claude-prime', 'on') + expect( + capturedRecords.filter((r) => r.channel === 'commands'), + ).toHaveLength(0) + }) }) diff --git a/packages/opencode/src/tests/prime-manager-registry.test.ts b/packages/opencode/src/tests/prime-manager-registry.test.ts new file mode 100644 index 00000000..351641e1 --- /dev/null +++ b/packages/opencode/src/tests/prime-manager-registry.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + type AccountStorage, + PRIME_DUE_OFFSET_MS, + PrimeManager, + type PrimeManagerOptions, +} from '@cortexkit/anthropic-auth-core' + +import { adoptPrimeManager } from '../prime-manager-registry.ts' + +const tempDirs: string[] = [] +const managers: PrimeManager[] = [] +const originalSetInterval = globalThis.setInterval +const originalClearInterval = globalThis.clearInterval + +afterEach(async () => { + for (const manager of managers.splice(0)) manager.stop() + globalThis.setInterval = originalSetInterval + globalThis.clearInterval = originalClearInterval + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ) +}) + +function storage(reset: number): AccountStorage { + return { + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + fallbackOn: [401, 403, 429], + accounts: [], + prime: { enabled: true }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + mainQuota: { + five_hour: { + usedPercent: 0, + remainingPercent: 100, + resetsAt: new Date(reset).toISOString(), + checkedAt: 1, + }, + }, + }, + } +} + +function options(input: { + storagePath: string + markerDir: string + reset: number + send: () => void +}): PrimeManagerOptions { + const snapshot = storage(input.reset) + return { + storagePath: input.storagePath, + markerDir: input.markerDir, + getAccountFingerprint: async () => '0123456789abcdef', + now: () => input.reset + PRIME_DUE_OFFSET_MS, + loadStorage: async () => snapshot, + refreshQuota: async () => ({ + quota: snapshot.quota!.mainQuota!, + fresh: true, + }), + sendPrime: async () => { + input.send() + return { ok: true, status: 200, ms: 1 } + }, + recordSuccess: async () => ({ + count: 1, + inputTokens: 0, + outputTokens: 0, + since: input.reset, + }), + } +} + +async function markerRoot(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'prime-registry-test-')) + tempDirs.push(directory) + return directory +} + +function adopt( + storagePath: string, + create: () => PrimeManager, + slot: string, + rebind: (manager: PrimeManager) => void, +): PrimeManager { + return adoptPrimeManager(storagePath, create, { + slot, + rebind, + }) +} + +describe('prime manager registry lifecycle', () => { + test('same-path adoption rebinds current closures before the next fire', async () => { + const root = await markerRoot() + const reset = Date.now() - PRIME_DUE_OFFSET_MS + const path = join(root, 'accounts.json') + let firstCalls = 0 + let secondCalls = 0 + const firstOptions = options({ + storagePath: path, + markerDir: root, + reset, + send: () => { + firstCalls += 1 + }, + }) + const secondOptions = options({ + storagePath: path, + markerDir: root, + reset, + send: () => { + secondCalls += 1 + }, + }) + const first = adopt( + path, + () => { + const manager = new PrimeManager(firstOptions) + managers.push(manager) + return manager + }, + 'project-a', + (manager) => manager.updateOptions(firstOptions), + ) + const second = adopt( + path, + () => { + throw new Error('same-path adoption constructed a duplicate manager') + }, + 'project-a', + (manager) => manager.updateOptions(secondOptions), + ) + + expect(second).toBe(first) + await second.tick() + expect(firstCalls).toBe(0) + expect(secondCalls).toBe(1) + }) + + test('different-path reload stops and evicts the previous slot manager', async () => { + const root = await markerRoot() + const reset = Date.now() - PRIME_DUE_OFFSET_MS + const firstPath = join(root, 'first.json') + const secondPath = join(root, 'second.json') + const firstOptions = options({ + storagePath: firstPath, + markerDir: root, + reset, + send() {}, + }) + const secondOptions = options({ + storagePath: secondPath, + markerDir: root, + reset, + send() {}, + }) + const first = adopt( + firstPath, + () => { + const manager = new PrimeManager(firstOptions) + managers.push(manager) + return manager + }, + 'project-reload', + (manager) => manager.updateOptions(firstOptions), + ) + first.start() + const second = adopt( + secondPath, + () => { + const manager = new PrimeManager(secondOptions) + managers.push(manager) + return manager + }, + 'project-reload', + (manager) => manager.updateOptions(secondOptions), + ) + + expect(second).not.toBe(first) + expect(first.isStopped()).toBe(true) + }) + + test('same-path adoption does not create a duplicate interval', async () => { + let intervalCalls = 0 + globalThis.setInterval = mock(() => { + intervalCalls += 1 + return { unref() {} } as unknown as ReturnType + }) as unknown as typeof setInterval + globalThis.clearInterval = mock(() => {}) as unknown as typeof clearInterval + const root = await markerRoot() + const path = join(root, 'accounts.json') + const managerOptions = options({ + storagePath: path, + markerDir: root, + reset: Date.now(), + send() {}, + }) + const first = adopt( + path, + () => { + const manager = new PrimeManager(managerOptions) + managers.push(manager) + return manager + }, + 'project-timer', + (manager) => manager.updateOptions(managerOptions), + ) + first.start() + const second = adopt( + path, + () => { + throw new Error('same-path adoption constructed a duplicate manager') + }, + 'project-timer', + (manager) => manager.updateOptions(managerOptions), + ) + second.start() + + expect(second).toBe(first) + expect(intervalCalls).toBe(1) + }) +}) diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts index a9808cef..82d7eac3 100644 --- a/packages/opencode/src/tests/quota-manager.test.ts +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -118,6 +118,80 @@ describe('QuotaManager', () => { }) describe('backoff', () => { + test('fallback refresh metadata distinguishes cached backoff from a network fetch', async () => { + let fetchCalls = 0 + const fetchMock = mock(() => { + fetchCalls += 1 + if (fetchCalls === 1) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + const cachedQuota = { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: now, + }, + } + qm.setFallback( + 'fallback-1', + { quota: cachedQuota, refreshAfter: now, checkedAt: now }, + 'fallback-token', + ) + + await expect( + qm.refreshFallback('fallback-1', 'fallback-token'), + ).rejects.toThrow('429') + const cached = await qm.refreshFallbackWithMetadata( + 'fallback-1', + 'fallback-token', + ) + expect(cached).toEqual({ quota: cachedQuota, fetched: false }) + + now += 61_000 + const fetched = await qm.refreshFallbackWithMetadata( + 'fallback-1', + 'fallback-token', + ) + expect(fetched.fetched).toBe(true) + expect(fetchCalls).toBe(2) + }) + + test('main refresh metadata also distinguishes cached backoff from a network fetch', async () => { + let fetchCalls = 0 + const fetchMock = mock(() => { + fetchCalls += 1 + if (fetchCalls === 1) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + const cachedQuota = { + five_hour: { + usedPercent: 25, + remainingPercent: 75, + checkedAt: now, + }, + } + qm.setMain('main-token', { + quota: cachedQuota, + refreshAfter: now, + checkedAt: now, + }) + + await expect(qm.refreshMain('main-token')).rejects.toThrow('429') + const cached = await qm.refreshMainWithMetadata('main-token') + expect(cached).toEqual({ quota: cachedQuota, fetched: false }) + + now += 61_000 + const fetched = await qm.refreshMainWithMetadata('main-token') + expect(fetched.fetched).toBe(true) + expect(fetchCalls).toBe(2) + }) + test('first 429 backs off for 60s', async () => { const fetchMock = mock(() => Promise.resolve(new Response('rate limited', { status: 429 })), diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts index 5507956a..73ef2702 100644 --- a/packages/opencode/src/tests/sidebar-state.test.ts +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -20,6 +20,9 @@ import { drainSidebarWrites, FIVE_HOUR_MS, formatFallbackModelLabel, + formatPrimeAccountValue, + formatPrimeCost, + formatPrimeTime, getCollapsedQuotaSummary, getFableRecoverySummary, getSidebarState, @@ -52,6 +55,65 @@ const quota = (used: number): AccountQuota => ({ seven_day: { usedPercent: used, remainingPercent: 100 - used }, }) +describe('prime display formatters', () => { + test('formats time and cost consistently for sidebar and dialog consumers', () => { + expect(formatPrimeTime(0)).toBe( + new Date(0).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + ) + expect(formatPrimeCost(0)).toBe('0') + expect(formatPrimeCost(0.000025)).toBe('2.50e-5') + expect(formatPrimeCost(0.125)).toBe('0.1250') + }) + + test('formats one sidebar value per prime account', () => { + const nextDueAt = Date.now() + 60 * 60_000 + const lastPrimedAt = Date.now() - 60_000 + + expect( + formatPrimeAccountValue({ + id: 'main', + label: 'main', + nextDueAt, + }), + ).toEqual({ text: formatPrimeTime(nextDueAt), hasError: false }) + expect( + formatPrimeAccountValue({ + id: 'work-alt', + label: 'work-alt', + lastPrimedAt, + lastResult: 'ok', + usage: { count: 3, inputTokens: 60, outputTokens: 3, since: 1 }, + estimatedCostUsd: 0.000075, + }), + ).toEqual({ + text: `primed ${formatPrimeTime(lastPrimedAt)} \u2713`, + hasError: false, + }) + expect( + formatPrimeAccountValue({ + id: 'work-alt', + label: 'work-alt', + usage: { count: 3, inputTokens: 60, outputTokens: 3, since: 1 }, + estimatedCostUsd: 0.000075, + }), + ).toEqual({ text: '\u2713 3 \u2248 $7.50e-5', hasError: false }) + expect( + formatPrimeAccountValue({ + id: 'broken', + label: 'broken', + lastResult: 'error', + }), + ).toEqual({ text: 'err', hasError: true }) + expect(formatPrimeAccountValue({ id: 'idle', label: 'idle' })).toEqual({ + text: '\u2014', + hasError: false, + }) + }) +}) + function make(overrides: Partial): SidebarState { return { ...DEFAULT_SIDEBAR_STATE, ...overrides } } @@ -842,6 +904,67 @@ describe('normalizeSidebarState', () => { expect(out.cacheKeep).toBeUndefined() }) + test('prime defaults to undefined when non-object', () => { + const out = normalizeSidebarState({ prime: 'bad' }) + expect(out.prime).toBeUndefined() + }) + + test('prime defaults to undefined when enabled is non-boolean', () => { + const out = normalizeSidebarState({ prime: { enabled: 'yes' } }) + expect(out.prime).toBeUndefined() + }) + + test('prime drops accounts with non-string id', () => { + const out = normalizeSidebarState({ + prime: { + enabled: true, + accounts: [ + { id: 'main' }, // no label → dropped + { id: 123, label: 'numeric' }, + { id: 'work', label: 'work' }, + ], + }, + }) + expect(out.prime?.accounts).toEqual([{ id: 'work', label: 'work' }]) + }) + + test('prime drops account with non-finite nextDueAt and preserves null', () => { + const out = normalizeSidebarState({ + prime: { + enabled: true, + accounts: [ + { id: 'main', label: 'main', nextDueAt: 'soon' }, + { id: 'work', label: 'work', nextDueAt: null }, + ], + }, + }) + expect(out.prime?.accounts?.[0]?.nextDueAt).toBeUndefined() + expect(out.prime?.accounts?.[1]?.nextDueAt).toBeNull() + }) + + test('prime drops invalid lastPrimedAt / lastResult / usage / cost', () => { + const out = normalizeSidebarState({ + prime: { + enabled: true, + accounts: [ + { + id: 'main', + label: 'main', + lastPrimedAt: 'never', + lastResult: 'unknown', + usage: { count: -1, inputTokens: 0, outputTokens: 0, since: -5 }, + estimatedCostUsd: 'cheap', + }, + ], + }, + }) + const acct = out.prime?.accounts?.[0] + expect(acct?.lastPrimedAt).toBeUndefined() + expect(acct?.lastResult).toBeUndefined() + expect(acct?.usage).toBeUndefined() + expect(acct?.estimatedCostUsd).toBeUndefined() + }) + test('route defaults when non-string', () => { const out = normalizeSidebarState({ route: 42 }) expect(out.route).toBe(DEFAULT_SIDEBAR_STATE.route) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 561885e4..597331a9 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -24,6 +24,7 @@ import { computeQuotaPacing, DEFAULT_SIDEBAR_STATE, FIVE_HOUR_MS, + formatPrimeAccountValue, formatScopedQuotaLabel, getCollapsedQuotaSummary, getFableRecoverySummary, @@ -650,6 +651,10 @@ function QuotaSidebar(props: { const cacheKeep = () => state().cacheKeep const showCache = () => prefs().sections.cache && cacheKeep() != null && cacheKeep()?.window != null + const prime = () => state().prime + const showPrime = () => prime() != null + const primeHasError = () => + prime()?.accounts.some((account) => account.lastResult === 'error') ?? false const relayValue = () => { const r = state().relay if (!r) return '\u2014' @@ -829,6 +834,29 @@ function QuotaSidebar(props: { + {/* Prime — only when enabled. Expanded view only (no collapsed surface). */} + + + + {(account) => { + const value = () => formatPrimeAccountValue(account) + return ( + + ) + }} + + + {/* Health — only when something is wrong */} diff --git a/packages/opencode/src/tui/command-dialogs.tsx b/packages/opencode/src/tui/command-dialogs.tsx index 22448c9d..cf193608 100644 --- a/packages/opencode/src/tui/command-dialogs.tsx +++ b/packages/opencode/src/tui/command-dialogs.tsx @@ -1,6 +1,8 @@ /** @jsxImportSource @opentui/solid */ +import type { PrimeAccountStatus } from '@cortexkit/anthropic-auth-core' import type { TuiPluginApi } from '@opencode-ai/plugin/tui' import type { OpenDialogPayload } from '../rpc/protocol.js' +import { formatPrimeCost, formatPrimeTime } from '../sidebar-state.js' type ApplyFn = ( command: OpenDialogPayload['command'], @@ -13,6 +15,20 @@ type KillswitchDialogConfig = { accounts?: Record> } +export const PRIME_DIALOG_OPTIONS = [ + { title: 'Enable', value: 'on' }, + { title: 'Disable', value: 'off' }, + { title: 'Status', value: 'status' }, + { title: 'Back', value: 'back' }, +] + +export function handlePrimeStatusOption( + option: { value: string }, + renderMain: () => void, +): void { + if (option.value === 'back') renderMain() +} + export function buildKillswitchThresholdSeed( config: KillswitchDialogConfig, accountIds: string[], @@ -61,6 +77,41 @@ function showText(api: TuiPluginApi, text: string) { )) } +/** + * Format the per-account status lines shown in the Claude prime Status + * view. Pure: consumed by the dialog's Status pane; the sidebar uses a + * different formatter (`formatPrimeSidebarValue`) for its one-line + * expanded row. + */ +export function buildPrimeStatusRows(accounts: PrimeAccountStatus[]): string[] { + const rows: string[] = [] + for (const account of accounts) { + if (account.usage?.count) { + const cost = account.estimatedCostUsd ?? 0 + rows.push( + `${account.label}: ${account.usage.count} ${account.usage.count === 1 ? 'prime' : 'primes'} \u2248 $${formatPrimeCost(cost)}`, + ) + } + if (account.nextDueAt && account.nextDueAt > Date.now()) { + rows.push( + `${account.label} \u00b7 next prime ${formatPrimeTime(account.nextDueAt)}`, + ) + } else if (account.lastPrimedAt) { + const time = formatPrimeTime(account.lastPrimedAt) + if (account.lastResult === 'error') { + rows.push(`${account.label} \u00b7 primed ${time} err`) + } else { + rows.push(`${account.label} \u00b7 primed ${time} \u2713`) + } + } else if (account.usage?.count) { + // already shown above + } else { + rows.push(`${account.label} \u2014 window active`) + } + } + return rows +} + export function openCommandDialog( api: TuiPluginApi, payload: OpenDialogPayload, @@ -205,6 +256,67 @@ export function openCommandDialog( return } + if (payload.command === 'claude-prime') { + // Prime modal spec: always four options — Enable / Disable / Status / + // Back — regardless of current state (the contextual toggle + // approach is forbidden). Status uses a DialogSelect replace with + // a single Back action (killswitch dialog-replace pattern), so the + // user has a working affordance to return to the main menu. + const DialogSelect = api.ui.DialogSelect + const openStatusView = () => { + const accounts = + (payload.knobs.accounts as PrimeAccountStatus[] | undefined) ?? [] + const lines = buildPrimeStatusRows(accounts) + const statusText = `Claude prime status:\n\n${lines.join('\n')}` + api.ui.dialog.setSize('xlarge') + api.ui.dialog.replace(() => ( + + {statusText} + + handlePrimeStatusOption(option, renderMain)} + /> + + + )) + } + const renderMain = () => { + const enabled = payload.knobs.enabled === true + api.ui.dialog.setSize('xlarge') + api.ui.dialog.replace(() => ( + { + if (option.value === 'back') { + api.ui.dialog.clear() + return + } + if (option.value === 'status') { + openStatusView() + return + } + void apply('claude-prime', String(option.value)).then((r) => { + api.ui.toast({ message: r.text }) + payload = { + command: 'claude-prime', + text: r.text, + knobs: r.knobs, + } + renderMain() + }) + }} + /> + )) + } + renderMain() + return + } + if (payload.command === 'claude-killswitch') { const config = (payload.knobs.config ?? {}) as KillswitchDialogConfig const accountIds = (payload.knobs.accountIds as string[]) ?? [] diff --git a/packages/pi/README.md b/packages/pi/README.md index 81c7e3e9..daa8f094 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -42,7 +42,7 @@ Pi state is stored separately from OpenCode at: Override the path with `PI_ANTHROPIC_AUTH_FILE`. The package also respects `PI_AGENT_DIR` when deriving the default sidecar path. -The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `claudeFast`, `dump`, `relay`, and fallback `accounts` blocks. Runtime OAuth/quota state is stored in `anthropic-auth-state.json`; sticky session assignments are stored separately in `anthropic-auth-routing-state.json` with SHA-256-hashed session IDs. +The sidecar uses the same JSON shape as the OpenCode package, including `routing`, `claudeCache`, `cacheKeep`, `prime`, `claudeFast`, `dump`, `relay`, and fallback `accounts` blocks. Runtime OAuth/quota state is stored in `anthropic-auth-state.json`; sticky session assignments are stored separately in `anthropic-auth-routing-state.json` with SHA-256-hashed session IDs. ## Commands @@ -59,6 +59,10 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing /claude-cachekeep 09-23 /claude-cachekeep off +/claude-prime +/claude-prime on +/claude-prime off + /claude-dump /claude-dump on /claude-dump off @@ -78,6 +82,16 @@ The sidecar uses the same JSON shape as the OpenCode package, including `routing `/claude-quota` reports sidecar OAuth fallback quota state from `~/.pi/agent/anthropic-auth.json`. `/claude-routing fallback-first` prefers usable OAuth fallback accounts before the main account; `/claude-routing main-first` restores the default. `/claude-routing sticky-balanced` assigns each Pi session to an OAuth account according to current 5-hour, 7-day, and matching model-scoped quota headroom, then persists that assignment across transient failures and process restarts. `/claude-routing reset` clears the current Pi session's assignment. Direct Opus sessions prefer usable accounts whose Fable quota is exhausted. API-key routes use the same sidecar shape as OpenCode and are sent directly to their configured Anthropic-compatible base URL, such as Kie's `https://api.kie.ai/claude`, but Pi only uses them after the main OAuth model response reports HTTP 429 or a streaming rate-limit error and a live quota check confirms 0% remaining. `/claude-cachekeep always` keeps active hybrid caches warm while Pi remains open; `/claude-cachekeep HH-HH` limits prewarms to a local time window. Both send `max_tokens: 0` pre-warm requests about five minutes before the 1-hour TTL expires. Running `/claude-cachekeep` without arguments lists live tracked sessions across Pi processes through a temporary lease registry that stores only session IDs and cache timing. `/claude-fast on` adds Anthropic `speed: "fast"` plus the `fast-mode-2026-02-01` beta header for supported Opus models (`claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, and `claude-opus-5`). +### Claude quota window priming + +Priming is off by default. When enabled from OpenCode with `/claude-prime on`, it watches each OAuth account's 5-hour quota reset and sends one minimal `claude-haiku-4-5` request about one minute after a confirmed reset. This starts the next window without waiting for a normal prompt. Usage is measured from response accounting. + +For an idle account with no cached reset time, one bootstrap request establishes the first observed window. Atomic temporary-file claims limit multiple processes sharing an account config to one request per account and reset. + +Prime marker identities live in `anthropic-auth-state.json`. Plugin-owned refresh rotations preserve the main account's lineage, while a host credential replacement creates a new lineage. An existing main lineage without a refresh-token binding attaches to the current credential on its first check without changing identity. On upgrade, an existing fallback account receives an identity during its first prime check; that one-time marker change can send one extra request in the current window. + +Pi's `/claude-prime` command displays status only. The `on` and `off` arguments are ignored; toggling priming requires OpenCode. + ## Relay The Pi package can use the same user-owned Cloudflare relay config as the OpenCode package. The relay setup helper currently lives in the OpenCode package CLI: diff --git a/packages/pi/src/commands.ts b/packages/pi/src/commands.ts index a38c3bac..9b61c422 100644 --- a/packages/pi/src/commands.ts +++ b/packages/pi/src/commands.ts @@ -1,8 +1,10 @@ import { buildClaudeQuotaSummary, buildFallbackQuotaSummaries, + buildPrimeAccountStatuses, CLAUDE_ACCOUNT_COMMAND_NAME, CLAUDE_CACHE_KEEP_COMMAND_NAME, + CLAUDE_PRIME_COMMAND_NAME, CLAUDE_ROUTING_COMMAND_NAME, createEmptyStorage, executeAccountCommand, @@ -11,6 +13,7 @@ import { executeDumpCommand, executeFastModeCommand, executeLoggingCommand, + executePrimeCommand, executeRoutingCommand, getCache1hPersistentMode, getCacheKeepWindow, @@ -22,6 +25,7 @@ import { isCacheKeepPersistentlyEnabled, isDumpPersistentlyEnabled, isFastModePersistentlyEnabled, + isPrimePersistentlyEnabled, loadAccounts, parseCache1hCommandAction, parseCacheKeepCommandAction, @@ -312,4 +316,25 @@ export function registerCommands(pi: ExtensionAPI) { notify(ctx, executeLoggingCommand({ argumentsText: args ?? '', level })) }, }) + + pi.registerCommand(CLAUDE_PRIME_COMMAND_NAME, { + description: "Show each OAuth account's five-hour quota window state", + handler: async (_args, ctx) => { + // Pi has no live PrimeManager; render persisted counters and the + // next-due timestamp from stored quota snapshots. Always pass + // `argumentsText: 'status'` to the pure executor so on/off args + // are display-only — Pi cannot toggle the setting. + const path = getPiAccountStoragePath() + const storage = await loadAccounts(path) + const accounts = buildPrimeAccountStatuses(storage, { now: Date.now() }) + notify( + ctx, + executePrimeCommand({ + argumentsText: 'status', + enabled: isPrimePersistentlyEnabled(storage), + accounts, + }).text, + ) + }, + }) } diff --git a/packages/pi/src/tests/commands.test.ts b/packages/pi/src/tests/commands.test.ts index 8e608cbf..bc5b4349 100644 --- a/packages/pi/src/tests/commands.test.ts +++ b/packages/pi/src/tests/commands.test.ts @@ -1,4 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from 'bun:test' import { createHash } from 'node:crypto' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -50,22 +57,32 @@ function mockPi(): { let tempDir: string let accountPath: string +let statePath: string beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'pi-commands-test-')) accountPath = join(tempDir, 'anthropic-auth.json') + // The core's getAccountStatePath places the state file alongside + // the config when the config ends with the account file name. + statePath = join(tempDir, 'anthropic-auth-state.json') process.env[ENV_KEY] = accountPath + process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE = statePath process.env[CACHEKEEP_REGISTRY_ENV_KEY] = join(tempDir, 'cachekeep-registry') process.env[ROUTING_STATE_ENV_KEY] = join(tempDir, 'routing-state.json') }) afterEach(async () => { delete process.env[ENV_KEY] + delete process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE delete process.env[CACHEKEEP_REGISTRY_ENV_KEY] delete process.env[ROUTING_STATE_ENV_KEY] await rm(tempDir, { recursive: true, force: true }) }) +afterAll(() => { + expect(process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE).toBeUndefined() +}) + describe('claude-account persistence', () => { test('disable persists to storage', async () => { await writeFile( @@ -379,3 +396,118 @@ describe('claude-routing persistence', () => { expect(storage.routing).toEqual({ mode: 'sticky-balanced' }) }) }) + +describe('claude-prime — Pi display-only contract', () => { + // The plan + rev-1 / rev-2 require that Pi's `/claude-prime` handler + // is display-only: it must NEVER call `setPrimePersistentEnabled`, + // and on/off/status args must all return the same status text. The + // byte-for-byte config + runtime-state file invariance ensures a + // future edit that wires Pi to the persistent setter would fail this + // test. + + function readConfigAndState() { + return Promise.all([ + readFile(accountPath, 'utf8').then((text) => JSON.parse(text)), + readFile(statePath, 'utf8') + .then((text) => JSON.parse(text)) + .catch(() => null), + ]).then(([config, state]) => ({ config, state })) + } + + test('registers a claude-prime command (sanity)', async () => { + await writeFile( + accountPath, + JSON.stringify({ version: 1, accounts: [] }), + 'utf8', + ) + const { pi, commands } = mockPi() + registerCommands(pi) + expect(commands.get('claude-prime')).toBeDefined() + }) + + test('status arg notifies the current status; config + state bytes are unchanged', async () => { + const initial = { + version: 1, + accounts: [], + prime: { enabled: true }, + } + await writeFile(accountPath, JSON.stringify(initial), 'utf8') + const { pi, commands } = mockPi() + registerCommands(pi) + const handler = commands.get('claude-prime')?.handler + expect(handler).toBeDefined() + + const { ctx, notified } = mockNotify() + await handler!('status', ctx) + + expect(notified[0] ?? '').toContain('## Claude Prime Status') + const { config, state } = await readConfigAndState() + expect(config).toEqual(initial) + // state file either unchanged (no runtime state) or non-existent. + expect(state === null || state.version === 1).toBe(true) + }) + + test('on arg in Pi is display-only — never toggles the persistent setting', async () => { + const initial = { + version: 1, + accounts: [], + prime: { enabled: false }, + } + await writeFile(accountPath, JSON.stringify(initial), 'utf8') + const { pi, commands } = mockPi() + registerCommands(pi) + const handler = commands.get('claude-prime')?.handler + expect(handler).toBeDefined() + + const { ctx, notified } = mockNotify() + await handler!('on', ctx) + + // The handler MUST notify, but the persistent state MUST stay off. + expect(notified.length).toBeGreaterThan(0) + const { config, state } = await readConfigAndState() + expect(config.prime?.enabled).toBe(false) + expect(state === null || state.prime?.enabled !== true).toBe(true) + }) + + test('off arg in Pi is display-only — never toggles the persistent setting', async () => { + const initial = { + version: 1, + accounts: [], + prime: { enabled: true }, + } + await writeFile(accountPath, JSON.stringify(initial), 'utf8') + const { pi, commands } = mockPi() + registerCommands(pi) + const handler = commands.get('claude-prime')?.handler + expect(handler).toBeDefined() + + const { ctx, notified } = mockNotify() + await handler!('off', ctx) + + expect(notified.length).toBeGreaterThan(0) + const { config, state } = await readConfigAndState() + expect(config.prime?.enabled).toBe(true) + expect(state === null || state.prime?.enabled !== false).toBe(true) + }) + + test('empty arg in Pi shows the status and does not mutate storage', async () => { + const initial = { + version: 1, + accounts: [], + prime: { enabled: true }, + } + await writeFile(accountPath, JSON.stringify(initial), 'utf8') + const { pi, commands } = mockPi() + registerCommands(pi) + const handler = commands.get('claude-prime')?.handler + expect(handler).toBeDefined() + + const { ctx, notified } = mockNotify() + await handler!('', ctx) + + expect(notified[0] ?? '').toContain('## Claude Prime Status') + const { config, state } = await readConfigAndState() + expect(config).toEqual(initial) + expect(state === null || state.version === 1).toBe(true) + }) +})