Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@cortexkit/anthropic-auth-core

Package Overview
Dependencies
Maintainers
1
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@cortexkit/anthropic-auth-core - npm Package Compare versions

Comparing version
1.4.1
to
1.5.0
+116
dist/quota-manager.d.ts
/**
* 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[];

@@ -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)

@@ -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';

@@ -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";

@@ -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": {