@cortexkit/anthropic-auth-core 1.19.1 → 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,10 +5,11 @@
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, } from "./accounts.js";
9
- import { mergeHeaderQuotaSnapshot } from "./quota-headers.js";
10
- export { tokenFingerprint } from "./token-fingerprint.js";
11
- import { tokenFingerprint } from "./token-fingerprint.js";
8
+ import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, getScopedQuotaWindowForModel, isQuotaPolicyAuthError, mergeMainQuotaErrorClearedAt, quotaBackoffActive, quotaSnapshotCheckedAt, } from './accounts.js';
9
+ import { logger } from './logger.js';
10
+ import { mergeHeaderQuotaSnapshot } from './quota-headers.js';
11
+ export { tokenFingerprint } from './token-fingerprint.js';
12
+ import { tokenFingerprint } from './token-fingerprint.js';
12
13
  // Capture real setTimeout before tests can mock globalThis.setTimeout
13
14
  const nativeSetTimeout = globalThis.setTimeout;
14
15
  function mergePollCompletionWithNewerHeaders(current, polled) {
@@ -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,83 +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
- // If the main account/token changed, invalidate the cache (including a
162
- // persisted seed) BEFORE the backoff short-circuit so a different account's
163
- // stale quota is never returned while the quota API is backed off.
164
- const fp = tokenFingerprint(accessToken);
165
- 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) {
166
209
  this.main = null;
167
- this.mainTokenFp = null;
210
+ this.mainAccountId = effectiveAccountId;
211
+ if (this.mainQuotaIdentityKnown)
212
+ this.mainGeneration += 1;
168
213
  }
169
- // Deduplicate — return in-flight promise only if same token fingerprint
170
- if (this.inflightMain && this.inflightMainFp === fp)
214
+ if (this.inflightMain && this.inflightMainAccountId === effectiveAccountId)
171
215
  return this.inflightMain;
172
216
  // Rate-limit — if API recently 429'd, return stale or throw
173
217
  if (this.isBackedOff()) {
174
- if (this.main)
175
- return this.main.quota;
218
+ if (this.main && this.mainAccountId === effectiveAccountId) {
219
+ return { quota: this.main.quota, fetched: false };
220
+ }
176
221
  throw new Error('Quota API rate-limited — try again later');
177
222
  }
178
- this.inflightMainFp = fp;
179
- this.inflightMain = this._fetchMain(accessToken);
223
+ this.inflightMainAccountId = effectiveAccountId;
224
+ const generation = this.mainGeneration;
225
+ this.inflightMain = this._fetchMain(effectiveAccountId, credential, generation);
180
226
  return this.inflightMain;
181
227
  }
182
- async refreshFallback(accountId, accessToken) {
183
- // Deduplicate per account+token so a same-label re-login never joins a
184
- // quota probe that was started with the previous credentials.
185
- const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
228
+ async refreshFallback(accountId, accessToken, account) {
229
+ return (await this.refreshFallbackWithMetadata(accountId, accessToken, account)).quota;
230
+ }
231
+ async refreshFallbackWithMetadata(accountId, accessToken, account) {
232
+ this.bindFallbackLineage(accountId, account);
233
+ const inflightKey = QuotaManager.fallbackInflightKey(accountId);
186
234
  const inflight = this.inflightFallbacks.get(inflightKey);
187
235
  if (inflight)
188
236
  return inflight;
189
237
  // Rate-limit — scoped to THIS fallback account only
190
- if (this.isFallbackBackedOff(accountId, accessToken)) {
191
- const cached = this.getFallback(accountId, accessToken);
238
+ if (this.isFallbackBackedOff(accountId)) {
239
+ const cached = this.getFallback(accountId);
192
240
  if (cached)
193
- return cached.quota;
241
+ return { quota: cached.quota, fetched: false };
194
242
  throw new Error('Quota API rate-limited — try again later');
195
243
  }
196
- const promise = this._fetchFallback(accountId, accessToken);
244
+ const generation = this.fallbackGenerations.get(accountId) ?? 0;
245
+ const promise = this._fetchFallback(accountId, accessToken, generation, account);
197
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
+ });
198
256
  return promise;
199
257
  }
200
258
  async refreshAllFallbacks(accounts) {
@@ -204,11 +262,12 @@ export class QuotaManager {
204
262
  continue;
205
263
  if (!account.access)
206
264
  continue;
207
- const cached = this.getFallback(account.id, account.access);
265
+ this.bindFallbackLineage(account.id, account);
266
+ const cached = this.getFallback(account.id);
208
267
  if (cached && now < cached.refreshAfter)
209
268
  continue;
210
269
  try {
211
- await this.refreshFallback(account.id, account.access);
270
+ await this.refreshFallback(account.id, account.access, account);
212
271
  }
213
272
  catch {
214
273
  // Best-effort — keep stale cache entry if fetch fails
@@ -223,7 +282,7 @@ export class QuotaManager {
223
282
  return;
224
283
  if (this.isBackedOff())
225
284
  return;
226
- void this.refreshMain(accessToken).catch(() => { });
285
+ void this.refreshMain(this.mainAccountId, accessToken).catch(() => { });
227
286
  }
228
287
  // =========================================================================
229
288
  // Staleness queries
@@ -236,12 +295,19 @@ export class QuotaManager {
236
295
  isMainStale(modelId) {
237
296
  if (!this.main)
238
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
+ }
239
304
  return (this.now() >= this.main.refreshAfter ||
240
305
  this.scopedWindowIsStale(this.main, modelId));
241
306
  }
242
- isFallbackStale(accountId, accessToken, modelId) {
243
- // Token-aware: a credential change invalidates the entry (treated as stale).
244
- 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);
245
311
  if (!entry)
246
312
  return true;
247
313
  return (this.now() >= entry.refreshAfter ||
@@ -266,7 +332,7 @@ export class QuotaManager {
266
332
  // =========================================================================
267
333
  updateStorage(storage) {
268
334
  this.storage = storage;
269
- this.seedMainFromStorage(storage);
335
+ this.seedMainFromStorage(storage, this.mainQuotaIdentityKnown ? this.mainAccountId : storage?.mainAccountId);
270
336
  this.seedMainBackoffFromStorage(storage);
271
337
  }
272
338
  /**
@@ -275,37 +341,106 @@ export class QuotaManager {
275
341
  * can stop this process from showing "checking…" or making a redundant quota
276
342
  * API call.
277
343
  */
278
- seedMainFromStorage(storage, accessToken) {
344
+ seedMainFromStorage(storage, mainAccountId) {
279
345
  const persisted = getPersistedMainQuota(storage);
280
346
  if (!persisted)
281
347
  return;
282
- const accessTokenFp = accessToken ? tokenFingerprint(accessToken) : null;
283
- if (accessTokenFp &&
284
- persisted.tokenFingerprint &&
285
- persisted.tokenFingerprint !== accessTokenFp) {
286
- return;
287
- }
288
348
  const entry = {
289
- 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
+ },
290
357
  refreshAfter: getQuotaNextRefreshAt(persisted.quota, storage, persisted.checkedAt),
291
358
  checkedAt: persisted.checkedAt,
292
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
+ }
293
379
  if (this.main &&
294
- this.main.checkedAt >= entry.checkedAt &&
295
- (!accessTokenFp ||
296
- !this.mainTokenFp ||
297
- this.mainTokenFp === accessTokenFp)) {
380
+ this.mainAccountId === mainAccountId &&
381
+ this.main.checkedAt >= entry.checkedAt) {
298
382
  return;
299
383
  }
300
384
  this.main = entry;
301
- this.mainTokenFp = persisted.tokenFingerprint ?? null;
385
+ if (mainAccountId !== undefined)
386
+ this.mainAccountId = mainAccountId;
302
387
  }
303
388
  seedMainBackoffFromStorage(storage) {
304
389
  const persistedError = storage?.quota?.mainLastQuotaApiError;
305
- this.mainLastApiError =
306
- persistedError && quotaBackoffActive(persistedError, this.now())
307
- ? persistedError
308
- : 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
+ }
309
444
  }
310
445
  /**
311
446
  * Seed fallback cache entries from persisted account.quota data.
@@ -314,22 +449,32 @@ export class QuotaManager {
314
449
  */
315
450
  seedFallbacksFromAccounts(accounts) {
316
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
+ }
317
459
  for (const account of accounts) {
460
+ const lineageChanged = this.bindFallbackLineage(account.id, account);
461
+ if (lineageChanged)
462
+ continue;
318
463
  if (account.enabled === false)
319
464
  continue;
320
465
  if (!account.quota)
321
466
  continue;
322
- const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
467
+ const checkedAt = quotaSnapshotCheckedAt(account.quota);
323
468
  if (checkedAt <= 0)
324
469
  continue;
325
- const existing = this.getFallback(account.id, account.access);
470
+ const existing = this.getFallback(account.id);
326
471
  if (existing && existing.checkedAt >= checkedAt)
327
472
  continue;
328
473
  this.setFallback(account.id, {
329
- quota: account.quota,
474
+ quota: { ...account.quota, accountIdentity: account.id },
330
475
  refreshAfter: checkedAt + checkInterval,
331
476
  checkedAt,
332
- }, account.access);
477
+ }, account);
333
478
  }
334
479
  }
335
480
  /**
@@ -337,34 +482,77 @@ export class QuotaManager {
337
482
  * account — a fallback account's 429 never reports here.
338
483
  */
339
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.
340
487
  return quotaBackoffActive(this.mainLastApiError, this.now());
341
488
  }
