From 5938687f5efd0398d2f0eb7a7b107c80f42e6895 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:19:45 +0200 Subject: [PATCH 1/4] feat(core): add QuotaManager with exponential backoff and persistence Unified quota cache and API gateway for main + fallback quota state. All consumers share one QuotaManager instance for consistent caching. Features: - Inflight deduplication prevents concurrent API calls - Exponential backoff (60s-15min) for 429/5xx errors - Persists main quota and backoff state to disk via callbacks - Cross-process file lock guard for quota API dedup - Seeds from persisted storage on construction - Integrates with FallbackAccountManager for shared staleness - Captures storage path at init to prevent test config corruption - Request-count-based refresh trigger (refreshEveryNRequests) --- packages/core/src/accounts.ts | 152 ++++++- packages/core/src/index.ts | 1 + packages/core/src/quota-manager.ts | 393 ++++++++++++++++++ packages/opencode/src/index.ts | 243 ++++++----- packages/opencode/src/tests/index.test.ts | 112 ++++- .../opencode/src/tests/quota-manager.test.ts | 307 ++++++++++++++ 6 files changed, 1096 insertions(+), 112 deletions(-) create mode 100644 packages/core/src/quota-manager.ts create mode 100644 packages/opencode/src/tests/quota-manager.test.ts diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index c5599df..e6cbf82 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -67,8 +67,12 @@ export type AccountStorage = { quota?: { enabled?: boolean checkIntervalMinutes?: number + refreshEveryNRequests?: number minimumRemaining?: Partial> failClosedOnUnknownQuota?: boolean + mainQuota?: OAuthQuotaSnapshot + mainQuotaCheckedAt?: number + mainLastQuotaApiError?: AccountOperationError } claudeCache?: { enabled?: boolean @@ -113,6 +117,7 @@ export type AccountManagerOptions = { now?: () => number fetchImpl?: typeof fetch configPath?: string + quotaManager?: import('./quota-manager.ts').QuotaManager } export type AccountRefreshError = { @@ -127,6 +132,9 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000 const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000 const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000 +const MIN_QUOTA_RETRY_DELAY_MS = 60_000 +const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000 +const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000 const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5 const DEFAULT_MINIMUM_REMAINING: Record = { five_hour: 0, @@ -608,12 +616,95 @@ export function formatRefreshBackoffMessage( return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}` } +function isTransientQuotaError(error: unknown): boolean { + if (!(error instanceof Error)) return false + // fetchOAuthQuotaSnapshot throws: "Claude quota check failed: {status} — {body}" + const statusMatch = error.message.match(/Claude quota check failed: (\d+)/) + if (statusMatch) { + const status = Number(statusMatch[1]) + return status === 429 || status >= 500 + } + // Network errors + return ( + error.message.includes('fetch failed') || + ('code' in error && + (error.code === 'ECONNRESET' || + error.code === 'ECONNREFUSED' || + error.code === 'ETIMEDOUT' || + error.code === 'UND_ERR_CONNECT_TIMEOUT')) + ) +} + +export function buildQuotaOperationError(input: { + error: unknown + now: number + previous?: AccountOperationError +}): AccountOperationError { + const previousRetryCount = input.previous?.retryCount ?? 0 + const retryCount = previousRetryCount + 1 + const delay = isTransientQuotaError(input.error) + ? Math.min( + MAX_QUOTA_RETRY_DELAY_MS, + MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6), + ) + : NON_TRANSIENT_QUOTA_RETRY_DELAY_MS + return { + message: formatErrorMessage(input.error), + checkedAt: input.now, + nextRetryAt: input.now + delay, + retryCount, + } +} + +export function quotaBackoffActive( + error: AccountOperationError | undefined, + now: number, +): boolean { + if (!error?.nextRetryAt || error.nextRetryAt <= now) return false + return true +} + +export function formatQuotaBackoffMessage( + error: AccountOperationError, + now: number, +): string { + const seconds = Math.max( + 1, + Math.ceil(((error.nextRetryAt ?? now) - now) / 1000), + ) + return `Quota API backed off for ${seconds}s after: ${error.message}` +} + export function getQuotaCheckIntervalMs(storage: AccountStorage | null) { const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES return Math.max(1, minutes) * 60_000 } +export function getPersistedMainQuota( + storage: AccountStorage | null, +): { quota: OAuthQuotaSnapshot; checkedAt: number } | null { + if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt) + return null + return { + quota: storage.quota.mainQuota, + checkedAt: storage.quota.mainQuotaCheckedAt, + } +} + +/** + * How often (in requests) to force a quota refresh, independent of the timer. + * Returns 0 when disabled (default). + */ +export function getQuotaRefreshEveryNRequests( + storage: AccountStorage | null, +): number { + const n = storage?.quota?.refreshEveryNRequests + return typeof n === 'number' && Number.isFinite(n) && n > 0 + ? Math.floor(n) + : 0 +} + function failClosedOnUnknownQuota(storage: AccountStorage | null) { return ( storage?.quota?.failClosedOnUnknownQuota ?? @@ -764,10 +855,11 @@ function recordQuotaRefreshError( error: unknown, now: number, ) { - account.lastQuotaRefreshError = { - message: formatErrorMessage(error), - checkedAt: now, - } + account.lastQuotaRefreshError = buildQuotaOperationError({ + error, + now, + previous: account.lastQuotaRefreshError, + }) if (error instanceof ClaudeOAuthRefreshError) { recordRefreshError(account, error, now) } @@ -780,11 +872,37 @@ export class FallbackAccountManager { private readonly refreshPromises = new Map>() private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null + readonly quotaManager: import('./quota-manager.ts').QuotaManager | null constructor(options: AccountManagerOptions = {}) { this.now = options.now ?? Date.now this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() + this.quotaManager = options.quotaManager ?? null + } + + /** + * Seed QuotaManager from persisted account.quota if no cache entry exists + * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh. + */ + private seedFallbackQuota( + account: OAuthAccount, + storage: AccountStorage, + ): void { + if (!this.quotaManager) return + if (this.quotaManager.getFallback(account.id)) return + if (!account.quota) return + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) return + const checkInterval = getQuotaCheckIntervalMs(storage) + this.quotaManager.setFallback(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) } async load() { @@ -840,7 +958,11 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (quotaIsStale(next, storage, this.now())) { + this.seedFallbackQuota(next, storage) + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (stale) { next = await this.refreshAccountQuota(next, storage) changed = true } @@ -937,7 +1059,16 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (!quotaIsStale(next, storage, this.now())) continue + if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) { + continue + } + this.seedFallbackQuota(next, storage) + // Use QuotaManager staleness when available (shared cache); + // fall back to per-account on-disk staleness otherwise. + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (!stale) continue await this.refreshAccountQuota(next, storage) changed = true } catch (error) { @@ -1085,6 +1216,15 @@ export class FallbackAccountManager { } target.lastQuotaRefreshError = undefined updateStoredAccount(storage, target) + // Sync to shared QuotaManager so all consumers see the same cache + if (this.quotaManager && target.quota) { + const now = this.now() + this.quotaManager.setFallback(target.id, { + quota: target.quota, + refreshAfter: now + getQuotaCheckIntervalMs(storage), + checkedAt: now, + }) + } return target } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9ae5fe..ce93e01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,5 +9,6 @@ export * from './dump.ts' export * from './fast.ts' export * from './logger.ts' export * from './pkce.ts' +export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts new file mode 100644 index 0000000..4ca0eb1 --- /dev/null +++ b/packages/core/src/quota-manager.ts @@ -0,0 +1,393 @@ +/** + * Unified quota cache and API gateway. + * + * Single source of truth for main + fallback quota state. All consumers + * share one QuotaManager instance so they see the same in-memory cache. + * Handles deduplication, rate-limiting (429 backoff), and staleness. + */ + +import type { + AccountOperationError, + AccountStorage, + OAuthAccount, + OAuthQuotaSnapshot, +} from './accounts.ts' +import { + acquireRefreshFileLock, + buildQuotaOperationError, + fetchOAuthQuotaSnapshot, + getPersistedMainQuota, + getQuotaCheckIntervalMs, + getQuotaNextRefreshAt, + getQuotaRefreshEveryNRequests, + quotaBackoffActive, +} from './accounts.ts' + +// Capture real setTimeout before tests can mock globalThis.setTimeout +const nativeSetTimeout = globalThis.setTimeout + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type QuotaEntry = { + quota: OAuthQuotaSnapshot + refreshAfter: number // Unix ms — earliest next refresh + checkedAt: number // when snapshot was fetched +} + +export type QuotaManagerOptions = { + storage: AccountStorage | null + fetchImpl?: typeof fetch + now?: () => number + onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number) => void + onApiError?: (error: AccountOperationError) => void +} + +// --------------------------------------------------------------------------- +// Class +// --------------------------------------------------------------------------- + +export class QuotaManager { + // --- State --- + private main: QuotaEntry | null = null + private mainAccessToken: string | null = null + private fallbacks = new Map() + + // --- Inflight deduplication --- + private inflightMain: Promise | null = null + private inflightFallbacks = new Map>() + + // --- Rate-limiting --- + private lastApiError: AccountOperationError | undefined = undefined + + // --- Serial API gate (prevents concurrent quota API calls) --- + private apiGate: Promise = Promise.resolve() + private lastApiCallAt = 0 + + // --- Config --- + private storage: AccountStorage | null + private readonly fetchImpl: typeof fetch + private readonly now: () => number + private readonly onMainQuotaFetched: QuotaManagerOptions['onMainQuotaFetched'] + private readonly onApiError: QuotaManagerOptions['onApiError'] + + constructor(opts: QuotaManagerOptions) { + this.storage = opts.storage + this.fetchImpl = opts.fetchImpl ?? fetch + this.now = opts.now ?? Date.now + this.onMainQuotaFetched = opts.onMainQuotaFetched + this.onApiError = opts.onApiError + + // Seed main quota from persisted storage + const persisted = getPersistedMainQuota(opts.storage) + if (persisted) { + this.main = { + quota: persisted.quota, + refreshAfter: getQuotaNextRefreshAt( + persisted.quota, + opts.storage, + persisted.checkedAt, + ), + checkedAt: persisted.checkedAt, + } + } + + // Seed backoff state from persisted storage + const persistedError = opts.storage?.quota?.mainLastQuotaApiError + if (persistedError && quotaBackoffActive(persistedError, this.now())) { + this.lastApiError = persistedError + } + } + + // ========================================================================= + // Get (synchronous, from cache) + // ========================================================================= + + getMain(): QuotaEntry | null { + return this.main + } + + getFallback(accountId: string): QuotaEntry | null { + return this.fallbacks.get(accountId) ?? null + } + + getAllFallbacks(): Map { + return this.fallbacks + } + + // ========================================================================= + // Set (manual inject — seeding from persisted account.quota on boot) + // ========================================================================= + + setMain(accessToken: string, entry: QuotaEntry): void { + this.mainAccessToken = accessToken + this.main = entry + } + + setFallback(accountId: string, entry: QuotaEntry): void { + this.fallbacks.set(accountId, entry) + } + + // ========================================================================= + // Refresh (async, deduplicated, rate-limited) + // ========================================================================= + + async refreshMain(accessToken: string): Promise { + // If token changed, invalidate cache + if (this.mainAccessToken && this.mainAccessToken !== accessToken) { + this.main = null + this.mainAccessToken = null + } + + // Deduplicate — return in-flight promise if already fetching + if (this.inflightMain) return this.inflightMain + + // Rate-limit — if API recently 429'd, return stale or throw + if (this.isBackedOff()) { + if (this.main) return this.main.quota + throw new Error('Quota API rate-limited — try again later') + } + + this.inflightMain = this._fetchMain(accessToken) + return this.inflightMain + } + + async refreshFallback( + accountId: string, + accessToken: string, + ): Promise { + // Deduplicate + const inflight = this.inflightFallbacks.get(accountId) + if (inflight) return inflight + + // Rate-limit + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + + const promise = this._fetchFallback(accountId, accessToken) + this.inflightFallbacks.set(accountId, promise) + return promise + } + + async refreshAllFallbacks(accounts: OAuthAccount[]): Promise { + const now = this.now() + + for (const account of accounts) { + if (account.enabled === false) continue + if (!account.access) continue + + const cached = this.fallbacks.get(account.id) + if (cached && now < cached.refreshAfter) continue + + try { + await this.refreshFallback(account.id, account.access) + } catch { + // Best-effort — keep stale cache entry if fetch fails + } + } + } + + /** + * Fire-and-forget refresh. Does not await, swallows errors. + */ + refreshMainInBackground(accessToken: string): void { + if (this.inflightMain) return + if (this.isBackedOff()) return + void this.refreshMain(accessToken).catch(() => {}) + } + + // ========================================================================= + // Staleness queries + // ========================================================================= + + isMainStale(): boolean { + if (!this.main) return true + return this.now() >= this.main.refreshAfter + } + + isFallbackStale(accountId: string): boolean { + const entry = this.fallbacks.get(accountId) + if (!entry) return true + return this.now() >= entry.refreshAfter + } + + shouldRefreshOnRequestCount(requestCount: number): boolean { + const everyN = getQuotaRefreshEveryNRequests(this.storage) + if (everyN <= 0) return false + return requestCount > 0 && requestCount % everyN === 0 + } + + /** + * Combined check: should a refresh happen right now? + * True if main is stale by time OR triggered by request count. + */ + needsRefresh(requestCount: number): boolean { + return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount) + } + + // ========================================================================= + // Config + // ========================================================================= + + updateStorage(storage: AccountStorage | null): void { + this.storage = storage + } + + /** + * Seed fallback cache entries from persisted account.quota data. + * Only seeds accounts that don't already have a cache entry. + * Prevents unnecessary API calls when persisted quota is still fresh. + */ + seedFallbacksFromAccounts(accounts: OAuthAccount[]): void { + const checkInterval = getQuotaCheckIntervalMs(this.storage) + for (const account of accounts) { + if (account.enabled === false) continue + if (this.fallbacks.has(account.id)) continue + if (!account.quota) continue + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) continue + this.fallbacks.set(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) + } + } + + /** + * Whether the API is currently in backoff due to a recent error. + */ + isBackedOff(): boolean { + return quotaBackoffActive(this.lastApiError, this.now()) + } + + getLastApiError(): AccountOperationError | undefined { + return this.lastApiError + } + + // ========================================================================= + // Private + // ========================================================================= + + /** Minimum gap between consecutive quota API calls (ms). */ + private static readonly API_CALL_GAP_MS = 1_000 + + /** + * Serialize API calls through a shared gate so only one + * quota API request runs at a time, with a minimum gap + * between calls. Prevents concurrent and rapid-fire calls + * from triggering Anthropic's rate limits. + */ + private _enqueueApiFetch(fn: () => Promise): Promise { + const gatedFn = async (): Promise => { + // Wait until minimum gap since last API call + const elapsed = this.now() - this.lastApiCallAt + if (elapsed < QuotaManager.API_CALL_GAP_MS) { + await new Promise((r) => { + const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed) + if (typeof id === 'object' && 'unref' in id) id.unref() + }) + } + this.lastApiCallAt = this.now() + return fn() + } + const queued = this.apiGate.then(gatedFn, gatedFn) + this.apiGate = queued.catch(() => {}) + return queued + } + + private async _fetchMain(accessToken: string): Promise { + 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 + throw new Error('Quota API rate-limited — try again later') + } + const fileLock = await acquireRefreshFileLock({ + name: 'opencode-main-quota-refresh', + ttlMs: 30_000, + }) + if (!fileLock) { + if (this.main) return this.main.quota + throw new Error('Quota refresh is already in progress') + } + try { + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.mainAccessToken = accessToken + this.main = { + quota, + refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now), + checkedAt: now, + } + this.lastApiError = undefined + this.onMainQuotaFetched?.(quota, now) + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + await fileLock.release() + } + } finally { + this.inflightMain = null + } + }) + } + + private async _fetchFallback( + accountId: string, + accessToken: string, + ): Promise { + return this._enqueueApiFetch(async () => { + try { + // Re-check backoff inside gate + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.fallbacks.set(accountId, { + quota, + refreshAfter: now + getQuotaCheckIntervalMs(this.storage), + checkedAt: now, + }) + this.lastApiError = undefined + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + this.inflightFallbacks.delete(accountId) + } + }) + } + + private _handleFetchError(error: unknown): void { + this.lastApiError = buildQuotaOperationError({ + error, + now: this.now(), + previous: this.lastApiError, + }) + this.onApiError?.(this.lastApiError) + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c8561e..eeb931c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,13 +21,12 @@ import { executeDumpCommand, executeFastModeCommand, FallbackAccountManager, - fetchOAuthQuotaSnapshot, + formatQuotaBackoffMessage, formatRefreshBackoffMessage, + getAccountStoragePath, getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, - getQuotaCheckIntervalMs, - getQuotaNextRefreshAt, getRelayConfig, hashRefreshToken, isCache1hEnabled, @@ -41,12 +40,12 @@ import { loadAccounts, log, mergeAnthropicBetas, - type OAuthQuotaSnapshot, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, type QuotaAccountSummary, + QuotaManager, quotaSnapshotPassesPolicy, type RelayConfig, refreshBackoffActive, @@ -86,12 +85,6 @@ const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES -type MainQuotaCache = { - accessToken: string - refreshAfter: number - quota: OAuthQuotaSnapshot -} - type NotificationRequest = { path: { id: string } body: { @@ -247,11 +240,50 @@ function throwHandledSentinel(): never { export const AnthropicAuthPlugin: Plugin = async (ctx) => { startEventLoopLagMonitor() const { client } = ctx - const fallbackManager = new FallbackAccountManager() + const accountStoragePath = getAccountStoragePath() + const initialStorage = await loadAccounts(accountStoragePath) + const quotaManager = new QuotaManager({ + storage: initialStorage, + onMainQuotaFetched: async (quota, checkedAt) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainQuota = quota + storage.quota.mainQuotaCheckedAt = checkedAt + storage.quota.mainLastQuotaApiError = undefined + await saveAccounts(storage, accountStoragePath) + } catch (error) { + log('[quota] failed to persist main quota', { + error: error instanceof Error ? error.message : String(error), + }) + } + }, + onApiError: async (error) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainLastQuotaApiError = error + await saveAccounts(storage, accountStoragePath) + } catch (e) { + log('[quota] failed to persist backoff state', { + error: e instanceof Error ? e.message : String(e), + }) + } + }, + }) + const fallbackManager = new FallbackAccountManager({ + quotaManager, + }) fallbackManager.startBackgroundRefresh() let latestRefreshMainAccessToken: (() => Promise) | null = null const cacheKeepManager = new CacheKeepManager({ - loadStorage: () => loadAccounts(), + loadStorage: () => loadAccounts(accountStoragePath), prepareHeaders: async (headers, target) => { if (!latestGetAuth) return headers const auth = await latestGetAuth() @@ -289,14 +321,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, log, }) - const initialCache1hStorage = await loadAccounts() - const relayConfig: RelayConfig | null = getRelayConfig(initialCache1hStorage) + const relayConfig: RelayConfig | null = getRelayConfig(initialStorage) setCache1hState({ - enabled: isCache1hPersistentlyEnabled(initialCache1hStorage), - mode: getCache1hPersistentMode(initialCache1hStorage), + enabled: isCache1hPersistentlyEnabled(initialStorage), + mode: getCache1hPersistentMode(initialStorage), }) - setDumpEnabled(isDumpPersistentlyEnabled(initialCache1hStorage)) - setFastModeEnabled(isFastModePersistentlyEnabled(initialCache1hStorage)) + setDumpEnabled(isDumpPersistentlyEnabled(initialStorage)) + setFastModeEnabled(isFastModePersistentlyEnabled(initialStorage)) let latestGetAuth: | (() => Promise<{ type: string @@ -324,13 +355,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function clearStaleMainRefreshError(refreshToken?: string) { if (!refreshToken) return - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const error = storage?.refresh?.mainLastRefreshError if (!storage?.refresh || !error?.tokenHash) return const tokenHash = hashRefreshToken(refreshToken) if (error.tokenHash === tokenHash) return storage.refresh.mainLastRefreshError = undefined - await saveAccounts(storage) + await saveAccounts(storage, accountStoragePath) log( '[refresh] opencode main oauth cleared stale backoff after token rotation', { @@ -347,10 +378,15 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { try { const auth = await latestGetAuth() if (auth.type === 'oauth' && auth.access) { + // Use QuotaManager cache; eager fetch on first request means + // this is always populated after the first API call. + const cached = quotaManager.getMain() + const quota = + cached?.quota ?? (await quotaManager.refreshMain(auth.access)) accounts.push({ name: 'OpenCode anthropic', role: 'main', - quota: await fetchOAuthQuotaSnapshot({ accessToken: auth.access }), + quota, }) } else if (auth.type === 'oauth') { accounts.push({ @@ -361,22 +397,34 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } } catch (error) { + const msg = error instanceof Error ? error.message : String(error) accounts.push({ name: 'OpenCode anthropic', role: 'main', - error: error instanceof Error ? error.message : String(error), + error: msg.includes('429') + ? 'Usage API rate limited — try again in a moment' + : msg, }) } } - const { storage, errors } = - await fallbackManager.refreshQuotaForAllAccounts() - accounts.push( - ...buildFallbackQuotaSummaries( - storage, - new Map(errors.map((error) => [error.accountId, error.message])), - ), + // Use QuotaManager for fallbacks — goes through serial API gate + const storage = await loadAccounts(accountStoragePath) + const fallbackAccts = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false && a.access, ) + try { + await quotaManager.refreshAllFallbacks(fallbackAccts) + } catch { + // Best-effort — stale cache is fine for display + } + // Overlay QuotaManager cache onto storage accounts for display + const fallbackEntries = quotaManager.getAllFallbacks() + for (const account of storage?.accounts ?? []) { + const cached = fallbackEntries.get(account.id) + if (cached) account.quota = cached.quota + } + accounts.push(...buildFallbackQuotaSummaries(storage, new Map())) if (!latestGetAuth) { accounts.unshift({ @@ -410,7 +458,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isCache1hPersistentlyEnabled(storage) const mode = getCache1hPersistentMode(storage) setCache1hState({ enabled, mode }) @@ -419,7 +467,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function executePersistentCacheKeepCommand(argumentsText: string) { const action = parseCacheKeepCommandAction(argumentsText) - let storage = await loadAccounts() + let storage = await loadAccounts(accountStoragePath) if (action.type === 'window') { storage = await setCacheKeepPersistentWindow( action.startHour, @@ -450,7 +498,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeDumpCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isDumpPersistentlyEnabled(storage) setDumpEnabled(enabled) return executeDumpCommand({ argumentsText, enabled }) @@ -465,7 +513,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeFastModeCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isFastModePersistentlyEnabled(storage) setFastModeEnabled(enabled) return executeFastModeCommand({ argumentsText, enabled }) @@ -581,9 +629,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Shared inflight refresh promise — prevents concurrent token refreshes // from racing against each other (and causing 401 cascades with token rotation) let refreshPromise: Promise | null = null - let mainQuotaCache: MainQuotaCache | null = null - let mainQuotaRefreshPromise: Promise | null = null - let mainQuotaRetryAfter = 0 async function refreshMainAccessToken() { if (!refreshPromise) { @@ -597,14 +642,16 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function updateMainRefreshState( update: (storage: AccountStorage) => void, ) { - const storage: AccountStorage = (await loadAccounts()) ?? { + const storage: AccountStorage = (await loadAccounts( + accountStoragePath, + )) ?? { version: 1, main: { type: 'opencode', provider: 'anthropic' }, accounts: [], } storage.refresh = storage.refresh ?? {} update(storage) - await saveAccounts(storage) + await saveAccounts(storage, accountStoragePath) } async function waitForConcurrentMainRefresh(previous: { @@ -665,7 +712,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const refreshTokenHash = hashRefreshToken(freshAuth.refresh) const mainError = storage?.refresh?.mainLastRefreshError log('[refresh] opencode main oauth refresh check', { @@ -751,7 +798,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { nextStorage.refresh.mainRefreshLeaseTokenHash = refreshTokenHash }) - const latestLease = await loadAccounts() + const latestLease = await loadAccounts(accountStoragePath) log( '[refresh] opencode main oauth refresh lease acquired', { @@ -897,7 +944,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const run = async () => { try { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) if (!mainRefreshEnabled(storage)) return const latestAuth = await getAuth() if (latestAuth.type !== 'oauth') return @@ -1143,7 +1190,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { isCache1hEnabled() && getCache1hMode() === 'hybrid' ) { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const tracked = await cacheKeepManager.track({ sessionId: relayAffinity, url: rewritten.url?.toString() ?? rewritten.input.toString(), @@ -1187,54 +1234,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return response } - async function refreshMainQuotaCache( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - const quota = await fetchOAuthQuotaSnapshot({ accessToken }) - mainQuotaCache = { - accessToken, - refreshAfter: getQuotaNextRefreshAt(quota, storage, now), - quota, - } - return quota - } - - function refreshMainQuotaCacheInBackground( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaRefreshPromise || now < mainQuotaRetryAfter) return - mainQuotaRefreshPromise = refreshMainQuotaCache( - accessToken, - storage, - ) - .catch((error) => { - mainQuotaRetryAfter = now + getQuotaCheckIntervalMs(storage) - throw error - }) - .finally(() => { - mainQuotaRefreshPromise = null - }) - void mainQuotaRefreshPromise.catch(() => {}) - } - - async function getMainQuotaForRouting( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaCache?.accessToken !== accessToken) { - return await refreshMainQuotaCache(accessToken, storage) - } - if (now >= mainQuotaCache.refreshAfter) { - refreshMainQuotaCacheInBackground(accessToken, storage) - } - return mainQuotaCache.quota - } - async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -1296,7 +1295,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!isReplayableRequest(input, init?.body)) return mainResponse const loadStart = nowMs() - const storage = existingStorage ?? (await loadAccounts()) + const storage = existingStorage ?? (await loadAccounts(accountStoragePath)) trace?.mark('fallback_load_storage', { ms: roundMs(nowMs() - loadStart), cached: !!existingStorage, @@ -1385,7 +1384,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { throw new Error('OAuth access token is missing after refresh') } const loadStart = nowMs() - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) + quotaManager.updateStorage(storage) + quotaManager.seedFallbacksFromAccounts(storage?.accounts ?? []) trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) let preselectedFallbackAccounts: | Awaited< @@ -1400,18 +1401,22 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) { try { const quotaStart = nowMs() - const mainQuota = await getMainQuotaForRouting( - auth.access, - storage, - ) + // Use QuotaManager: get cached or eagerly refresh if first time + let routingQuota = quotaManager.getMain()?.quota + if (!routingQuota) { + routingQuota = await quotaManager.refreshMain(auth.access) + } else if (quotaManager.isMainStale()) { + // Background refresh — return stale to avoid blocking + void quotaManager.refreshMain(auth.access).catch(() => {}) + } trace.mark('main_quota_for_routing', { ms: roundMs(nowMs() - quotaStart), - passes: quotaSnapshotPassesPolicy(mainQuota, storage), + passes: quotaSnapshotPassesPolicy(routingQuota, storage), }) - if (!quotaSnapshotPassesPolicy(mainQuota, storage)) { + if (!quotaSnapshotPassesPolicy(routingQuota, storage)) { const fallbackStart = nowMs() preselectedFallbackAccounts = - await fallbackManager.getUsableFallbackAccounts() + await fallbackManager.getUsableFallbackAccounts(storage) trace.mark('preselect_fallback_accounts', { ms: roundMs(nowMs() - fallbackStart), accounts: preselectedFallbackAccounts.length, @@ -1439,6 +1444,46 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Main quota checks should optimize routing, not break requests. } } + + // Fail-closed: if failClosedOnUnknownQuota is set, quota API is backed off, + // and we have no cached quota, block the request. + const mainQuota = quotaManager.getMain()?.quota + if ( + storage?.quota?.failClosedOnUnknownQuota && + !mainQuota && + quotaManager.isBackedOff() + ) { + const lastError = quotaManager.getLastApiError() + const msg = lastError + ? formatQuotaBackoffMessage(lastError, Date.now()) + : 'Quota API unavailable' + log('[killswitch] blocked: quota API backed off', { + 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, + ), + }, + }, + ) + } const mainResponse = await sendWithAccessToken( input, init, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index be2a3de..920bf13 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -263,6 +263,18 @@ describe('auth.loader', () => { let capturedBody: string | undefined globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -331,7 +343,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined let capturedHeaders: Headers | undefined globalThis.fetch = mock((input: any, init: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url capturedBody = init?.body capturedHeaders = new Headers(init?.headers) return Promise.resolve( @@ -790,7 +814,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -832,7 +868,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -869,7 +917,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined const mockClient = createMockClient() - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -924,7 +984,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) capturedHeaders = new Headers(init?.headers) return Promise.resolve(new Response(null, { status: 200 })) @@ -974,7 +1046,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) return Promise.resolve(new Response(null, { status: 200 })) }, @@ -1653,7 +1737,19 @@ describe('auth.loader', () => { let capturedUrl: string | undefined globalThis.fetch = mock((input: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -1875,6 +1971,8 @@ describe('auth.loader', () => { ]) expect(second).toBe('message-2') + // Background quota refresh involves file-lock I/O; wait for it to fire. + await new Promise((r) => setTimeout(r, 50)) expect(quotaCalls).toBe(2) expect(messageCalls).toBe(2) } finally { diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts new file mode 100644 index 0000000..94f7cdb --- /dev/null +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -0,0 +1,307 @@ +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 { QuotaManager } from '@cortexkit/anthropic-auth-core' + +function makeQuotaResponse(now: number) { + return new Response( + JSON.stringify({ + five_hour: { + utilization: 25, + resets_at: new Date(now + 3600_000).toISOString(), + }, + seven_day: { + utilization: 50, + }, + }), + { status: 200 }, + ) +} + +describe('QuotaManager', () => { + let now: number + let tempDir: string + + beforeEach(async () => { + now = 1_000_000 + tempDir = await mkdtemp(join(tmpdir(), 'qm-test-')) + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( + tempDir, + 'anthropic-auth.json', + ) + }) + + afterEach(async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + await rm(tempDir, { recursive: true, force: true }) + }) + + function createQM(fetchImpl?: typeof fetch) { + return new QuotaManager({ + storage: null, + fetchImpl, + now: () => now, + }) + } + + describe('backoff', () => { + test('first 429 backs off for 60s', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + + expect(qm.isBackedOff()).toBe(true) + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('repeated 429s escalate backoff exponentially', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // First failure: 60s + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + expect(qm.isBackedOff()).toBe(false) + + // Second failure: 120s + try { + await qm.refreshMain('token') + } catch {} + now += 119_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('backoff caps at 15 minutes', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // Trigger 8 failures to exceed cap + for (let i = 0; i < 8; i++) { + try { + await qm.refreshMain('token') + } catch {} + now += 16 * 60_000 + } + + try { + await qm.refreshMain('token') + } catch {} + now += 14 * 60_000 + expect(qm.isBackedOff()).toBe(true) + now += 2 * 60_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('successful fetch resets backoff', async () => { + let failNext = true + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + failNext = false + await qm.refreshMain('token') + expect(qm.isBackedOff()).toBe(false) + + // Next failure starts from 60s again (not escalated) + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('getLastApiError exposes backoff state', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + expect(qm.getLastApiError()).toBeUndefined() + + try { + await qm.refreshMain('token') + } catch {} + + const err = qm.getLastApiError() + expect(err).toBeDefined() + expect(err!.retryCount).toBe(1) + expect(err!.nextRetryAt).toBeGreaterThan(now) + }) + + test('500 errors also trigger backoff', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('internal error', { status: 500 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + }) + + test('returns cached quota during backoff', async () => { + let failNext = false + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + const first = await qm.refreshMain('token') + expect(first).toBeDefined() + + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + + const cached = qm.getMain() + expect(cached).not.toBeNull() + }) + }) + + describe('persistence', () => { + test('seeds main quota from persisted storage', () => { + const quota = { + quotas: [], + expires: new Date(2_000_000).toISOString(), + } + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainQuota: quota as any, + mainQuotaCheckedAt: 900_000, + }, + }, + now: () => 1_000_000, + }) + + const main = qm.getMain() + expect(main).not.toBeNull() + expect(main!.checkedAt).toBe(900_000) + }) + + test('calls onMainQuotaFetched after successful fetch', async () => { + let callbackQuota: any = null + const fetchMock = mock(() => + Promise.resolve(makeQuotaResponse(now)), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onMainQuotaFetched: (quota, checkedAt) => { + callbackQuota = { quota, checkedAt } + }, + }) + + await qm.refreshMain('token') + expect(callbackQuota).not.toBeNull() + expect(callbackQuota.checkedAt).toBe(now) + }) + + test('seeds backoff state from persisted storage', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: now - 30_000, + nextRetryAt: now + 30_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(true) + }) + + test('ignores expired persisted backoff', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'old error', + checkedAt: now - 120_000, + nextRetryAt: now - 60_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(false) + }) + + test('calls onApiError callback on failure', async () => { + let errorCallback: any = null + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onApiError: (error) => { + errorCallback = error + }, + }) + + try { + await qm.refreshMain('token') + } catch {} + + expect(errorCallback).not.toBeNull() + expect(errorCallback.retryCount).toBe(1) + expect(errorCallback.nextRetryAt).toBeGreaterThan(now) + }) + }) +}) From 545bea11d85547ac58c974ea6bdb00aec46d1365 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:43:35 +0200 Subject: [PATCH 2/4] feat(opencode): TUI sidebar quota widget Adds a TUI sidebar widget showing real-time quota usage for main and fallback accounts. Displays usage bars, reset times, relay status, fast mode, and cache-keepalive state. New files: sidebar-state.ts, tui.tsx, scripts/copy-tui.mjs Modified: package.json (TUI deps, exports), index.ts (writeSidebarState) --- packages/core/src/cachekeep.ts | 4 + packages/opencode/package.json | 15 +- packages/opencode/scripts/copy-tui.mjs | 9 + packages/opencode/src/index.ts | 47 +++++ packages/opencode/src/sidebar-state.ts | 69 +++++++ packages/opencode/src/tui.tsx | 251 +++++++++++++++++++++++++ packages/opencode/tsconfig.build.json | 2 +- 7 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/scripts/copy-tui.mjs create mode 100644 packages/opencode/src/sidebar-state.ts create mode 100644 packages/opencode/src/tui.tsx diff --git a/packages/core/src/cachekeep.ts b/packages/core/src/cachekeep.ts index f129fee..61caf01 100644 --- a/packages/core/src/cachekeep.ts +++ b/packages/core/src/cachekeep.ts @@ -303,6 +303,10 @@ export class CacheKeepManager { return { trackedSessions: targets.length, nextPrewarmAt } } + trackedCount(): number { + return this.targets.size + } + track(input: { sessionId?: string | null url: string diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c9df889..3742d70 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -13,8 +13,16 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./tui": { + "types": "./dist/tui.d.ts", + "default": "./dist/tui.tsx" } }, + "oc-plugin": [ + ".", + "./tui" + ], "bin": { "opencode-anthropic-auth": "dist/cli.js" }, @@ -27,7 +35,7 @@ "LICENSE" ], "scripts": { - "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly", + "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/sidebar-state.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --minify && tsc -p tsconfig.build.json --emitDeclarationOnly && node scripts/copy-tui.mjs", "build:dev": "rm -rf dist && tsc -p tsconfig.build.json", "dev": "bun ../../scripts/dev.ts", "dev:clean": "bun ../../scripts/dev-clean.ts", @@ -41,6 +49,9 @@ "@opencode-ai/plugin": "*" }, "dependencies": { - "@cortexkit/anthropic-auth-core": "1.2.2" + "@cortexkit/anthropic-auth-core": "1.2.2", + "@opentui/core": ">=0.1.92", + "@opentui/solid": ">=0.1.92", + "solid-js": "^1.9.10" } } diff --git a/packages/opencode/scripts/copy-tui.mjs b/packages/opencode/scripts/copy-tui.mjs new file mode 100644 index 0000000..037c216 --- /dev/null +++ b/packages/opencode/scripts/copy-tui.mjs @@ -0,0 +1,9 @@ +import { copyFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const src = resolve(__dirname, '..', 'src', 'tui.tsx') +const dest = resolve(__dirname, '..', 'dist', 'tui.tsx') +copyFileSync(src, dest) +console.log('copied tui.tsx to dist/') \ No newline at end of file diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index eeb931c..cc8390f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -66,6 +66,7 @@ import { } from '@cortexkit/anthropic-auth-core' import type { Plugin } from '@opencode-ai/plugin' import { resolvePromptContext } from './prompt-context.ts' +import { type SidebarState, setSidebarState } from './sidebar-state.ts' import { addFastModeBetaHeader, createStrippedStream, @@ -328,6 +329,49 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) setDumpEnabled(isDumpPersistentlyEnabled(initialStorage)) setFastModeEnabled(isFastModePersistentlyEnabled(initialStorage)) + + function writeSidebarState( + storage: Awaited>, + activeId: string | undefined, + route: string, + ) { + const mainEntry = quotaManager.getMain() + const state: SidebarState = { + main: { + quota: mainEntry?.quota ?? null, + }, + fallbacks: (storage?.accounts ?? []) + .filter((a) => a.enabled !== false) + .map((a) => ({ + id: a.id, + label: a.label, + quota: a.quota ?? null, + enabled: a.enabled !== false, + })), + activeId, + route, + relay: relayConfig + ? { enabled: true, transport: relayConfig.transport ?? 'http' } + : null, + fastMode: isFastModeEnabled(), + cacheKeep: { + enabled: isCacheKeepHybridActive(storage), + window: + storage?.cacheKeep?.startHour != null && + storage?.cacheKeep?.endHour != null + ? `${storage.cacheKeep.startHour}-${storage.cacheKeep.endHour}` + : undefined, + trackedSessions: cacheKeepManager.trackedCount(), + }, + lastUpdated: Date.now(), + } + setSidebarState(state).catch((error) => + log('[sidebar] state write failed', { + error: error instanceof Error ? error.message : String(error), + }), + ) + } + let latestGetAuth: | (() => Promise<{ type: string @@ -579,6 +623,8 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { input.sessionID, await buildQuotaCommandSummary(), ) + const cmdStorage = await loadAccounts() + writeSidebarState(cmdStorage, 'main', 'main') throwHandledSentinel() } @@ -1010,6 +1056,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { } startMainBackgroundRefresh() + writeSidebarState(initialStorage, 'main', 'main') function isReplayableRequest( input: string | URL | Request, diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts new file mode 100644 index 0000000..7e2cb4d --- /dev/null +++ b/packages/opencode/src/sidebar-state.ts @@ -0,0 +1,69 @@ +export interface QuotaWindow { + usedPercent: number + remainingPercent: number + resetsAt?: string +} + +export interface AccountQuota { + five_hour?: QuotaWindow + seven_day?: QuotaWindow +} + +export interface SidebarAccountState { + id: string + label: string | undefined + quota: AccountQuota | null + enabled: boolean +} + +export interface SidebarState { + main: { + quota: AccountQuota | null + } + fallbacks: SidebarAccountState[] + activeId: string | undefined + route: string + relay: { enabled: boolean; transport: string } | null + fastMode: boolean + cacheKeep?: { + enabled: boolean + window?: string + trackedSessions?: number + } + lastUpdated: number +} + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const STATE_DIR = join(tmpdir(), 'opencode-anthropic-auth') +const STATE_FILE = join(STATE_DIR, 'sidebar-state.json') + +export const DEFAULT_SIDEBAR_STATE: SidebarState = { + main: { quota: null }, + fallbacks: [], + activeId: undefined, + route: 'main', + relay: null, + fastMode: false, + lastUpdated: 0, +} + +export async function getSidebarState(): Promise { + try { + const raw = await readFile(STATE_FILE, 'utf8') + return JSON.parse(raw) as SidebarState + } catch { + return DEFAULT_SIDEBAR_STATE + } +} + +export async function setSidebarState(state: SidebarState): Promise { + try { + await mkdir(STATE_DIR, { recursive: true }) + await writeFile(STATE_FILE, JSON.stringify(state), 'utf8') + } catch { + // Best-effort — sidebar is non-critical + } +} diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx new file mode 100644 index 0000000..ceef011 --- /dev/null +++ b/packages/opencode/src/tui.tsx @@ -0,0 +1,251 @@ +/** @jsxImportSource @opentui/solid */ + +import { readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { + TuiPlugin, + TuiPluginApi, + TuiPluginModule, +} from '@opencode-ai/plugin/tui' +import { createSignal, For, onCleanup, Show } from 'solid-js' +import type { AccountQuota, SidebarState } from './sidebar-state.js' + +const STATE_FILE = join( + tmpdir(), + 'opencode-anthropic-auth', + 'sidebar-state.json', +) +const POLL_MS = 2000 + +const DEFAULT_STATE: SidebarState = { + main: { quota: null }, + fallbacks: [], + activeId: undefined, + route: 'main', + relay: null, + fastMode: false, + lastUpdated: 0, +} + +async function readStateFromFile(): Promise { + try { + const raw = await readFile(STATE_FILE, 'utf8') + return JSON.parse(raw) as SidebarState + } catch { + return DEFAULT_STATE + } +} + +const ID = 'cortexkit.anthropic-auth' +const BAR_WIDTH = 12 +const BAR_FILLED = '\u2588' +const BAR_EMPTY = '\u2591' + +function quotaBar(usedPct: number, width = BAR_WIDTH): string { + const filled = Math.round((usedPct / 100) * width) + return BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(width - filled) +} + +function barColor(usedPct: number, api: TuiPluginApi): string { + if (usedPct < 50) + return api.theme.current.success ?? api.theme.current.accent ?? 'green' + if (usedPct < 80) return api.theme.current.warning ?? 'yellow' + return api.theme.current.error ?? 'red' +} + +function formatResetIn(resetsAt: string | undefined): string { + if (!resetsAt) return '' + const ms = new Date(resetsAt).getTime() - Date.now() + if (ms <= 0) return 'now' + const mins = Math.floor(ms / 60_000) + if (mins < 60) return `${mins}m` + const hrs = Math.floor(mins / 60) + const rm = mins % 60 + return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h` +} + +function QuotaBar(props: { + label: string + usedPct: number + api: TuiPluginApi +}) { + const color = () => barColor(props.usedPct, props.api) + const muted = () => props.api.theme.current.textMuted + return ( + + {` ${props.label} `} + {quotaBar(props.usedPct)} + {` ${String(Math.round(props.usedPct)).padStart(3)}%`} + + ) +} + +function AccountSection(props: { + name: string + quota: AccountQuota | null + active: boolean + api: TuiPluginApi +}) { + const dotColor = () => + props.active + ? (props.api.theme.current.success ?? + props.api.theme.current.accent ?? + 'green') + : (props.api.theme.current.textMuted ?? 'gray') + const muted = () => props.api.theme.current.textMuted + const resetStr = () => formatResetIn(props.quota?.five_hour?.resetsAt) + return ( + + + + {'* '} + + {props.name} + + {` ${resetStr()}`} + + + {' checking...'}} + > + + + + + ) +} + +function QuotaSidebar(props: { api: TuiPluginApi }) { + const [state, setState] = createSignal(DEFAULT_STATE) + let lastUpdated = 0 + + async function refresh() { + const next = await readStateFromFile() + if (next.lastUpdated !== lastUpdated) { + lastUpdated = next.lastUpdated + setState(next) + + } + } + + // Poll globalThis since server and TUI load separate module instances + const timer = setInterval(refresh, POLL_MS) + onCleanup(() => clearInterval(timer)) + + // Also refresh on OpenCode events for faster updates + const unsubs = [ + props.api.event.on('session.updated', refresh), + props.api.event.on('message.updated', refresh), + ] + onCleanup(() => { + for (const u of unsubs) u() + }) + + // Initial refresh after short delay (server plugin may not have written yet) + setTimeout(refresh, 500) + setTimeout(refresh, 2000) + + + + const hasData = () => + state().main.quota != null || state().fallbacks.length > 0 + const muted = () => props.api.theme.current.textMuted ?? '#71717a' + + return ( + + + {'\u2500 Claude Quota \u2500\u2500\u2500\u2500\u2500'} + + {' Waiting...'}} + > + + + f.enabled)}> + {(fb) => ( + + + + + )} + + + + + { + '\u2500\u2500 Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500' + } + + + {' Route: '} + + {state().route} + + + + {' Mode: '} + {state().fastMode ? 'fast \u26a1' : 'std'} + + + {' Relay: '} + {'\u2014'}} + > + {`${state().relay?.transport} `} + + {'*'} + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 150, + slots: { + sidebar_content(_ctx: unknown, _props: { session_id: string }) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id: ID, + tui, +} + +export default plugin diff --git a/packages/opencode/tsconfig.build.json b/packages/opencode/tsconfig.build.json index 0f2a29d..0bd81dd 100644 --- a/packages/opencode/tsconfig.build.json +++ b/packages/opencode/tsconfig.build.json @@ -10,5 +10,5 @@ "rewriteRelativeImportExtensions": true }, "include": ["src/**/*.ts"], - "exclude": ["src/tests/**"] + "exclude": ["src/tests/**", "src/tui.tsx"] } From d89dd9d54748035deb470ec328518313b1a7d4bc Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:25:41 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(opencode):=20killswitch=20=E2=80=94=20?= =?UTF-8?q?block=20requests=20when=20quota=20drops=20below=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-account request blocking when remaining quota drops below configurable thresholds. Returns synthetic 429 when all accounts (main + fallbacks) are below their thresholds. Includes /claude-killswitch slash command for runtime management. Features: - Per-account threshold overrides (5h and 7d windows) - Eager quota refresh on first request for killswitch evaluation - Skip-main routing when main is killed (try surviving fallbacks) - Filter killed accounts from reactive fallback path - Retry-After header with earliest quota reset time --- README.md | 10 +- packages/core/src/accounts.ts | 118 +++++ packages/core/src/index.ts | 1 + packages/core/src/killswitch.ts | 163 ++++++ packages/opencode/README.md | 53 +- packages/opencode/src/index.ts | 155 ++++++ .../opencode/src/tests/killswitch.test.ts | 501 ++++++++++++++++++ 7 files changed, 997 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/killswitch.ts create mode 100644 packages/opencode/src/tests/killswitch.test.ts diff --git a/README.md b/README.md index 5e9448b..3f641b8 100644 --- a/README.md +++ b/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-fast`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-cachekeep`, `/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-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/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 - **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. @@ -169,6 +170,11 @@ Example: }, "failClosedOnUnknownQuota": true }, + "killswitch": { + "enabled": false, + "main": { "five_hour": 5, "seven_day": 10 }, + "accounts": {} + }, "claudeCache": { "enabled": false, "mode": "explicit" diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index e6cbf82..d3060a1 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -48,6 +48,18 @@ export type AccountQuotaWindow = { checkedAt: number } +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?: { @@ -96,6 +108,7 @@ export type AccountStorage = { fallbackToDirect?: boolean transport?: 'http' | 'websocket' } + killswitch?: KillswitchConfig accounts: OAuthAccount[] } @@ -254,6 +267,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), @@ -726,6 +740,110 @@ 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. +// --------------------------------------------------------------------------- + +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) + for (const key of ['five_hour', 'seven_day'] as const) { + const window = quota?.[key] + if (!window) return !failClosedOnUnknownQuota(storage) + if (window.remainingPercent < thresholds[key]) return false + } + 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, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ce93e01..ab3668b 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..64d97fd --- /dev/null +++ b/packages/core/src/killswitch.ts @@ -0,0 +1,163 @@ +import type { + KillswitchConfig, + KillswitchThresholds, + QuotaWindowName, +} from './accounts.ts' + +export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch' + +const DEFAULT_THRESHOLDS: Record = { + five_hour: 5, + seven_day: 10, +} + +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 b2ab2bf..908f69f 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-fast`, `/claude-quota`, `/claude-dump` | `/claude-cache`, `/claude-cachekeep`, `/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-fast`, `/claude-quota`, `/claude-dump`, `/claude-killswitch` | `/claude-cache`, `/claude-cachekeep`, `/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 - **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. @@ -169,6 +170,11 @@ Example: }, "failClosedOnUnknownQuota": true }, + "killswitch": { + "enabled": false, + "main": { "five_hour": 5, "seven_day": 10 }, + "accounts": {} + }, "claudeCache": { "enabled": false, "mode": "explicit" @@ -258,6 +264,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 eeb931c..3e2c970 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -20,6 +20,7 @@ import { executeCacheKeepCommand, executeDumpCommand, executeFastModeCommand, + executeKillswitchCommand, FallbackAccountManager, formatQuotaBackoffMessage, formatRefreshBackoffMessage, @@ -27,6 +28,7 @@ import { getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, + getKillswitchConfig, getRelayConfig, hashRefreshToken, isCache1hEnabled, @@ -37,9 +39,14 @@ import { isFastModeEnabled, isFastModePersistentlyEnabled, isFastModeSupportedModel, + isKillswitchEnabled, + KILLSWITCH_COMMAND_NAME, + killswitchPassesPolicy, + killswitchRetryAfterSeconds, loadAccounts, log, mergeAnthropicBetas, + type OAuthQuotaSnapshot, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, @@ -62,6 +69,7 @@ import { setDumpPersistentEnabled, setFastModeEnabled, setFastModePersistentEnabled, + setKillswitchPersistent, shouldFallbackStatus, } from '@cortexkit/anthropic-auth-core' import type { Plugin } from '@opencode-ai/plugin' @@ -548,6 +556,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { description: 'Show or toggle Anthropic fast mode for supported Opus models.', }, + [KILLSWITCH_COMMAND_NAME]: { + template: KILLSWITCH_COMMAND_NAME, + description: + 'Manage killswitch — hard-block requests when quota drops below per-account thresholds.', + }, } }, 'command.execute.before': async (input: { @@ -599,6 +612,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', @@ -1234,6 +1265,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return response } + function getFallbackQuota(account: { + id: string + quota?: OAuthQuotaSnapshot + }): OAuthQuotaSnapshot | undefined { + return quotaManager.getFallback(account.id)?.quota ?? account.quota + } + async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -1326,6 +1364,18 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { accounts: accounts.length, }) } + if (isKillswitchEnabled(storage)) { + const before = accounts.length + accounts = accounts.filter((a) => + killswitchPassesPolicy(a.quota, storage, a.id), + ) + if (accounts.length < before) { + log('[killswitch] filtered fallbacks', { + before, + after: accounts.length, + }) + } + } return ( (await tryUsableFallbackAccounts( input, @@ -1338,9 +1388,12 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) } + let sessionRequestCount = 0 + return { apiKey: '', async fetch(input: string | URL | Request, init?: RequestInit) { + sessionRequestCount++ const initialBody = init?.body const trace = createPerfTrace({ bodyBytes: @@ -1484,6 +1537,108 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, ) } + // 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(), + }) + } + } + } + + if (isKillswitchEnabled(storage) && mainQuota) { + const mainKilled = !killswitchPassesPolicy(mainQuota, storage) + const mainBelowRoutingThreshold = + mainQuotaRoutingEnabled(storage) && + !quotaSnapshotPassesPolicy(mainQuota, storage) + const mainUnroutable = mainKilled || mainBelowRoutingThreshold + const fallbackAccounts = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false, + ) + const allFallbacksKilled = + fallbackAccounts.length === 0 || + fallbackAccounts.every( + (a) => + !killswitchPassesPolicy( + getFallbackQuota(a), + storage, + a.id, + ), + ) + + if (mainUnroutable && allFallbacksKilled) { + const now = Date.now() + 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 mainKillswitched = + isKillswitchEnabled(storage) && + mainQuota != null && + !killswitchPassesPolicy(mainQuota, storage) + + if (mainKillswitched && isReplayableRequest(input, init?.body)) { + log('[route] skipping main (killswitch), trying fallbacks') + const allFallbacks = + await fallbackManager.getUsableFallbackAccounts(storage) + const survivingFallbacks = allFallbacks.filter((a) => + killswitchPassesPolicy(a.quota, storage, a.id), + ) + if (survivingFallbacks.length > 0) { + const fallbackResponse = await tryUsableFallbackAccounts( + input, + init, + survivingFallbacks, + storage, + undefined, + trace, + ) + if (fallbackResponse) { + trace.done('return_killswitch_fallback', { + status: fallbackResponse.status, + }) + return createStrippedStream(fallbackResponse) + } + } + log( + '[killswitch] surviving fallbacks exhausted, falling through to main', + ) + } + const mainResponse = await sendWithAccessToken( input, init, diff --git a/packages/opencode/src/tests/killswitch.test.ts b/packages/opencode/src/tests/killswitch.test.ts new file mode 100644 index 0000000..b1b8a79 --- /dev/null +++ b/packages/opencode/src/tests/killswitch.test.ts @@ -0,0 +1,501 @@ +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) + }) +}) + +// --------------------------------------------------------------------------- +// 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) + }) +}) From 09b34c3c4cc524c510c60d203e388e3b1342d6bf Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 21:15:07 +0200 Subject: [PATCH 4/4] feat(opencode): add killswitch indicators to TUI sidebar Shows killed account status (red dot), quota/refresh backoff state, and kill summary in the sidebar widget. --- packages/opencode/src/index.ts | 24 ++++++++++++++++++-- packages/opencode/src/sidebar-state.ts | 8 ++++++- packages/opencode/src/tui.tsx | 31 +++++++++++++++++++++----- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f9bcefc..44c8ee4 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -344,9 +344,22 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { route: string, ) { const mainEntry = quotaManager.getMain() + const ksEnabled = isKillswitchEnabled(storage) + const lastApiError = quotaManager.getLastApiError() + const mainRefreshError = storage?.refresh?.mainLastRefreshError const state: SidebarState = { main: { quota: mainEntry?.quota ?? null, + killed: + ksEnabled && mainEntry?.quota != null + ? !killswitchPassesPolicy(mainEntry.quota, storage) + : false, + quotaBackedOff: quotaManager.isBackedOff(), + quotaBackoffUntil: lastApiError?.nextRetryAt, + refreshBackedOff: mainRefreshError + ? refreshBackoffActive(mainRefreshError, undefined, Date.now()) + : false, + refreshBackoffUntil: mainRefreshError?.nextRetryAt, }, fallbacks: (storage?.accounts ?? []) .filter((a) => a.enabled !== false) @@ -354,6 +367,10 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { id: a.id, label: a.label, quota: a.quota ?? null, + killed: + ksEnabled && a.quota != null + ? !killswitchPassesPolicy(a.quota, storage, a.id) + : false, enabled: a.enabled !== false, })), activeId, @@ -1505,9 +1522,12 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { let routingQuota = quotaManager.getMain()?.quota if (!routingQuota) { routingQuota = await quotaManager.refreshMain(auth.access) - } else if (quotaManager.isMainStale()) { + } else if (quotaManager.needsRefresh(sessionRequestCount)) { // Background refresh — return stale to avoid blocking - void quotaManager.refreshMain(auth.access).catch(() => {}) + void quotaManager + .refreshMain(auth.access) + .then(() => writeSidebarState(storage, 'main', 'main')) + .catch(() => {}) } trace.mark('main_quota_for_routing', { ms: roundMs(nowMs() - quotaStart), diff --git a/packages/opencode/src/sidebar-state.ts b/packages/opencode/src/sidebar-state.ts index 7e2cb4d..069518b 100644 --- a/packages/opencode/src/sidebar-state.ts +++ b/packages/opencode/src/sidebar-state.ts @@ -13,12 +13,18 @@ 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 + refreshBackoffUntil?: number } fallbacks: SidebarAccountState[] activeId: string | undefined @@ -41,7 +47,7 @@ const STATE_DIR = join(tmpdir(), 'opencode-anthropic-auth') const STATE_FILE = join(STATE_DIR, 'sidebar-state.json') export const DEFAULT_SIDEBAR_STATE: SidebarState = { - main: { quota: null }, + main: { quota: null, killed: false }, fallbacks: [], activeId: undefined, route: 'main', diff --git a/packages/opencode/src/tui.tsx b/packages/opencode/src/tui.tsx index ceef011..25cfb94 100644 --- a/packages/opencode/src/tui.tsx +++ b/packages/opencode/src/tui.tsx @@ -86,15 +86,16 @@ function QuotaBar(props: { function AccountSection(props: { name: string quota: AccountQuota | null + killed: boolean active: boolean api: TuiPluginApi }) { const dotColor = () => - props.active - ? (props.api.theme.current.success ?? - props.api.theme.current.accent ?? - 'green') - : (props.api.theme.current.textMuted ?? 'gray') + props.killed + ? (props.api.theme.current.error ?? 'red') + : props.active + ? (props.api.theme.current.success ?? props.api.theme.current.accent ?? 'green') + : (props.api.theme.current.textMuted ?? 'gray') const muted = () => props.api.theme.current.textMuted const resetStr = () => formatResetIn(props.quota?.five_hour?.resetsAt) return ( @@ -176,6 +177,7 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { @@ -186,6 +188,7 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { @@ -228,6 +231,24 @@ function QuotaSidebar(props: { api: TuiPluginApi }) { + + {' Kill: '} + f.killed)} + fallback={{'\u2014'}} + > + + {`${[ + state().main.killed ? 'main' : '', + ...state() + .fallbacks.filter((f) => f.killed) + .map((f) => f.label ?? f.id), + ] + .filter(Boolean) + .join(', ')} blocked`} + + + ) }