From 731431af448b4305ef33fb2f7dd8979d32b5d2b5 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 | 390 ++++++++++++++++++ packages/opencode/src/index.ts | 239 ++++++----- packages/opencode/src/tests/index.test.ts | 112 ++++- .../opencode/src/tests/quota-manager.test.ts | 307 ++++++++++++++ 6 files changed, 1091 insertions(+), 110 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..e6b72f4 --- /dev/null +++ b/packages/core/src/quota-manager.ts @@ -0,0 +1,390 @@ +/** + * 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: + persisted.checkedAt + getQuotaCheckIntervalMs(opts.storage), + 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..3bc332a 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,7 +355,7 @@ 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) @@ -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,7 +642,9 @@ 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: [], @@ -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 18123aefebea49c3336ebba51d1eb9ffa6b0b1db Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:23:57 +0200 Subject: [PATCH 2/4] feat(opencode): show quota usage toast after quota refresh Displays quota usage bar notifications via client.tui.showToast after quota data is refreshed. Shows main and fallback account usage with visual bars, percentage, and reset time. Toast variant reflects severity (info < 70%, warning >= 70%, error >= 90%). --- packages/opencode/src/index.ts | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 3bc332a..3b3eb52 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -40,6 +40,7 @@ import { loadAccounts, log, mergeAnthropicBetas, + type OAuthQuotaSnapshot, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, @@ -519,6 +520,110 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeFastModeCommand({ argumentsText, enabled }) } + function quotaBar(pct: number, width = 16): string { + const filled = Math.min(Math.round((pct / 100) * width), width) + return '█'.repeat(filled) + '░'.repeat(width - filled) + } + + function formatResetIn(resetsAt: string | undefined): string { + if (!resetsAt) return '' + const ms = new Date(resetsAt).getTime() - Date.now() + if (ms <= 0) return 'resets now' + const mins = Math.floor(ms / 60_000) + if (mins < 60) return `resets ${mins}m` + const hrs = Math.floor(mins / 60) + const rm = mins % 60 + return rm > 0 ? `resets ${hrs}h${rm}m` : `resets ${hrs}h` + } + + function showQuotaToast( + quota: OAuthQuotaSnapshot | null, + fallbacks?: Array<{ + id: string + label?: string + quota?: OAuthQuotaSnapshot + }>, + activeAccountId?: string, + ) { + const sections: string[] = [] + let globalMaxUsed = 0 + + // Main account + if (quota) { + const fh = quota.five_hour + const sd = quota.seven_day + if (fh || sd) { + const mainActive = activeAccountId === 'main' + const indicator = mainActive ? '🟢' : ' ' + const reset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} main${reset ? ` (${reset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + // Fallback accounts + if (fallbacks?.length) { + for (const fb of fallbacks) { + const q = fb.quota + if (!q) continue + const fh = q.five_hour + const sd = q.seven_day + if (!fh && !sd) continue + const name = fb.label || 'alt' + const fbActive = activeAccountId === fb.id + const indicator = fbActive ? '🟢' : ' ' + const fbReset = formatResetIn(fh?.resetsAt) + const lines: string[] = [ + `${indicator} ${name}${fbReset ? ` (${fbReset})` : ''}`, + ] + if (fh) { + lines.push( + `5h ${quotaBar(fh.usedPercent)} ${Math.round(fh.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, fh.usedPercent) + } + if (sd) { + lines.push( + `1w ${quotaBar(sd.usedPercent)} ${Math.round(sd.usedPercent)}%`, + ) + globalMaxUsed = Math.max(globalMaxUsed, sd.usedPercent) + } + sections.push(lines.join('\n')) + } + } + + if (!sections.length) return + const message = sections.join('\n\n') + const variant = + globalMaxUsed >= 90 ? 'error' : globalMaxUsed >= 70 ? 'warning' : 'info' + + // biome-ignore lint/suspicious/noExplicitAny: SDK client.tui type not exposed to server plugins + void (client.tui as any) + ?.showToast?.({ + body: { + title: 'Claude Quota', + message, + variant, + duration: variant === 'error' ? 8000 : 5000, + }, + }) + ?.catch?.(() => {}) + } + return { config: async (config: { command?: Record }) => { config.command = { @@ -1388,6 +1493,27 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { quotaManager.updateStorage(storage) quotaManager.seedFallbacksFromAccounts(storage?.accounts ?? []) trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) + + /** Show quota toast from current QuotaManager state */ + function showQuotaToastFromCache() { + const mainEntry = quotaManager.getMain() + if (!mainEntry) return + const fallbacks = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false, + ) + const mainPassesPolicy = quotaSnapshotPassesPolicy( + mainEntry.quota, + storage, + ) + let activeId: string | undefined + if (mainPassesPolicy) { + activeId = 'main' + } else { + activeId = fallbacks[0]?.id + } + showQuotaToast(mainEntry.quota, fallbacks, activeId) + } + let preselectedFallbackAccounts: | Awaited< ReturnType< @@ -1405,6 +1531,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { let routingQuota = quotaManager.getMain()?.quota if (!routingQuota) { routingQuota = await quotaManager.refreshMain(auth.access) + showQuotaToastFromCache() } else if (quotaManager.isMainStale()) { // Background refresh — return stale to avoid blocking void quotaManager.refreshMain(auth.access).catch(() => {}) From 65f5a2163526bf382892368f448643dddc131fa6 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 | 159 ++++++ packages/opencode/README.md | 53 +- packages/opencode/src/index.ts | 160 +++++- .../opencode/src/tests/killswitch.test.ts | 501 ++++++++++++++++++ 7 files changed, 997 insertions(+), 5 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..a2cd777 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. +// --------------------------------------------------------------------------- + +export const DEFAULT_KILLSWITCH_THRESHOLDS: Record = { + five_hour: 5, + seven_day: 10, +} + +function normalizeKillswitchThresholds( + thresholds: KillswitchThresholds | undefined, +): Record { + return { + five_hour: + thresholds?.five_hour ?? + thresholds?.['5h'] ?? + DEFAULT_KILLSWITCH_THRESHOLDS.five_hour, + seven_day: + thresholds?.seven_day ?? + thresholds?.['1w'] ?? + DEFAULT_KILLSWITCH_THRESHOLDS.seven_day, + } +} + +export function isKillswitchEnabled(storage: AccountStorage | null) { + return storage?.killswitch?.enabled === true +} + +function getKillswitchThresholdsForAccount( + storage: AccountStorage | null, + accountId?: string, +): Record { + if (!storage?.killswitch) return DEFAULT_KILLSWITCH_THRESHOLDS + if (accountId && storage.killswitch.accounts?.[accountId]) { + return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]) + } + return normalizeKillswitchThresholds(storage.killswitch.main) +} + +/** + * Returns true if the account's quota is above its killswitch threshold. + * When killswitch is disabled, always returns true. + */ +export function killswitchPassesPolicy( + quota: OAuthQuotaSnapshot | undefined, + storage: AccountStorage | null, + accountId?: string, +) { + if (!isKillswitchEnabled(storage)) return true + const thresholds = getKillswitchThresholdsForAccount(storage, accountId) + 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..e32b73c --- /dev/null +++ b/packages/core/src/killswitch.ts @@ -0,0 +1,159 @@ +import type { + KillswitchConfig, + KillswitchThresholds, + QuotaWindowName, +} from './accounts.ts' +import { DEFAULT_KILLSWITCH_THRESHOLDS } from './accounts.ts' + +export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch' + +export type KillswitchCommandAction = + | { type: 'status' } + | { type: 'on' } + | { type: 'off' } + | { type: 'set'; entries: Array<{ account: string; fh: number; sd: number }> } + | { type: 'usage' } + +export function parseKillswitchCommandAction( + argumentsText: string, +): KillswitchCommandAction { + const parts = argumentsText.trim().split(/\s+/).filter(Boolean) + if (parts.length === 0) return { type: 'status' } + if (parts.length === 1 && parts[0] === 'on') return { type: 'on' } + if (parts.length === 1 && parts[0] === 'off') return { type: 'off' } + + if (parts[0] === 'set') { + const entries: Array<{ account: string; fh: number; sd: number }> = [] + for (let i = 1; i < parts.length; i++) { + const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/) + if (!match) return { type: 'usage' } + const [, account, fhStr, sdStr] = match as RegExpMatchArray & + [string, string, string, string] + entries.push({ + account, + fh: Number.parseInt(fhStr, 10), + sd: Number.parseInt(sdStr, 10), + }) + } + if (entries.length === 0) return { type: 'usage' } + return { type: 'set', entries } + } + + return { type: 'usage' } +} + +function buildStatusTable( + config: KillswitchConfig, + accountIds: string[], +): string { + const enabled = config.enabled === true + const lines: string[] = [ + '## Killswitch', + '', + `Status: **${enabled ? 'ON' : 'OFF'}**`, + ] + + if (enabled) { + lines.push('') + lines.push('| Account | 5h threshold | 1w threshold |') + lines.push('| ------- | ------------ | ------------ |') + + const mainT = config.main ?? {} + const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_KILLSWITCH_THRESHOLDS.five_hour + const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_KILLSWITCH_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_KILLSWITCH_THRESHOLDS.five_hour + const asd = t.seven_day ?? t['1w'] ?? DEFAULT_KILLSWITCH_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_KILLSWITCH_THRESHOLDS.five_hour, + seven_day: DEFAULT_KILLSWITCH_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 3bc332a..b50ca54 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: @@ -1447,7 +1500,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Fail-closed: if failClosedOnUnknownQuota is set, quota API is backed off, // and we have no cached quota, block the request. - const mainQuota = quotaManager.getMain()?.quota + let mainQuota = quotaManager.getMain()?.quota if ( storage?.quota?.failClosedOnUnknownQuota && !mainQuota && @@ -1484,6 +1537,111 @@ 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(), + }) + } + } + } + + // Re-read after potential killswitch refresh + mainQuota = quotaManager.getMain()?.quota + + 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 89237f21e328f973d8314fb557f6bdd5cf3eefe8 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 21:14:27 +0200 Subject: [PATCH 4/4] feat(opencode): add killswitch indicators to quota toast Shows red dot for killed accounts and green dot for active accounts in quota toast notifications. Killswitch-aware active account selection skips killed accounts when determining the routable account. --- packages/opencode/src/index.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 3886414..c34b56c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -551,6 +551,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { quota?: OAuthQuotaSnapshot }>, activeAccountId?: string, + isKilled?: (quota: OAuthQuotaSnapshot | undefined, accountId?: string) => boolean, ) { const sections: string[] = [] let globalMaxUsed = 0 @@ -560,8 +561,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const fh = quota.five_hour const sd = quota.seven_day if (fh || sd) { + const mainKilled = isKilled?.(quota) ?? false const mainActive = activeAccountId === 'main' - const indicator = mainActive ? '🟢' : ' ' + const indicator = mainKilled ? '🔴' : mainActive ? '🟢' : ' ' const reset = formatResetIn(fh?.resetsAt) const lines: string[] = [ `${indicator} main${reset ? ` (${reset})` : ''}`, @@ -591,8 +593,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const sd = q.seven_day if (!fh && !sd) continue const name = fb.label || 'alt' + const fbKilled = isKilled?.(q, fb.id) ?? false const fbActive = activeAccountId === fb.id - const indicator = fbActive ? '🟢' : ' ' + const indicator = fbKilled ? '🔴' : fbActive ? '🟢' : ' ' const fbReset = formatResetIn(fh?.resetsAt) const lines: string[] = [ `${indicator} ${name}${fbReset ? ` (${fbReset})` : ''}`, @@ -1553,17 +1556,25 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const fallbacks = (storage?.accounts ?? []).filter( (a) => a.enabled !== false, ) + const ksEnabled = isKillswitchEnabled(storage) + const ksIsKilled = ksEnabled + ? (q: OAuthQuotaSnapshot | undefined, id?: string) => + !killswitchPassesPolicy(q, storage, id) + : undefined const mainPassesPolicy = quotaSnapshotPassesPolicy( mainEntry.quota, storage, ) + const mainIsKilled = ksIsKilled?.(mainEntry.quota) ?? false let activeId: string | undefined - if (mainPassesPolicy) { + if (!mainIsKilled && mainPassesPolicy) { activeId = 'main' } else { - activeId = fallbacks[0]?.id + activeId = fallbacks.find( + (fb) => !(ksIsKilled?.(fb.quota, fb.id) ?? false), + )?.id } - showQuotaToast(mainEntry.quota, fallbacks, activeId) + showQuotaToast(mainEntry.quota, fallbacks, activeId, ksIsKilled) } let preselectedFallbackAccounts: