@cortexkit/anthropic-auth-core 1.4.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from "./accounts.js";
2
+ export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch';
3
+ export function parseKillswitchCommandAction(argumentsText) {
4
+ const parts = argumentsText.trim().split(/\s+/).filter(Boolean);
5
+ if (parts.length === 0)
6
+ return { type: 'status' };
7
+ if (parts.length === 1 && parts[0] === 'on')
8
+ return { type: 'on' };
9
+ if (parts.length === 1 && parts[0] === 'off')
10
+ return { type: 'off' };
11
+ if (parts[0] === 'set') {
12
+ const entries = [];
13
+ for (let i = 1; i < parts.length; i++) {
14
+ const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/);
15
+ if (!match)
16
+ return { type: 'usage' };
17
+ const [, account, fhStr, sdStr] = match;
18
+ entries.push({
19
+ account,
20
+ fh: Number.parseInt(fhStr, 10),
21
+ sd: Number.parseInt(sdStr, 10),
22
+ });
23
+ }
24
+ if (entries.length === 0)
25
+ return { type: 'usage' };
26
+ return { type: 'set', entries };
27
+ }
28
+ return { type: 'usage' };
29
+ }
30
+ function buildStatusTable(config, accountIds) {
31
+ const enabled = config.enabled === true;
32
+ const lines = [
33
+ '## Killswitch',
34
+ '',
35
+ `Status: **${enabled ? 'ON' : 'OFF'}**`,
36
+ ];
37
+ if (enabled) {
38
+ lines.push('');
39
+ lines.push('| Account | 5h threshold | 1w threshold |');
40
+ lines.push('| ------- | ------------ | ------------ |');
41
+ const mainT = config.main ?? {};
42
+ const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
43
+ const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
44
+ lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`);
45
+ for (const id of accountIds) {
46
+ const t = config.accounts?.[id] ?? config.main ?? {};
47
+ const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
48
+ const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
49
+ lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`);
50
+ }
51
+ }
52
+ return lines.join('\n');
53
+ }
54
+ const USAGE_TEXT = [
55
+ '## Killswitch Commands',
56
+ '',
57
+ '```',
58
+ `/${KILLSWITCH_COMMAND_NAME} — show status`,
59
+ `/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`,
60
+ `/${KILLSWITCH_COMMAND_NAME} off — disable`,
61
+ `/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`,
62
+ `/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`,
63
+ '```',
64
+ ].join('\n');
65
+ export function executeKillswitchCommand(input) {
66
+ const action = parseKillswitchCommandAction(input.argumentsText);
67
+ if (action.type === 'status') {
68
+ const status = buildStatusTable(input.config, input.accountIds);
69
+ return { text: `${status}\n\n${USAGE_TEXT}` };
70
+ }
71
+ if (action.type === 'on') {
72
+ const updated = {
73
+ ...input.config,
74
+ enabled: true,
75
+ main: input.config.main ?? {
76
+ five_hour: DEFAULT_THRESHOLDS.five_hour,
77
+ seven_day: DEFAULT_THRESHOLDS.seven_day,
78
+ },
79
+ };
80
+ const status = buildStatusTable(updated, input.accountIds);
81
+ return {
82
+ text: `## Killswitch Enabled\n\n${status}`,
83
+ updatedConfig: updated,
84
+ };
85
+ }
86
+ if (action.type === 'off') {
87
+ const updated = { ...input.config, enabled: false };
88
+ return {
89
+ text: '## Killswitch Disabled',
90
+ updatedConfig: updated,
91
+ };
92
+ }
93
+ if (action.type === 'set') {
94
+ const accounts = { ...(input.config.accounts ?? {}) };
95
+ const updated = {
96
+ ...input.config,
97
+ enabled: true,
98
+ accounts,
99
+ };
100
+ for (const entry of action.entries) {
101
+ const thresholds = {
102
+ five_hour: entry.fh,
103
+ seven_day: entry.sd,
104
+ };
105
+ if (entry.account === 'main') {
106
+ updated.main = thresholds;
107
+ }
108
+ else if (entry.account === 'all') {
109
+ updated.main = thresholds;
110
+ for (const id of input.accountIds) {
111
+ accounts[id] = thresholds;
112
+ }
113
+ }
114
+ else {
115
+ accounts[entry.account] = thresholds;
116
+ }
117
+ }
118
+ const status = buildStatusTable(updated, input.accountIds);
119
+ return {
120
+ text: `## Killswitch Updated\n\n${status}`,
121
+ updatedConfig: updated,
122
+ };
123
+ }
124
+ // usage
125
+ const status = buildStatusTable(input.config, input.accountIds);
126
+ return { text: `${status}\n\n${USAGE_TEXT}` };
127
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Unified quota cache and API gateway.
3
+ *
4
+ * Single source of truth for main + fallback quota state. All consumers
5
+ * share one QuotaManager instance so they see the same in-memory cache.
6
+ * Handles deduplication, rate-limiting (429 backoff), and staleness.
7
+ */
8
+ import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
9
+ /**
10
+ * Stable, non-reversible fingerprint of an access token. Used to detect a
11
+ * main-account switch so a different account's persisted/cached quota is never
12
+ * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
13
+ */
14
+ export declare function tokenFingerprint(token: string): string;
15
+ export type QuotaEntry = {
16
+ quota: OAuthQuotaSnapshot;
17
+ refreshAfter: number;
18
+ checkedAt: number;
19
+ };
20
+ export type QuotaManagerOptions = {
21
+ storage: AccountStorage | null;
22
+ fetchImpl?: typeof fetch;
23
+ now?: () => number;
24
+ onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string) => void;
25
+ onApiError?: (error: AccountOperationError) => void;
26
+ };
27
+ export declare class QuotaManager {
28
+ private main;
29
+ private mainTokenFp;
30
+ private fallbacks;
31
+ private fallbackTokenFps;
32
+ private inflightMain;
33
+ private inflightFallbacks;
34
+ private mainLastApiError;
35
+ private fallbackApiErrors;
36
+ private fallbackErrorTokenFps;
37
+ private apiGate;
38
+ private lastApiCallAt;
39
+ private storage;
40
+ private readonly fetchImpl;
41
+ private readonly now;
42
+ private readonly onMainQuotaFetched;
43
+ private readonly onApiError;
44
+ constructor(opts: QuotaManagerOptions);
45
+ /**
46
+ * Cached main quota entry. Pass the live access token to enforce token
47
+ * binding: if the cached entry was produced by a different token (main
48
+ * account switched), it is dropped and null is returned so the caller
49
+ * refetches for the current account. Called without a token (e.g. for
50
+ * display) it returns whatever is cached.
51
+ */
52
+ getMain(accessToken?: string): QuotaEntry | null;
53
+ /**
54
+ * Cached fallback quota entry. Pass the live access token to enforce token
55
+ * binding: if the entry was produced by a different token (account re-login),
56
+ * it is dropped and null is returned so the caller refetches.
57
+ */
58
+ getFallback(accountId: string, accessToken?: string): QuotaEntry | null;
59
+ getAllFallbacks(): Map<string, QuotaEntry>;
60
+ setMain(accessToken: string, entry: QuotaEntry): void;
61
+ setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
62
+ refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
63
+ refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
64
+ refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
65
+ /**
66
+ * Fire-and-forget refresh. Does not await, swallows errors.
67
+ */
68
+ refreshMainInBackground(accessToken: string): void;
69
+ isMainStale(): boolean;
70
+ isFallbackStale(accountId: string, accessToken?: string): boolean;
71
+ shouldRefreshOnRequestCount(requestCount: number): boolean;
72
+ /**
73
+ * Combined check: should a refresh happen right now?
74
+ * True if main is stale by time OR triggered by request count.
75
+ */
76
+ needsRefresh(requestCount: number): boolean;
77
+ updateStorage(storage: AccountStorage | null): void;
78
+ /**
79
+ * Seed fallback cache entries from persisted account.quota data.
80
+ * Only seeds accounts that don't already have a cache entry.
81
+ * Prevents unnecessary API calls when persisted quota is still fresh.
82
+ */
83
+ seedFallbacksFromAccounts(accounts: OAuthAccount[]): void;
84
+ /**
85
+ * Whether the MAIN quota API is currently in backoff. Scoped to the main
86
+ * account — a fallback account's 429 never reports here.
87
+ */
88
+ isBackedOff(): boolean;
89
+ /**
90
+ * Whether a specific fallback account's quota API is in backoff.
91
+ */
92
+ isFallbackBackedOff(accountId: string, accessToken?: string): boolean;
93
+ getLastApiError(): AccountOperationError | undefined;
94
+ /** Minimum gap between consecutive quota API calls (ms). */
95
+ private static readonly API_CALL_GAP_MS;
96
+ private static fallbackInflightKey;
97
+ /**
98
+ * Serialize API calls through a shared gate so only one
99
+ * quota API request runs at a time, with a minimum gap
100
+ * between calls. Prevents concurrent and rapid-fire calls
101
+ * from triggering Anthropic's rate limits.
102
+ */
103
+ private _enqueueApiFetch;
104
+ private _fetchMain;
105
+ private _fetchFallback;
106
+ private static isAuthError;
107
+ /** Main quota failure: arms main-only backoff and persists via onApiError. */
108
+ private _handleMainFetchError;
109
+ /**
110
+ * Fallback quota failure: arms backoff for THIS account only. Never touches
111
+ * main backoff state and never calls onApiError (which persists the main
112
+ * quota error) — the per-account error is recorded by the caller via the
113
+ * account's lastQuotaRefreshError.
114
+ */
115
+ private _handleFallbackFetchError;
116
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Unified quota cache and API gateway.
3
+ *
4
+ * Single source of truth for main + fallback quota state. All consumers
5
+ * share one QuotaManager instance so they see the same in-memory cache.
6
+ * Handles deduplication, rate-limiting (429 backoff), and staleness.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, quotaBackoffActive, } from "./accounts.js";
10
+ // Capture real setTimeout before tests can mock globalThis.setTimeout
11
+ const nativeSetTimeout = globalThis.setTimeout;
12
+ /**
13
+ * Stable, non-reversible fingerprint of an access token. Used to detect a
14
+ * main-account switch so a different account's persisted/cached quota is never
15
+ * reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
16
+ */
17
+ export function tokenFingerprint(token) {
18
+ return createHash('sha256').update(token).digest('hex').slice(0, 16);
19
+ }
20
+ // ---------------------------------------------------------------------------
21
+ // Class
22
+ // ---------------------------------------------------------------------------
23
+ export class QuotaManager {
24
+ // --- State ---
25
+ main = null;
26
+ mainTokenFp = null;
27
+ fallbacks = new Map();
28
+ // Fingerprint of the access token that produced each fallback cache entry, so
29
+ // a re-login (credential change) for the same account id invalidates the
30
+ // stale entry instead of being treated as fresh.
31
+ fallbackTokenFps = new Map();
32
+ // --- Inflight deduplication ---
33
+ inflightMain = null;
34
+ inflightFallbacks = new Map();
35
+ // --- Rate-limiting (scoped per route so a fallback 429 never backs off the
36
+ // main account or vice versa) ---
37
+ mainLastApiError = undefined;
38
+ fallbackApiErrors = new Map();
39
+ fallbackErrorTokenFps = new Map();
40
+ // --- Serial API gate (prevents concurrent quota API calls) ---
41
+ apiGate = Promise.resolve();
42
+ lastApiCallAt = 0;
43
+ // --- Config ---
44
+ storage;
45
+ fetchImpl;
46
+ now;
47
+ onMainQuotaFetched;
48
+ onApiError;
49
+ constructor(opts) {
50
+ this.storage = opts.storage;
51
+ this.fetchImpl = opts.fetchImpl ?? fetch;
52
+ this.now = opts.now ?? Date.now;
53
+ this.onMainQuotaFetched = opts.onMainQuotaFetched;
54
+ this.onApiError = opts.onApiError;
55
+ // Seed main quota from persisted storage, bound to the token fingerprint
56
+ // that produced it. refreshMain() drops this seed if the live token's
57
+ // fingerprint differs (main-account switch), preventing stale wrong-account
58
+ // quota from being served during backoff.
59
+ const persisted = getPersistedMainQuota(opts.storage);
60
+ if (persisted) {
61
+ this.main = {
62
+ quota: persisted.quota,
63
+ refreshAfter: getQuotaNextRefreshAt(persisted.quota, opts.storage, persisted.checkedAt),
64
+ checkedAt: persisted.checkedAt,
65
+ };
66
+ this.mainTokenFp = persisted.tokenFingerprint ?? null;
67
+ }
68
+ // Seed backoff state from persisted storage
69
+ const persistedError = opts.storage?.quota?.mainLastQuotaApiError;
70
+ if (persistedError && quotaBackoffActive(persistedError, this.now())) {
71
+ this.mainLastApiError = persistedError;
72
+ }
73
+ }
74
+ // =========================================================================
75
+ // Get (synchronous, from cache)
76
+ // =========================================================================
77
+ /**
78
+ * Cached main quota entry. Pass the live access token to enforce token
79
+ * binding: if the cached entry was produced by a different token (main
80
+ * account switched), it is dropped and null is returned so the caller
81
+ * refetches for the current account. Called without a token (e.g. for
82
+ * display) it returns whatever is cached.
83
+ */
84
+ getMain(accessToken) {
85
+ if (accessToken &&
86
+ this.main &&
87
+ this.mainTokenFp &&
88
+ this.mainTokenFp !== tokenFingerprint(accessToken)) {
89
+ this.main = null;
90
+ this.mainTokenFp = null;
91
+ }
92
+ return this.main;
93
+ }
94
+ /**
95
+ * Cached fallback quota entry. Pass the live access token to enforce token
96
+ * binding: if the entry was produced by a different token (account re-login),
97
+ * it is dropped and null is returned so the caller refetches.
98
+ */
99
+ getFallback(accountId, accessToken) {
100
+ const entry = this.fallbacks.get(accountId) ?? null;
101
+ if (!accessToken || !entry)
102
+ return entry;
103
+ const fp = this.fallbackTokenFps.get(accountId);
104
+ if (fp !== tokenFingerprint(accessToken)) {
105
+ this.fallbacks.delete(accountId);
106
+ this.fallbackTokenFps.delete(accountId);
107
+ return null;
108
+ }
109
+ return entry;
110
+ }
111
+ getAllFallbacks() {
112
+ return this.fallbacks;
113
+ }
114
+ // =========================================================================
115
+ // Set (manual inject — seeding from persisted account.quota on boot)
116
+ // =========================================================================
117
+ setMain(accessToken, entry) {
118
+ this.mainTokenFp = tokenFingerprint(accessToken);
119
+ this.main = entry;
120
+ }
121
+ setFallback(accountId, entry, accessToken) {
122
+ this.fallbacks.set(accountId, entry);
123
+ if (accessToken) {
124
+ this.fallbackTokenFps.set(accountId, tokenFingerprint(accessToken));
125
+ }
126
+ else {
127
+ this.fallbackTokenFps.delete(accountId);
128
+ }
129
+ }
130
+ // =========================================================================
131
+ // Refresh (async, deduplicated, rate-limited)
132
+ // =========================================================================
133
+ async refreshMain(accessToken) {
134
+ // If the main account/token changed, invalidate the cache (including a
135
+ // persisted seed) BEFORE the backoff short-circuit so a different account's
136
+ // stale quota is never returned while the quota API is backed off.
137
+ const fp = tokenFingerprint(accessToken);
138
+ if (this.mainTokenFp && this.mainTokenFp !== fp) {
139
+ this.main = null;
140
+ this.mainTokenFp = null;
141
+ }
142
+ // Deduplicate — return in-flight promise if already fetching
143
+ if (this.inflightMain)
144
+ return this.inflightMain;
145
+ // Rate-limit — if API recently 429'd, return stale or throw
146
+ if (this.isBackedOff()) {
147
+ if (this.main)
148
+ return this.main.quota;
149
+ throw new Error('Quota API rate-limited — try again later');
150
+ }
151
+ this.inflightMain = this._fetchMain(accessToken);
152
+ return this.inflightMain;
153
+ }
154
+ async refreshFallback(accountId, accessToken) {
155
+ // Deduplicate per account+token so a same-label re-login never joins a
156
+ // quota probe that was started with the previous credentials.
157
+ const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
158
+ const inflight = this.inflightFallbacks.get(inflightKey);
159
+ if (inflight)
160
+ return inflight;
161
+ // Rate-limit — scoped to THIS fallback account only
162
+ if (this.isFallbackBackedOff(accountId, accessToken)) {
163
+ const cached = this.getFallback(accountId, accessToken);
164
+ if (cached)
165
+ return cached.quota;
166
+ throw new Error('Quota API rate-limited — try again later');
167
+ }
168
+ const promise = this._fetchFallback(accountId, accessToken);
169
+ this.inflightFallbacks.set(inflightKey, promise);
170
+ return promise;
171
+ }
172
+ async refreshAllFallbacks(accounts) {
173
+ const now = this.now();
174
+ for (const account of accounts) {
175
+ if (account.enabled === false)
176
+ continue;
177
+ if (!account.access)
178
+ continue;
179
+ const cached = this.getFallback(account.id, account.access);
180
+ if (cached && now < cached.refreshAfter)
181
+ continue;
182
+ try {
183
+ await this.refreshFallback(account.id, account.access);
184
+ }
185
+ catch {
186
+ // Best-effort — keep stale cache entry if fetch fails
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Fire-and-forget refresh. Does not await, swallows errors.
192
+ */
193
+ refreshMainInBackground(accessToken) {
194
+ if (this.inflightMain)
195
+ return;
196
+ if (this.isBackedOff())
197
+ return;
198
+ void this.refreshMain(accessToken).catch(() => { });
199
+ }
200
+ // =========================================================================
201
+ // Staleness queries
202
+ // =========================================================================
203
+ isMainStale() {
204
+ if (!this.main)
205
+ return true;
206
+ return this.now() >= this.main.refreshAfter;
207
+ }
208
+ isFallbackStale(accountId, accessToken) {
209
+ // Token-aware: a credential change invalidates the entry (treated as stale).
210
+ const entry = this.getFallback(accountId, accessToken);
211
+ if (!entry)
212
+ return true;
213
+ return this.now() >= entry.refreshAfter;
214
+ }
215
+ shouldRefreshOnRequestCount(requestCount) {
216
+ const everyN = getQuotaRefreshEveryNRequests(this.storage);
217
+ if (everyN <= 0)
218
+ return false;
219
+ return requestCount > 0 && requestCount % everyN === 0;
220
+ }
221
+ /**
222
+ * Combined check: should a refresh happen right now?
223
+ * True if main is stale by time OR triggered by request count.
224
+ */
225
+ needsRefresh(requestCount) {
226
+ return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount);
227
+ }
228
+ // =========================================================================
229
+ // Config
230
+ // =========================================================================
231
+ updateStorage(storage) {
232
+ this.storage = storage;
233
+ }
234
+ /**
235
+ * Seed fallback cache entries from persisted account.quota data.
236
+ * Only seeds accounts that don't already have a cache entry.
237
+ * Prevents unnecessary API calls when persisted quota is still fresh.
238
+ */
239
+ seedFallbacksFromAccounts(accounts) {
240
+ const checkInterval = getQuotaCheckIntervalMs(this.storage);
241
+ for (const account of accounts) {
242
+ if (account.enabled === false)
243
+ continue;
244
+ if (this.getFallback(account.id, account.access))
245
+ continue;
246
+ if (!account.quota)
247
+ continue;
248
+ const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
249
+ if (checkedAt <= 0)
250
+ continue;
251
+ this.setFallback(account.id, {
252
+ quota: account.quota,
253
+ refreshAfter: checkedAt + checkInterval,
254
+ checkedAt,
255
+ }, account.access);
256
+ }
257
+ }
258
+ /**
259
+ * Whether the MAIN quota API is currently in backoff. Scoped to the main
260
+ * account — a fallback account's 429 never reports here.
261
+ */
262
+ isBackedOff() {
263
+ return quotaBackoffActive(this.mainLastApiError, this.now());
264
+ }
265
+ /**
266
+ * Whether a specific fallback account's quota API is in backoff.
267
+ */
268
+ isFallbackBackedOff(accountId, accessToken) {
269
+ if (accessToken) {
270
+ const errorFp = this.fallbackErrorTokenFps.get(accountId);
271
+ if (errorFp !== tokenFingerprint(accessToken))
272
+ return false;
273
+ }
274
+ return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now());
275
+ }
276
+ getLastApiError() {
277
+ return this.mainLastApiError;
278
+ }
279
+ // =========================================================================
280
+ // Private
281
+ // =========================================================================
282
+ /** Minimum gap between consecutive quota API calls (ms). */
283
+ static API_CALL_GAP_MS = 1_000;
284
+ static fallbackInflightKey(accountId, accessToken) {
285
+ return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
286
+ }
287
+ /**
288
+ * Serialize API calls through a shared gate so only one
289
+ * quota API request runs at a time, with a minimum gap
290
+ * between calls. Prevents concurrent and rapid-fire calls
291
+ * from triggering Anthropic's rate limits.
292
+ */
293
+ _enqueueApiFetch(fn) {
294
+ const gatedFn = async () => {
295
+ // Wait until minimum gap since last API call
296
+ const elapsed = this.now() - this.lastApiCallAt;
297
+ if (elapsed < QuotaManager.API_CALL_GAP_MS) {
298
+ await new Promise((r) => {
299
+ const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed);
300
+ if (typeof id === 'object' && 'unref' in id)
301
+ id.unref();
302
+ });
303
+ }
304
+ this.lastApiCallAt = this.now();
305
+ return fn();
306
+ };
307
+ const queued = this.apiGate.then(gatedFn, gatedFn);
308
+ this.apiGate = queued.catch(() => { });
309
+ return queued;
310
+ }
311
+ async _fetchMain(accessToken) {
312
+ return this._enqueueApiFetch(async () => {
313
+ try {
314
+ // Re-check backoff inside gate — may have been set by
315
+ // a preceding queued call while we waited
316
+ if (this.isBackedOff()) {
317
+ if (this.main)
318
+ return this.main.quota;
319
+ throw new Error('Quota API rate-limited — try again later');
320
+ }
321
+ const fileLock = await acquireRefreshFileLock({
322
+ name: 'opencode-main-quota-refresh',
323
+ ttlMs: 30_000,
324
+ });
325
+ if (!fileLock) {
326
+ if (this.main)
327
+ return this.main.quota;
328
+ throw new Error('Quota refresh is already in progress');
329
+ }
330
+ try {
331
+ const quota = await fetchOAuthQuotaSnapshot({
332
+ accessToken,
333
+ fetchImpl: this.fetchImpl,
334
+ now: this.now,
335
+ });
336
+ const now = this.now();
337
+ this.mainTokenFp = tokenFingerprint(accessToken);
338
+ this.main = {
339
+ quota,
340
+ refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now),
341
+ checkedAt: now,
342
+ };
343
+ this.mainLastApiError = undefined;
344
+ this.onMainQuotaFetched?.(quota, now, this.mainTokenFp);
345
+ return quota;
346
+ }
347
+ catch (error) {
348
+ this._handleMainFetchError(error);
349
+ throw error;
350
+ }
351
+ finally {
352
+ await fileLock.release();
353
+ }
354
+ }
355
+ finally {
356
+ this.inflightMain = null;
357
+ }
358
+ });
359
+ }
360
+ async _fetchFallback(accountId, accessToken) {
361
+ return this._enqueueApiFetch(async () => {
362
+ try {
363
+ // Re-check backoff inside gate — scoped to this fallback account
364
+ if (this.isFallbackBackedOff(accountId, accessToken)) {
365
+ const cached = this.getFallback(accountId, accessToken);
366
+ if (cached)
367
+ return cached.quota;
368
+ throw new Error('Quota API rate-limited — try again later');
369
+ }
370
+ const quota = await fetchOAuthQuotaSnapshot({
371
+ accessToken,
372
+ fetchImpl: this.fetchImpl,
373
+ now: this.now,
374
+ });
375
+ const now = this.now();
376
+ this.setFallback(accountId, {
377
+ quota,
378
+ refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
379
+ checkedAt: now,
380
+ }, accessToken);
381
+ this.fallbackApiErrors.delete(accountId);
382
+ this.fallbackErrorTokenFps.delete(accountId);
383
+ return quota;
384
+ }
385
+ catch (error) {
386
+ this._handleFallbackFetchError(accountId, accessToken, error);
387
+ throw error;
388
+ }
389
+ finally {
390
+ this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken));
391
+ }
392
+ });
393
+ }
394
+ // A 401 is an auth/token problem, not a rate limit. The caller refreshes the
395
+ // token and retries; backing off the quota API here would block that retry,
396
+ // so surface 401s without recording backoff state.
397
+ static isAuthError(error) {
398
+ const message = error instanceof Error ? error.message : String(error);
399
+ return /quota check failed: 401\b/.test(message);
400
+ }
401
+ /** Main quota failure: arms main-only backoff and persists via onApiError. */
402
+ _handleMainFetchError(error) {
403
+ if (QuotaManager.isAuthError(error))
404
+ return;
405
+ this.mainLastApiError = buildQuotaOperationError({
406
+ error,
407
+ now: this.now(),
408
+ previous: this.mainLastApiError,
409
+ });
410
+ this.onApiError?.(this.mainLastApiError);
411
+ }
412
+ /**
413
+ * Fallback quota failure: arms backoff for THIS account only. Never touches
414
+ * main backoff state and never calls onApiError (which persists the main
415
+ * quota error) — the per-account error is recorded by the caller via the
416
+ * account's lastQuotaRefreshError.
417
+ */
418
+ _handleFallbackFetchError(accountId, accessToken, error) {
419
+ if (QuotaManager.isAuthError(error))
420
+ return;
421
+ const tokenFp = tokenFingerprint(accessToken);
422
+ const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp
423
+ ? this.fallbackApiErrors.get(accountId)
424
+ : undefined;
425
+ this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
426
+ error,
427
+ now: this.now(),
428
+ previous,
429
+ }));
430
+ this.fallbackErrorTokenFps.set(accountId, tokenFp);
431
+ }
432
+ }