@cortexkit/anthropic-auth-core 1.20.0 → 1.22.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.
@@ -5,7 +5,8 @@
5
5
  * share one QuotaManager instance so they see the same in-memory cache.
6
6
  * Handles deduplication, rate-limiting (429 backoff), and staleness.
7
7
  */
8
- import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, quotaBackoffActive, quotaSnapshotCheckedAt, } from './accounts.js';
8
+ import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, mergeMainQuotaErrorClearedAt, quotaBackoffActive, quotaSnapshotCheckedAt, } from './accounts.js';
9
+ import { logger } from './logger.js';
9
10
  import { mergeHeaderQuotaSnapshot } from './quota-headers.js';
10
11
  export { tokenFingerprint } from './token-fingerprint.js';
11
12
  import { tokenFingerprint } from './token-fingerprint.js';
@@ -38,21 +39,23 @@ function mergePollCompletionWithNewerHeaders(current, polled) {
38
39
  export class QuotaManager {
39
40
  // --- State ---
40
41
  main = null;
41
- mainTokenFp = null;
42
+ mainAccountId;
43
+ mainQuotaIdentityKnown = false;
44
+ mainQuotaIdentityStrict = false;
45
+ mainGeneration = 0;
46
+ mainQuotaErrorGeneration = 0;
47
+ mainQuotaErrorClearedAt;
42
48
  fallbacks = new Map();
43
- // Fingerprint of the access token that produced each fallback cache entry, so
44
- // a re-login (credential change) for the same account id invalidates the
45
- // stale entry instead of being treated as fresh.
46
- fallbackTokenFps = new Map();
49
+ fallbackAuthLineages = new Map();
50
+ fallbackGenerations = new Map();
47
51
  // --- Inflight deduplication ---
48
52
  inflightMain = null;
49
- inflightMainFp = null;
53
+ inflightMainAccountId;
50
54
  inflightFallbacks = new Map();
51
55
  // --- Rate-limiting (scoped per route so a fallback 429 never backs off the
52
56
  // main account or vice versa) ---
53
57
  mainLastApiError = undefined;
54
58
  fallbackApiErrors = new Map();
55
- fallbackErrorTokenFps = new Map();
56
59
  // --- Serial API gate (prevents concurrent quota API calls) ---
57
60
  apiGate = Promise.resolve();
58
61
  lastApiCallAt = 0;
@@ -62,55 +65,43 @@ export class QuotaManager {
62
65
  now;
63
66
  onMainQuotaFetched;
64
67
  onApiError;
68
+ onFallbackQuotaFetched;
69
+ onFallbackApiError;
65
70
  constructor(opts) {
66
71
  this.storage = opts.storage;
67
72
  this.fetchImpl = opts.fetchImpl ?? fetch;
68
73
  this.now = opts.now ?? Date.now;
69
74
  this.onMainQuotaFetched = opts.onMainQuotaFetched;
70
75
  this.onApiError = opts.onApiError;
71
- // Seed main quota from persisted storage, bound to the token fingerprint
72
- // that produced it. refreshMain() drops this seed if the live token's
73
- // fingerprint differs (main-account switch), preventing stale wrong-account
74
- // quota from being served during backoff.
75
- this.seedMainFromStorage(opts.storage);
76
+ this.onFallbackQuotaFetched = opts.onFallbackQuotaFetched;
77
+ this.onFallbackApiError = opts.onFallbackApiError;
78
+ this.seedMainFromStorage(opts.storage, opts.storage?.mainAccountId);
76
79
  this.seedMainBackoffFromStorage(opts.storage);
77
80
  }
78
81
  // =========================================================================
79
82
  // Get (synchronous, from cache)
80
83
  // =========================================================================
81
84
  /**
82
- * Cached main quota entry. Pass the live access token to enforce token
83
- * binding: if the cached entry was produced by a different token (main
84
- * account switched), it is dropped and null is returned so the caller
85
- * refetches for the current account. Called without a token (e.g. for
86
- * display) it returns whatever is cached.
85
+ * Cached main quota entry, scoped to the account behind the main credential.
86
+ * An unresolved identity keeps a retained cache unverified rather than exposing it.
87
87
  */
88
- getMain(accessToken) {
89
- if (accessToken &&
90
- this.main &&
91
- this.mainTokenFp &&
92
- this.mainTokenFp !== tokenFingerprint(accessToken)) {
93
- this.main = null;
94
- this.mainTokenFp = null;
88
+ getMain(mainAccountId) {
89
+ if (this.mainAccountId !== mainAccountId)
90
+ return null;
91
+ if (this.mainQuotaIdentityKnown &&
92
+ this.mainQuotaIdentityStrict &&
93
+ (mainAccountId === undefined ||
94
+ this.main?.quota.accountIdentity !== mainAccountId)) {
95
+ return null;
95
96
  }
96
97
  return this.main;
97
98
  }
98
99
  /**
99
- * Cached fallback quota entry. Pass the live access token to enforce token
100
- * binding: if the entry was produced by a different token (account re-login),
101
- * it is dropped and null is returned so the caller refetches.
100
+ * Cached fallback quota entry, scoped to the configured account id.
102
101
  */
103
- getFallback(accountId, accessToken) {
104
- const entry = this.fallbacks.get(accountId) ?? null;
105
- if (!accessToken || !entry)
106
- return entry;
107
- const fp = this.fallbackTokenFps.get(accountId);
108
- if (fp !== tokenFingerprint(accessToken)) {
109
- this.fallbacks.delete(accountId);
110
- this.fallbackTokenFps.delete(accountId);
111
- return null;
112
- }
113
- return entry;
102
+ getFallback(accountId, account) {
103
+ this.bindFallbackLineage(accountId, account);
104
+ return this.fallbacks.get(accountId) ?? null;
114
105
  }
115
106
  getAllFallbacks() {
116
107
  return this.fallbacks;
@@ -118,104 +109,175 @@ export class QuotaManager {
118
109
  // =========================================================================
119
110
  // Set (manual inject — seeding from persisted account.quota on boot)
120
111
  // =========================================================================
121
- setMain(accessToken, entry) {
122
- this.mainTokenFp = tokenFingerprint(accessToken);
112
+ setMain(mainAccountId, entry) {
113
+ this.mainAccountId = mainAccountId;
123
114
  this.main = entry;
124
115
  }
125
- setFallback(accountId, entry, accessToken) {
126
- this.fallbacks.set(accountId, entry);
127
- if (accessToken) {
128
- this.fallbackTokenFps.set(accountId, tokenFingerprint(accessToken));
116
+ /** Bind quota state to the account behind the current main credential. */
117
+ setMainQuotaAccountIdentity(accountIdentity, strict = true) {
118
+ const previousIdentityKnown = this.mainQuotaIdentityKnown;
119
+ const previousAccountIdentity = this.mainAccountId;
120
+ const changed = !previousIdentityKnown ||
121
+ this.mainAccountId !== accountIdentity ||
122
+ this.mainQuotaIdentityStrict !== strict;
123
+ this.mainQuotaIdentityKnown = true;
124
+ this.mainQuotaIdentityStrict = strict;
125
+ if (!changed)
126
+ return undefined;
127
+ this.mainGeneration += 1;
128
+ this.mainAccountId = accountIdentity;
129
+ if (accountIdentity !== undefined &&
130
+ this.main?.quota.accountIdentity !== accountIdentity) {
131
+ this.main = null;
129
132
  }
130
- else {
131
- this.fallbackTokenFps.delete(accountId);
133
+ if (accountIdentity !== undefined &&
134
+ this.mainLastApiError &&
135
+ (this.mainLastApiError.accountIdentity !== undefined
136
+ ? this.mainLastApiError.accountIdentity !== accountIdentity
137
+ : previousIdentityKnown &&
138
+ previousAccountIdentity !== undefined &&
139
+ previousAccountIdentity !== accountIdentity)) {
140
+ this.mainLastApiError = undefined;
141
+ this.mainQuotaErrorGeneration += 1;
142
+ this.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt(this.mainQuotaErrorClearedAt, this.now());
143
+ return this.mainQuotaErrorGeneration;
132
144
  }
145
+ return undefined;
146
+ }
147
+ setFallback(accountId, entry, account) {
148
+ this.bindFallbackLineage(accountId, account);
149
+ this.fallbacks.set(accountId, entry);
150
+ }
151
+ clearFallback(accountId) {
152
+ this.fallbackGenerations.set(accountId, (this.fallbackGenerations.get(accountId) ?? 0) + 1);
153
+ this.fallbacks.delete(accountId);
154
+ this.fallbackApiErrors.delete(accountId);
155
+ this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId));
133
156
  }
134
- pushMainFromHeaders(accessToken, incoming) {
157
+ pushMainFromHeaders(mainAccountId, incoming) {
135
158
  const checkedAt = incoming.checkedAt ?? this.now();
136
- const accessTokenFp = tokenFingerprint(accessToken);
137
- const quota = mergeHeaderQuotaSnapshot(this.mainTokenFp === accessTokenFp ? this.main?.quota : undefined, incoming);
159
+ const quota = mergeHeaderQuotaSnapshot(this.mainAccountId === mainAccountId &&
160
+ (!this.mainQuotaIdentityKnown ||
161
+ !this.mainQuotaIdentityStrict ||
162
+ this.main?.quota.accountIdentity === mainAccountId)
163
+ ? this.main?.quota
164
+ : undefined, {
165
+ ...incoming,
166
+ ...(mainAccountId !== undefined && { accountIdentity: mainAccountId }),
167
+ });
138
168
  const entry = {
139
169
  quota,
140
170
  checkedAt,
141
171
  refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
142
172
  };
143
- this.setMain(accessToken, entry);
173
+ this.setMain(mainAccountId, entry);
144
174
  return entry;
145
175
  }
146
- pushFallbackFromHeaders(accountId, accessToken, incoming) {
176
+ pushFallbackFromHeaders(accountId, incoming, account) {
177
+ if (!this.acceptFallbackQuotaObservation(accountId, account))
178
+ return null;
147
179
  const checkedAt = incoming.checkedAt ?? this.now();
148
- const quota = mergeHeaderQuotaSnapshot(this.getFallback(accountId, accessToken)?.quota, incoming);
180
+ const quota = mergeHeaderQuotaSnapshot(this.fallbacks.get(accountId)?.quota, {
181
+ ...incoming,
182
+ accountIdentity: accountId,
183
+ });
149
184
  const entry = {
150
185
  quota,
151
186
  checkedAt,
152
187
  refreshAfter: getQuotaNextRefreshAt(quota, this.storage, checkedAt),
153
188
  };
154
- this.setFallback(accountId, entry, accessToken);
189
+ this.fallbacks.set(accountId, entry);
155
190
  return entry;
156
191
  }
157
192
  // =========================================================================
158
193
  // Refresh (async, deduplicated, rate-limited)
159
194
  // =========================================================================
160
- async refreshMain(accessToken) {
161
- return (await this.refreshMainWithMetadata(accessToken)).quota;
162
- }
163
- async refreshMainWithMetadata(accessToken) {
164
- // If the main account/token changed, invalidate the cache (including a
165
- // persisted seed) BEFORE the backoff short-circuit so a different account's
166
- // stale quota is never returned while the quota API is backed off.
167
- const fp = tokenFingerprint(accessToken);
168
- if (this.mainTokenFp && this.mainTokenFp !== fp) {
195
+ async refreshMain(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
196
+ const mainAccountId = mainAccountIdOrAccessToken;
197
+ const credential = accessToken ?? mainAccountIdOrAccessToken;
198
+ if (!credential)
199
+ throw new Error('Main OAuth access token is unavailable');
200
+ return (await this.refreshMainWithMetadata(mainAccountId, credential, expectedIdentityGeneration)).quota;
201
+ }
202
+ async refreshMainWithMetadata(mainAccountIdOrAccessToken, accessToken, expectedIdentityGeneration) {
203
+ const effectiveAccountId = mainAccountIdOrAccessToken;
204
+ const credential = accessToken ?? mainAccountIdOrAccessToken;
205
+ if (!credential)
206
+ throw new Error('Main OAuth access token is unavailable');
207
+ if (expectedIdentityGeneration !== undefined &&
208
+ (this.mainGeneration !== expectedIdentityGeneration ||
209
+ this.mainAccountId !== effectiveAccountId)) {
210
+ throw new Error('Main quota identity changed before refresh');
211
+ }
212
+ if (this.mainAccountId !== effectiveAccountId) {
169
213
  this.main = null;
170
- this.mainTokenFp = null;
214
+ this.mainAccountId = effectiveAccountId;
215
+ if (this.mainQuotaIdentityKnown)
216
+ this.mainGeneration += 1;
171
217
  }
172
- // Deduplicate — return in-flight promise only if same token fingerprint
173
- if (this.inflightMain && this.inflightMainFp === fp)
218
+ if (this.inflightMain && this.inflightMainAccountId === effectiveAccountId)
174
219
  return this.inflightMain;
175
220
  // Rate-limit — if API recently 429'd, return stale or throw
176
221
  if (this.isBackedOff()) {
177
- if (this.main)
222
+ if (this.main && this.mainAccountId === effectiveAccountId) {
178
223
  return { quota: this.main.quota, fetched: false };
224
+ }
179
225
  throw new Error('Quota API rate-limited — try again later');
180
226
  }
181
- this.inflightMainFp = fp;
182
- this.inflightMain = this._fetchMain(accessToken);
227
+ this.inflightMainAccountId = effectiveAccountId;
228
+ const generation = this.mainGeneration;
229
+ this.inflightMain = this._fetchMain(effectiveAccountId, credential, generation);
183
230
  return this.inflightMain;
184
231
  }
185
- async refreshFallback(accountId, accessToken) {
186
- return (await this.refreshFallbackWithMetadata(accountId, accessToken))
187
- .quota;
232
+ async refreshFallback(accountId, accessToken, account) {
233
+ return (await this.refreshFallbackWithMetadata(accountId, accessToken, account)).quota;
188
234
  }
189
- async refreshFallbackWithMetadata(accountId, accessToken) {
190
- // Deduplicate per account+token so a same-label re-login never joins a
191
- // quota probe that was started with the previous credentials.
192
- const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
235
+ async refreshFallbackWithMetadata(accountId, accessToken, account) {
236
+ this.bindFallbackLineage(accountId, account);
237
+ const inflightKey = QuotaManager.fallbackInflightKey(accountId);
193
238
  const inflight = this.inflightFallbacks.get(inflightKey);
194
239
  if (inflight)
195
240
  return inflight;
196
241
  // Rate-limit — scoped to THIS fallback account only
197
- if (this.isFallbackBackedOff(accountId, accessToken)) {
198
- const cached = this.getFallback(accountId, accessToken);
242
+ if (this.isFallbackBackedOff(accountId)) {
243
+ const cached = this.getFallback(accountId);
199
244
  if (cached)
200
245
  return { quota: cached.quota, fetched: false };
201
246
  throw new Error('Quota API rate-limited — try again later');
202
247
  }
203
- const promise = this._fetchFallback(accountId, accessToken);
248
+ const generation = this.fallbackGenerations.get(accountId) ?? 0;
249
+ const promise = this._fetchFallback(accountId, accessToken, generation, account);
204
250
  this.inflightFallbacks.set(inflightKey, promise);
251
+ void promise.then(() => {
252
+ if (this.inflightFallbacks.get(inflightKey) === promise) {
253
+ this.inflightFallbacks.delete(inflightKey);
254
+ }
255
+ }, () => {
256
+ if (this.inflightFallbacks.get(inflightKey) === promise) {
257
+ this.inflightFallbacks.delete(inflightKey);
258
+ }
259
+ });
205
260
  return promise;
206
261
  }
207
- async refreshAllFallbacks(accounts) {
262
+ async refreshAllFallbacks(accounts, resolveAccessToken) {
208
263
  const now = this.now();
209
264
  for (const account of accounts) {
210
265
  if (account.enabled === false)
211
266
  continue;
212
- if (!account.access)
267
+ const accessToken = resolveAccessToken?.(account) ??
268
+ (account.access &&
269
+ account.expires !== undefined &&
270
+ account.expires > now
271
+ ? account.access
272
+ : undefined);
273
+ if (!accessToken)
213
274
  continue;
214
- const cached = this.getFallback(account.id, account.access);
275
+ this.bindFallbackLineage(account.id, account);
276
+ const cached = this.getFallback(account.id);
215
277
  if (cached && now < cached.refreshAfter)
216
278
  continue;
217
279
  try {
218
- await this.refreshFallback(account.id, account.access);
280
+ await this.refreshFallback(account.id, accessToken, account);
219
281
  }
220
282
  catch {
221
283
  // Best-effort — keep stale cache entry if fetch fails
@@ -230,7 +292,7 @@ export class QuotaManager {
230
292
  return;
231
293
  if (this.isBackedOff())
232
294
  return;
233
- void this.refreshMain(accessToken).catch(() => { });
295
+ void this.refreshMain(this.mainAccountId, accessToken).catch(() => { });
234
296
  }
235
297
  // =========================================================================
236
298
  // Staleness queries
@@ -243,12 +305,19 @@ export class QuotaManager {
243
305
  isMainStale(modelId) {
244
306
  if (!this.main)
245
307
  return true;
308
+ if (this.mainQuotaIdentityKnown &&
309
+ this.mainQuotaIdentityStrict &&
310
+ (this.mainAccountId === undefined ||
311
+ this.main.quota.accountIdentity !== this.mainAccountId)) {
312
+ return true;
313
+ }
246
314
  return (this.now() >= this.main.refreshAfter ||
247
315
  this.scopedWindowIsStale(this.main, modelId));
248
316
  }
249
- isFallbackStale(accountId, accessToken, modelId) {
250
- // Token-aware: a credential change invalidates the entry (treated as stale).
251
- const entry = this.getFallback(accountId, accessToken);
317
+ isFallbackStale(accountId, _accessToken, modelId, account) {
318
+ // Keep the unused slot to avoid breaking internal callers that pass modelId
319
+ // third; cached quota is scoped to stable account identity.
320
+ const entry = this.getFallback(accountId, account);
252
321
  if (!entry)
253
322
  return true;
254
323
  return (this.now() >= entry.refreshAfter ||
@@ -273,7 +342,7 @@ export class QuotaManager {
273
342
  // =========================================================================
274
343
  updateStorage(storage) {
275
344
  this.storage = storage;
276
- this.seedMainFromStorage(storage);
345
+ this.seedMainFromStorage(storage, this.mainQuotaIdentityKnown ? this.mainAccountId : storage?.mainAccountId);
277
346
  this.seedMainBackoffFromStorage(storage);
278
347
  }
279
348
  /**
@@ -282,37 +351,106 @@ export class QuotaManager {
282
351
  * can stop this process from showing "checking…" or making a redundant quota
283
352
  * API call.
284
353
  */
285
- seedMainFromStorage(storage, accessToken) {
354
+ seedMainFromStorage(storage, mainAccountId) {
286
355
  const persisted = getPersistedMainQuota(storage);
287
356
  if (!persisted)
288
357
  return;
289
- const accessTokenFp = accessToken ? tokenFingerprint(accessToken) : null;
290
- if (accessTokenFp &&
291
- persisted.tokenFingerprint &&
292
- persisted.tokenFingerprint !== accessTokenFp) {
293
- return;
294
- }
295
358
  const entry = {
296
- quota: persisted.quota,
359
+ quota: {
360
+ ...persisted.quota,
361
+ ...((!this.mainQuotaIdentityKnown || !this.mainQuotaIdentityStrict) &&
362
+ mainAccountId !== undefined &&
363
+ persisted.accountIdentity === undefined && {
364
+ accountIdentity: mainAccountId,
365
+ }),
366
+ },
297
367
  refreshAfter: getQuotaNextRefreshAt(persisted.quota, storage, persisted.checkedAt),
298
368
  checkedAt: persisted.checkedAt,
299
369
  };
370
+ const current = this.main &&
371
+ this.mainAccountId === mainAccountId &&
372
+ (!this.mainQuotaIdentityKnown ||
373
+ !this.mainQuotaIdentityStrict ||
374
+ this.main.quota.accountIdentity === mainAccountId)
375
+ ? this.main
376
+ : undefined;
377
+ if (current &&
378
+ current.checkedAt >= entry.checkedAt &&
379
+ current.quota.source === 'headers' &&
380
+ entry.quota.source === 'poll') {
381
+ const mergedQuota = mergeHeaderQuotaSnapshot(entry.quota, current.quota);
382
+ this.main = {
383
+ quota: mergedQuota,
384
+ refreshAfter: getQuotaNextRefreshAt(mergedQuota, storage, current.checkedAt),
385
+ checkedAt: current.checkedAt,
386
+ };
387
+ return;
388
+ }
300
389
  if (this.main &&
301
- this.main.checkedAt >= entry.checkedAt &&
302
- (!accessTokenFp ||
303
- !this.mainTokenFp ||
304
- this.mainTokenFp === accessTokenFp)) {
390
+ this.mainAccountId === mainAccountId &&
391
+ this.main.checkedAt >= entry.checkedAt) {
305
392
  return;
306
393
  }
307
394
  this.main = entry;
308
- this.mainTokenFp = persisted.tokenFingerprint ?? null;
395
+ if (mainAccountId !== undefined)
396
+ this.mainAccountId = mainAccountId;
309
397
  }
310
398
  seedMainBackoffFromStorage(storage) {
311
399
  const persistedError = storage?.quota?.mainLastQuotaApiError;
312
- this.mainLastApiError =
313
- persistedError && quotaBackoffActive(persistedError, this.now())
314
- ? persistedError
315
- : undefined;
400
+ const persistedIdentity = persistedError?.accountIdentity;
401
+ const persistedGeneration = typeof storage?.quota?.mainQuotaErrorGeneration === 'number' &&
402
+ Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
403
+ storage.quota.mainQuotaErrorGeneration >= 0
404
+ ? storage.quota.mainQuotaErrorGeneration
405
+ : undefined;
406
+ const persistedClearedAt = typeof storage?.quota?.mainQuotaErrorClearedAt === 'number' &&
407
+ Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
408
+ storage.quota.mainQuotaErrorClearedAt >= 0
409
+ ? storage.quota.mainQuotaErrorClearedAt
410
+ : undefined;
411
+ if (persistedClearedAt !== undefined &&
412
+ (this.mainQuotaErrorClearedAt === undefined ||
413
+ persistedClearedAt > this.mainQuotaErrorClearedAt)) {
414
+ this.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt(this.mainQuotaErrorClearedAt, persistedClearedAt);
415
+ if ((!persistedError ||
416
+ (typeof persistedError.checkedAt === 'number' &&
417
+ persistedError.checkedAt <= persistedClearedAt)) &&
418
+ (this.mainLastApiError?.checkedAt === undefined ||
419
+ this.mainLastApiError.checkedAt <= persistedClearedAt)) {
420
+ this.mainLastApiError = undefined;
421
+ }
422
+ }
423
+ if (persistedGeneration !== undefined &&
424
+ persistedGeneration > this.mainQuotaErrorGeneration) {
425
+ this.mainQuotaErrorGeneration = persistedGeneration;
426
+ if (!persistedError &&
427
+ (this.mainLastApiError?.checkedAt === undefined ||
428
+ this.mainQuotaErrorClearedAt === undefined ||
429
+ this.mainLastApiError.checkedAt <= this.mainQuotaErrorClearedAt)) {
430
+ this.mainLastApiError = undefined;
431
+ }
432
+ }
433
+ if (persistedError &&
434
+ (!this.mainQuotaIdentityKnown ||
435
+ persistedIdentity === undefined ||
436
+ persistedIdentity === this.mainAccountId) &&
437
+ (this.mainQuotaErrorClearedAt === undefined ||
438
+ typeof persistedError.checkedAt !== 'number' ||
439
+ persistedError.checkedAt > this.mainQuotaErrorClearedAt) &&
440
+ (this.mainLastApiError?.checkedAt === undefined ||
441
+ persistedError.checkedAt >= this.mainLastApiError.checkedAt) &&
442
+ quotaBackoffActive(persistedError, this.now())) {
443
+ this.mainLastApiError = persistedError;
444
+ if (persistedGeneration !== undefined) {
445
+ this.mainQuotaErrorGeneration = persistedGeneration;
446
+ }
447
+ return;
448
+ }
449
+ if (this.mainAccountId !== undefined &&
450
+ this.mainLastApiError?.accountIdentity !== undefined &&
451
+ this.mainLastApiError.accountIdentity !== this.mainAccountId) {
452
+ this.mainLastApiError = undefined;
453
+ }
316
454
  }
317
455
  /**
318
456
  * Seed fallback cache entries from persisted account.quota data.
@@ -321,22 +459,44 @@ export class QuotaManager {
321
459
  */
322
460
  seedFallbacksFromAccounts(accounts) {
323
461
  const checkInterval = getQuotaCheckIntervalMs(this.storage);
462
+ const configuredIds = new Set(accounts.map((account) => account.id));
463
+ for (const accountId of this.fallbackAuthLineages.keys()) {
464
+ if (!configuredIds.has(accountId)) {
465
+ this.clearFallback(accountId);
466
+ this.fallbackAuthLineages.delete(accountId);
467
+ }
468
+ }
324
469
  for (const account of accounts) {
470
+ const lineageChanged = this.bindFallbackLineage(account.id, account);
471
+ if (lineageChanged)
472
+ continue;
325
473
  if (account.enabled === false)
326
474
  continue;
475
+ const persistedError = account.lastQuotaRefreshError;
476
+ const persistedClearAt = quotaSnapshotCheckedAt(account.quota);
477
+ const currentError = this.fallbackApiErrors.get(account.id);
478
+ if (persistedError &&
479
+ quotaBackoffActive(persistedError, this.now()) &&
480
+ persistedClearAt <= (persistedError.checkedAt ?? 0) &&
481
+ (!currentError || persistedError.checkedAt >= currentError.checkedAt)) {
482
+ this.fallbackApiErrors.set(account.id, persistedError);
483
+ }
484
+ else if (currentError && persistedClearAt >= currentError.checkedAt) {
485
+ this.fallbackApiErrors.delete(account.id);
486
+ }
327
487
  if (!account.quota)
328
488
  continue;
329
489
  const checkedAt = quotaSnapshotCheckedAt(account.quota);
330
490
  if (checkedAt <= 0)
331
491
  continue;
332
- const existing = this.getFallback(account.id, account.access);
492
+ const existing = this.getFallback(account.id);
333
493
  if (existing && existing.checkedAt >= checkedAt)
334
494
  continue;
335
495
  this.setFallback(account.id, {
336
- quota: account.quota,
496
+ quota: { ...account.quota, accountIdentity: account.id },
337
497
  refreshAfter: checkedAt + checkInterval,
338
498
  checkedAt,
339
- }, account.access);
499
+ }, account);
340
500
  }
341
501
  }
342
502
  /**
@@ -344,34 +504,77 @@ export class QuotaManager {
344
504
  * account — a fallback account's 429 never reports here.
345
505
  */
346
506
  isBackedOff() {
507
+ // Unlike cached numbers above, restrictions stay active while identity is
508
+ // ambiguous; only a confirmed account change may release an inherited guard.
347
509
  return quotaBackoffActive(this.mainLastApiError, this.now());
348
510
  }
349
511
  /**
350
512
  * Whether a specific fallback account's quota API is in backoff.
351
513
  */
352
- isFallbackBackedOff(accountId, accessToken) {
353
- if (accessToken) {
354
- const errorFp = this.fallbackErrorTokenFps.get(accountId);
355
- if (errorFp !== tokenFingerprint(accessToken))
356
- return false;
357
- }
514
+ isFallbackBackedOff(accountId) {
358
515
  return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now());
359
516
  }
360
517
  getLastApiError() {
361
518
  return this.mainLastApiError;
362
519
  }
520
+ getMainQuotaErrorGeneration() {
521
+ return this.mainQuotaErrorGeneration;
522
+ }
523
+ getMainQuotaIdentityGeneration() {
524
+ return this.mainGeneration;
525
+ }
526
+ getMainQuotaErrorClearedAt() {
527
+ return this.mainQuotaErrorClearedAt;
528
+ }
529
+ clearMainBackoff() {
530
+ if (!this.mainLastApiError)
531
+ return undefined;
532
+ this.mainLastApiError = undefined;
533
+ this.mainQuotaErrorGeneration += 1;
534
+ this.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt(this.mainQuotaErrorClearedAt, this.now());
535
+ return this.mainQuotaErrorGeneration;
536
+ }
363
537
  // =========================================================================
364
538
  // Private
365
539
  // =========================================================================
366
540
  /** Minimum gap between consecutive quota API calls (ms). */
367
541
  static API_CALL_GAP_MS = 1_000;
368
- static fallbackInflightKey(accountId, accessToken) {
369
- return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
542
+ static fallbackInflightKey(accountId) {
543
+ return accountId;
370
544
  }
371
545
  static quotaLockName(accountId) {
372
546
  const safeId = accountId.replace(/[^a-zA-Z0-9._-]+/g, '-');
373
547
  return `opencode-fallback-quota-refresh-${safeId || 'account'}`;
374
548
  }
549
+ bindFallbackLineage(accountId, account) {
550
+ if (!account)
551
+ return false;
552
+ const hadLineage = this.fallbackAuthLineages.has(accountId);
553
+ const previousLineage = this.fallbackAuthLineages.get(accountId);
554
+ const lineageChanged = hadLineage &&
555
+ account.authLineageId !== undefined &&
556
+ previousLineage !== account.authLineageId;
557
+ if (lineageChanged)
558
+ this.clearFallback(accountId);
559
+ this.fallbackAuthLineages.set(accountId, account.authLineageId);
560
+ return lineageChanged;
561
+ }
562
+ acceptFallbackQuotaObservation(accountId, account) {
563
+ const hasLineage = this.fallbackAuthLineages.has(accountId);
564
+ const currentLineage = this.fallbackAuthLineages.get(accountId);
565
+ const observedLineage = account?.authLineageId;
566
+ if (hasLineage && currentLineage !== observedLineage) {
567
+ logger.trace('quota', 'skipped stale fallback quota observation after lineage change', {
568
+ accountId,
569
+ storedLineage: currentLineage,
570
+ servedLineage: observedLineage,
571
+ });
572
+ return false;
573
+ }
574
+ if (!hasLineage)
575
+ this.fallbackAuthLineages.set(accountId, observedLineage);
576
+ return true;
577
+ }
375
578
  /**
376
579
  * Serialize API calls through a shared gate so only one
377
580
  * quota API request runs at a time, with a minimum gap
@@ -396,24 +599,34 @@ export class QuotaManager {
396
599
  this.apiGate = queued.catch(() => { });
397
600
  return queued;
398
601
  }
399
- async _fetchMain(accessToken) {
400
- const thisFetchFp = tokenFingerprint(accessToken);
602
+ async _fetchMain(mainAccountId, accessToken, generation) {
401
603
  return this._enqueueApiFetch(async () => {
402
604
  try {
403
605
  // Re-check backoff inside gate — may have been set by
404
606
  // a preceding queued call while we waited
405
607
  if (this.isBackedOff()) {
406
- if (this.main)
608
+ if (this.main && this.mainAccountId === mainAccountId) {
407
609
  return { quota: this.main.quota, fetched: false };
610
+ }
408
611
  throw new Error('Quota API rate-limited — try again later');
409
612
  }
613
+ if (this.mainQuotaIdentityKnown &&
614
+ this.mainQuotaIdentityStrict &&
615
+ this.mainGeneration !== generation) {
616
+ return {
617
+ quota: { accountIdentity: mainAccountId },
618
+ fetched: false,
619
+ };
620
+ }
410
621
  const fileLock = await acquireRefreshFileLock({
411
622
  name: 'opencode-main-quota-refresh',
412
623
  ttlMs: 30_000,
413
624
  });
414
625
  if (!fileLock) {
415
626
  const cached = this.main;
416
- if (cached && this.now() < cached.refreshAfter) {
627
+ if (cached &&
628
+ this.mainAccountId === mainAccountId &&
629
+ this.now() < cached.refreshAfter) {
417
630
  return { quota: cached.quota, fetched: false };
418
631
  }
419
632
  throw new Error('Quota refresh is already in progress');
@@ -426,19 +639,46 @@ export class QuotaManager {
426
639
  now: this.now,
427
640
  });
428
641
  const now = this.now();
429
- const completedQuota = mergePollCompletionWithNewerHeaders(this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, quota);
430
- this.mainTokenFp = tokenFingerprint(accessToken);
642
+ if (this.mainQuotaIdentityKnown &&
643
+ this.mainQuotaIdentityStrict &&
644
+ this.mainGeneration !== generation) {
645
+ return {
646
+ quota: {
647
+ ...quota,
648
+ ...(mainAccountId !== undefined && {
649
+ accountIdentity: mainAccountId,
650
+ }),
651
+ },
652
+ fetched: true,
653
+ };
654
+ }
655
+ const completedQuota = mergePollCompletionWithNewerHeaders(this.mainAccountId === mainAccountId &&
656
+ (!this.mainQuotaIdentityKnown ||
657
+ !this.mainQuotaIdentityStrict ||
658
+ this.main?.quota.accountIdentity === mainAccountId)
659
+ ? this.main?.quota
660
+ : undefined, {
661
+ ...quota,
662
+ ...(mainAccountId !== undefined && {
663
+ accountIdentity: mainAccountId,
664
+ }),
665
+ });
666
+ this.mainAccountId = mainAccountId;
431
667
  this.main = {
432
668
  quota: completedQuota,
433
669
  refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
434
670
  checkedAt: completedQuota.checkedAt ?? now,
435
671
  };
436
- this.mainLastApiError = undefined;
437
- this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
672
+ if (this.mainLastApiError) {
673
+ this.mainLastApiError = undefined;
674
+ this.mainQuotaErrorGeneration += 1;
675
+ this.mainQuotaErrorClearedAt = mergeMainQuotaErrorClearedAt(this.mainQuotaErrorClearedAt, now);
676
+ }
677
+ this.onMainQuotaFetched?.(completedQuota, now, tokenFingerprint(accessToken), fetchStartedAt, this.mainQuotaErrorGeneration, this.mainQuotaErrorClearedAt);
438
678
  return { quota: completedQuota, fetched: true };
439
679
  }
440
680
  catch (error) {
441
- this._handleMainFetchError(error);
681
+ this._handleMainFetchError(error, generation);
442
682
  throw error;
443
683
  }
444
684
  finally {
@@ -446,19 +686,22 @@ export class QuotaManager {
446
686
  }
447
687
  }
448
688
  finally {
449
- if (this.inflightMainFp === thisFetchFp) {
689
+ if (this.inflightMainAccountId === mainAccountId) {
450
690
  this.inflightMain = null;
451
- this.inflightMainFp = null;
691
+ this.inflightMainAccountId = undefined;
452
692
  }
453
693
  }
454
694
  });
455
695
  }
456
- async _fetchFallback(accountId, accessToken) {
696
+ async _fetchFallback(accountId, accessToken, generation, account) {
457
697
  return this._enqueueApiFetch(async () => {
458
698
  try {
699
+ if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) {
700
+ return { quota: { accountIdentity: accountId }, fetched: false };
701
+ }
459
702
  // Re-check backoff inside gate — scoped to this fallback account
460
- if (this.isFallbackBackedOff(accountId, accessToken)) {
461
- const cached = this.getFallback(accountId, accessToken);
703
+ if (this.isFallbackBackedOff(accountId)) {
704
+ const cached = this.getFallback(accountId, account);
462
705
  if (cached)
463
706
  return { quota: cached.quota, fetched: false };
464
707
  throw new Error('Quota API rate-limited — try again later');
@@ -468,27 +711,34 @@ export class QuotaManager {
468
711
  ttlMs: 30_000,
469
712
  });
470
713
  if (!fileLock) {
471
- const cached = this.getFallback(accountId, accessToken);
714
+ const cached = this.getFallback(accountId, account);
472
715
  if (cached && this.now() < cached.refreshAfter) {
473
716
  return { quota: cached.quota, fetched: false };
474
717
  }
475
718
  throw new Error('Quota refresh is already in progress');
476
719
  }
477
720
  try {
721
+ const fetchStartedAt = this.now();
478
722
  const quota = await fetchOAuthQuotaSnapshot({
479
723
  accessToken,
480
724
  fetchImpl: this.fetchImpl,
481
725
  now: this.now,
482
726
  });
483
727
  const now = this.now();
484
- const completedQuota = mergePollCompletionWithNewerHeaders(this.getFallback(accountId, accessToken)?.quota, quota);
728
+ if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) {
729
+ return {
730
+ quota: { ...quota, accountIdentity: accountId },
731
+ fetched: true,
732
+ };
733
+ }
734
+ const completedQuota = mergePollCompletionWithNewerHeaders(this.getFallback(accountId, account)?.quota, { ...quota, accountIdentity: accountId });
485
735
  this.setFallback(accountId, {
486
736
  quota: completedQuota,
487
737
  refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
488
738
  checkedAt: completedQuota.checkedAt ?? now,
489
- }, accessToken);
739
+ }, account);
490
740
  this.fallbackApiErrors.delete(accountId);
491
- this.fallbackErrorTokenFps.delete(accountId);
741
+ this.onFallbackQuotaFetched?.(accountId, completedQuota, now, fetchStartedAt);
492
742
  return { quota: completedQuota, fetched: true };
493
743
  }
494
744
  finally {
@@ -496,12 +746,9 @@ export class QuotaManager {
496
746
  }
497
747
  }
498
748
  catch (error) {
499
- this._handleFallbackFetchError(accountId, accessToken, error);
749
+ this._handleFallbackFetchError(accountId, error, generation);
500
750
  throw error;
501
751
  }
502
- finally {
503
- this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken));
504
- }
505
752
  });
506
753
  }
507
754
  // A 401 is an auth/token problem and a 403 is an account/org policy problem,
@@ -515,15 +762,24 @@ export class QuotaManager {
515
762
  return /quota check failed: 401\b/.test(message);
516
763
  }
517
764
  /** Main quota failure: arms main-only backoff and persists via onApiError. */
518
- _handleMainFetchError(error) {
765
+ _handleMainFetchError(error, generation) {
766
+ if (this.mainGeneration !== generation)
767
+ return;
768
+ if (this.mainQuotaIdentityKnown &&
769
+ this.mainQuotaIdentityStrict &&
770
+ this.mainAccountId === undefined) {
771
+ return;
772
+ }
519
773
  if (QuotaManager.isAuthError(error))
520
774
  return;
521
775
  this.mainLastApiError = buildQuotaOperationError({
522
776
  error,
523
777
  now: this.now(),
778
+ accountIdentity: this.mainAccountId,
524
779
  previous: this.mainLastApiError,
525
780
  });
526
- this.onApiError?.(this.mainLastApiError);
781
+ this.mainQuotaErrorGeneration += 1;
782
+ this.onApiError?.(this.mainLastApiError, this.mainQuotaErrorGeneration);
527
783
  }
528
784
  /**
529
785
  * Fallback quota failure: arms backoff for THIS account only. Never touches
@@ -531,18 +787,19 @@ export class QuotaManager {
531
787
  * quota error) — the per-account error is recorded by the caller via the
532
788
  * account's lastQuotaRefreshError.
533
789
  */
534
- _handleFallbackFetchError(accountId, accessToken, error) {
790
+ _handleFallbackFetchError(accountId, error, generation) {
791
+ if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation)
792
+ return;
535
793
  if (QuotaManager.isAuthError(error))
536
794
  return;
537
- const tokenFp = tokenFingerprint(accessToken);
538
- const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp
539
- ? this.fallbackApiErrors.get(accountId)
540
- : undefined;
541
- this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
795
+ const previous = this.fallbackApiErrors.get(accountId);
796
+ const quotaError = buildQuotaOperationError({
542
797
  error,
543
798
  now: this.now(),
544
799
  previous,
545
- }));
546
- this.fallbackErrorTokenFps.set(accountId, tokenFp);
800
+ accountIdentity: accountId,
801
+ });
802
+ this.fallbackApiErrors.set(accountId, quotaError);
803
+ this.onFallbackApiError?.(accountId, quotaError);
547
804
  }
548
805
  }