342
489
  /**
343
490
  * Whether a specific fallback account's quota API is in backoff.
344
491
  */
345
- isFallbackBackedOff(accountId, accessToken) {
346
- if (accessToken) {
347
- const errorFp = this.fallbackErrorTokenFps.get(accountId);
348
- if (errorFp !== tokenFingerprint(accessToken))
349
- return false;
350
- }
492
+ isFallbackBackedOff(accountId) {
351
493
  return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now());
352
494
  }
353
495
  getLastApiError() {
354
496
  return this.mainLastApiError;
355
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
+ }
356
515
  // =========================================================================
357
516
  // Private
358
517
  // =========================================================================
359
518
  /** Minimum gap between consecutive quota API calls (ms). */
360
519
  static API_CALL_GAP_MS = 1_000;
361
- static fallbackInflightKey(accountId, accessToken) {
362
- return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
520
+ static fallbackInflightKey(accountId) {
521
+ return accountId;
363
522
  }
364
523
  static quotaLockName(accountId) {
365
524
  const safeId = accountId.replace(/[^a-zA-Z0-9._-]+/g, '-');
366
525
  return `opencode-fallback-quota-refresh-${safeId || 'account'}`;
367
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
+ }
368
556
  /**
369
557
  * Serialize API calls through a shared gate so only one
370
558
  * quota API request runs at a time, with a minimum gap
@@ -389,25 +577,36 @@ export class QuotaManager {
389
577
  this.apiGate = queued.catch(() => { });
390
578
  return queued;
391
579
  }
392
- async _fetchMain(accessToken) {
393
- const thisFetchFp = tokenFingerprint(accessToken);
580
+ async _fetchMain(mainAccountId, accessToken, generation) {
394
581
  return this._enqueueApiFetch(async () => {
395
582
  try {
396
583
  // Re-check backoff inside gate — may have been set by
397
584
  // a preceding queued call while we waited
398
585
  if (this.isBackedOff()) {
399
- if (this.main)
400
- return this.main.quota;
586
+ if (this.main && this.mainAccountId === mainAccountId) {
587
+ return { quota: this.main.quota, fetched: false };
588
+ }
401
589
  throw new Error('Quota API rate-limited — try again later');
402
590
  }
591
+ if (this.mainQuotaIdentityKnown &&
592
+ this.mainQuotaIdentityStrict &&
593
+ this.mainGeneration !== generation) {
594
+ return {
595
+ quota: { accountIdentity: mainAccountId },
596
+ fetched: false,
597
+ };
598
+ }
403
599
  const fileLock = await acquireRefreshFileLock({
404
600
  name: 'opencode-main-quota-refresh',
405
601
  ttlMs: 30_000,
406
602
  });
407
603
  if (!fileLock) {
408
604
  const cached = this.main;
409
- if (cached && this.now() < cached.refreshAfter)
410
- return cached.quota;
605
+ if (cached &&
606
+ this.mainAccountId === mainAccountId &&
607
+ this.now() < cached.refreshAfter) {
608
+ return { quota: cached.quota, fetched: false };
609
+ }
411
610
  throw new Error('Quota refresh is already in progress');
412
611
  }
413
612
  try {
@@ -418,19 +617,46 @@ export class QuotaManager {
418
617
  now: this.now,
419
618
  });
420
619
  const now = this.now();
421
- const completedQuota = mergePollCompletionWithNewerHeaders(this.mainTokenFp === thisFetchFp ? this.main?.quota : undefined, quota);
422
- 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;
423
645
  this.main = {
424
646
  quota: completedQuota,
425
647
  refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
426
648
  checkedAt: completedQuota.checkedAt ?? now,
427
649
  };
428
- this.mainLastApiError = undefined;
429
- this.onMainQuotaFetched?.(completedQuota, now, this.mainTokenFp, fetchStartedAt);
430
- return completedQuota;
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);
656
+ return { quota: completedQuota, fetched: true };
431
657
  }
432
658
  catch (error) {
433
- this._handleMainFetchError(error);
659
+ this._handleMainFetchError(error, generation);
434
660
  throw error;
435
661
  }
436
662
  finally {
@@ -438,21 +664,24 @@ export class QuotaManager {
438
664
  }
439
665
  }
440
666
  finally {
441
- if (this.inflightMainFp === thisFetchFp) {
667
+ if (this.inflightMainAccountId === mainAccountId) {
442
668
  this.inflightMain = null;
443
- this.inflightMainFp = null;
669
+ this.inflightMainAccountId = undefined;
444
670
  }
445
671
  }
446
672
  });
447
673
  }
448
- async _fetchFallback(accountId, accessToken) {
674
+ async _fetchFallback(accountId, accessToken, generation, account) {
449
675
  return this._enqueueApiFetch(async () => {
450
676
  try {
677
+ if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation) {
678
+ return { quota: { accountIdentity: accountId }, fetched: false };
679
+ }
451
680
  // Re-check backoff inside gate — scoped to this fallback account
452
- if (this.isFallbackBackedOff(accountId, accessToken)) {
453
- const cached = this.getFallback(accountId, accessToken);
681
+ if (this.isFallbackBackedOff(accountId)) {
682
+ const cached = this.getFallback(accountId, account);
454
683
  if (cached)
455
- return cached.quota;
684
+ return { quota: cached.quota, fetched: false };
456
685
  throw new Error('Quota API rate-limited — try again later');
457
686
  }
458
687
  const fileLock = await acquireRefreshFileLock({
@@ -460,9 +689,10 @@ export class QuotaManager {
460
689
  ttlMs: 30_000,
461
690
  });
462
691
  if (!fileLock) {
463
- const cached = this.getFallback(accountId, accessToken);
464
- if (cached && this.now() < cached.refreshAfter)
465
- return cached.quota;
692
+ const cached = this.getFallback(accountId, account);
693
+ if (cached && this.now() < cached.refreshAfter) {
694
+ return { quota: cached.quota, fetched: false };
695
+ }
466
696
  throw new Error('Quota refresh is already in progress');
467
697
  }
468
698
  try {
@@ -472,27 +702,29 @@ export class QuotaManager {
472
702
  now: this.now,
473
703
  });
474
704
  const now = this.now();
475
- 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 });
476
712
  this.setFallback(accountId, {
477
713
  quota: completedQuota,
478
714
  refreshAfter: getQuotaNextRefreshAt(completedQuota, this.storage, now),
479
715
  checkedAt: completedQuota.checkedAt ?? now,
480
- }, accessToken);
716
+ }, account);
481
717
  this.fallbackApiErrors.delete(accountId);
482
- this.fallbackErrorTokenFps.delete(accountId);
483
- return completedQuota;
718
+ return { quota: completedQuota, fetched: true };
484
719
  }
485
720
  finally {
486
721
  await fileLock.release();
487
722
  }
488
723
  }
489
724
  catch (error) {
490
- this._handleFallbackFetchError(accountId, accessToken, error);
725
+ this._handleFallbackFetchError(accountId, error, generation);
491
726
  throw error;
492
727
  }
493
- finally {
494
- this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken));
495
- }
496
728
  });
497
729
  }
498
730
  // A 401 is an auth/token problem and a 403 is an account/org policy problem,
@@ -506,15 +738,24 @@ export class QuotaManager {
506
738
  return /quota check failed: 401\b/.test(message);
507
739
  }
508
740
  /** Main quota failure: arms main-only backoff and persists via onApiError. */
509
- _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
+ }
510
749
  if (QuotaManager.isAuthError(error))
