From 5938687f5efd0398d2f0eb7a7b107c80f42e6895 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Thu, 21 May 2026 20:19:45 +0200 Subject: [PATCH 1/2] feat(core): add QuotaManager with exponential backoff and persistence Unified quota cache and API gateway for main + fallback quota state. All consumers share one QuotaManager instance for consistent caching. Features: - Inflight deduplication prevents concurrent API calls - Exponential backoff (60s-15min) for 429/5xx errors - Persists main quota and backoff state to disk via callbacks - Cross-process file lock guard for quota API dedup - Seeds from persisted storage on construction - Integrates with FallbackAccountManager for shared staleness - Captures storage path at init to prevent test config corruption - Request-count-based refresh trigger (refreshEveryNRequests) --- packages/core/src/accounts.ts | 152 ++++++- packages/core/src/index.ts | 1 + packages/core/src/quota-manager.ts | 393 ++++++++++++++++++ packages/opencode/src/index.ts | 243 ++++++----- packages/opencode/src/tests/index.test.ts | 112 ++++- .../opencode/src/tests/quota-manager.test.ts | 307 ++++++++++++++ 6 files changed, 1096 insertions(+), 112 deletions(-) create mode 100644 packages/core/src/quota-manager.ts create mode 100644 packages/opencode/src/tests/quota-manager.test.ts diff --git a/packages/core/src/accounts.ts b/packages/core/src/accounts.ts index c5599df..e6cbf82 100644 --- a/packages/core/src/accounts.ts +++ b/packages/core/src/accounts.ts @@ -67,8 +67,12 @@ export type AccountStorage = { quota?: { enabled?: boolean checkIntervalMinutes?: number + refreshEveryNRequests?: number minimumRemaining?: Partial> failClosedOnUnknownQuota?: boolean + mainQuota?: OAuthQuotaSnapshot + mainQuotaCheckedAt?: number + mainLastQuotaApiError?: AccountOperationError } claudeCache?: { enabled?: boolean @@ -113,6 +117,7 @@ export type AccountManagerOptions = { now?: () => number fetchImpl?: typeof fetch configPath?: string + quotaManager?: import('./quota-manager.ts').QuotaManager } export type AccountRefreshError = { @@ -127,6 +132,9 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10 const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000 const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000 const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000 +const MIN_QUOTA_RETRY_DELAY_MS = 60_000 +const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000 +const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000 const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5 const DEFAULT_MINIMUM_REMAINING: Record = { five_hour: 0, @@ -608,12 +616,95 @@ export function formatRefreshBackoffMessage( return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}` } +function isTransientQuotaError(error: unknown): boolean { + if (!(error instanceof Error)) return false + // fetchOAuthQuotaSnapshot throws: "Claude quota check failed: {status} — {body}" + const statusMatch = error.message.match(/Claude quota check failed: (\d+)/) + if (statusMatch) { + const status = Number(statusMatch[1]) + return status === 429 || status >= 500 + } + // Network errors + return ( + error.message.includes('fetch failed') || + ('code' in error && + (error.code === 'ECONNRESET' || + error.code === 'ECONNREFUSED' || + error.code === 'ETIMEDOUT' || + error.code === 'UND_ERR_CONNECT_TIMEOUT')) + ) +} + +export function buildQuotaOperationError(input: { + error: unknown + now: number + previous?: AccountOperationError +}): AccountOperationError { + const previousRetryCount = input.previous?.retryCount ?? 0 + const retryCount = previousRetryCount + 1 + const delay = isTransientQuotaError(input.error) + ? Math.min( + MAX_QUOTA_RETRY_DELAY_MS, + MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6), + ) + : NON_TRANSIENT_QUOTA_RETRY_DELAY_MS + return { + message: formatErrorMessage(input.error), + checkedAt: input.now, + nextRetryAt: input.now + delay, + retryCount, + } +} + +export function quotaBackoffActive( + error: AccountOperationError | undefined, + now: number, +): boolean { + if (!error?.nextRetryAt || error.nextRetryAt <= now) return false + return true +} + +export function formatQuotaBackoffMessage( + error: AccountOperationError, + now: number, +): string { + const seconds = Math.max( + 1, + Math.ceil(((error.nextRetryAt ?? now) - now) / 1000), + ) + return `Quota API backed off for ${seconds}s after: ${error.message}` +} + export function getQuotaCheckIntervalMs(storage: AccountStorage | null) { const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES return Math.max(1, minutes) * 60_000 } +export function getPersistedMainQuota( + storage: AccountStorage | null, +): { quota: OAuthQuotaSnapshot; checkedAt: number } | null { + if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt) + return null + return { + quota: storage.quota.mainQuota, + checkedAt: storage.quota.mainQuotaCheckedAt, + } +} + +/** + * How often (in requests) to force a quota refresh, independent of the timer. + * Returns 0 when disabled (default). + */ +export function getQuotaRefreshEveryNRequests( + storage: AccountStorage | null, +): number { + const n = storage?.quota?.refreshEveryNRequests + return typeof n === 'number' && Number.isFinite(n) && n > 0 + ? Math.floor(n) + : 0 +} + function failClosedOnUnknownQuota(storage: AccountStorage | null) { return ( storage?.quota?.failClosedOnUnknownQuota ?? @@ -764,10 +855,11 @@ function recordQuotaRefreshError( error: unknown, now: number, ) { - account.lastQuotaRefreshError = { - message: formatErrorMessage(error), - checkedAt: now, - } + account.lastQuotaRefreshError = buildQuotaOperationError({ + error, + now, + previous: account.lastQuotaRefreshError, + }) if (error instanceof ClaudeOAuthRefreshError) { recordRefreshError(account, error, now) } @@ -780,11 +872,37 @@ export class FallbackAccountManager { private readonly refreshPromises = new Map>() private refreshTimer: ReturnType | null = null private quotaTimer: ReturnType | null = null + readonly quotaManager: import('./quota-manager.ts').QuotaManager | null constructor(options: AccountManagerOptions = {}) { this.now = options.now ?? Date.now this.fetchImpl = options.fetchImpl ?? fetch this.configPath = options.configPath ?? getAccountStoragePath() + this.quotaManager = options.quotaManager ?? null + } + + /** + * Seed QuotaManager from persisted account.quota if no cache entry exists + * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh. + */ + private seedFallbackQuota( + account: OAuthAccount, + storage: AccountStorage, + ): void { + if (!this.quotaManager) return + if (this.quotaManager.getFallback(account.id)) return + if (!account.quota) return + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) return + const checkInterval = getQuotaCheckIntervalMs(storage) + this.quotaManager.setFallback(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) } async load() { @@ -840,7 +958,11 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (quotaIsStale(next, storage, this.now())) { + this.seedFallbackQuota(next, storage) + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (stale) { next = await this.refreshAccountQuota(next, storage) changed = true } @@ -937,7 +1059,16 @@ export class FallbackAccountManager { next = await this.refreshAccount(next, storage) changed = true } - if (!quotaIsStale(next, storage, this.now())) continue + if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) { + continue + } + this.seedFallbackQuota(next, storage) + // Use QuotaManager staleness when available (shared cache); + // fall back to per-account on-disk staleness otherwise. + const stale = this.quotaManager + ? this.quotaManager.isFallbackStale(next.id) + : quotaIsStale(next, storage, this.now()) + if (!stale) continue await this.refreshAccountQuota(next, storage) changed = true } catch (error) { @@ -1085,6 +1216,15 @@ export class FallbackAccountManager { } target.lastQuotaRefreshError = undefined updateStoredAccount(storage, target) + // Sync to shared QuotaManager so all consumers see the same cache + if (this.quotaManager && target.quota) { + const now = this.now() + this.quotaManager.setFallback(target.id, { + quota: target.quota, + refreshAfter: now + getQuotaCheckIntervalMs(storage), + checkedAt: now, + }) + } return target } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9ae5fe..ce93e01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,5 +9,6 @@ export * from './dump.ts' export * from './fast.ts' export * from './logger.ts' export * from './pkce.ts' +export * from './quota-manager.ts' export * from './quotas.ts' export * from './relay.ts' diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts new file mode 100644 index 0000000..4ca0eb1 --- /dev/null +++ b/packages/core/src/quota-manager.ts @@ -0,0 +1,393 @@ +/** + * Unified quota cache and API gateway. + * + * Single source of truth for main + fallback quota state. All consumers + * share one QuotaManager instance so they see the same in-memory cache. + * Handles deduplication, rate-limiting (429 backoff), and staleness. + */ + +import type { + AccountOperationError, + AccountStorage, + OAuthAccount, + OAuthQuotaSnapshot, +} from './accounts.ts' +import { + acquireRefreshFileLock, + buildQuotaOperationError, + fetchOAuthQuotaSnapshot, + getPersistedMainQuota, + getQuotaCheckIntervalMs, + getQuotaNextRefreshAt, + getQuotaRefreshEveryNRequests, + quotaBackoffActive, +} from './accounts.ts' + +// Capture real setTimeout before tests can mock globalThis.setTimeout +const nativeSetTimeout = globalThis.setTimeout + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type QuotaEntry = { + quota: OAuthQuotaSnapshot + refreshAfter: number // Unix ms — earliest next refresh + checkedAt: number // when snapshot was fetched +} + +export type QuotaManagerOptions = { + storage: AccountStorage | null + fetchImpl?: typeof fetch + now?: () => number + onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number) => void + onApiError?: (error: AccountOperationError) => void +} + +// --------------------------------------------------------------------------- +// Class +// --------------------------------------------------------------------------- + +export class QuotaManager { + // --- State --- + private main: QuotaEntry | null = null + private mainAccessToken: string | null = null + private fallbacks = new Map() + + // --- Inflight deduplication --- + private inflightMain: Promise | null = null + private inflightFallbacks = new Map>() + + // --- Rate-limiting --- + private lastApiError: AccountOperationError | undefined = undefined + + // --- Serial API gate (prevents concurrent quota API calls) --- + private apiGate: Promise = Promise.resolve() + private lastApiCallAt = 0 + + // --- Config --- + private storage: AccountStorage | null + private readonly fetchImpl: typeof fetch + private readonly now: () => number + private readonly onMainQuotaFetched: QuotaManagerOptions['onMainQuotaFetched'] + private readonly onApiError: QuotaManagerOptions['onApiError'] + + constructor(opts: QuotaManagerOptions) { + this.storage = opts.storage + this.fetchImpl = opts.fetchImpl ?? fetch + this.now = opts.now ?? Date.now + this.onMainQuotaFetched = opts.onMainQuotaFetched + this.onApiError = opts.onApiError + + // Seed main quota from persisted storage + const persisted = getPersistedMainQuota(opts.storage) + if (persisted) { + this.main = { + quota: persisted.quota, + refreshAfter: getQuotaNextRefreshAt( + persisted.quota, + opts.storage, + persisted.checkedAt, + ), + checkedAt: persisted.checkedAt, + } + } + + // Seed backoff state from persisted storage + const persistedError = opts.storage?.quota?.mainLastQuotaApiError + if (persistedError && quotaBackoffActive(persistedError, this.now())) { + this.lastApiError = persistedError + } + } + + // ========================================================================= + // Get (synchronous, from cache) + // ========================================================================= + + getMain(): QuotaEntry | null { + return this.main + } + + getFallback(accountId: string): QuotaEntry | null { + return this.fallbacks.get(accountId) ?? null + } + + getAllFallbacks(): Map { + return this.fallbacks + } + + // ========================================================================= + // Set (manual inject — seeding from persisted account.quota on boot) + // ========================================================================= + + setMain(accessToken: string, entry: QuotaEntry): void { + this.mainAccessToken = accessToken + this.main = entry + } + + setFallback(accountId: string, entry: QuotaEntry): void { + this.fallbacks.set(accountId, entry) + } + + // ========================================================================= + // Refresh (async, deduplicated, rate-limited) + // ========================================================================= + + async refreshMain(accessToken: string): Promise { + // If token changed, invalidate cache + if (this.mainAccessToken && this.mainAccessToken !== accessToken) { + this.main = null + this.mainAccessToken = null + } + + // Deduplicate — return in-flight promise if already fetching + if (this.inflightMain) return this.inflightMain + + // Rate-limit — if API recently 429'd, return stale or throw + if (this.isBackedOff()) { + if (this.main) return this.main.quota + throw new Error('Quota API rate-limited — try again later') + } + + this.inflightMain = this._fetchMain(accessToken) + return this.inflightMain + } + + async refreshFallback( + accountId: string, + accessToken: string, + ): Promise { + // Deduplicate + const inflight = this.inflightFallbacks.get(accountId) + if (inflight) return inflight + + // Rate-limit + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + + const promise = this._fetchFallback(accountId, accessToken) + this.inflightFallbacks.set(accountId, promise) + return promise + } + + async refreshAllFallbacks(accounts: OAuthAccount[]): Promise { + const now = this.now() + + for (const account of accounts) { + if (account.enabled === false) continue + if (!account.access) continue + + const cached = this.fallbacks.get(account.id) + if (cached && now < cached.refreshAfter) continue + + try { + await this.refreshFallback(account.id, account.access) + } catch { + // Best-effort — keep stale cache entry if fetch fails + } + } + } + + /** + * Fire-and-forget refresh. Does not await, swallows errors. + */ + refreshMainInBackground(accessToken: string): void { + if (this.inflightMain) return + if (this.isBackedOff()) return + void this.refreshMain(accessToken).catch(() => {}) + } + + // ========================================================================= + // Staleness queries + // ========================================================================= + + isMainStale(): boolean { + if (!this.main) return true + return this.now() >= this.main.refreshAfter + } + + isFallbackStale(accountId: string): boolean { + const entry = this.fallbacks.get(accountId) + if (!entry) return true + return this.now() >= entry.refreshAfter + } + + shouldRefreshOnRequestCount(requestCount: number): boolean { + const everyN = getQuotaRefreshEveryNRequests(this.storage) + if (everyN <= 0) return false + return requestCount > 0 && requestCount % everyN === 0 + } + + /** + * Combined check: should a refresh happen right now? + * True if main is stale by time OR triggered by request count. + */ + needsRefresh(requestCount: number): boolean { + return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount) + } + + // ========================================================================= + // Config + // ========================================================================= + + updateStorage(storage: AccountStorage | null): void { + this.storage = storage + } + + /** + * Seed fallback cache entries from persisted account.quota data. + * Only seeds accounts that don't already have a cache entry. + * Prevents unnecessary API calls when persisted quota is still fresh. + */ + seedFallbacksFromAccounts(accounts: OAuthAccount[]): void { + const checkInterval = getQuotaCheckIntervalMs(this.storage) + for (const account of accounts) { + if (account.enabled === false) continue + if (this.fallbacks.has(account.id)) continue + if (!account.quota) continue + const checkedAt = Math.max( + account.quota.five_hour?.checkedAt ?? 0, + account.quota.seven_day?.checkedAt ?? 0, + ) + if (checkedAt <= 0) continue + this.fallbacks.set(account.id, { + quota: account.quota, + refreshAfter: checkedAt + checkInterval, + checkedAt, + }) + } + } + + /** + * Whether the API is currently in backoff due to a recent error. + */ + isBackedOff(): boolean { + return quotaBackoffActive(this.lastApiError, this.now()) + } + + getLastApiError(): AccountOperationError | undefined { + return this.lastApiError + } + + // ========================================================================= + // Private + // ========================================================================= + + /** Minimum gap between consecutive quota API calls (ms). */ + private static readonly API_CALL_GAP_MS = 1_000 + + /** + * Serialize API calls through a shared gate so only one + * quota API request runs at a time, with a minimum gap + * between calls. Prevents concurrent and rapid-fire calls + * from triggering Anthropic's rate limits. + */ + private _enqueueApiFetch(fn: () => Promise): Promise { + const gatedFn = async (): Promise => { + // Wait until minimum gap since last API call + const elapsed = this.now() - this.lastApiCallAt + if (elapsed < QuotaManager.API_CALL_GAP_MS) { + await new Promise((r) => { + const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed) + if (typeof id === 'object' && 'unref' in id) id.unref() + }) + } + this.lastApiCallAt = this.now() + return fn() + } + const queued = this.apiGate.then(gatedFn, gatedFn) + this.apiGate = queued.catch(() => {}) + return queued + } + + private async _fetchMain(accessToken: string): Promise { + return this._enqueueApiFetch(async () => { + try { + // Re-check backoff inside gate — may have been set by + // a preceding queued call while we waited + if (this.isBackedOff()) { + if (this.main) return this.main.quota + throw new Error('Quota API rate-limited — try again later') + } + const fileLock = await acquireRefreshFileLock({ + name: 'opencode-main-quota-refresh', + ttlMs: 30_000, + }) + if (!fileLock) { + if (this.main) return this.main.quota + throw new Error('Quota refresh is already in progress') + } + try { + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.mainAccessToken = accessToken + this.main = { + quota, + refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now), + checkedAt: now, + } + this.lastApiError = undefined + this.onMainQuotaFetched?.(quota, now) + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + await fileLock.release() + } + } finally { + this.inflightMain = null + } + }) + } + + private async _fetchFallback( + accountId: string, + accessToken: string, + ): Promise { + return this._enqueueApiFetch(async () => { + try { + // Re-check backoff inside gate + if (this.isBackedOff()) { + const cached = this.fallbacks.get(accountId) + if (cached) return cached.quota + throw new Error('Quota API rate-limited — try again later') + } + const quota = await fetchOAuthQuotaSnapshot({ + accessToken, + fetchImpl: this.fetchImpl, + now: this.now, + }) + const now = this.now() + this.fallbacks.set(accountId, { + quota, + refreshAfter: now + getQuotaCheckIntervalMs(this.storage), + checkedAt: now, + }) + this.lastApiError = undefined + return quota + } catch (error) { + this._handleFetchError(error) + throw error + } finally { + this.inflightFallbacks.delete(accountId) + } + }) + } + + private _handleFetchError(error: unknown): void { + this.lastApiError = buildQuotaOperationError({ + error, + now: this.now(), + previous: this.lastApiError, + }) + this.onApiError?.(this.lastApiError) + } +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c8561e..eeb931c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,13 +21,12 @@ import { executeDumpCommand, executeFastModeCommand, FallbackAccountManager, - fetchOAuthQuotaSnapshot, + formatQuotaBackoffMessage, formatRefreshBackoffMessage, + getAccountStoragePath, getCache1hMode, getCache1hPersistentMode, getCacheKeepWindow, - getQuotaCheckIntervalMs, - getQuotaNextRefreshAt, getRelayConfig, hashRefreshToken, isCache1hEnabled, @@ -41,12 +40,12 @@ import { loadAccounts, log, mergeAnthropicBetas, - type OAuthQuotaSnapshot, parseCache1hCommandAction, parseCacheKeepCommandAction, parseDumpCommandAction, parseFastModeCommandAction, type QuotaAccountSummary, + QuotaManager, quotaSnapshotPassesPolicy, type RelayConfig, refreshBackoffActive, @@ -86,12 +85,6 @@ const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240 const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES -type MainQuotaCache = { - accessToken: string - refreshAfter: number - quota: OAuthQuotaSnapshot -} - type NotificationRequest = { path: { id: string } body: { @@ -247,11 +240,50 @@ function throwHandledSentinel(): never { export const AnthropicAuthPlugin: Plugin = async (ctx) => { startEventLoopLagMonitor() const { client } = ctx - const fallbackManager = new FallbackAccountManager() + const accountStoragePath = getAccountStoragePath() + const initialStorage = await loadAccounts(accountStoragePath) + const quotaManager = new QuotaManager({ + storage: initialStorage, + onMainQuotaFetched: async (quota, checkedAt) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainQuota = quota + storage.quota.mainQuotaCheckedAt = checkedAt + storage.quota.mainLastQuotaApiError = undefined + await saveAccounts(storage, accountStoragePath) + } catch (error) { + log('[quota] failed to persist main quota', { + error: error instanceof Error ? error.message : String(error), + }) + } + }, + onApiError: async (error) => { + try { + const storage = (await loadAccounts(accountStoragePath)) ?? { + version: 1 as const, + accounts: [], + } + storage.quota = storage.quota ?? {} + storage.quota.mainLastQuotaApiError = error + await saveAccounts(storage, accountStoragePath) + } catch (e) { + log('[quota] failed to persist backoff state', { + error: e instanceof Error ? e.message : String(e), + }) + } + }, + }) + const fallbackManager = new FallbackAccountManager({ + quotaManager, + }) fallbackManager.startBackgroundRefresh() let latestRefreshMainAccessToken: (() => Promise) | null = null const cacheKeepManager = new CacheKeepManager({ - loadStorage: () => loadAccounts(), + loadStorage: () => loadAccounts(accountStoragePath), prepareHeaders: async (headers, target) => { if (!latestGetAuth) return headers const auth = await latestGetAuth() @@ -289,14 +321,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }, log, }) - const initialCache1hStorage = await loadAccounts() - const relayConfig: RelayConfig | null = getRelayConfig(initialCache1hStorage) + const relayConfig: RelayConfig | null = getRelayConfig(initialStorage) setCache1hState({ - enabled: isCache1hPersistentlyEnabled(initialCache1hStorage), - mode: getCache1hPersistentMode(initialCache1hStorage), + enabled: isCache1hPersistentlyEnabled(initialStorage), + mode: getCache1hPersistentMode(initialStorage), }) - setDumpEnabled(isDumpPersistentlyEnabled(initialCache1hStorage)) - setFastModeEnabled(isFastModePersistentlyEnabled(initialCache1hStorage)) + setDumpEnabled(isDumpPersistentlyEnabled(initialStorage)) + setFastModeEnabled(isFastModePersistentlyEnabled(initialStorage)) let latestGetAuth: | (() => Promise<{ type: string @@ -324,13 +355,13 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function clearStaleMainRefreshError(refreshToken?: string) { if (!refreshToken) return - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const error = storage?.refresh?.mainLastRefreshError if (!storage?.refresh || !error?.tokenHash) return const tokenHash = hashRefreshToken(refreshToken) if (error.tokenHash === tokenHash) return storage.refresh.mainLastRefreshError = undefined - await saveAccounts(storage) + await saveAccounts(storage, accountStoragePath) log( '[refresh] opencode main oauth cleared stale backoff after token rotation', { @@ -347,10 +378,15 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { try { const auth = await latestGetAuth() if (auth.type === 'oauth' && auth.access) { + // Use QuotaManager cache; eager fetch on first request means + // this is always populated after the first API call. + const cached = quotaManager.getMain() + const quota = + cached?.quota ?? (await quotaManager.refreshMain(auth.access)) accounts.push({ name: 'OpenCode anthropic', role: 'main', - quota: await fetchOAuthQuotaSnapshot({ accessToken: auth.access }), + quota, }) } else if (auth.type === 'oauth') { accounts.push({ @@ -361,22 +397,34 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } } catch (error) { + const msg = error instanceof Error ? error.message : String(error) accounts.push({ name: 'OpenCode anthropic', role: 'main', - error: error instanceof Error ? error.message : String(error), + error: msg.includes('429') + ? 'Usage API rate limited — try again in a moment' + : msg, }) } } - const { storage, errors } = - await fallbackManager.refreshQuotaForAllAccounts() - accounts.push( - ...buildFallbackQuotaSummaries( - storage, - new Map(errors.map((error) => [error.accountId, error.message])), - ), + // Use QuotaManager for fallbacks — goes through serial API gate + const storage = await loadAccounts(accountStoragePath) + const fallbackAccts = (storage?.accounts ?? []).filter( + (a) => a.enabled !== false && a.access, ) + try { + await quotaManager.refreshAllFallbacks(fallbackAccts) + } catch { + // Best-effort — stale cache is fine for display + } + // Overlay QuotaManager cache onto storage accounts for display + const fallbackEntries = quotaManager.getAllFallbacks() + for (const account of storage?.accounts ?? []) { + const cached = fallbackEntries.get(account.id) + if (cached) account.quota = cached.quota + } + accounts.push(...buildFallbackQuotaSummaries(storage, new Map())) if (!latestGetAuth) { accounts.unshift({ @@ -410,7 +458,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isCache1hPersistentlyEnabled(storage) const mode = getCache1hPersistentMode(storage) setCache1hState({ enabled, mode }) @@ -419,7 +467,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function executePersistentCacheKeepCommand(argumentsText: string) { const action = parseCacheKeepCommandAction(argumentsText) - let storage = await loadAccounts() + let storage = await loadAccounts(accountStoragePath) if (action.type === 'window') { storage = await setCacheKeepPersistentWindow( action.startHour, @@ -450,7 +498,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeDumpCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isDumpPersistentlyEnabled(storage) setDumpEnabled(enabled) return executeDumpCommand({ argumentsText, enabled }) @@ -465,7 +513,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return executeFastModeCommand({ argumentsText, enabled }) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const enabled = isFastModePersistentlyEnabled(storage) setFastModeEnabled(enabled) return executeFastModeCommand({ argumentsText, enabled }) @@ -581,9 +629,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Shared inflight refresh promise — prevents concurrent token refreshes // from racing against each other (and causing 401 cascades with token rotation) let refreshPromise: Promise | null = null - let mainQuotaCache: MainQuotaCache | null = null - let mainQuotaRefreshPromise: Promise | null = null - let mainQuotaRetryAfter = 0 async function refreshMainAccessToken() { if (!refreshPromise) { @@ -597,14 +642,16 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { async function updateMainRefreshState( update: (storage: AccountStorage) => void, ) { - const storage: AccountStorage = (await loadAccounts()) ?? { + const storage: AccountStorage = (await loadAccounts( + accountStoragePath, + )) ?? { version: 1, main: { type: 'opencode', provider: 'anthropic' }, accounts: [], } storage.refresh = storage.refresh ?? {} update(storage) - await saveAccounts(storage) + await saveAccounts(storage, accountStoragePath) } async function waitForConcurrentMainRefresh(previous: { @@ -665,7 +712,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) } - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const refreshTokenHash = hashRefreshToken(freshAuth.refresh) const mainError = storage?.refresh?.mainLastRefreshError log('[refresh] opencode main oauth refresh check', { @@ -751,7 +798,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { nextStorage.refresh.mainRefreshLeaseTokenHash = refreshTokenHash }) - const latestLease = await loadAccounts() + const latestLease = await loadAccounts(accountStoragePath) log( '[refresh] opencode main oauth refresh lease acquired', { @@ -897,7 +944,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { const run = async () => { try { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) if (!mainRefreshEnabled(storage)) return const latestAuth = await getAuth() if (latestAuth.type !== 'oauth') return @@ -1143,7 +1190,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { isCache1hEnabled() && getCache1hMode() === 'hybrid' ) { - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) const tracked = await cacheKeepManager.track({ sessionId: relayAffinity, url: rewritten.url?.toString() ?? rewritten.input.toString(), @@ -1187,54 +1234,6 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { return response } - async function refreshMainQuotaCache( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - const quota = await fetchOAuthQuotaSnapshot({ accessToken }) - mainQuotaCache = { - accessToken, - refreshAfter: getQuotaNextRefreshAt(quota, storage, now), - quota, - } - return quota - } - - function refreshMainQuotaCacheInBackground( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaRefreshPromise || now < mainQuotaRetryAfter) return - mainQuotaRefreshPromise = refreshMainQuotaCache( - accessToken, - storage, - ) - .catch((error) => { - mainQuotaRetryAfter = now + getQuotaCheckIntervalMs(storage) - throw error - }) - .finally(() => { - mainQuotaRefreshPromise = null - }) - void mainQuotaRefreshPromise.catch(() => {}) - } - - async function getMainQuotaForRouting( - accessToken: string, - storage: Awaited>, - ) { - const now = Date.now() - if (mainQuotaCache?.accessToken !== accessToken) { - return await refreshMainQuotaCache(accessToken, storage) - } - if (now >= mainQuotaCache.refreshAfter) { - refreshMainQuotaCacheInBackground(accessToken, storage) - } - return mainQuotaCache.quota - } - async function tryUsableFallbackAccounts( input: string | URL | Request, init: RequestInit | undefined, @@ -1296,7 +1295,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { if (!isReplayableRequest(input, init?.body)) return mainResponse const loadStart = nowMs() - const storage = existingStorage ?? (await loadAccounts()) + const storage = existingStorage ?? (await loadAccounts(accountStoragePath)) trace?.mark('fallback_load_storage', { ms: roundMs(nowMs() - loadStart), cached: !!existingStorage, @@ -1385,7 +1384,9 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { throw new Error('OAuth access token is missing after refresh') } const loadStart = nowMs() - const storage = await loadAccounts() + const storage = await loadAccounts(accountStoragePath) + quotaManager.updateStorage(storage) + quotaManager.seedFallbacksFromAccounts(storage?.accounts ?? []) trace.mark('load_storage', { ms: roundMs(nowMs() - loadStart) }) let preselectedFallbackAccounts: | Awaited< @@ -1400,18 +1401,22 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { ) { try { const quotaStart = nowMs() - const mainQuota = await getMainQuotaForRouting( - auth.access, - storage, - ) + // Use QuotaManager: get cached or eagerly refresh if first time + let routingQuota = quotaManager.getMain()?.quota + if (!routingQuota) { + routingQuota = await quotaManager.refreshMain(auth.access) + } else if (quotaManager.isMainStale()) { + // Background refresh — return stale to avoid blocking + void quotaManager.refreshMain(auth.access).catch(() => {}) + } trace.mark('main_quota_for_routing', { ms: roundMs(nowMs() - quotaStart), - passes: quotaSnapshotPassesPolicy(mainQuota, storage), + passes: quotaSnapshotPassesPolicy(routingQuota, storage), }) - if (!quotaSnapshotPassesPolicy(mainQuota, storage)) { + if (!quotaSnapshotPassesPolicy(routingQuota, storage)) { const fallbackStart = nowMs() preselectedFallbackAccounts = - await fallbackManager.getUsableFallbackAccounts() + await fallbackManager.getUsableFallbackAccounts(storage) trace.mark('preselect_fallback_accounts', { ms: roundMs(nowMs() - fallbackStart), accounts: preselectedFallbackAccounts.length, @@ -1439,6 +1444,46 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => { // Main quota checks should optimize routing, not break requests. } } + + // Fail-closed: if failClosedOnUnknownQuota is set, quota API is backed off, + // and we have no cached quota, block the request. + const mainQuota = quotaManager.getMain()?.quota + if ( + storage?.quota?.failClosedOnUnknownQuota && + !mainQuota && + quotaManager.isBackedOff() + ) { + const lastError = quotaManager.getLastApiError() + const msg = lastError + ? formatQuotaBackoffMessage(lastError, Date.now()) + : 'Quota API unavailable' + log('[killswitch] blocked: quota API backed off', { + nextRetryAt: lastError?.nextRetryAt, + retryCount: lastError?.retryCount, + }) + return new Response( + JSON.stringify({ + type: 'error', + error: { type: 'rate_limit_error', message: msg }, + }), + { + status: 429, + headers: { + 'content-type': 'application/json', + 'retry-after': String( + lastError?.nextRetryAt + ? Math.max( + 1, + Math.ceil( + (lastError.nextRetryAt - Date.now()) / 1000, + ), + ) + : 60, + ), + }, + }, + ) + } const mainResponse = await sendWithAccessToken( input, init, diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index be2a3de..920bf13 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -263,6 +263,18 @@ describe('auth.loader', () => { let capturedBody: string | undefined globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -331,7 +343,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined let capturedHeaders: Headers | undefined globalThis.fetch = mock((input: any, init: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url capturedBody = init?.body capturedHeaders = new Headers(init?.headers) return Promise.resolve( @@ -790,7 +814,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -832,7 +868,19 @@ describe('auth.loader', () => { let capturedHeaders: Headers | undefined let capturedBody: string | undefined - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedHeaders = init?.headers capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) @@ -869,7 +917,19 @@ describe('auth.loader', () => { let capturedBody: string | undefined const mockClient = createMockClient() - globalThis.fetch = mock((_input: any, init: any) => { + globalThis.fetch = mock((input: any, init: any) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = init?.body return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -924,7 +984,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) capturedHeaders = new Headers(init?.headers) return Promise.resolve(new Response(null, { status: 200 })) @@ -974,7 +1046,19 @@ describe('auth.loader', () => { const mockClient = createMockClient() globalThis.fetch = mock( - (_input: string | URL | Request, init?: RequestInit) => { + (input: string | URL | Request, init?: RequestInit) => { + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } capturedBody = String(init?.body) return Promise.resolve(new Response(null, { status: 200 })) }, @@ -1653,7 +1737,19 @@ describe('auth.loader', () => { let capturedUrl: string | undefined globalThis.fetch = mock((input: any) => { - capturedUrl = extractUrl(input) + const url = extractUrl(input) + if (url.includes('/api/oauth/usage')) { + return Promise.resolve( + new Response( + JSON.stringify({ + five_hour: { utilization: 0 }, + seven_day: { utilization: 0 }, + }), + { status: 200 }, + ), + ) + } + capturedUrl = url return Promise.resolve(new Response(null, { status: 200 })) }) as unknown as typeof fetch @@ -1875,6 +1971,8 @@ describe('auth.loader', () => { ]) expect(second).toBe('message-2') + // Background quota refresh involves file-lock I/O; wait for it to fire. + await new Promise((r) => setTimeout(r, 50)) expect(quotaCalls).toBe(2) expect(messageCalls).toBe(2) } finally { diff --git a/packages/opencode/src/tests/quota-manager.test.ts b/packages/opencode/src/tests/quota-manager.test.ts new file mode 100644 index 0000000..94f7cdb --- /dev/null +++ b/packages/opencode/src/tests/quota-manager.test.ts @@ -0,0 +1,307 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { QuotaManager } from '@cortexkit/anthropic-auth-core' + +function makeQuotaResponse(now: number) { + return new Response( + JSON.stringify({ + five_hour: { + utilization: 25, + resets_at: new Date(now + 3600_000).toISOString(), + }, + seven_day: { + utilization: 50, + }, + }), + { status: 200 }, + ) +} + +describe('QuotaManager', () => { + let now: number + let tempDir: string + + beforeEach(async () => { + now = 1_000_000 + tempDir = await mkdtemp(join(tmpdir(), 'qm-test-')) + process.env.OPENCODE_ANTHROPIC_AUTH_FILE = join( + tempDir, + 'anthropic-auth.json', + ) + }) + + afterEach(async () => { + delete process.env.OPENCODE_ANTHROPIC_AUTH_FILE + await rm(tempDir, { recursive: true, force: true }) + }) + + function createQM(fetchImpl?: typeof fetch) { + return new QuotaManager({ + storage: null, + fetchImpl, + now: () => now, + }) + } + + describe('backoff', () => { + test('first 429 backs off for 60s', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + + expect(qm.isBackedOff()).toBe(true) + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('repeated 429s escalate backoff exponentially', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // First failure: 60s + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + expect(qm.isBackedOff()).toBe(false) + + // Second failure: 120s + try { + await qm.refreshMain('token') + } catch {} + now += 119_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('backoff caps at 15 minutes', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + // Trigger 8 failures to exceed cap + for (let i = 0; i < 8; i++) { + try { + await qm.refreshMain('token') + } catch {} + now += 16 * 60_000 + } + + try { + await qm.refreshMain('token') + } catch {} + now += 14 * 60_000 + expect(qm.isBackedOff()).toBe(true) + now += 2 * 60_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('successful fetch resets backoff', async () => { + let failNext = true + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + + now += 61_000 + failNext = false + await qm.refreshMain('token') + expect(qm.isBackedOff()).toBe(false) + + // Next failure starts from 60s again (not escalated) + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + now += 59_000 + expect(qm.isBackedOff()).toBe(true) + now += 2_000 + expect(qm.isBackedOff()).toBe(false) + }) + + test('getLastApiError exposes backoff state', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + expect(qm.getLastApiError()).toBeUndefined() + + try { + await qm.refreshMain('token') + } catch {} + + const err = qm.getLastApiError() + expect(err).toBeDefined() + expect(err!.retryCount).toBe(1) + expect(err!.nextRetryAt).toBeGreaterThan(now) + }) + + test('500 errors also trigger backoff', async () => { + const fetchMock = mock(() => + Promise.resolve(new Response('internal error', { status: 500 })), + ) as unknown as typeof fetch + const qm = createQM(fetchMock) + + try { + await qm.refreshMain('token') + } catch {} + expect(qm.isBackedOff()).toBe(true) + }) + + test('returns cached quota during backoff', async () => { + let failNext = false + const fetchMock = mock(() => { + if (failNext) { + return Promise.resolve(new Response('rate limited', { status: 429 })) + } + return Promise.resolve(makeQuotaResponse(now)) + }) as unknown as typeof fetch + const qm = createQM(fetchMock) + + const first = await qm.refreshMain('token') + expect(first).toBeDefined() + + failNext = true + now += 1_100 + try { + await qm.refreshMain('token') + } catch {} + + const cached = qm.getMain() + expect(cached).not.toBeNull() + }) + }) + + describe('persistence', () => { + test('seeds main quota from persisted storage', () => { + const quota = { + quotas: [], + expires: new Date(2_000_000).toISOString(), + } + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainQuota: quota as any, + mainQuotaCheckedAt: 900_000, + }, + }, + now: () => 1_000_000, + }) + + const main = qm.getMain() + expect(main).not.toBeNull() + expect(main!.checkedAt).toBe(900_000) + }) + + test('calls onMainQuotaFetched after successful fetch', async () => { + let callbackQuota: any = null + const fetchMock = mock(() => + Promise.resolve(makeQuotaResponse(now)), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onMainQuotaFetched: (quota, checkedAt) => { + callbackQuota = { quota, checkedAt } + }, + }) + + await qm.refreshMain('token') + expect(callbackQuota).not.toBeNull() + expect(callbackQuota.checkedAt).toBe(now) + }) + + test('seeds backoff state from persisted storage', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'Claude quota check failed: 429 — rate limited', + checkedAt: now - 30_000, + nextRetryAt: now + 30_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(true) + }) + + test('ignores expired persisted backoff', () => { + const qm = new QuotaManager({ + storage: { + version: 1, + accounts: [], + quota: { + mainLastQuotaApiError: { + message: 'old error', + checkedAt: now - 120_000, + nextRetryAt: now - 60_000, + retryCount: 1, + }, + }, + }, + now: () => now, + }) + + expect(qm.isBackedOff()).toBe(false) + }) + + test('calls onApiError callback on failure', async () => { + let errorCallback: any = null + const fetchMock = mock(() => + Promise.resolve(new Response('rate limited', { status: 429 })), + ) as unknown as typeof fetch + + const qm = new QuotaManager({ + storage: null, + fetchImpl: fetchMock, + now: () => now, + onApiError: (error) => { + errorCallback = error + }, + }) + + try { + await qm.refreshMain('token') + } catch {} + + expect(errorCallback).not.toBeNull() + expect(errorCallback.retryCount).toBe(1) + expect(errorCallback.nextRetryAt).toBeGreaterThan(now) + }) + }) +}) From b20b6c0e4f966c7907cdb1de0986f54a54ef9de4 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/2] 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 | 133 ++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index eeb931c..6c33e12 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,9 +1531,14 @@ 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(() => {}) + // Show toast when it completes + void quotaManager + .refreshMain(auth.access) + .then(() => showQuotaToastFromCache()) + .catch(() => {}) } trace.mark('main_quota_for_routing', { ms: roundMs(nowMs() - quotaStart),