@cortexkit/anthropic-auth-core
Advanced tools
| /** | ||
| * 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'; | ||
| /** | ||
| * Stable, non-reversible fingerprint of an access token. Used to detect a | ||
| * main-account switch so a different account's persisted/cached quota is never | ||
| * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota. | ||
| */ | ||
| export declare function tokenFingerprint(token: string): string; | ||
| export type QuotaEntry = { | ||
| quota: OAuthQuotaSnapshot; | ||
| refreshAfter: number; | ||
| checkedAt: number; | ||
| }; | ||
| export type QuotaManagerOptions = { | ||
| storage: AccountStorage | null; | ||
| fetchImpl?: typeof fetch; | ||
| now?: () => number; | ||
| onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string) => void; | ||
| onApiError?: (error: AccountOperationError) => void; | ||
| }; | ||
| export declare class QuotaManager { | ||
| private main; | ||
| private mainTokenFp; | ||
| private fallbacks; | ||
| private fallbackTokenFps; | ||
| private inflightMain; | ||
| private inflightFallbacks; | ||
| private mainLastApiError; | ||
| private fallbackApiErrors; | ||
| private fallbackErrorTokenFps; | ||
| private apiGate; | ||
| private lastApiCallAt; | ||
| private storage; | ||
| private readonly fetchImpl; | ||
| private readonly now; | ||
| private readonly onMainQuotaFetched; | ||
| private readonly onApiError; | ||
| constructor(opts: QuotaManagerOptions); | ||
| /** | ||
| * Cached main quota entry. Pass the live access token to enforce token | ||
| * binding: if the cached entry was produced by a different token (main | ||
| * account switched), it is dropped and null is returned so the caller | ||
| * refetches for the current account. Called without a token (e.g. for | ||
| * display) it returns whatever is cached. | ||
| */ | ||
| getMain(accessToken?: string): QuotaEntry | null; | ||
| /** | ||
| * Cached fallback quota entry. Pass the live access token to enforce token | ||
| * binding: if the entry was produced by a different token (account re-login), | ||
| * it is dropped and null is returned so the caller refetches. | ||
| */ | ||
| getFallback(accountId: string, accessToken?: string): QuotaEntry | null; | ||
| getAllFallbacks(): Map<string, QuotaEntry>; | ||
| setMain(accessToken: string, entry: QuotaEntry): void; | ||
| setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void; | ||
| refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>; | ||
| refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>; | ||
| refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>; | ||
| /** | ||
| * Fire-and-forget refresh. Does not await, swallows errors. | ||
| */ | ||
| refreshMainInBackground(accessToken: string): void; | ||
| isMainStale(): boolean; | ||
| isFallbackStale(accountId: string, accessToken?: string): boolean; | ||
| shouldRefreshOnRequestCount(requestCount: number): boolean; | ||
| /** | ||
| * Combined check: should a refresh happen right now? | ||
| * True if main is stale by time OR triggered by request count. | ||
| */ | ||
| needsRefresh(requestCount: number): boolean; | ||
| updateStorage(storage: AccountStorage | null): void; | ||
| /** | ||
| * 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; | ||
| /** | ||
| * Whether the MAIN quota API is currently in backoff. Scoped to the main | ||
| * account — a fallback account's 429 never reports here. | ||
| */ | ||
| isBackedOff(): boolean; | ||
| /** | ||
| * Whether a specific fallback account's quota API is in backoff. | ||
| */ | ||
| isFallbackBackedOff(accountId: string, accessToken?: string): boolean; | ||
| getLastApiError(): AccountOperationError | undefined; | ||
| /** Minimum gap between consecutive quota API calls (ms). */ | ||
| private static readonly API_CALL_GAP_MS; | ||
| private static fallbackInflightKey; | ||
| /** | ||
| * 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; | ||
| private _fetchMain; | ||
| private _fetchFallback; | ||
| private static isAuthError; | ||
| /** Main quota failure: arms main-only backoff and persists via onApiError. */ | ||
| private _handleMainFetchError; | ||
| /** | ||
| * Fallback quota failure: arms backoff for THIS account only. Never touches | ||
| * main backoff state and never calls onApiError (which persists the main | ||
| * quota error) — the per-account error is recorded by the caller via the | ||
| * account's lastQuotaRefreshError. | ||
| */ | ||
| private _handleFallbackFetchError; | ||
| } |
| /** | ||
| * 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 { createHash } from 'node:crypto'; | ||
| import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, quotaBackoffActive, } from "./accounts.js"; | ||
| // Capture real setTimeout before tests can mock globalThis.setTimeout | ||
| const nativeSetTimeout = globalThis.setTimeout; | ||
| /** | ||
| * Stable, non-reversible fingerprint of an access token. Used to detect a | ||
| * main-account switch so a different account's persisted/cached quota is never | ||
| * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota. | ||
| */ | ||
| export function tokenFingerprint(token) { | ||
| return createHash('sha256').update(token).digest('hex').slice(0, 16); | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // Class | ||
| // --------------------------------------------------------------------------- | ||
| export class QuotaManager { | ||
| // --- State --- | ||
| main = null; | ||
| mainTokenFp = null; | ||
| fallbacks = new Map(); | ||
| // Fingerprint of the access token that produced each fallback cache entry, so | ||
| // a re-login (credential change) for the same account id invalidates the | ||
| // stale entry instead of being treated as fresh. | ||
| fallbackTokenFps = new Map(); | ||
| // --- Inflight deduplication --- | ||
| inflightMain = null; | ||
| inflightFallbacks = new Map(); | ||
| // --- Rate-limiting (scoped per route so a fallback 429 never backs off the | ||
| // main account or vice versa) --- | ||
| mainLastApiError = undefined; | ||
| fallbackApiErrors = new Map(); | ||
| fallbackErrorTokenFps = new Map(); | ||
| // --- Serial API gate (prevents concurrent quota API calls) --- | ||
| apiGate = Promise.resolve(); | ||
| lastApiCallAt = 0; | ||
| // --- Config --- | ||
| storage; | ||
| fetchImpl; | ||
| now; | ||
| onMainQuotaFetched; | ||
| onApiError; | ||
| constructor(opts) { | ||
| 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, bound to the token fingerprint | ||
| // that produced it. refreshMain() drops this seed if the live token's | ||
| // fingerprint differs (main-account switch), preventing stale wrong-account | ||
| // quota from being served during backoff. | ||
| const persisted = getPersistedMainQuota(opts.storage); | ||
| if (persisted) { | ||
| this.main = { | ||
| quota: persisted.quota, | ||
| refreshAfter: getQuotaNextRefreshAt(persisted.quota, opts.storage, persisted.checkedAt), | ||
| checkedAt: persisted.checkedAt, | ||
| }; | ||
| this.mainTokenFp = persisted.tokenFingerprint ?? null; | ||
| } | ||
| // Seed backoff state from persisted storage | ||
| const persistedError = opts.storage?.quota?.mainLastQuotaApiError; | ||
| if (persistedError && quotaBackoffActive(persistedError, this.now())) { | ||
| this.mainLastApiError = persistedError; | ||
| } | ||
| } | ||
| // ========================================================================= | ||
| // Get (synchronous, from cache) | ||
| // ========================================================================= | ||
| /** | ||
| * Cached main quota entry. Pass the live access token to enforce token | ||
| * binding: if the cached entry was produced by a different token (main | ||
| * account switched), it is dropped and null is returned so the caller | ||
| * refetches for the current account. Called without a token (e.g. for | ||
| * display) it returns whatever is cached. | ||
| */ | ||
| getMain(accessToken) { | ||
| if (accessToken && | ||
| this.main && | ||
| this.mainTokenFp && | ||
| this.mainTokenFp !== tokenFingerprint(accessToken)) { | ||
| this.main = null; | ||
| this.mainTokenFp = null; | ||
| } | ||
| return this.main; | ||
| } | ||
| /** | ||
| * Cached fallback quota entry. Pass the live access token to enforce token | ||
| * binding: if the entry was produced by a different token (account re-login), | ||
| * it is dropped and null is returned so the caller refetches. | ||
| */ | ||
| getFallback(accountId, accessToken) { | ||
| const entry = this.fallbacks.get(accountId) ?? null; | ||
| if (!accessToken || !entry) | ||
| return entry; | ||
| const fp = this.fallbackTokenFps.get(accountId); | ||
| if (fp !== tokenFingerprint(accessToken)) { | ||
| this.fallbacks.delete(accountId); | ||
| this.fallbackTokenFps.delete(accountId); | ||
| return null; | ||
| } | ||
| return entry; | ||
| } | ||
| getAllFallbacks() { | ||
| return this.fallbacks; | ||
| } | ||
| // ========================================================================= | ||
| // Set (manual inject — seeding from persisted account.quota on boot) | ||
| // ========================================================================= | ||
| setMain(accessToken, entry) { | ||
| this.mainTokenFp = tokenFingerprint(accessToken); | ||
| this.main = entry; | ||
| } | ||
| setFallback(accountId, entry, accessToken) { | ||
| this.fallbacks.set(accountId, entry); | ||
| if (accessToken) { | ||
| this.fallbackTokenFps.set(accountId, tokenFingerprint(accessToken)); | ||
| } | ||
| else { | ||
| this.fallbackTokenFps.delete(accountId); | ||
| } | ||
| } | ||
| // ========================================================================= | ||
| // Refresh (async, deduplicated, rate-limited) | ||
| // ========================================================================= | ||
| async refreshMain(accessToken) { | ||
| // If the main account/token changed, invalidate the cache (including a | ||
| // persisted seed) BEFORE the backoff short-circuit so a different account's | ||
| // stale quota is never returned while the quota API is backed off. | ||
| const fp = tokenFingerprint(accessToken); | ||
| if (this.mainTokenFp && this.mainTokenFp !== fp) { | ||
| this.main = null; | ||
| this.mainTokenFp = 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, accessToken) { | ||
| // Deduplicate per account+token so a same-label re-login never joins a | ||
| // quota probe that was started with the previous credentials. | ||
| const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken); | ||
| const inflight = this.inflightFallbacks.get(inflightKey); | ||
| if (inflight) | ||
| return inflight; | ||
| // Rate-limit — scoped to THIS fallback account only | ||
| if (this.isFallbackBackedOff(accountId, accessToken)) { | ||
| const cached = this.getFallback(accountId, accessToken); | ||
| if (cached) | ||
| return cached.quota; | ||
| throw new Error('Quota API rate-limited — try again later'); | ||
| } | ||
| const promise = this._fetchFallback(accountId, accessToken); | ||
| this.inflightFallbacks.set(inflightKey, promise); | ||
| return promise; | ||
| } | ||
| async refreshAllFallbacks(accounts) { | ||
| const now = this.now(); | ||
| for (const account of accounts) { | ||
| if (account.enabled === false) | ||
| continue; | ||
| if (!account.access) | ||
| continue; | ||
| const cached = this.getFallback(account.id, account.access); | ||
| 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) { | ||
| if (this.inflightMain) | ||
| return; | ||
| if (this.isBackedOff()) | ||
| return; | ||
| void this.refreshMain(accessToken).catch(() => { }); | ||
| } | ||
| // ========================================================================= | ||
| // Staleness queries | ||
| // ========================================================================= | ||
| isMainStale() { | ||
| if (!this.main) | ||
| return true; | ||
| return this.now() >= this.main.refreshAfter; | ||
| } | ||
| isFallbackStale(accountId, accessToken) { | ||
| // Token-aware: a credential change invalidates the entry (treated as stale). | ||
| const entry = this.getFallback(accountId, accessToken); | ||
| if (!entry) | ||
| return true; | ||
| return this.now() >= entry.refreshAfter; | ||
| } | ||
| shouldRefreshOnRequestCount(requestCount) { | ||
| 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) { | ||
| return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount); | ||
| } | ||
| // ========================================================================= | ||
| // Config | ||
| // ========================================================================= | ||
| updateStorage(storage) { | ||
| 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) { | ||
| const checkInterval = getQuotaCheckIntervalMs(this.storage); | ||
| for (const account of accounts) { | ||
| if (account.enabled === false) | ||
| continue; | ||
| if (this.getFallback(account.id, account.access)) | ||
| 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.setFallback(account.id, { | ||
| quota: account.quota, | ||
| refreshAfter: checkedAt + checkInterval, | ||
| checkedAt, | ||
| }, account.access); | ||
| } | ||
| } | ||
| /** | ||
| * Whether the MAIN quota API is currently in backoff. Scoped to the main | ||
| * account — a fallback account's 429 never reports here. | ||
| */ | ||
| isBackedOff() { | ||
| return quotaBackoffActive(this.mainLastApiError, this.now()); | ||
| } | ||
| /** | ||
| * Whether a specific fallback account's quota API is in backoff. | ||
| */ | ||
| isFallbackBackedOff(accountId, accessToken) { | ||
| if (accessToken) { | ||
| const errorFp = this.fallbackErrorTokenFps.get(accountId); | ||
| if (errorFp !== tokenFingerprint(accessToken)) | ||
| return false; | ||
| } | ||
| return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now()); | ||
| } | ||
| getLastApiError() { | ||
| return this.mainLastApiError; | ||
| } | ||
| // ========================================================================= | ||
| // Private | ||
| // ========================================================================= | ||
| /** Minimum gap between consecutive quota API calls (ms). */ | ||
| static API_CALL_GAP_MS = 1_000; | ||
| static fallbackInflightKey(accountId, accessToken) { | ||
| return JSON.stringify([accountId, tokenFingerprint(accessToken)]); | ||
| } | ||
| /** | ||
| * 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. | ||
| */ | ||
| _enqueueApiFetch(fn) { | ||
| const gatedFn = async () => { | ||
| // 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; | ||
| } | ||
| async _fetchMain(accessToken) { | ||
| return this._enqueueApiFetch(async () => { | ||
| try { | ||
| // Re-check backoff inside gate — may have been set by | ||
| // a preceding queued call while we waited | ||
| if (this.isBackedOff()) { | ||
| if (this.main) | ||
| return this.main.quota; | ||
| 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.mainTokenFp = tokenFingerprint(accessToken); | ||
| this.main = { | ||
| quota, | ||
| refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now), | ||
| checkedAt: now, | ||
| }; | ||
| this.mainLastApiError = undefined; | ||
| this.onMainQuotaFetched?.(quota, now, this.mainTokenFp); | ||
| return quota; | ||
| } | ||
| catch (error) { | ||
| this._handleMainFetchError(error); | ||
| throw error; | ||
| } | ||
| finally { | ||
| await fileLock.release(); | ||
| } | ||
| } | ||
| finally { | ||
| this.inflightMain = null; | ||
| } | ||
| }); | ||
| } | ||
| async _fetchFallback(accountId, accessToken) { | ||
| return this._enqueueApiFetch(async () => { | ||
| try { | ||
| // Re-check backoff inside gate — scoped to this fallback account | ||
| if (this.isFallbackBackedOff(accountId, accessToken)) { | ||
| const cached = this.getFallback(accountId, accessToken); | ||
| if (cached) | ||
| return cached.quota; | ||
| 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.setFallback(accountId, { | ||
| quota, | ||
| refreshAfter: now + getQuotaCheckIntervalMs(this.storage), | ||
| checkedAt: now, | ||
| }, accessToken); | ||
| this.fallbackApiErrors.delete(accountId); | ||
| this.fallbackErrorTokenFps.delete(accountId); | ||
| return quota; | ||
| } | ||
| catch (error) { | ||
| this._handleFallbackFetchError(accountId, accessToken, error); | ||
| throw error; | ||
| } | ||
| finally { | ||
| this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken)); | ||
| } | ||
| }); | ||
| } | ||
| // A 401 is an auth/token problem, not a rate limit. The caller refreshes the | ||
| // token and retries; backing off the quota API here would block that retry, | ||
| // so surface 401s without recording backoff state. | ||
| static isAuthError(error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return /quota check failed: 401\b/.test(message); | ||
| } | ||
| /** Main quota failure: arms main-only backoff and persists via onApiError. */ | ||
| _handleMainFetchError(error) { | ||
| if (QuotaManager.isAuthError(error)) | ||
| return; | ||
| this.mainLastApiError = buildQuotaOperationError({ | ||
| error, | ||
| now: this.now(), | ||
| previous: this.mainLastApiError, | ||
| }); | ||
| this.onApiError?.(this.mainLastApiError); | ||
| } | ||
| /** | ||
| * Fallback quota failure: arms backoff for THIS account only. Never touches | ||
| * main backoff state and never calls onApiError (which persists the main | ||
| * quota error) — the per-account error is recorded by the caller via the | ||
| * account's lastQuotaRefreshError. | ||
| */ | ||
| _handleFallbackFetchError(accountId, accessToken, error) { | ||
| if (QuotaManager.isAuthError(error)) | ||
| return; | ||
| const tokenFp = tokenFingerprint(accessToken); | ||
| const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp | ||
| ? this.fallbackApiErrors.get(accountId) | ||
| : undefined; | ||
| this.fallbackApiErrors.set(accountId, buildQuotaOperationError({ | ||
| error, | ||
| now: this.now(), | ||
| previous, | ||
| })); | ||
| this.fallbackErrorTokenFps.set(accountId, tokenFp); | ||
| } | ||
| } |
+39
-1
@@ -56,4 +56,9 @@ import { type Cache1hMode } from './constants.ts'; | ||
| checkIntervalMinutes?: number; | ||
| refreshEveryNRequests?: number; | ||
| minimumRemaining?: Partial<Record<QuotaWindowName | '5h' | '1w', number>>; | ||
| failClosedOnUnknownQuota?: boolean; | ||
| mainQuota?: OAuthQuotaSnapshot; | ||
| mainQuotaCheckedAt?: number; | ||
| mainQuotaToken?: string; | ||
| mainLastQuotaApiError?: AccountOperationError; | ||
| }; | ||
@@ -89,2 +94,3 @@ claudeCache?: { | ||
| configPath?: string; | ||
| quotaManager?: import('./quota-manager.ts').QuotaManager; | ||
| }; | ||
@@ -129,3 +135,20 @@ export type AccountRefreshError = { | ||
| export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string; | ||
| export declare function buildQuotaOperationError(input: { | ||
| error: unknown; | ||
| now: number; | ||
| previous?: AccountOperationError; | ||
| }): AccountOperationError; | ||
| export declare function quotaBackoffActive(error: AccountOperationError | undefined, now: number): boolean; | ||
| export declare function formatQuotaBackoffMessage(error: AccountOperationError, now: number): string; | ||
| export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number; | ||
| export declare function getPersistedMainQuota(storage: AccountStorage | null): { | ||
| quota: OAuthQuotaSnapshot; | ||
| checkedAt: number; | ||
| tokenFingerprint?: string; | ||
| } | null; | ||
| /** | ||
| * How often (in requests) to force a quota refresh, independent of the timer. | ||
| * Returns 0 when disabled (default). | ||
| */ | ||
| export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number; | ||
| export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean; | ||
@@ -148,3 +171,9 @@ export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number; | ||
| private quotaTimer; | ||
| readonly quotaManager: import('./quota-manager.ts').QuotaManager | null; | ||
| constructor(options?: AccountManagerOptions); | ||
| /** | ||
| * 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; | ||
| load(): Promise<AccountStorage | null>; | ||
@@ -157,5 +186,14 @@ save(storage: AccountStorage): Promise<void>; | ||
| accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean; | ||
| /** | ||
| * Return the account with its quota overlaid from the unified QuotaManager | ||
| * cache (token-bound) when available, so quota-policy decisions use the same | ||
| * source of truth as the staleness check. Falls back to the stored | ||
| * account.quota when no manager is wired or the cache has no entry. | ||
| */ | ||
| private quotaPolicyAccount; | ||
| refreshDueAccounts(): Promise<void>; | ||
| refreshQuotaForDueAccounts(): Promise<void>; | ||
| refreshQuotaForAllAccounts(): Promise<{ | ||
| refreshQuotaForAllAccounts(options?: { | ||
| force?: boolean; | ||
| }): Promise<{ | ||
| storage: AccountStorage | null; | ||
@@ -162,0 +200,0 @@ errors: AccountRefreshError[]; |
+141
-22
@@ -19,2 +19,5 @@ import { createHash, randomUUID } from 'node:crypto'; | ||
| 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; | ||
@@ -456,2 +459,24 @@ const DEFAULT_MINIMUM_REMAINING = { | ||
| } | ||
| export function buildQuotaOperationError(input) { | ||
| 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, now) { | ||
| if (!error?.nextRetryAt || error.nextRetryAt <= now) | ||
| return false; | ||
| return true; | ||
| } | ||
| export function formatQuotaBackoffMessage(error, now) { | ||
| 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) { | ||
@@ -461,2 +486,21 @@ const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES; | ||
| } | ||
| export function getPersistedMainQuota(storage) { | ||
| if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt) | ||
| return null; | ||
| return { | ||
| quota: storage.quota.mainQuota, | ||
| checkedAt: storage.quota.mainQuotaCheckedAt, | ||
| tokenFingerprint: storage.quota.mainQuotaToken, | ||
| }; | ||
| } | ||
| /** | ||
| * How often (in requests) to force a quota refresh, independent of the timer. | ||
| * Returns 0 when disabled (default). | ||
| */ | ||
| export function getQuotaRefreshEveryNRequests(storage) { | ||
| const n = storage?.quota?.refreshEveryNRequests; | ||
| return typeof n === 'number' && Number.isFinite(n) && n > 0 | ||
| ? Math.floor(n) | ||
| : 0; | ||
| } | ||
| function failClosedOnUnknownQuota(storage) { | ||
@@ -603,6 +647,7 @@ return (storage?.quota?.failClosedOnUnknownQuota ?? | ||
| function recordQuotaRefreshError(account, error, now) { | ||
| account.lastQuotaRefreshError = { | ||
| message: formatErrorMessage(error), | ||
| checkedAt: now, | ||
| }; | ||
| account.lastQuotaRefreshError = buildQuotaOperationError({ | ||
| error, | ||
| now, | ||
| previous: account.lastQuotaRefreshError, | ||
| }); | ||
| if (error instanceof ClaudeOAuthRefreshError) { | ||
@@ -625,2 +670,3 @@ recordRefreshError(account, error, now); | ||
| quotaTimer = null; | ||
| quotaManager; | ||
| constructor(options = {}) { | ||
@@ -630,3 +676,25 @@ this.now = options.now ?? Date.now; | ||
| 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. | ||
| */ | ||
| seedFallbackQuota(account, storage) { | ||
| if (!this.quotaManager) | ||
| return; | ||
| if (this.quotaManager.getFallback(account.id, account.access)) | ||
| 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, | ||
| }, account.access); | ||
| } | ||
| async load() { | ||
@@ -680,7 +748,19 @@ return loadAccounts(this.configPath); | ||
| } | ||
| if (quotaIsStale(next, storage, this.now())) { | ||
| this.seedFallbackQuota(next, storage); | ||
| const stale = this.quotaManager | ||
| ? this.quotaManager.isFallbackStale(next.id, next.access) | ||
| : quotaIsStale(next, storage, this.now()); | ||
| // Skip the request-time refresh when this account's quota API is | ||
| // backed off (recent 429/5xx). Hitting it again would extend the | ||
| // backoff; evaluate policy on the cached/seeded quota instead. Mirrors | ||
| // the background refreshQuotaForDueAccounts() guard. | ||
| if (stale && | ||
| !quotaBackoffActive(next.lastQuotaRefreshError, this.now())) { | ||
| next = await this.refreshAccountQuota(next, storage); | ||
| changed = true; | ||
| } | ||
| if (this.accountPassesQuotaPolicy(next, storage)) | ||
| // Single source of truth: evaluate quota policy from the unified | ||
| // QuotaManager cache (the same source as the staleness check above) so | ||
| // an active-route refresh that updated only the cache is not ignored. | ||
| if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage)) | ||
| usable.push(next); | ||
@@ -718,2 +798,14 @@ } | ||
| } | ||
| /** | ||
| * Return the account with its quota overlaid from the unified QuotaManager | ||
| * cache (token-bound) when available, so quota-policy decisions use the same | ||
| * source of truth as the staleness check. Falls back to the stored | ||
| * account.quota when no manager is wired or the cache has no entry. | ||
| */ | ||
| quotaPolicyAccount(account) { | ||
| if (!this.quotaManager) | ||
| return account; | ||
| const cached = this.quotaManager.getFallback(account.id, account.access)?.quota; | ||
| return cached ? { ...account, quota: cached } : account; | ||
| } | ||
| async refreshDueAccounts() { | ||
@@ -730,7 +822,7 @@ const storage = await this.load(); | ||
| if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) { | ||
| log('[refresh] fallback oauth skipped backoff', { | ||
| accountId: account.id, | ||
| nextRetryAt: account.lastRefreshError?.nextRetryAt, | ||
| retryCount: account.lastRefreshError?.retryCount, | ||
| }); | ||
| // Backoff skips are steady-state while a fallback account is waiting for | ||
| // its next retry. Logging every background tick from every OpenCode | ||
| // process creates noise without adding new diagnostic signal; the | ||
| // failure/backoff itself is recorded when the refresh attempt fails and | ||
| // shown by /claude-quota. | ||
| continue; | ||
@@ -779,4 +871,13 @@ } | ||
| } | ||
| if (!quotaIsStale(next, storage, this.now())) | ||
| 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, next.access) | ||
| : quotaIsStale(next, storage, this.now()); | ||
| if (!stale) | ||
| continue; | ||
| await this.refreshAccountQuota(next, storage); | ||
@@ -795,3 +896,3 @@ changed = true; | ||
| } | ||
| async refreshQuotaForAllAccounts() { | ||
| async refreshQuotaForAllAccounts(options = {}) { | ||
| const storage = await this.load(); | ||
@@ -801,2 +902,3 @@ const errors = []; | ||
| return { storage, errors }; | ||
| const force = options.force ?? false; | ||
| let changed = false; | ||
@@ -817,3 +919,6 @@ for (const account of storage.accounts) { | ||
| } | ||
| if (!quotaIsStale(next, storage, this.now())) { | ||
| // force (manual /claude-quota) bypasses the staleness skip to fetch | ||
| // fresh numbers on demand. refreshAccountQuota still respects 429 | ||
| // backoff via QuotaManager.refreshFallback. | ||
| if (!force && !quotaIsStale(next, storage, this.now())) { | ||
| if (next.lastQuotaRefreshError) { | ||
@@ -964,8 +1069,14 @@ next.lastQuotaRefreshError = undefined; | ||
| } | ||
| try { | ||
| target.quota = await fetchOAuthQuotaSnapshot({ | ||
| accessToken: target.access, | ||
| // Unify on the shared QuotaManager when present: it adds inflight | ||
| // deduplication and 429 backoff gating around the same quota API. Fall back | ||
| // to a direct fetch only when no QuotaManager is wired (e.g. in isolation). | ||
| const fetchSnapshot = (accessToken) => this.quotaManager | ||
| ? this.quotaManager.refreshFallback(target.id, accessToken) | ||
| : fetchOAuthQuotaSnapshot({ | ||
| accessToken, | ||
| fetchImpl: this.fetchImpl, | ||
| now: this.now, | ||
| }); | ||
| try { | ||
| target.quota = await fetchSnapshot(target.access); | ||
| } | ||
@@ -981,12 +1092,20 @@ catch (error) { | ||
| throw error; | ||
| target.quota = await fetchOAuthQuotaSnapshot({ | ||
| accessToken: target.access, | ||
| fetchImpl: this.fetchImpl, | ||
| now: this.now, | ||
| }); | ||
| // 401 does not arm QuotaManager backoff, so this retry proceeds. | ||
| target.quota = await fetchSnapshot(target.access); | ||
| } | ||
| target.lastQuotaRefreshError = undefined; | ||
| updateStoredAccount(storage, target); | ||
| // Sync to shared QuotaManager so all consumers see the same cache. The | ||
| // refreshFallback path already cached the snapshot; re-set here so | ||
| // refreshAfter reflects this storage's check interval consistently. | ||
| if (this.quotaManager && target.quota) { | ||
| const now = this.now(); | ||
| this.quotaManager.setFallback(target.id, { | ||
| quota: target.quota, | ||
| refreshAfter: now + getQuotaCheckIntervalMs(storage), | ||
| checkedAt: now, | ||
| }, target.access); | ||
| } | ||
| return target; | ||
| } | ||
| } |
@@ -76,2 +76,3 @@ import type { AccountStorage } from './accounts.ts'; | ||
| }; | ||
| trackedCount(): number; | ||
| track(input: { | ||
@@ -78,0 +79,0 @@ sessionId?: string | null; |
@@ -208,2 +208,5 @@ import { signRequestBody } from "./cch.js"; | ||
| } | ||
| trackedCount() { | ||
| return this.targets.size; | ||
| } | ||
| track(input) { | ||
@@ -210,0 +213,0 @@ if (!input.sessionId) |
+1
-0
@@ -12,4 +12,5 @@ export * from './accounts.ts'; | ||
| export * from './pkce.ts'; | ||
| export * from './quota-manager.ts'; | ||
| export * from './quotas.ts'; | ||
| export * from './relay.ts'; | ||
| export * from './routing.ts'; |
+1
-0
@@ -12,4 +12,5 @@ export * from "./accounts.js"; | ||
| export * from "./pkce.js"; | ||
| export * from "./quota-manager.js"; | ||
| export * from "./quotas.js"; | ||
| export * from "./relay.js"; | ||
| export * from "./routing.js"; |
+7
-1
@@ -244,2 +244,8 @@ import { Buffer } from 'node:buffer'; | ||
| } | ||
| function relayControlErrorMessage(message) { | ||
| const detail = message.message || 'relay websocket error'; | ||
| return message.status | ||
| ? `relay websocket error ${message.status}: ${detail}` | ||
| : detail; | ||
| } | ||
| class PersistentRelaySession { | ||
@@ -565,3 +571,3 @@ config; | ||
| ? new RelayStateMismatchError(message.message || 'state mismatch') | ||
| : new Error(message.message || 'relay websocket error'); | ||
| : new Error(relayControlErrorMessage(message)); | ||
| this.failPending(error); | ||
@@ -568,0 +574,0 @@ } |
+1
-1
| { | ||
| "name": "@cortexkit/anthropic-auth-core", | ||
| "version": "1.4.1", | ||
| "version": "1.5.0", | ||
| "type": "module", | ||
@@ -5,0 +5,0 @@ "repository": { |
197762
18.67%35
6.06%4802
17.55%13
18.18%