511
750
  return;
512
751
  this.mainLastApiError = buildQuotaOperationError({
513
752
  error,
514
753
  now: this.now(),
754
+ accountIdentity: this.mainAccountId,
515
755
  previous: this.mainLastApiError,
516
756
  });
517
- this.onApiError?.(this.mainLastApiError);
757
+ this.mainQuotaErrorGeneration += 1;
758
+ this.onApiError?.(this.mainLastApiError, this.mainQuotaErrorGeneration);
518
759
  }
519
760
  /**
520
761
  * Fallback quota failure: arms backoff for THIS account only. Never touches
@@ -522,18 +763,17 @@ export class QuotaManager {
522
763
  * quota error) — the per-account error is recorded by the caller via the
523
764
  * account's lastQuotaRefreshError.
524
765
  */
525
- _handleFallbackFetchError(accountId, accessToken, error) {
766
+ _handleFallbackFetchError(accountId, error, generation) {
767
+ if ((this.fallbackGenerations.get(accountId) ?? 0) !== generation)
768
+ return;
526
769
  if (QuotaManager.isAuthError(error))
527
770
  return;
528
- const tokenFp = tokenFingerprint(accessToken);
529
- const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp
530
- ? this.fallbackApiErrors.get(accountId)
531
- : undefined;
771
+ const previous = this.fallbackApiErrors.get(accountId);
532
772
  this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
533
773
  error,
534
774
  now: this.now(),
535
775
  previous,
776
+ accountIdentity: accountId,
536
777
  }));
537
- this.fallbackErrorTokenFps.set(accountId, tokenFp);
538
778
  }
539
779
  }