diff --git a/README.md b/README.md index ea85cfe..8884cba 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Primary Claude Pro/Max OAuth | OpenCode `/connect anthropic` | Pi `/login anthropic` | | 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` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | +| 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` | | Quota sidebar widget | OpenCode TUI plugin via `tui.json` | Not available | -| Fallback accounts, quota routing, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | +| 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 @@ -33,6 +33,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors. - **Quota sidebar widget**: register the OpenCode TUI plugin in `tui.json` to render a live sidebar with per-account quota, routing, cache, and health state. +- **Killswitch**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted. - **User-owned Cloudflare relay**: optionally provision your own Worker relay to reduce repeated client upload bytes for large OpenCode or Pi requests. - **Claude-compatible request hardening**: final-body billing signing, safer token refresh persistence, replay-safe fallback retries, and subagent cache isolation. @@ -175,6 +176,11 @@ Example: }, "failClosedOnUnknownQuota": true }, + "killswitch": { + "enabled": false, + "main": { "five_hour": 5, "seven_day": 10 }, + "accounts": {} + }, "claudeCache": { "enabled": false, "mode": "explicit" @@ -293,7 +299,9 @@ The sidebar polls plugin state and refreshes on OpenCode session and message eve - **Quota** — per-account 5-hour and 7-day usage bars for the main account and each enabled fallback, with a status word (`active`, `blocked`, or `idle`) and the soonest reset time. - **Routing** — the current route, standard/fast mode, and relay transport state. - **Cache** — the 1-hour cache keepalive window and the number of tracked sessions, shown when cache keepalive is configured. -- **Health** — quota-API and token-refresh backoff countdowns. This section is hidden unless a backoff is active, and a `LIMITED` badge appears in the header. +- **Health** — quota-API and token-refresh backoff countdowns and the killswitch block list. This section is hidden unless one of these conditions is active, and a `LIMITED` badge appears in the header. + +Click the `CLAUDE` header to collapse or expand the sidebar. Collapsed, it shows the active account's 5-hour quota usage and a fast-mode row when fast mode is on; the header shows the plugin version (or a `LIMITED` badge when degraded). Collapse state is per-session and resets when OpenCode restarts. ## Claude prompt cache control diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index 3c558b4..40bdaed 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -53,6 +53,18 @@ export type AccountQuotaWindow = { export type RoutingMode = 'main-first' | 'fallback-first' +export type KillswitchThresholds = Partial< + Record +> + +export type KillswitchConfig = { + enabled?: boolean + /** Thresholds for the main OAuth account (remaining % below which the account is killed). */ + main?: KillswitchThresholds + /** Per-account overrides keyed by account ID. Accounts without an entry use the `main` thresholds. */ + accounts?: Record +} + export type AccountStorage = { version: 1 main?: { @@ -107,6 +119,7 @@ export type AccountStorage = { fallbackToDirect?: boolean transport?: 'http' | 'websocket' } + killswitch?: KillswitchConfig accounts: OAuthAccount[] } @@ -129,6 +142,11 @@ export type AccountManagerOptions = { fetchImpl?: typeof fetch configPath?: string quotaManager?: import('./quota-manager.ts').QuotaManager + // Invoked after a background quota pass persists at least one fallback storage + // change (token refresh, quota update, or error recording), so consumers + // (e.g. the OpenCode sidebar) can re-render without a request flowing through + // the fetch handler. + onFallbackStorageChanged?: () => void } export type AccountRefreshError = { @@ -269,6 +287,7 @@ function normalizeStorage(value: unknown): AccountStorage | null { claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined, cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined, relay: isRecord(value.relay) ? value.relay : undefined, + killswitch: isRecord(value.killswitch) ? value.killswitch : undefined, accounts: value.accounts .map(normalizeAccount) .filter((account): account is OAuthAccount => account != null), @@ -778,6 +797,118 @@ export function quotaSnapshotPassesPolicy( return true } +// --------------------------------------------------------------------------- +// Killswitch — hard-block requests when remaining quota drops below per-account +// thresholds, even if the API would still accept them. +// --------------------------------------------------------------------------- + +export const DEFAULT_KILLSWITCH_THRESHOLDS: Record = { + five_hour: 5, + seven_day: 10, +} + +function normalizeKillswitchThresholds( + thresholds: KillswitchThresholds | undefined, +): Record { + return { + five_hour: + thresholds?.five_hour ?? + thresholds?.['5h'] ?? + DEFAULT_KILLSWITCH_THRESHOLDS.five_hour, + seven_day: + thresholds?.seven_day ?? + thresholds?.['1w'] ?? + DEFAULT_KILLSWITCH_THRESHOLDS.seven_day, + } +} + +export function isKillswitchEnabled(storage: AccountStorage | null) { + return storage?.killswitch?.enabled === true +} + +function getKillswitchThresholdsForAccount( + storage: AccountStorage | null, + accountId?: string, +): Record { + if (!storage?.killswitch) return DEFAULT_KILLSWITCH_THRESHOLDS + if (accountId && storage.killswitch.accounts?.[accountId]) { + return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]) + } + return normalizeKillswitchThresholds(storage.killswitch.main) +} + +/** + * Returns true if the account's quota is above its killswitch threshold. + * When killswitch is disabled, always returns true. + */ +export function killswitchPassesPolicy( + quota: OAuthQuotaSnapshot | undefined, + storage: AccountStorage | null, + accountId?: string, +) { + if (!isKillswitchEnabled(storage)) return true + const thresholds = getKillswitchThresholdsForAccount(storage, accountId) + let sawUnknownWindow = false + for (const key of ['five_hour', 'seven_day'] as const) { + const window = quota?.[key] + // Defer the unknown-window decision: a quota snapshot can legally carry + // only one window, and a present window below its threshold must still + // block even if the other window is missing. + if (!window) { + sawUnknownWindow = true + continue + } + if (window.remainingPercent < thresholds[key]) return false + } + if (sawUnknownWindow) return !failClosedOnUnknownQuota(storage) + return true +} + +/** + * Find the earliest reset time across all accounts' quota windows. + * Returns seconds from `now` until that reset, or 300 as a fallback. + */ +export function killswitchRetryAfterSeconds( + mainQuota: OAuthQuotaSnapshot | undefined, + fallbackAccounts: Array<{ quota?: OAuthQuotaSnapshot }>, + now: number, +): number { + const resetTimes: number[] = [] + const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)] + for (const quota of allQuotas) { + for (const key of ['five_hour', 'seven_day'] as const) { + const resetStr = quota?.[key]?.resetsAt + if (!resetStr) continue + const resetTime = Date.parse(resetStr) + if (Number.isFinite(resetTime) && resetTime > now) { + resetTimes.push(resetTime) + } + } + } + if (!resetTimes.length) return 300 + return Math.max(1, Math.ceil((Math.min(...resetTimes) - now) / 1000)) + 60 +} + +export function getKillswitchConfig( + storage: AccountStorage | null, +): KillswitchConfig { + return storage?.killswitch ?? { enabled: false } +} + +export async function setKillswitchPersistent( + config: KillswitchConfig, + path = getAccountStoragePath(), +) { + const storage = (await loadAccounts(path)) ?? { + version: 1, + main: { type: 'opencode' as const, provider: 'anthropic' as const }, + accounts: [], + } + storage.killswitch = config + await saveAccounts(storage, path) + return storage +} + export function getQuotaNextRefreshAt( quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, @@ -978,12 +1109,14 @@ export class FallbackAccountManager { private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null readonly quotaManager: import('./quota-manager.ts').QuotaManager | null + private readonly onFallbackStorageChanged: (() => void) | undefined constructor(options: AccountManagerOptions = {}) { this.now = options.now ?? Date.now this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() this.quotaManager = options.quotaManager ?? null + this.onFallbackStorageChanged = options.onFallbackStorageChanged } /** @@ -1233,7 +1366,10 @@ export class FallbackAccountManager { // Quota probes are advisory; failed probes fail closed at selection time. } } - if (changed) await this.save(storage) + if (changed) { + await this.save(storage) + this.onFallbackStorageChanged?.() + } } async refreshQuotaForAllAccounts(options: { force?: boolean } = {}) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c2b19e..33063ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export * from './claude-code.ts' export * from './constants.ts' export * from './dump.ts' export * from './fast.ts' +export * from './killswitch.ts' export * from './logger.ts' export * from './pkce.ts' export * from './quota-manager.ts' diff --git a/packages/core/src/killswitch.ts b/packages/core/src/killswitch.ts new file mode 100644 index 0000000..e011e65 --- /dev/null +++ b/packages/core/src/killswitch.ts @@ -0,0 +1,155 @@ +import type { KillswitchConfig, KillswitchThresholds } from './accounts.ts' +import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from './accounts.ts' + +export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch' + +export type KillswitchCommandAction = + | { type: 'status' } + | { type: 'on' } + | { type: 'off' } + | { type: 'set'; entries: Array<{ account: string; fh: number; sd: number }> } + | { type: 'usage' } + +export function parseKillswitchCommandAction( + argumentsText: string, +): KillswitchCommandAction { + const parts = argumentsText.trim().split(/\s+/).filter(Boolean) + if (parts.length === 0) return { type: 'status' } + if (parts.length === 1 && parts[0] === 'on') return { type: 'on' } + if (parts.length === 1 && parts[0] === 'off') return { type: 'off' } + + if (parts[0] === 'set') { + const entries: Array<{ account: string; fh: number; sd: number }> = [] + for (let i = 1; i < parts.length; i++) { + const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/) + if (!match) return { type: 'usage' } + const [, account, fhStr, sdStr] = match as RegExpMatchArray & + [string, string, string, string] + entries.push({ + account, + fh: Number.parseInt(fhStr, 10), + sd: Number.parseInt(sdStr, 10), + }) + } + if (entries.length === 0) return { type: 'usage' } + return { type: 'set', entries } + } + + return { type: 'usage' } +} + +function buildStatusTable( + config: KillswitchConfig, + accountIds: string[], +): string { + const enabled = config.enabled === true + const lines: string[] = [ + '## Killswitch', + '', + `Status: **${enabled ? 'ON' : 'OFF'}**`, + ] + + if (enabled) { + lines.push('') + lines.push('| Account | 5h threshold | 1w threshold |') + lines.push('| ------- | ------------ | ------------ |') + + const mainT = config.main ?? {} + const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour + const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day + lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`) + + for (const id of accountIds) { + const t = config.accounts?.[id] ?? config.main ?? {} + const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour + const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day + lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`) + } + } + + return lines.join('\n') +} + +const USAGE_TEXT = [ + '## Killswitch Commands', + '', + '```', + `/${KILLSWITCH_COMMAND_NAME} — show status`, + `/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`, + `/${KILLSWITCH_COMMAND_NAME} off — disable`, + `/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`, + `/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`, + '```', +].join('\n') + +export function executeKillswitchCommand(input: { + argumentsText: string + config: KillswitchConfig + accountIds: string[] +}): { text: string; updatedConfig?: KillswitchConfig } { + const action = parseKillswitchCommandAction(input.argumentsText) + + if (action.type === 'status') { + const status = buildStatusTable(input.config, input.accountIds) + return { text: `${status}\n\n${USAGE_TEXT}` } + } + + if (action.type === 'on') { + const updated: KillswitchConfig = { + ...input.config, + enabled: true, + main: input.config.main ?? { + five_hour: DEFAULT_THRESHOLDS.five_hour, + seven_day: DEFAULT_THRESHOLDS.seven_day, + }, + } + const status = buildStatusTable(updated, input.accountIds) + return { + text: `## Killswitch Enabled\n\n${status}`, + updatedConfig: updated, + } + } + + if (action.type === 'off') { + const updated: KillswitchConfig = { ...input.config, enabled: false } + return { + text: '## Killswitch Disabled', + updatedConfig: updated, + } + } + + if (action.type === 'set') { + const accounts = { ...(input.config.accounts ?? {}) } + const updated: KillswitchConfig = { + ...input.config, + enabled: true, + accounts, + } + for (const entry of action.entries) { + const thresholds: KillswitchThresholds = { + five_hour: entry.fh, + seven_day: entry.sd, + } + if (entry.account === 'main') { + updated.main = thresholds + } else if (entry.account === 'all') { + updated.main = thresholds + for (const id of input.accountIds) { + accounts[id] = thresholds + } + } else { + accounts[entry.account] = thresholds + } + } + + const status = buildStatusTable(updated, input.accountIds) + return { + text: `## Killswitch Updated\n\n${status}`, + updatedConfig: updated, + } + } + + // usage + const status = buildStatusTable(input.config, input.accountIds) + return { text: `${status}\n\n${USAGE_TEXT}` } +} diff --git a/packages/opencode/README.md b/packages/opencode/README.md index bfbe596..6080e59 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -19,8 +19,8 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one | Primary Claude Pro/Max OAuth | OpenCode `/connect anthropic` | Pi `/login anthropic` | | 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` | -| Commands | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-cachekeep`, `/claude-routing`, `/claude-fast`, `/claude-quota`, `/claude-dump` | -| Fallback accounts, quota routing, relay, dumps, fast mode | Supported | Supported through the same shared core and Pi sidecar | +| 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` | +| 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 @@ -31,6 +31,7 @@ This repo is a Bun workspace monorepo with two user-facing integrations and one - **Cache keepalive**: use `/claude-cachekeep HH-HH` to pre-warm hybrid cache anchors for active sessions before the 1-hour TTL expires. - **Fast mode toggle**: use `/claude-fast on|off` to request Anthropic fast mode for supported Opus models. - **Live quota visibility**: use `/claude-quota` to see main and fallback quota state, reset times, and refresh errors. +- **Killswitch**: per-account hard-block thresholds that stop requests before hitting Anthropic's rate limits, with synthetic 429 retry-after when all accounts are exhausted. - **User-owned Cloudflare relay**: optionally provision your own Worker relay to reduce repeated client upload bytes for large OpenCode or Pi requests. - **Claude-compatible request hardening**: final-body billing signing, safer token refresh persistence, replay-safe fallback retries, and subagent cache isolation. @@ -173,6 +174,11 @@ Example: }, "failClosedOnUnknownQuota": true }, + "killswitch": { + "enabled": false, + "main": { "five_hour": 5, "seven_day": 10 }, + "accounts": {} + }, "claudeCache": { "enabled": false, "mode": "explicit" @@ -264,6 +270,49 @@ In OpenCode, this includes the main Anthropic account and sidecar fallback accou Reset times are rendered as relative durations, such as `resets in 10m` or `resets in 1h 15m`. +## Killswitch + +The killswitch is a per-account hard-block that stops requests when remaining quota drops below configured thresholds, even if Anthropic's API would still accept them. Unlike `minimumRemaining` (which routes to fallback accounts), the killswitch removes accounts from the routing pool entirely. + +Add a `killswitch` block to the sidecar config: + +```json +"killswitch": { + "enabled": true, + "main": { + "five_hour": 5, + "seven_day": 10 + }, + "accounts": { + "work-alt": { + "five_hour": 10, + "seven_day": 20 + } + } +} +``` + +Thresholds are remaining-percent values. With `five_hour: 5`, the account is killed when less than 5% of the 5-hour quota window remains. Accounts without an entry in `accounts` fall back to the `main` thresholds. The aliases `5h` and `1w` are also accepted. + +Behavior: + +- When an account is killed, it is skipped during routing. Surviving accounts are tried instead. +- When all accounts (main and all enabled fallbacks) are killed, the plugin returns a synthetic 429 response with a `retry-after` header set to the earliest quota reset time across all accounts. +- On the first request after restart, the plugin eagerly fetches main quota so the killswitch evaluates immediately. +- `/claude-quota` shows killswitch status and per-account killed/active state. + +Manage the killswitch from inside OpenCode: + +```text +/claude-killswitch — show status and command cheatsheet +/claude-killswitch on — enable with current or default thresholds +/claude-killswitch off — disable +/claude-killswitch set all:5,10 — set all accounts to 5h≥5%, 1w≥10% +/claude-killswitch set main:3,8 work-alt:5,10 — per-account thresholds +``` + +Changes made with `/claude-killswitch` are persisted to the sidecar config. + ## Claude prompt cache control Both OpenCode and Pi packages add a slash command for Anthropic's 1-hour ephemeral prompt-cache TTL: diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index b273cd1..6bcd097 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,13 +21,16 @@ import { executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, + executeKillswitchCommand, executeRoutingCommand, FallbackAccountManager, + formatQuotaBackoffMessage, formatRefreshBackoffMessage, getAccountStoragePath, getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, + getKillswitchConfig, getRelayConfig, getRoutingMode, hashRefreshToken, @@ -39,9 +42,14 @@ import { isFastModeEnabled, isFastModePersistentlyEnabled, isFastModeSupportedModel, + isKillswitchEnabled, + KILLSWITCH_COMMAND_NAME, + killswitchPassesPolicy, + killswitchRetryAfterSeconds, loadAccounts, log, mergeAnthropicBetas, + type OAuthQuotaSnapshot, PARALLEL_TOOL_CALLS_SYSTEM_PROMPT, parseCache1hCommandAction, parseCacheKeepCommandAction, @@ -65,6 +73,7 @@ import { setDumpPersistentEnabled, setFastModeEnabled, setFastModePersistentEnabled, + setKillswitchPersistent, setRoutingMode, shouldFallbackStatus, } from '@cortexkit/anthropic-auth-core' @@ -306,6 +315,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) const fallbackManager = new FallbackAccountManager({ quotaManager, + onFallbackStorageChanged: () => { + void refreshSidebarQuota() + }, }) fallbackManager.startBackgroundRefresh() let latestRefreshMainAccessToken: (() => Promise) | null = null @@ -355,6 +367,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { setDumpEnabled(isDumpPersistentlyEnabled(initialStorage)) setFastModeEnabled(isFastModePersistentlyEnabled(initialStorage)) + // Remembers the last explicit routing decision so quota-only sidebar refreshes + // (background main/fallback quota landing) do not reset the active account. + let lastSidebarRouting: { activeId: string | undefined; route: string } = { + activeId: 'main', + route: 'main', + } + function writeSidebarState( storage: Awaited>, options: { @@ -364,12 +383,20 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { mainRefreshToken?: string }, ) { + lastSidebarRouting = { activeId: options.activeId, route: options.route } const mainEntry = quotaManager.getMain(options.mainAccessToken) + const ksEnabled = isKillswitchEnabled(storage) const lastApiError = quotaManager.getLastApiError() const mainRefreshError = storage?.refresh?.mainLastRefreshError const state: SidebarState = { main: { quota: mainEntry?.quota ?? null, + // No `quota != null` guard: under failClosedOnUnknownQuota the + // killswitch blocks unknown-quota accounts, so the sidebar must show + // them as killed too (killswitchPassesPolicy handles the null case). + killed: ksEnabled + ? !killswitchPassesPolicy(mainEntry?.quota, storage) + : false, quotaBackedOff: quotaManager.isBackedOff(), quotaBackoffUntil: lastApiError?.nextRetryAt, refreshBackedOff: mainRefreshError @@ -383,18 +410,27 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, fallbacks: (storage?.accounts ?? []) .filter((account) => account.enabled !== false) - .map((account) => ({ - id: account.id, - label: account.label, + .map((account) => { // Token-aware read: if a fallback account was re-logged with the same // id/label, an old in-memory quota snapshot must not be shown as the // new account's quota. - quota: account.access + const quota = account.access ? (quotaManager.getFallback(account.id, account.access)?.quota ?? null) - : null, - enabled: account.enabled !== false, - })), + : null + return { + id: account.id, + label: account.label, + quota, + // No `quota != null` guard: under failClosedOnUnknownQuota the + // killswitch blocks unknown-quota accounts, so the sidebar must + // show them as killed too. + killed: ksEnabled + ? !killswitchPassesPolicy(quota ?? undefined, storage, account.id) + : false, + enabled: account.enabled !== false, + } + }), activeId: options.activeId, route: options.route, relay: (() => { @@ -425,6 +461,30 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) } + // Re-write the sidebar using the LAST known routing decision, refreshing only + // the quota numbers. Used by async quota refreshes (main + background fallback) + // so they never clobber the active account back to 'main'. + async function refreshSidebarQuota() { + const storage = await loadAccounts(accountStoragePath) + let access: string | undefined + let refresh: string | undefined + if (latestGetAuth) { + try { + const auth = await latestGetAuth() + access = auth.access + refresh = auth.refresh + } catch { + // best-effort + } + } + writeSidebarState(storage, { + activeId: lastSidebarRouting.activeId, + route: lastSidebarRouting.route, + mainAccessToken: access, + mainRefreshToken: refresh, + }) + } + let latestGetAuth: | (() => Promise<{ type: string @@ -670,6 +730,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { description: 'Show or change Claude account routing between main-first and fallback-first.', }, + [KILLSWITCH_COMMAND_NAME]: { + template: KILLSWITCH_COMMAND_NAME, + description: + 'Manage killswitch — hard-block requests when quota drops below per-account thresholds.', + }, } }, 'experimental.chat.system.transform': async ( @@ -750,6 +815,24 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) throwHandledSentinel() } + + if (input.command === KILLSWITCH_COMMAND_NAME) { + const storage = await loadAccounts() + const config = getKillswitchConfig(storage) + const accountIds = (storage?.accounts ?? []) + .filter((a) => a.enabled !== false) + .map((a) => a.id) + const result = executeKillswitchCommand({ + argumentsText: input.arguments, + config, + accountIds, + }) + if (result.updatedConfig) { + await setKillswitchPersistent(result.updatedConfig) + } + await sendIgnoredMessage(ctx, input.sessionID, result.text) + throwHandledSentinel() + } }, auth: { provider: 'anthropic', @@ -1404,6 +1487,36 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return response } + function getFallbackQuota(account: { + id: string + access?: string + quota?: OAuthQuotaSnapshot + }): OAuthQuotaSnapshot | undefined { + // Token-aware read: a cached entry bound to a different access token + // (account re-login) is dropped so a stale snapshot is never used. + return ( + quotaManager.getFallback(account.id, account.access)?.quota ?? + account.quota + ) + } + + // The fallbacks routing may actually send to: usable accounts that + // also pass the killswitch policy. Every fallback-selection path + // (fallback-first, soft-quota skip-main, the killswitch gate, reactive + // retries) must go through this so the killswitch is a hard block on + // ALL routes — a killswitch-killed account must never serve a request, + // even if it still passes the softer routing quota policy. + async function getRoutableFallbackAccounts( + storageArg: Awaited>, + ) { + const usable = + await fallbackManager.getUsableFallbackAccounts(storageArg) + if (!isKillswitchEnabled(storageArg)) return usable + return usable.filter((a) => + killswitchPassesPolicy(getFallbackQuota(a), storageArg, a.id), + ) + } + async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -1520,6 +1633,21 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { accounts: accounts.length, }) } + if (isKillswitchEnabled(storage)) { + const before = accounts.length + accounts = accounts.filter((a) => + // Prefer the fresh QuotaManager cache (updated by the eager + // killswitch refresh) over the request-start storage snapshot, + // matching the other killswitch fallback filters. + killswitchPassesPolicy(getFallbackQuota(a), storage, a.id), + ) + if (accounts.length < before) { + log('[killswitch] filtered fallbacks', { + before, + after: accounts.length, + }) + } + } return ( (await tryUsableFallbackAccounts( input, @@ -1592,7 +1720,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { try { const fallbackStart = nowMs() preselectedFallbackAccounts = - await fallbackManager.getUsableFallbackAccounts(storage) + await getRoutableFallbackAccounts(storage) trace.mark('fallback_first_get_accounts', { ms: roundMs(nowMs() - fallbackStart), accounts: preselectedFallbackAccounts.length, @@ -1690,14 +1818,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // sidebar again once the new main quota lands. void quotaManager .refreshMain(auth.access) - .then(() => - writeSidebarState(storage, { - activeId: 'main', - route: 'main', - mainAccessToken: auth.access, - mainRefreshToken: auth.refresh, - }), - ) + .then(() => { + void refreshSidebarQuota() + }) .catch(() => {}) } // Update the sidebar every replayable request so fallback @@ -1715,7 +1838,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!quotaSnapshotPassesPolicy(routingQuota, storage)) { const fallbackStart = nowMs() preselectedFallbackAccounts = - await fallbackManager.getUsableFallbackAccounts(storage) + await getRoutableFallbackAccounts(storage) trace.mark('preselect_fallback_accounts', { ms: roundMs(nowMs() - fallbackStart), accounts: preselectedFallbackAccounts.length, @@ -1748,6 +1871,157 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } } + // Fail-closed: if failClosedOnUnknownQuota is set, quota API is backed off, + // and we have no cached quota, block the request. Token-aware read + // so a previous account's cached quota can't satisfy this check + // (and feed the killswitch eval below) after a main-account switch. + let mainQuota = quotaManager.getMain(auth.access)?.quota + if ( + storage?.quota?.failClosedOnUnknownQuota && + !mainQuota && + quotaManager.isBackedOff() + ) { + const lastError = quotaManager.getLastApiError() + const msg = lastError + ? formatQuotaBackoffMessage(lastError, Date.now()) + : 'Quota API unavailable' + log('[quota] blocked: quota API backed off (failClosed)', { + nextRetryAt: lastError?.nextRetryAt, + retryCount: lastError?.retryCount, + }) + return new Response( + JSON.stringify({ + type: 'error', + error: { type: 'rate_limit_error', message: msg }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String( + lastError?.nextRetryAt + ? Math.max( + 1, + Math.ceil( + (lastError.nextRetryAt - Date.now()) / 1000, + ), + ) + : 60, + ), + }, + }, + ) + } + // Killswitch — eagerly refresh quota so it can evaluate + if (isKillswitchEnabled(storage)) { + const needsRefresh = + quotaManager.needsRefresh(sessionRequestCount) + if (needsRefresh) { + try { + const fallbackAccts = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false && a.access, + ) + await Promise.all([ + quotaManager.refreshMain(auth.access), + quotaManager.refreshAllFallbacks(fallbackAccts), + ]) + } catch (error) { + log('[quota] killswitch refresh failed', { + error: + error instanceof Error ? error.message : String(error), + backedOff: quotaManager.isBackedOff(), + }) + } + } + // Re-read after the eager refresh so the killswitch evaluates + // against fresh quota. The initial read above is null on the + // first request, before the refresh populates the cache. + mainQuota = quotaManager.getMain(auth.access)?.quota + } + + if ( + isKillswitchEnabled(storage) && + // No `mainQuota &&` guard: when main quota is unknown (eager + // refresh failed on the first request) killswitchPassesPolicy + // returns false under failClosedOnUnknownQuota, so the killswitch + // must still block / reroute instead of falling through to main. + !killswitchPassesPolicy(mainQuota, storage) + ) { + // Main is killswitch-killed. Decide where to route from the SAME + // set routing will actually use — usable fallbacks that also + // pass the killswitch policy. Deriving the 429 decision from this + // single source of truth means an account that passes the quota + // check but is dropped by routing (expired/un-refreshable token, + // refresh backoff, below routing threshold) cannot suppress the + // 429 and let the request fall through to the killed main. A + // non-replayable body cannot use a fallback at all, so it has no + // survivors by definition. + const canReplayToFallback = isReplayableRequest( + input, + init?.body, + ) + const survivingFallbacks = canReplayToFallback + ? await getRoutableFallbackAccounts(storage) + : [] + + if (survivingFallbacks.length > 0) { + log('[route] skipping main (killswitch), trying fallbacks') + const fallbackResponse = await tryUsableFallbackAccounts( + input, + init, + survivingFallbacks, + storage, + undefined, + trace, + { + // Correct the sidebar's active account — the routing + // writeback above optimistically set it to 'main', which + // is wrong once the killswitch hands off to a fallback. + onSuccess: (account) => + writeCurrentSidebarState(account.id, 'fallback'), + }, + ) + // The killswitch is a HARD block: it must never fall through to + // the killed main. tryUsableFallbackAccounts returns the last + // upstream error on exhaustion (returnLastOnExhausted defaults + // to true), so a transient fallback failure surfaces that real + // error rather than being retried on the killswitched main. + if (fallbackResponse) { + trace.done('return_killswitch_fallback', { + status: fallbackResponse.status, + }) + return createStrippedStream(fallbackResponse) + } + } + // Nowhere to route (no surviving fallback, or none produced a + // response): hard-block instead of using the killed main. + const now = Date.now() + const fallbackAccounts = (storage?.accounts ?? []) + .filter((a) => a.enabled !== false) + .map((a) => ({ ...a, quota: getFallbackQuota(a) })) + const retryAfter = killswitchRetryAfterSeconds( + mainQuota, + fallbackAccounts, + now, + ) + return new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'rate_limit_error', + message: `Killswitch: no routable accounts. Retry in ${Math.floor(retryAfter / 60)}m ${retryAfter % 60}s.`, + }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String(retryAfter), + }, + }, + ) + } + const mainResponse = await sendWithAccessToken( input, init, diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index f8c0752..83640fb 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -13,12 +13,14 @@ export interface SidebarAccountState { id: string label: string | undefined quota: AccountQuota | null + killed: boolean enabled: boolean } export interface SidebarState { main: { quota: AccountQuota | null + killed: boolean quotaBackedOff?: boolean quotaBackoffUntil?: number refreshBackedOff?: boolean @@ -50,7 +52,7 @@ export function getSidebarStateFile(): string { } export const DEFAULT_SIDEBAR_STATE: SidebarState = { - main: { quota: null }, + main: { quota: null, killed: false }, fallbacks: [], activeId: undefined, route: 'main', @@ -77,3 +79,35 @@ export async function setSidebarState(state: SidebarState): Promise { // Best-effort — sidebar is non-critical } } + +// Resolve the currently-active account from activeId for the collapsed sidebar +// view. activeId === 'main' (or undefined/unmatched/disabled) → the main +// account; otherwise the enabled fallback whose id matches. `killed` reflects +// the killswitch-blocked state so the collapsed row can flag it. +export function resolveActiveAccount(state: SidebarState): { + id: string + name: string + quota: AccountQuota | null + killed: boolean +} { + const activeId = state.activeId + if (activeId && activeId !== 'main') { + const fallback = state.fallbacks.find( + (account) => account.enabled && account.id === activeId, + ) + if (fallback) { + return { + id: fallback.id, + name: fallback.label ?? fallback.id, + quota: fallback.quota, + killed: fallback.killed, + } + } + } + return { + id: 'main', + name: 'main', + quota: state.main.quota, + killed: state.main.killed, + } +} diff --git a/packages/opencode/src/tests/accounts.test.ts b/packages/opencode/src/tests/accounts.test.ts index 608eda8..3f6ff68 100644 --- a/packages/opencode/src/tests/accounts.test.ts +++ b/packages/opencode/src/tests/accounts.test.ts @@ -707,6 +707,49 @@ describe('FallbackAccountManager', () => { expect(fetchImpl).toHaveBeenCalledTimes(1) }) + test('refreshQuotaForDueAccounts fires onFallbackStorageChanged when storage changes', async () => { + const storage = baseStorage() + storage.accounts.push({ + id: 'idle-stale', + type: 'oauth', + access: 'idle-access', + refresh: 'idle-refresh', + expires: 20_000_000, + quota: { + // checkedAt far in the past → stale → will be refreshed this pass. + five_hour: { usedPercent: 5, remainingPercent: 95, checkedAt: 1 }, + seven_day: { usedPercent: 5, remainingPercent: 95, checkedAt: 1 }, + }, + }) + await saveAccounts(storage) + + const fetchImpl = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 40 }, + seven_day: { utilization: 30 }, + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + let fired = 0 + const manager = new FallbackAccountManager({ + fetchImpl, + now: () => 50_000_000, // well past checkedAt → stale + onFallbackStorageChanged: () => { + fired += 1 + }, + }) + + await manager.refreshQuotaForDueAccounts() + + expect(fetchImpl).toHaveBeenCalled() + expect(fired).toBe(1) + }) + test('refreshes fallback token and retries quota check after stale access token 401', async () => { const storage = baseStorage() storage.accounts.push({ diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index fc715b5..4a0fcb8 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -9,6 +9,7 @@ import { resetDumpState, resetFastModeState, saveAccounts, + tokenFingerprint, } from '@cortexkit/anthropic-auth-core' import { AnthropicAuthPlugin } from '../index' import { getSidebarState } from '../sidebar-state' @@ -2538,6 +2539,89 @@ describe('auth.loader', () => { } }) + test('async main refresh does not clobber the active fallback in the sidebar', async () => { + const staleCheckedAt = Date.now() - 100 * 60_000 // far past → main quota is stale + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + // Cached, stale, and FAILING five_hour policy (used 95% → remaining 5% < 10%). + mainQuota: { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: staleCheckedAt, + }, + seven_day: { + usedPercent: 10, + remainingPercent: 90, + checkedAt: staleCheckedAt, + }, + }, + mainQuotaCheckedAt: staleCheckedAt, + // Bind the cached main quota to the access token the loader will use. + mainQuotaToken: tokenFingerprint('main-access'), + } as AccountStorage['quota'], + }), + ) + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + // Delay the background main refresh so it settles AFTER the fallback + // write — this is the race that causes the clobber in production. + return Bun.sleep(40).then( + () => + new Response( + JSON.stringify({ + five_hour: { utilization: 0.95 }, + seven_day: { utilization: 0.1 }, + }), + { status: 200 }, + ), + ) + } + // Anthropic messages call — fallback serves 200, main would not be reached. + return Promise.resolve(new Response('ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + await result.fetch(MESSAGES_URL, { + method: 'POST', + body: JSON.stringify({ + model: 'claude-opus-4-8', + messages: [{ role: 'user', content: 'hello' }], + }), + }) + + // Fallback served → active id should be the fallback. + const state = await waitForSidebarState( + (candidate) => candidate.activeId === 'fallback-1', + ) + expect(state.route).toBe('fallback') + + // Let the fire-and-forget refreshMain().then(...) settle. + await Bun.sleep(80) + + // REGRESSION: pre-fix the async callback rewrites activeId to 'main'. + const after = await getSidebarState() + expect(after.activeId).toBe('fallback-1') + }) + test('fetch wrapper retries with fallback when main streaming body reports rate limit', async () => { await useTempAccountFile(createFallbackStorage()) const authorizations: string[] = [] @@ -2690,4 +2774,468 @@ describe('auth.loader', () => { expect(response.status).toBe(429) expect(calls).toBe(1) }) + + test('background fallback refresh updates the sidebar without a request', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + quota: { + // Stale (old checkedAt) → background pass will refresh it. + five_hour: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + seven_day: { + usedPercent: 0, + remainingPercent: 100, + checkedAt: 1, + }, + }, + }, + ], + }), + ) + + globalThis.fetch = mock((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0.42 }, + seven_day: { utilization: 0.1 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + // Running the loader starts the background refresh (immediate first pass). + await plugin.auth.loader( + () => + Promise.resolve({ + type: 'oauth', + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }), + { models: {} }, + ) + + // The background pass refreshes the stale fallback and the hook re-writes the + // sidebar — without any request to the messages endpoint. + // utilization: 0.42 → usedPercent: 0.42 (stored as-is, not multiplied by 100) + const state = await waitForSidebarState( + (candidate) => + candidate.fallbacks[0]?.quota?.five_hour?.usedPercent === 0.42, + ) + expect(state.fallbacks[0]?.id).toBe('fallback-1') + }) +}) + +describe('killswitch fetch gate', () => { + const originalFetch = globalThis.fetch + const originalSetInterval = globalThis.setInterval + + beforeEach(() => { + globalThis.fetch = originalFetch + // Prevent the plugin's background quota-refresh interval from leaking a + // real timer that fires during later tests (test-isolation flake). + globalThis.setInterval = mock( + () => ({ unref() {} }) as unknown as ReturnType, + ) as unknown as typeof setInterval + }) + + afterEach(() => { + globalThis.fetch = originalFetch + globalThis.setInterval = originalSetInterval + }) + + const oauthLoader = () => + Promise.resolve({ + type: 'oauth' as const, + access: 'main-access', + refresh: 'main-refresh', + expires: Date.now() + 100000, + }) + + // Main below the soft routing threshold but ABOVE the killswitch threshold, + // with no fallbacks: the killswitch must not hard-block — the request falls + // through to main as it would with the killswitch disabled. + test('does not 429 when main is only below the routing threshold', async () => { + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + killswitch: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + }), + ) + + globalThis.fetch = mock((input: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + // five_hour remaining 8% (< routing 10, > killswitch 5), + // seven_day remaining 60% (above both). + JSON.stringify({ + five_hour: { utilization: 92 }, + seven_day: { utilization: 40 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('message-ok') + }) + + // Main killed (below killswitch threshold) with a non-replayable body and a + // healthy fallback: the fallback cannot accept the request, so the killswitch + // must 429 rather than silently serving the killed main account. + test('429s a non-replayable request when main is killed even if a fallback is alive', async () => { + await useTempAccountFile( + createFallbackStorage({ + killswitch: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + }), + ) + + globalThis.fetch = mock((input: any, init: any) => { + if (extractUrl(input).includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + // main killed (remaining 2%), fallback healthy (remaining 90%). + const utilization = authorization.includes('main-access') ? 98 : 10 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('hi')) + controller.close() + }, + }) + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, { + method: 'POST', + body: stream, + duplex: 'half', + } as RequestInit) + + expect(response.status).toBe(429) + expect(await response.text()).toContain('Killswitch') + }) + + test('429s a replayable request when main is killed and the only fallback passes killswitch but fails routing quota policy', async () => { + // The fallback is above its killswitch threshold (so it passes the + // killswitch quota check) but below the routing minimumRemaining, so + // getUsableFallbackAccounts — and therefore routing — drops it. The 429 + // decision must be derived from the routable set, not the storage snapshot, + // so the request is hard-blocked instead of falling through to the killed + // main account. + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + }), + ) + + let mainServed = false + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + // main killed (5h remaining 2%). fallback 5h remaining 7% — above the + // killswitch threshold (5) but below the routing minimumRemaining (10). + const fiveHourUtil = authorization.includes('main-access') ? 98 : 93 + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: fiveHourUtil }, + seven_day: { utilization: 50 }, + }), + { status: 200 }, + ), + ) + } + mainServed = true + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(429) + expect(await response.text()).toContain('Killswitch') + // Must NOT have fallen through to the killswitched main account. + expect(mainServed).toBe(false) + }) + + test('fallback-first routing does not serve from a killswitch-killed fallback', async () => { + // killswitch threshold (5h:50) is higher than the routing minimumRemaining + // (5h:10): a fallback at 30% passes routing policy but is killswitch-killed. + // fallback-first must NOT serve from it — it should fall through to the + // healthy main account instead. + await useTempAccountFile( + createFallbackStorage({ + routing: { mode: 'fallback-first' }, + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + }), + ) + + let servedAuth: string | undefined + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + const isMain = authorization.includes('main-access') + // main healthy (80%); fallback at 30% — below killswitch (50), above + // routing minimumRemaining (10). + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 20 : 70 }, + seven_day: { utilization: isMain ? 20 : 50 }, + }), + { status: 200 }, + ), + ) + } + servedAuth = new Headers(init?.headers).get('authorization') ?? '' + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + expect(servedAuth).toContain('main-access') + expect(servedAuth).not.toContain('fallback-access') + }) + + test('fail-closed killswitch blocks the first request when main quota is unknown', async () => { + // failClosedOnUnknownQuota=true: on the first request the quota API is down, + // so the eager refresh fails and main quota stays unknown. The killswitch + // must treat main as killed (fail-closed) and 429 rather than fall through + // to main — even before the quota-API backoff is armed. + await useTempAccountFile( + createFallbackStorage({ + accounts: [], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + killswitch: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + }), + ) + + let mainServed = false + globalThis.fetch = mock((input: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + // Quota API down → eager refresh fails → main quota stays unknown. + return Promise.resolve(new Response('upstream error', { status: 500 })) + } + mainServed = true + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(429) + expect(mainServed).toBe(false) + }) + + test('sidebar marks the fallback active when the killswitch routes to it', async () => { + // killswitch threshold (5h:50) is above the routing minimumRemaining + // (5h:10): main at 30% passes routing (so the routing writeback optimistically + // sets the sidebar to 'main') but is killswitch-killed, so the killswitch gate + // hands off to the healthy fallback. The sidebar's active account must be + // corrected to that fallback, not left showing 'main'. + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + }), + ) + + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + const isMain = authorization.includes('main-access') + // main 5h 30% (passes routing 10, fails killswitch 50 → killed); + // fallback 5h 90% (passes both). 7d healthy for both. + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 70 : 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + return Promise.resolve(new Response('message-ok', { status: 200 })) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(200) + const state = await waitForSidebarState((s) => s.activeId === 'fallback-1') + expect(state.activeId).toBe('fallback-1') + }) + + test('killswitch returns the surviving fallback error rather than falling through to the killed main', async () => { + // main is killswitch-killed; a surviving fallback is tried but returns 429. + // The killswitch is a hard block, so the request must surface the fallback's + // real error — never retry on the killed main. + await useTempAccountFile( + createFallbackStorage({ + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: false, + }, + killswitch: { enabled: true, main: { five_hour: 50, seven_day: 10 } }, + accounts: [ + { + id: 'fallback-1', + type: 'oauth', + access: 'fallback-access', + refresh: 'fallback-refresh', + expires: Date.now() + 5 * 60 * 60 * 1000, + }, + ], + }), + ) + + let mainServed = false + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + const authorization = + new Headers(init?.headers).get('authorization') ?? '' + if (url.includes('/api/oauth/usage')) { + const isMain = authorization.includes('main-access') + // main 30% (passes routing 10, fails killswitch 50 → killed); + // fallback 90% (a survivor). + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: isMain ? 70 : 10 }, + seven_day: { utilization: 10 }, + }), + { status: 200 }, + ), + ) + } + if (authorization.includes('main-access')) { + mainServed = true + return Promise.resolve(new Response('main-ok', { status: 200 })) + } + // Surviving fallback is rate-limited at request time. + return Promise.resolve( + new Response(JSON.stringify({ error: 'fallback-limited' }), { + status: 429, + }), + ) + }) as unknown as typeof fetch + + const plugin = await getPlugin() + const result = await plugin.auth.loader(oauthLoader, { models: {} }) + const response = await result.fetch(MESSAGES_URL, EMPTY_POST) + + expect(response.status).toBe(429) + const body = await response.text() + expect(body).toContain('fallback-limited') + expect(body).not.toContain('Killswitch: no routable') + expect(mainServed).toBe(false) + }) }) diff --git a/packages/opencode/src/tests/killswitch.test.ts b/packages/opencode/src/tests/killswitch.test.ts new file mode 100644 index 0000000..c9c0682 --- /dev/null +++ b/packages/opencode/src/tests/killswitch.test.ts @@ -0,0 +1,537 @@ +import { afterEach, beforeEach, 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, + executeKillswitchCommand, + getKillswitchConfig, + getQuotaRefreshEveryNRequests, + isKillswitchEnabled, + killswitchPassesPolicy, + killswitchRetryAfterSeconds, + loadAccounts, + parseKillswitchCommandAction, + saveAccounts, + setKillswitchPersistent, +} from '@cortexkit/anthropic-auth-core' + +let tempDir: string +let accountPath: string + +const baseStorage = (): AccountStorage => ({ + version: 1, + main: { type: 'opencode', provider: 'anthropic' }, + fallbackOn: [401, 403, 429], + quota: { + enabled: true, + checkIntervalMinutes: 5, + minimumRemaining: { five_hour: 10, seven_day: 20 }, + failClosedOnUnknownQuota: true, + }, + accounts: [], +}) + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'anthropic-auth-ks-test-')) + accountPath = join(tempDir, 'anthropic-auth.json') + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = accountPath +}) + +afterEach(async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + await rm(tempDir, { recursive: true, force: true }) + mock.restore() +}) + +// --------------------------------------------------------------------------- +// parseKillswitchCommandAction +// --------------------------------------------------------------------------- +describe('parseKillswitchCommandAction', () => { + test('bare command returns status', () => { + expect(parseKillswitchCommandAction('')).toEqual({ type: 'status' }) + }) + + test('on/off', () => { + expect(parseKillswitchCommandAction('on')).toEqual({ type: 'on' }) + expect(parseKillswitchCommandAction('off')).toEqual({ type: 'off' }) + }) + + test('set with single account', () => { + expect(parseKillswitchCommandAction('set main:3,8')).toEqual({ + type: 'set', + entries: [{ account: 'main', fh: 3, sd: 8 }], + }) + }) + + test('set with multiple accounts', () => { + expect(parseKillswitchCommandAction('set main:3,8 work-alt:5,10')).toEqual({ + type: 'set', + entries: [ + { account: 'main', fh: 3, sd: 8 }, + { account: 'work-alt', fh: 5, sd: 10 }, + ], + }) + }) + + test('set all', () => { + expect(parseKillswitchCommandAction('set all:5,10')).toEqual({ + type: 'set', + entries: [{ account: 'all', fh: 5, sd: 10 }], + }) + }) + + test('set with no args returns usage', () => { + expect(parseKillswitchCommandAction('set')).toEqual({ type: 'usage' }) + }) + + test('set with bad format returns usage', () => { + expect(parseKillswitchCommandAction('set main:abc')).toEqual({ + type: 'usage', + }) + }) + + test('unknown subcommand returns usage', () => { + expect(parseKillswitchCommandAction('bogus')).toEqual({ type: 'usage' }) + }) +}) + +// --------------------------------------------------------------------------- +// killswitchPassesPolicy +// --------------------------------------------------------------------------- +describe('killswitchPassesPolicy', () => { + test('passes when killswitch is disabled', () => { + const storage = baseStorage() + expect(killswitchPassesPolicy(undefined, storage)).toBe(true) + }) + + test('passes when quota is above threshold', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + } + const quota = { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage)).toBe(true) + }) + + test('fails when five_hour remaining is below threshold', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 10, seven_day: 10 }, + } + const quota = { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage)).toBe(false) + }) + + test('fails when seven_day remaining is below threshold', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 20 }, + } + const quota = { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 90, + remainingPercent: 10, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage)).toBe(false) + }) + + test('uses per-account overrides', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + accounts: { 'work-alt': { five_hour: 20, seven_day: 30 } }, + } + const quota = { + five_hour: { + usedPercent: 85, + remainingPercent: 15, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 75, + remainingPercent: 25, + checkedAt: Date.now(), + }, + } + // main thresholds: 5h>=5, 1w>=10 → 15% and 25% pass + expect(killswitchPassesPolicy(quota, storage)).toBe(true) + // work-alt thresholds: 5h>=20, 1w>=30 → 15% < 20 → fails + expect(killswitchPassesPolicy(quota, storage, 'work-alt')).toBe(false) + }) + + test('account without override falls back to main thresholds', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + accounts: {}, + } + const quota = { + five_hour: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + seven_day: { + usedPercent: 50, + remainingPercent: 50, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage, 'unknown-id')).toBe(true) + }) + + test('missing quota with failClosedOnUnknownQuota returns false', () => { + const storage = baseStorage() + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + } + expect(killswitchPassesPolicy(undefined, storage)).toBe(false) + }) + + test('missing quota without failClosedOnUnknownQuota returns true', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + } + expect(killswitchPassesPolicy(undefined, storage)).toBe(true) + }) + + test('blocks on a below-threshold window even when the other window is missing (failClosed=false)', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + } + // five_hour absent, seven_day present and below its 10% threshold: the + // missing window must not short-circuit past the real below-threshold one. + const quota = { + seven_day: { + usedPercent: 98, + remainingPercent: 2, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage)).toBe(false) + }) + + test('passes a present above-threshold window when the other is missing (failClosed=false)', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota, failClosedOnUnknownQuota: false } + storage.killswitch = { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + } + const quota = { + seven_day: { + usedPercent: 20, + remainingPercent: 80, + checkedAt: Date.now(), + }, + } + expect(killswitchPassesPolicy(quota, storage)).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// killswitchRetryAfterSeconds +// --------------------------------------------------------------------------- +describe('killswitchRetryAfterSeconds', () => { + test('returns earliest reset across all accounts', () => { + const now = Date.now() + const mainQuota = { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + resetsAt: new Date(now + 600_000).toISOString(), // 10 min + checkedAt: now, + }, + } + const fallbacks = [ + { + quota: { + five_hour: { + usedPercent: 90, + remainingPercent: 10, + resetsAt: new Date(now + 300_000).toISOString(), // 5 min — earliest + checkedAt: now, + }, + }, + }, + ] + const seconds = killswitchRetryAfterSeconds(mainQuota, fallbacks, now) + // 300s until reset + 60s buffer + expect(seconds).toBeGreaterThanOrEqual(359) + expect(seconds).toBeLessThanOrEqual(361) + }) + + test('returns 300 fallback when no reset times available', () => { + expect(killswitchRetryAfterSeconds(undefined, [], Date.now())).toBe(300) + }) + + test('ignores past reset times', () => { + const now = Date.now() + const mainQuota = { + five_hour: { + usedPercent: 95, + remainingPercent: 5, + resetsAt: new Date(now - 60_000).toISOString(), // in the past + checkedAt: now, + }, + } + expect(killswitchRetryAfterSeconds(mainQuota, [], now)).toBe(300) + }) +}) + +// --------------------------------------------------------------------------- +// isKillswitchEnabled / getKillswitchConfig +// --------------------------------------------------------------------------- +describe('killswitch config helpers', () => { + test('isKillswitchEnabled returns false for null storage', () => { + expect(isKillswitchEnabled(null)).toBe(false) + }) + + test('isKillswitchEnabled returns false when not configured', () => { + expect(isKillswitchEnabled(baseStorage())).toBe(false) + }) + + test('isKillswitchEnabled returns true when enabled', () => { + const storage = baseStorage() + storage.killswitch = { enabled: true } + expect(isKillswitchEnabled(storage)).toBe(true) + }) + + test('getKillswitchConfig returns defaults for null storage', () => { + expect(getKillswitchConfig(null)).toEqual({ enabled: false }) + }) +}) + +// --------------------------------------------------------------------------- +// setKillswitchPersistent +// --------------------------------------------------------------------------- +describe('setKillswitchPersistent', () => { + test('persists killswitch config to disk', async () => { + await saveAccounts(baseStorage(), accountPath) + await setKillswitchPersistent( + { + enabled: true, + main: { five_hour: 3, seven_day: 8 }, + accounts: { 'work-alt': { five_hour: 5, seven_day: 10 } }, + }, + accountPath, + ) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.killswitch?.enabled).toBe(true) + expect(loaded?.killswitch?.main?.five_hour).toBe(3) + expect(loaded?.killswitch?.accounts?.['work-alt']?.five_hour).toBe(5) + }) + + test('preserves existing storage fields', async () => { + const storage = baseStorage() + storage.claudeCache = { enabled: true, mode: 'hybrid' } + await saveAccounts(storage, accountPath) + + await setKillswitchPersistent({ enabled: true }, accountPath) + + const loaded = await loadAccounts(accountPath) + expect(loaded?.claudeCache?.enabled).toBe(true) + expect(loaded?.killswitch?.enabled).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// executeKillswitchCommand +// --------------------------------------------------------------------------- +describe('executeKillswitchCommand', () => { + const accountIds = ['work-alt'] + + test('status shows table and cheatsheet when enabled', () => { + const result = executeKillswitchCommand({ + argumentsText: '', + config: { + enabled: true, + main: { five_hour: 5, seven_day: 10 }, + }, + accountIds, + }) + expect(result.text).toContain('## Killswitch') + expect(result.text).toContain('Status: **ON**') + expect(result.text).toContain('main') + expect(result.text).toContain('work-alt') + expect(result.text).toContain('/claude-killswitch on') + expect(result.text).toContain('/claude-killswitch set') + expect(result.updatedConfig).toBeUndefined() + }) + + test('status shows OFF when disabled', () => { + const result = executeKillswitchCommand({ + argumentsText: '', + config: { enabled: false }, + accountIds, + }) + expect(result.text).toContain('Status: **OFF**') + expect(result.updatedConfig).toBeUndefined() + }) + + test('on enables with defaults if no thresholds set', () => { + const result = executeKillswitchCommand({ + argumentsText: 'on', + config: { enabled: false }, + accountIds, + }) + expect(result.text).toContain('Killswitch Enabled') + expect(result.updatedConfig?.enabled).toBe(true) + expect(result.updatedConfig?.main?.five_hour).toBe(5) + expect(result.updatedConfig?.main?.seven_day).toBe(10) + }) + + test('on preserves existing thresholds', () => { + const result = executeKillswitchCommand({ + argumentsText: 'on', + config: { + enabled: false, + main: { five_hour: 3, seven_day: 8 }, + }, + accountIds, + }) + expect(result.updatedConfig?.enabled).toBe(true) + expect(result.updatedConfig?.main?.five_hour).toBe(3) + }) + + test('off disables', () => { + const result = executeKillswitchCommand({ + argumentsText: 'off', + config: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + accountIds, + }) + expect(result.text).toContain('Killswitch Disabled') + expect(result.updatedConfig?.enabled).toBe(false) + }) + + test('set updates main thresholds', () => { + const result = executeKillswitchCommand({ + argumentsText: 'set main:3,8', + config: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + accountIds, + }) + expect(result.text).toContain('Killswitch Updated') + expect(result.updatedConfig?.main?.five_hour).toBe(3) + expect(result.updatedConfig?.main?.seven_day).toBe(8) + }) + + test('set updates per-account thresholds', () => { + const result = executeKillswitchCommand({ + argumentsText: 'set work-alt:2,5', + config: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + accountIds, + }) + expect(result.updatedConfig?.accounts?.['work-alt']?.five_hour).toBe(2) + expect(result.updatedConfig?.accounts?.['work-alt']?.seven_day).toBe(5) + // main untouched + expect(result.updatedConfig?.main?.five_hour).toBe(5) + }) + + test('set all applies to main and all accounts', () => { + const result = executeKillswitchCommand({ + argumentsText: 'set all:7,15', + config: { enabled: true, main: { five_hour: 5, seven_day: 10 } }, + accountIds, + }) + expect(result.updatedConfig?.main?.five_hour).toBe(7) + expect(result.updatedConfig?.accounts?.['work-alt']?.five_hour).toBe(7) + }) + + test('invalid set syntax returns usage', () => { + const result = executeKillswitchCommand({ + argumentsText: 'set garbage', + config: { enabled: true }, + accountIds, + }) + expect(result.text).toContain('/claude-killswitch') + expect(result.updatedConfig).toBeUndefined() + }) +}) + +describe('getQuotaRefreshEveryNRequests', () => { + test('returns 0 when quota config is missing', () => { + expect(getQuotaRefreshEveryNRequests(null)).toBe(0) + expect( + getQuotaRefreshEveryNRequests({ ...baseStorage(), quota: undefined }), + ).toBe(0) + }) + + test('returns 0 when refreshEveryNRequests is not set', () => { + const storage = baseStorage() + expect(getQuotaRefreshEveryNRequests(storage)).toBe(0) + }) + + test('returns the configured value', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota!, refreshEveryNRequests: 3 } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(3) + }) + + test('returns 0 for zero or negative values', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota!, refreshEveryNRequests: 0 } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(0) + + storage.quota = { ...storage.quota!, refreshEveryNRequests: -1 } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(0) + }) + + test('floors fractional values', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota!, refreshEveryNRequests: 3.7 } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(3) + }) + + test('returns 0 for NaN/Infinity', () => { + const storage = baseStorage() + storage.quota = { ...storage.quota!, refreshEveryNRequests: NaN } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(0) + + storage.quota = { ...storage.quota!, refreshEveryNRequests: Infinity } + expect(getQuotaRefreshEveryNRequests(storage)).toBe(0) + }) +}) diff --git a/packages/opencode/src/tests/sidebar-state.test.ts b/packages/opencode/src/tests/sidebar-state.test.ts new file mode 100644 index 0000000..868512d --- /dev/null +++ b/packages/opencode/src/tests/sidebar-state.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from 'bun:test' +import { + type AccountQuota, + DEFAULT_SIDEBAR_STATE, + resolveActiveAccount, + type SidebarAccountState, + type SidebarState, +} from '../sidebar-state' + +const quota = (used: number): AccountQuota => ({ + five_hour: { usedPercent: used, remainingPercent: 100 - used }, + seven_day: { usedPercent: used, remainingPercent: 100 - used }, +}) + +const main = ( + q: AccountQuota | null, + killed = false, +): SidebarState['main'] => ({ quota: q, killed }) + +const fb = ( + overrides: Partial & { id: string }, +): SidebarAccountState => ({ + label: undefined, + quota: null, + killed: false, + enabled: true, + ...overrides, +}) + +function make(overrides: Partial): SidebarState { + return { ...DEFAULT_SIDEBAR_STATE, ...overrides } +} + +describe('resolveActiveAccount', () => { + test('activeId "main" resolves to the main account', () => { + const state = make({ activeId: 'main', main: main(quota(20)) }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('main') + expect(active.name).toBe('main') + expect(active.quota?.five_hour?.usedPercent).toBe(20) + expect(active.killed).toBe(false) + }) + + test('activeId matching an enabled fallback resolves to that fallback (label name)', () => { + const state = make({ + activeId: 'fb1', + fallbacks: [fb({ id: 'fb1', label: 'work', quota: quota(40) })], + }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('fb1') + expect(active.name).toBe('work') + expect(active.quota?.five_hour?.usedPercent).toBe(40) + }) + + test('fallback without a label uses its id as the name', () => { + const state = make({ + activeId: 'fb1', + fallbacks: [fb({ id: 'fb1', label: undefined, quota: quota(5) })], + }) + expect(resolveActiveAccount(state).name).toBe('fb1') + }) + + test('activeId matching a DISABLED fallback falls back to main', () => { + const state = make({ + activeId: 'fb1', + main: main(quota(12)), + fallbacks: [ + fb({ id: 'fb1', label: 'work', quota: quota(40), enabled: false }), + ], + }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('main') + expect(active.quota?.five_hour?.usedPercent).toBe(12) + }) + + test('undefined activeId resolves to main', () => { + const state = make({ activeId: undefined, main: main(quota(7)) }) + expect(resolveActiveAccount(state).id).toBe('main') + }) + + test('unmatched activeId resolves to main', () => { + const state = make({ + activeId: 'ghost', + main: main(null), + fallbacks: [fb({ id: 'fb1', label: 'work', quota: quota(40) })], + }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('main') + expect(active.quota).toBeNull() + }) + + test('carries through the killed flag for the active main account', () => { + const state = make({ activeId: 'main', main: main(quota(95), true) }) + expect(resolveActiveAccount(state).killed).toBe(true) + }) + + test('carries through the killed flag for the active fallback account', () => { + const state = make({ + activeId: 'fb1', + fallbacks: [ + fb({ id: 'fb1', label: 'work', quota: quota(99), killed: true }), + ], + }) + const active = resolveActiveAccount(state) + expect(active.id).toBe('fb1') + expect(active.killed).toBe(true) + }) +}) diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index 7ab8f3a..f3f643a 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -1,17 +1,21 @@ /** @jsxImportSource @opentui/solid */ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import type { TuiPlugin, TuiPluginApi, TuiPluginModule, } from '@opencode-ai/plugin/tui' -import { For, Show, createSignal, onCleanup } from 'solid-js' +import { For, type JSX, Show, createSignal, onCleanup } from 'solid-js' import { type AccountQuota, DEFAULT_SIDEBAR_STATE, type SidebarState, getSidebarState, + resolveActiveAccount, } from './sidebar-state.js' const POLL_MS = 1500 @@ -22,6 +26,20 @@ const BAR_WIDTH = 10 const BAR_FILLED = '\u2588' const BAR_EMPTY = '\u2591' +// Plugin version for the header (mirrors the Magic Context / AFT convention). +// Read at runtime from package.json relative to this module — NOT a TS JSON +// import, which would break the declaration build (package.json is outside +// rootDir). Empty string on any failure → header shows the badge with no version. +const PLUGIN_VERSION: string = (() => { + try { + const here = dirname(fileURLToPath(import.meta.url)) + const raw = readFileSync(join(here, '..', 'package.json'), 'utf8') + return (JSON.parse(raw) as { version?: string }).version ?? '' + } catch { + return '' + } +})() + // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface const SINGLE_BORDER = { type: 'single' } as any @@ -116,6 +134,21 @@ function StatRow(props: { ) } +// Compact row for the collapsed view: muted label left, caller-provided value +// right. Mirrors StatRow's layout so columns line up. +function CollapsedRow(props: { + theme: ThemeCurrent + label: string + children: JSX.Element +}) { + return ( + + {props.label} + {props.children} + + ) +} + // Quota window row: muted label left, tone-colored bar + percentage right, // with an optional muted reset suffix. function QuotaRow(props: { @@ -161,11 +194,14 @@ function AccountBlock(props: { theme: ThemeCurrent name: string quota: AccountQuota | null + killed: boolean active: boolean marginTop?: number }) { - const statusWord = () => (props.active ? 'active' : 'idle') - const statusTone = (): Tone => (props.active ? 'ok' : 'muted') + const statusWord = () => + props.killed ? 'blocked' : props.active ? 'active' : 'idle' + const statusTone = (): Tone => + props.killed ? 'err' : props.active ? 'ok' : 'muted' return ( @@ -203,6 +239,7 @@ async function readStateFromFile(): Promise { function QuotaSidebar(props: { api: TuiPluginApi }) { const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE) + const [collapsed, setCollapsed] = createSignal(false) let lastUpdated = 0 let debounce: ReturnType | null = null @@ -241,9 +278,23 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { const hasData = () => state().main.quota != null || enabledFallbacks().length > 0 + const killedNames = () => + [ + state().main.killed ? 'main' : '', + ...enabledFallbacks() + .filter((f) => f.killed) + .map((f) => f.label ?? f.id), + ].filter(Boolean) + const headerLabel = () => + !hasData() ? 'CLAUDE' : collapsed() ? '\u25b6 CLAUDE' : '\u25bc CLAUDE' + const activeAccount = () => resolveActiveAccount(state()) + const activeFiveHourPct = () => + activeAccount().quota?.five_hour?.usedPercent ?? null + const quotaBackedOff = () => state().main.quotaBackedOff === true const refreshBackedOff = () => state().main.refreshBackedOff === true - const degraded = () => quotaBackedOff() || refreshBackedOff() + const degraded = () => + killedNames().length > 0 || quotaBackedOff() || refreshBackedOff() const cacheKeep = () => state().cacheKeep const showCache = () => cacheKeep() != null && cacheKeep()?.window != null @@ -264,19 +315,30 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { paddingLeft={1} paddingRight={1} > - {/* Header: CLAUDE badge + optional LIMITED degraded badge */} + {/* Header: ▼/▶ CLAUDE badge (click to collapse) + version, or LIMITED badge */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */} { + if (hasData()) setCollapsed((value) => !value) + }} > - {'CLAUDE'} + {headerLabel()} - + + {`v${PLUGIN_VERSION}`} + + } + > - - {'Waiting for quota\u2026'} - - } - > - {/* Quota */} - - - - {(fb) => ( - - )} - + {/* Collapsed: active account 5h quota + dot (red ⊘ when blocked), + plus fast-mode when on */} + + + {'\u2014'}} + > + + + + {`${String(Math.round(activeFiveHourPct() as number)).padStart(3)}%`} + + + + {activeAccount().killed ? ' \u2298' : ' \u25cf'} + + + + + + + + {'fast'} + + + - {/* Routing */} - - - - - - {/* Cache */} - - + {/* Expanded: full sections. Also render when there's no data so the + sidebar can never go blank if data clears while collapsed. */} + + + {'Waiting for quota\u2026'} + + } + > + {/* Quota */} + + + + {(fb) => ( + + )} + + + + {/* Routing */} + + + - 0}> + + {/* Cache */} + + + 0}> + + - - {/* Health — only when something is wrong */} - - - - + {/* Health — only when something is wrong */} + + + + + + + + - + 0}>