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