@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.
- package/README.md +1 -1
- package/dist/accounts.d.ts +24 -14
- package/dist/accounts.js +234 -143
- package/dist/claude-code.d.ts +3 -1
- package/dist/claude-code.js +124 -21
- package/dist/dump.d.ts +1 -0
- package/dist/dump.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/oauth-profile.d.ts +2 -1
- package/dist/oauth-profile.js +10 -4
- package/dist/quota-header-feed.d.ts +40 -0
- package/dist/quota-header-feed.js +248 -0
- package/dist/quota-headers.js +6 -4
- package/dist/quota-manager.d.ts +36 -27
- package/dist/quota-manager.js +375 -144
- package/dist/sticky-routing.d.ts +14 -2
- package/dist/sticky-routing.js +30 -13
- package/package.json +1 -1
package/dist/quota-manager.js
CHANGED
|
@@ -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
|
-
|
|
42
|
+
mainAccountId;
|
|
43
|
+
mainQuotaIdentityKnown = false;
|
|
44
|
+
mainQuotaIdentityStrict = false;
|
|
45
|
+
mainGeneration = 0;
|
|
46
|
+
mainQuotaErrorGeneration = 0;
|
|
47
|
+
mainQuotaErrorClearedAt;
|
|
42
48
|
fallbacks = new Map();
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
*
|
|
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(
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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,
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
122
|
-
this.
|
|
108
|
+
setMain(mainAccountId, entry) {
|
|
109
|
+
this.mainAccountId = mainAccountId;
|
|
123
110
|
this.main = entry;
|
|
124
111
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
this.
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
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(
|
|
169
|
+
this.setMain(mainAccountId, entry);
|
|
144
170
|
return entry;
|
|
145
171
|
}
|
|
146
|
-
pushFallbackFromHeaders(accountId,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
210
|
+
this.mainAccountId = effectiveAccountId;
|
|
211
|
+
if (this.mainQuotaIdentityKnown)
|
|
212
|
+
this.mainGeneration += 1;
|
|
171
213
|
}
|
|
172
|
-
|
|
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.
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
198
|
-
const cached = this.getFallback(accountId
|
|
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
|
|
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
|
-
|
|
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,
|
|
250
|
-
//
|
|
251
|
-
|
|
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,
|
|
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:
|
|
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.
|
|
302
|
-
|
|
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
|
-
|
|
385
|
+
if (mainAccountId !== undefined)
|
|
386
|
+
this.mainAccountId = mainAccountId;
|
|
309
387
|
}
|
|
310
388
|
seedMainBackoffFromStorage(storage) {
|
|
311
389
|
const persistedError = storage?.quota?.mainLastQuotaApiError;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
369
|
-
return
|
|
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 &&
|
|
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
|
-
|
|
430
|
-
|
|
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
|
|
437
|
-
|
|
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.
|
|
667
|
+
if (this.inflightMainAccountId === mainAccountId) {
|
|
450
668
|
this.inflightMain = null;
|
|
451
|
-
this.
|
|
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
|
|
461
|
-
const cached = this.getFallback(accountId,
|
|
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,
|
|
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
|
-
|
|
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
|
-
},
|
|
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,
|
|
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.
|
|
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,
|
|
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
|
|
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
|
}
|