@timo972/cc-router 0.10.1 → 0.11.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/CHANGELOG.md +106 -0
- package/README.md +4 -3
- package/dist/config/manager.js +9 -0
- package/dist/providers/anthropic/rate-limit-headers.js +44 -0
- package/dist/providers/anthropic/usage.js +24 -5
- package/dist/proxy/anthropic-messages-route.js +456 -0
- package/dist/proxy/anthropic-response-capture.js +40 -0
- package/dist/proxy/event-sequence.js +18 -0
- package/dist/proxy/lease-lifecycle.js +20 -13
- package/dist/proxy/messages-cross-route.js +7 -0
- package/dist/proxy/openai-ingress.js +207 -108
- package/dist/proxy/responses-server.js +9 -1
- package/dist/proxy/server.js +81 -109
- package/dist/proxy/stats.js +21 -2
- package/dist/proxy/token-pool.js +302 -37
- package/dist/proxy/upstream-retry.js +87 -0
- package/package.json +1 -1
package/dist/proxy/token-pool.js
CHANGED
|
@@ -1,8 +1,55 @@
|
|
|
1
1
|
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "./types.js";
|
|
2
2
|
import { canUseExtraUsage, normalizeModelFamily } from "../providers/anthropic/usage.js";
|
|
3
|
+
import { nextEventSequence } from "./event-sequence.js";
|
|
3
4
|
import { EmptyPoolError, NoEligibleAccountError, } from "./account-pool.js";
|
|
4
5
|
// Re-export so existing importers (anthropic-routing.ts, tests) keep working.
|
|
5
6
|
export { EmptyPoolError, NoEligibleAccountError };
|
|
7
|
+
/**
|
|
8
|
+
* Operations on one scope's cooldown entries.
|
|
9
|
+
*
|
|
10
|
+
* Both cooldown maps — global scopes and model families — need identical
|
|
11
|
+
* handling, and keeping two copies of it is what let the same merge bug live
|
|
12
|
+
* on in the model path after the global path was fixed. Every mutation goes
|
|
13
|
+
* through these four functions so the invariant has one home.
|
|
14
|
+
*/
|
|
15
|
+
const cooldownEntries = {
|
|
16
|
+
/**
|
|
17
|
+
* Add a new expiry, dropping entries the new event makes redundant.
|
|
18
|
+
*
|
|
19
|
+
* The new event carries the highest sequence, so any entry it also outlasts
|
|
20
|
+
* can neither outlive it nor be released before it. Entries that expire
|
|
21
|
+
* later still say something this one does not, and are kept with their own
|
|
22
|
+
* sequences — collapsing them onto the newest sequence would let a later,
|
|
23
|
+
* shorter cooldown revive an expiry a refresh had already retired.
|
|
24
|
+
*/
|
|
25
|
+
merge(entries, until, recordedSeq) {
|
|
26
|
+
const surviving = (entries ?? []).filter(entry => entry.until > until);
|
|
27
|
+
surviving.push({ until, recordedSeq });
|
|
28
|
+
return surviving;
|
|
29
|
+
},
|
|
30
|
+
/** Drop entries a refresh at `supersedingSeq` was initiated after. */
|
|
31
|
+
retireBefore(entries, supersedingSeq) {
|
|
32
|
+
return entries.filter(entry => supersedingSeq <= entry.recordedSeq);
|
|
33
|
+
},
|
|
34
|
+
/** Drop entries whose expiry has passed. */
|
|
35
|
+
live(entries, nowMs) {
|
|
36
|
+
return entries.filter(entry => entry.until > nowMs);
|
|
37
|
+
},
|
|
38
|
+
/** The scope's effective expiry: the furthest-out entry still standing. */
|
|
39
|
+
latestUntil(entries) {
|
|
40
|
+
let latest = 0;
|
|
41
|
+
for (const entry of entries ?? [])
|
|
42
|
+
latest = Math.max(latest, entry.until);
|
|
43
|
+
return latest;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
/** Store `entries` under `key`, removing the key entirely when nothing is left. */
|
|
47
|
+
function putEntries(map, key, entries) {
|
|
48
|
+
if (entries.length === 0)
|
|
49
|
+
map.delete(key);
|
|
50
|
+
else
|
|
51
|
+
map.set(key, entries);
|
|
52
|
+
}
|
|
6
53
|
const MAX_TRUSTED_RATE_LIMIT_RESET_MS = 8 * 24 * 60 * 60 * 1_000;
|
|
7
54
|
/**
|
|
8
55
|
* Returns the reset timestamp (seconds) that must pass before the account
|
|
@@ -24,6 +71,92 @@ function modelScopedClaim(claim) {
|
|
|
24
71
|
claim !== "seven_day_oauth_apps" &&
|
|
25
72
|
claim !== "seven_day_overage_included";
|
|
26
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* True only for a utilization the provider actually reported as below its
|
|
76
|
+
* limit. An absent or non-finite value is "unknown", never headroom: these
|
|
77
|
+
* checks decide whether to *release* a block, so unknown must not unbench.
|
|
78
|
+
*/
|
|
79
|
+
function reportsHeadroom(value) {
|
|
80
|
+
return typeof value === "number" && Number.isFinite(value) && value < 1;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The snapshot's place in the event order, or undefined when it has none.
|
|
84
|
+
*
|
|
85
|
+
* This is deliberately the *initiation* token, not `fetchedAt`: the latter is
|
|
86
|
+
* stamped after the response body is parsed, so a refresh already on the wire
|
|
87
|
+
* when a limit is hit completes afterwards while describing the account as it
|
|
88
|
+
* was before. Comparing that would treat pre-limit data as post-limit
|
|
89
|
+
* evidence. Wall-clock ms cannot substitute either — the 429, its headers, and
|
|
90
|
+
* the refresh the router starts from it all land in one event-loop turn and
|
|
91
|
+
* read the same millisecond. A snapshot with no token never supersedes.
|
|
92
|
+
*/
|
|
93
|
+
function supersedingSequence(usage) {
|
|
94
|
+
if (!usage || usage.fetchStatus !== "fresh")
|
|
95
|
+
return undefined;
|
|
96
|
+
const requestedSeq = usage.requestedSeq;
|
|
97
|
+
return typeof requestedSeq === "number" && Number.isFinite(requestedSeq)
|
|
98
|
+
? requestedSeq
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* True when a usage refresh started after the limiting headers landed and
|
|
103
|
+
* reports the claimed window below its limit.
|
|
104
|
+
*
|
|
105
|
+
* `status` is a snapshot of the last response's headers, and the reset
|
|
106
|
+
* timestamps it is cleared against belong to the window that was limiting
|
|
107
|
+
* *then*. Anything that refills that window early — a plan upgrade being the
|
|
108
|
+
* common case — leaves the flag describing a limit that no longer exists,
|
|
109
|
+
* and a benched account never receives the response that would correct it.
|
|
110
|
+
* The usage endpoint is the provider's own account of the same windows, so a
|
|
111
|
+
* later snapshot showing headroom supersedes the flag. This is the same
|
|
112
|
+
* supersession rule `TokenPool.releaseCooldownsSupersededByUsage` applies to
|
|
113
|
+
* the pool's cooldown map.
|
|
114
|
+
*
|
|
115
|
+
* Only the claimed window counts. An unattributed claim requires both windows,
|
|
116
|
+
* mirroring the reset-based rule above it; a claim naming a quota the snapshot
|
|
117
|
+
* does not report cannot reach here at all, since `stillBlocked` is already
|
|
118
|
+
* false for it.
|
|
119
|
+
*/
|
|
120
|
+
function usageSupersedesRateLimitedStatus(r) {
|
|
121
|
+
const usage = r.usage;
|
|
122
|
+
const supersedingSeq = supersedingSequence(usage);
|
|
123
|
+
if (supersedingSeq === undefined)
|
|
124
|
+
return false;
|
|
125
|
+
// Headers with no ordering token cannot be compared, so they stand.
|
|
126
|
+
if (r.lastUpdatedSeq === undefined || supersedingSeq <= r.lastUpdatedSeq)
|
|
127
|
+
return false;
|
|
128
|
+
if (r.claim === "five_hour")
|
|
129
|
+
return reportsHeadroom(usage?.fiveHour?.utilization);
|
|
130
|
+
if (r.claim === "seven_day")
|
|
131
|
+
return reportsHeadroom(usage?.sevenDay?.utilization);
|
|
132
|
+
if (r.claim === "") {
|
|
133
|
+
return reportsHeadroom(usage?.fiveHour?.utilization) &&
|
|
134
|
+
reportsHeadroom(usage?.sevenDay?.utilization);
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether the usage snapshot is the newer account of capacity than the last
|
|
140
|
+
* response headers, and so the one routing should believe.
|
|
141
|
+
*
|
|
142
|
+
* Decided on the event order for the same reason supersession is: a refresh
|
|
143
|
+
* that starts before a response and finishes after it carries the *older*
|
|
144
|
+
* picture despite the later clock reading. Preferring it there would hide a
|
|
145
|
+
* fresher exhaustion signal behind a snapshot that never saw it — and, since
|
|
146
|
+
* cooldown release already runs on sequences, would leave the two halves of
|
|
147
|
+
* the same decision disagreeing about which source is current. Snapshots
|
|
148
|
+
* predating the ordering tokens fall back to the timestamp comparison.
|
|
149
|
+
*/
|
|
150
|
+
function usageOutranksHeaders(r) {
|
|
151
|
+
const usage = r.usage;
|
|
152
|
+
if (!usage)
|
|
153
|
+
return false;
|
|
154
|
+
const usageSeq = usage.requestedSeq;
|
|
155
|
+
if (typeof usageSeq === "number" && Number.isFinite(usageSeq) && r.lastUpdatedSeq !== undefined) {
|
|
156
|
+
return usageSeq > r.lastUpdatedSeq;
|
|
157
|
+
}
|
|
158
|
+
return usage.fetchedAt >= r.lastUpdated;
|
|
159
|
+
}
|
|
27
160
|
/** Accept only reset timestamps within the same bounded horizon as cooldown evidence. */
|
|
28
161
|
function trustworthyResetMs(resetAtSeconds, nowMs) {
|
|
29
162
|
if (typeof resetAtSeconds !== "number" || !Number.isFinite(resetAtSeconds) || resetAtSeconds <= 0) {
|
|
@@ -52,10 +185,9 @@ function trustworthyResetMs(resetAtSeconds, nowMs) {
|
|
|
52
185
|
* window expires, `status` flips back to `"allowed"`. The callback fires
|
|
53
186
|
* once per recovery so the dashboard can surface it.
|
|
54
187
|
*/
|
|
55
|
-
function clearExpiredRateLimitWindows(a, nowMs
|
|
188
|
+
function clearExpiredRateLimitWindows(a, nowMs) {
|
|
56
189
|
const nowSec = Math.floor(nowMs / 1000);
|
|
57
190
|
const r = a.rateLimits;
|
|
58
|
-
let recovered = false;
|
|
59
191
|
if (r.fiveHourReset > 0 && nowSec >= r.fiveHourReset) {
|
|
60
192
|
r.fiveHourUtil = 0;
|
|
61
193
|
r.fiveHourReset = 0;
|
|
@@ -64,17 +196,23 @@ function clearExpiredRateLimitWindows(a, nowMs, onExpired) {
|
|
|
64
196
|
r.sevenDayUtil = 0;
|
|
65
197
|
r.sevenDayReset = 0;
|
|
66
198
|
}
|
|
199
|
+
// A rolled-over window is no longer *known* to be spent, which is what
|
|
200
|
+
// unblocks routing — but we have not heard a new figure from the provider
|
|
201
|
+
// either. Clearing the reading rather than writing a zero keeps that
|
|
202
|
+
// distinction: safeUtilization() reads it as 0 for blocking decisions, while
|
|
203
|
+
// supersession, which releases cooldowns only on reported headroom, still
|
|
204
|
+
// sees nothing reported and leaves them to expire on their own terms.
|
|
67
205
|
const usage = r.usage;
|
|
68
206
|
if (usage) {
|
|
69
207
|
for (const window of [usage.fiveHour, usage.sevenDay]) {
|
|
70
208
|
if (window && window.resetAt > 0 && nowSec >= window.resetAt) {
|
|
71
|
-
window.utilization
|
|
209
|
+
delete window.utilization;
|
|
72
210
|
window.resetAt = 0;
|
|
73
211
|
}
|
|
74
212
|
}
|
|
75
213
|
for (const limit of usage.modelLimits) {
|
|
76
214
|
if (limit.resetAt > 0 && nowSec >= limit.resetAt) {
|
|
77
|
-
limit.utilization
|
|
215
|
+
delete limit.utilization;
|
|
78
216
|
limit.resetAt = 0;
|
|
79
217
|
}
|
|
80
218
|
}
|
|
@@ -86,24 +224,40 @@ function clearExpiredRateLimitWindows(a, nowMs, onExpired) {
|
|
|
86
224
|
const stillBlocked = (r.claim === "five_hour" && r.fiveHourReset > 0) ||
|
|
87
225
|
(r.claim === "seven_day" && r.sevenDayReset > 0) ||
|
|
88
226
|
(r.claim === "" && (r.fiveHourReset > 0 || r.sevenDayReset > 0));
|
|
89
|
-
if (!stillBlocked) {
|
|
227
|
+
if (!stillBlocked || usageSupersedesRateLimitedStatus(r)) {
|
|
90
228
|
r.status = "allowed";
|
|
91
|
-
recovered = true;
|
|
92
229
|
}
|
|
93
230
|
}
|
|
94
|
-
if (recovered && onExpired)
|
|
95
|
-
onExpired(a);
|
|
96
231
|
}
|
|
97
232
|
export class TokenPool {
|
|
98
233
|
accounts;
|
|
99
234
|
inFlight = new Map();
|
|
100
235
|
cooldowns = new Map();
|
|
101
236
|
now;
|
|
237
|
+
nextSequence;
|
|
238
|
+
/**
|
|
239
|
+
* Accounts known to be blocked for every model, so recovery is announced
|
|
240
|
+
* once when the final blocker clears.
|
|
241
|
+
*
|
|
242
|
+
* Membership is seeded from two places, because the blockers differ in kind.
|
|
243
|
+
* A `rate_limited` flag or a spent window persists until something rolls it
|
|
244
|
+
* over, so observing the account is enough to notice it. A cooldown instead
|
|
245
|
+
* erases itself at its expiry: `globalUntil > now()` already reads false by
|
|
246
|
+
* the time the next sweep arrives, so an account left idle across a short
|
|
247
|
+
* cooldown would never be recorded as blocked and its recovery would go
|
|
248
|
+
* unreported. The account-global cooldown setters therefore record it when
|
|
249
|
+
* the cooldown is created. Model cooldowns deliberately do not — an account
|
|
250
|
+
* spent on one family is still serving the rest, so it never left rotation.
|
|
251
|
+
*
|
|
252
|
+
* Weak so an account removed while blocked is not retained.
|
|
253
|
+
*/
|
|
254
|
+
globallyBlocked = new WeakSet();
|
|
102
255
|
currentIndex = 0;
|
|
103
256
|
nextAmbiguousCooldownToken = 1;
|
|
104
257
|
constructor(accounts, options = {}) {
|
|
105
258
|
this.accounts = accounts;
|
|
106
259
|
this.now = options.now ?? Date.now;
|
|
260
|
+
this.nextSequence = options.nextSequence ?? nextEventSequence;
|
|
107
261
|
}
|
|
108
262
|
/**
|
|
109
263
|
* Compatibility wrapper for request sites that do not yet retain leases.
|
|
@@ -156,7 +310,7 @@ export class TokenPool {
|
|
|
156
310
|
const account = this.findById(accountId);
|
|
157
311
|
if (!account)
|
|
158
312
|
return null;
|
|
159
|
-
|
|
313
|
+
this.refreshBlockingState(account);
|
|
160
314
|
if (this.hardBlock(account, context) || this.overUserCap(account))
|
|
161
315
|
return null;
|
|
162
316
|
return this.createLease(account, false);
|
|
@@ -165,7 +319,7 @@ export class TokenPool {
|
|
|
165
319
|
const account = this.findById(accountId);
|
|
166
320
|
if (!account)
|
|
167
321
|
return false;
|
|
168
|
-
|
|
322
|
+
this.refreshBlockingState(account);
|
|
169
323
|
return this.hardBlock(account, context) === null && !this.overUserCap(account);
|
|
170
324
|
}
|
|
171
325
|
getInFlight(accountId) {
|
|
@@ -181,14 +335,23 @@ export class TokenPool {
|
|
|
181
335
|
setCooldownForAccount(account, durationMs) {
|
|
182
336
|
this.setGlobalCooldownForAccount(account, durationMs);
|
|
183
337
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Apply an account-global cooldown to the exact account incarnation routed.
|
|
340
|
+
*
|
|
341
|
+
* `usageWindow` names the window whose limit caused this, when the usage
|
|
342
|
+
* endpoint reports one for it. Omitting it — an upstream overload, an
|
|
343
|
+
* OAuth-apps quota, any caller that cannot attribute the limit — makes the
|
|
344
|
+
* cooldown purely time-based, since no snapshot describes that limit.
|
|
345
|
+
*/
|
|
346
|
+
setGlobalCooldownForAccount(account, durationMs, usageWindow) {
|
|
186
347
|
const expiry = this.proposedExpiry(account, durationMs);
|
|
187
348
|
if (expiry === undefined)
|
|
188
349
|
return;
|
|
189
350
|
const state = this.cooldownsFor(account);
|
|
190
|
-
|
|
351
|
+
const scope = usageWindow ?? "unscoped";
|
|
352
|
+
putEntries(state.definiteGlobal, scope, cooldownEntries.merge(state.definiteGlobal.get(scope), expiry, this.nextSequence()));
|
|
191
353
|
this.recomputeGlobalUntil(state);
|
|
354
|
+
this.globallyBlocked.add(account);
|
|
192
355
|
}
|
|
193
356
|
/** Apply a model-family cooldown to the exact account incarnation routed. */
|
|
194
357
|
setModelCooldownForAccount(account, modelFamily, durationMs) {
|
|
@@ -197,7 +360,7 @@ export class TokenPool {
|
|
|
197
360
|
if (expiry === undefined || family === undefined)
|
|
198
361
|
return;
|
|
199
362
|
const state = this.cooldownsFor(account);
|
|
200
|
-
state.modelUntil
|
|
363
|
+
putEntries(state.modelUntil, family, cooldownEntries.merge(state.modelUntil.get(family), expiry, this.nextSequence()));
|
|
201
364
|
}
|
|
202
365
|
/** Mark a conservative global cooldown as eligible for later narrowing. */
|
|
203
366
|
setAmbiguousGlobalCooldownForAccount(account, durationMs, modelFamily) {
|
|
@@ -212,6 +375,7 @@ export class TokenPool {
|
|
|
212
375
|
...(family ? { modelFamily: family } : {}),
|
|
213
376
|
});
|
|
214
377
|
this.recomputeGlobalUntil(state);
|
|
378
|
+
this.globallyBlocked.add(account);
|
|
215
379
|
return token;
|
|
216
380
|
}
|
|
217
381
|
/** Narrow only ambiguity-owned global state after a successful usage refresh. */
|
|
@@ -229,7 +393,7 @@ export class TokenPool {
|
|
|
229
393
|
return false;
|
|
230
394
|
const proposed = this.proposedExpiry(account, durationMs) ?? 0;
|
|
231
395
|
const expiry = Math.max(pending.until, proposed);
|
|
232
|
-
state.modelUntil
|
|
396
|
+
putEntries(state.modelUntil, family, cooldownEntries.merge(state.modelUntil.get(family), expiry, this.nextSequence()));
|
|
233
397
|
state.pendingAmbiguous.delete(token);
|
|
234
398
|
this.recomputeGlobalUntil(state);
|
|
235
399
|
if (state.globalUntil <= this.now()) {
|
|
@@ -250,7 +414,7 @@ export class TokenPool {
|
|
|
250
414
|
if (!current)
|
|
251
415
|
return 0;
|
|
252
416
|
const family = normalizeModelFamily(context?.modelFamily ?? context?.requestedModel);
|
|
253
|
-
return Math.max(current.globalUntil, family ? current.modelUntil.get(family)
|
|
417
|
+
return Math.max(current.globalUntil, family ? cooldownEntries.latestUntil(current.modelUntil.get(family)) : 0);
|
|
254
418
|
}
|
|
255
419
|
isCoolingDown(accountId, context) {
|
|
256
420
|
if (context !== undefined)
|
|
@@ -283,10 +447,13 @@ export class TokenPool {
|
|
|
283
447
|
return {
|
|
284
448
|
globalUntilMs: current.globalUntil,
|
|
285
449
|
modelCooldowns: [...current.modelUntil]
|
|
286
|
-
.
|
|
287
|
-
|
|
288
|
-
.
|
|
289
|
-
|
|
450
|
+
.map(([modelFamily, entries]) => ({
|
|
451
|
+
modelFamily,
|
|
452
|
+
untilMs: cooldownEntries.latestUntil(entries),
|
|
453
|
+
}))
|
|
454
|
+
.filter(cooldown => cooldown.untilMs > 0)
|
|
455
|
+
.sort((left, right) => left.modelFamily.localeCompare(right.modelFamily))
|
|
456
|
+
.slice(0, 12),
|
|
290
457
|
};
|
|
291
458
|
}
|
|
292
459
|
hardBlock(account, context) {
|
|
@@ -351,7 +518,7 @@ export class TokenPool {
|
|
|
351
518
|
},
|
|
352
519
|
];
|
|
353
520
|
const usage = account.rateLimits.usage;
|
|
354
|
-
if (!usage || usage.fetchStatus === "unavailable" ||
|
|
521
|
+
if (!usage || usage.fetchStatus === "unavailable" || !usageOutranksHeaders(account.rateLimits)) {
|
|
355
522
|
return headers;
|
|
356
523
|
}
|
|
357
524
|
return [
|
|
@@ -417,35 +584,135 @@ export class TokenPool {
|
|
|
417
584
|
state = {
|
|
418
585
|
globalUntil: 0,
|
|
419
586
|
modelUntil: new Map(),
|
|
420
|
-
|
|
587
|
+
definiteGlobal: new Map(),
|
|
421
588
|
pendingAmbiguous: new Map(),
|
|
422
589
|
};
|
|
423
590
|
this.cooldowns.set(account, state);
|
|
424
591
|
}
|
|
425
592
|
return state;
|
|
426
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* Roll one account's expiry and supersession state forward, announcing
|
|
596
|
+
* recovery exactly when the last account-global blocker clears.
|
|
597
|
+
*
|
|
598
|
+
* The listener used to hang off the `rate_limited` header flag alone, which
|
|
599
|
+
* both over- and under-reports once anything else can block the account: a
|
|
600
|
+
* 429 overlapping a 529 would announce recovery while the overload cooldown
|
|
601
|
+
* still kept the account out of rotation, and because the flag only flips
|
|
602
|
+
* once, the moment it genuinely came back passed unannounced. Every blocker
|
|
603
|
+
* is settled first, then the transition is judged on all of them together.
|
|
604
|
+
*/
|
|
605
|
+
refreshBlockingState(account) {
|
|
606
|
+
// Seeded before rolling forward as well as after, because the two checks
|
|
607
|
+
// answer different questions. A blocker that expires purely by the clock
|
|
608
|
+
// already reads as clear on the way in, so the remembered flag is what
|
|
609
|
+
// detects that transition; the pre-roll check is what lets an account
|
|
610
|
+
// first observed at the very moment it recovers still count as having
|
|
611
|
+
// been blocked.
|
|
612
|
+
if (this.accountGloballyBlocked(account))
|
|
613
|
+
this.globallyBlocked.add(account);
|
|
614
|
+
clearExpiredRateLimitWindows(account, this.now());
|
|
615
|
+
const state = this.cooldowns.get(account);
|
|
616
|
+
if (state)
|
|
617
|
+
this.clearExpiredCooldownState(account, state);
|
|
618
|
+
if (this.accountGloballyBlocked(account))
|
|
619
|
+
this.globallyBlocked.add(account);
|
|
620
|
+
else if (this.globallyBlocked.delete(account))
|
|
621
|
+
this.onCooldownExpired?.(account);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Whether anything blocks this account for *every* model, which is what the
|
|
625
|
+
* recovery listener speaks about. Deliberately excludes model-scoped state:
|
|
626
|
+
* an account spent on one family but serving another has not left rotation.
|
|
627
|
+
*/
|
|
628
|
+
accountGloballyBlocked(account) {
|
|
629
|
+
const r = account.rateLimits;
|
|
630
|
+
if (r.status === "rate_limited" && !modelScopedClaim(r.claim))
|
|
631
|
+
return true;
|
|
632
|
+
// Compared against the clock rather than swept first, so this reads
|
|
633
|
+
// correctly both before and after the state is rolled forward.
|
|
634
|
+
if ((this.cooldowns.get(account)?.globalUntil ?? 0) > this.now())
|
|
635
|
+
return true;
|
|
636
|
+
return this.globalWindows(account).some(window => window.utilization >= 1) &&
|
|
637
|
+
!this.canUsePaidExtra(account);
|
|
638
|
+
}
|
|
427
639
|
clearExpiredCooldownState(account, state) {
|
|
428
640
|
const now = this.now();
|
|
429
|
-
|
|
430
|
-
state.
|
|
641
|
+
for (const [scope, entries] of state.definiteGlobal) {
|
|
642
|
+
putEntries(state.definiteGlobal, scope, cooldownEntries.live(entries, now));
|
|
643
|
+
}
|
|
431
644
|
for (const [token, pending] of state.pendingAmbiguous) {
|
|
432
645
|
if (pending.until <= now)
|
|
433
646
|
state.pendingAmbiguous.delete(token);
|
|
434
647
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (expiry <= now)
|
|
438
|
-
state.modelUntil.delete(family);
|
|
648
|
+
for (const [family, entries] of state.modelUntil) {
|
|
649
|
+
putEntries(state.modelUntil, family, cooldownEntries.live(entries, now));
|
|
439
650
|
}
|
|
651
|
+
this.releaseCooldownsSupersededByUsage(account, state);
|
|
652
|
+
this.recomputeGlobalUntil(state);
|
|
440
653
|
this.deleteEmptyCooldowns(account, state);
|
|
441
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Drop cooldowns whose evidence a newer usage snapshot has superseded.
|
|
657
|
+
*
|
|
658
|
+
* A cooldown expiry is only a cache of "no capacity until T", derived from
|
|
659
|
+
* the reset timestamps attached to a 429. Anything that refills the window
|
|
660
|
+
* ahead of that timestamp — a plan upgrade being the common case — leaves
|
|
661
|
+
* the cached expiry describing a limit that no longer exists, and because
|
|
662
|
+
* the account is benched no new response ever arrives to correct it. That
|
|
663
|
+
* is the same deadlock `clearExpiredRateLimitWindows` resolves for the
|
|
664
|
+
* header snapshot, which cannot see cooldowns held here.
|
|
665
|
+
*
|
|
666
|
+
* Two conditions keep this from unbenching an account that is still limited.
|
|
667
|
+
* The refresh must have *started* after the cooldown was recorded, so data
|
|
668
|
+
* gathered before the limiting response is never mistaken for evidence about
|
|
669
|
+
* it. And the snapshot must report on the scope that produced the cooldown:
|
|
670
|
+
* only the claimed window releases a global cooldown, and only the matching
|
|
671
|
+
* family releases a model cooldown. Cooldowns for limits no snapshot
|
|
672
|
+
* describes are `unscoped` and stay purely time-based, as do ambiguous ones.
|
|
673
|
+
*
|
|
674
|
+
* Each scope is judged on its own, so releasing one leaves any other still
|
|
675
|
+
* running — an overload cooldown outlives the quota cooldown beside it.
|
|
676
|
+
*
|
|
677
|
+
* Within scope, releasing opens no hole: `hardBlock` reads the same snapshot
|
|
678
|
+
* for its exhausted-window check, so an account whose capacity is genuinely
|
|
679
|
+
* gone stays blocked on that check instead.
|
|
680
|
+
*/
|
|
681
|
+
releaseCooldownsSupersededByUsage(account, state) {
|
|
682
|
+
const usage = account.rateLimits.usage;
|
|
683
|
+
const supersedingSeq = supersedingSequence(usage);
|
|
684
|
+
if (usage === undefined || supersedingSeq === undefined)
|
|
685
|
+
return;
|
|
686
|
+
// Retire only the expiries this refresh was initiated after. A 429 that
|
|
687
|
+
// landed while it was in flight still stands on its own evidence.
|
|
688
|
+
for (const [scope, entries] of state.definiteGlobal) {
|
|
689
|
+
if (scope === "unscoped")
|
|
690
|
+
continue;
|
|
691
|
+
const window = scope === "five_hour" ? usage.fiveHour : usage.sevenDay;
|
|
692
|
+
if (!reportsHeadroom(window?.utilization))
|
|
693
|
+
continue;
|
|
694
|
+
putEntries(state.definiteGlobal, scope, cooldownEntries.retireBefore(entries, supersedingSeq));
|
|
695
|
+
}
|
|
696
|
+
// A family the snapshot does not mention is left alone: silence is not
|
|
697
|
+
// evidence of headroom. `active` is deliberately not consulted, matching
|
|
698
|
+
// matchingModelWindows() — utilization is what gates routing.
|
|
699
|
+
for (const [family, entries] of state.modelUntil) {
|
|
700
|
+
const limit = usage.modelLimits.find(candidate => normalizeModelFamily(candidate.modelFamily) === family);
|
|
701
|
+
if (!limit || !reportsHeadroom(limit.utilization))
|
|
702
|
+
continue;
|
|
703
|
+
putEntries(state.modelUntil, family, cooldownEntries.retireBefore(entries, supersedingSeq));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
442
706
|
deleteEmptyCooldowns(account, state) {
|
|
443
707
|
if (state.globalUntil === 0 && state.modelUntil.size === 0) {
|
|
444
708
|
this.cooldowns.delete(account);
|
|
445
709
|
}
|
|
446
710
|
}
|
|
447
711
|
recomputeGlobalUntil(state) {
|
|
448
|
-
let globalUntil =
|
|
712
|
+
let globalUntil = 0;
|
|
713
|
+
for (const entries of state.definiteGlobal.values()) {
|
|
714
|
+
globalUntil = Math.max(globalUntil, cooldownEntries.latestUntil(entries));
|
|
715
|
+
}
|
|
449
716
|
for (const pending of state.pendingAmbiguous.values()) {
|
|
450
717
|
globalUntil = Math.max(globalUntil, pending.until);
|
|
451
718
|
}
|
|
@@ -459,7 +726,10 @@ export class TokenPool {
|
|
|
459
726
|
const current = this.cooldowns.get(account);
|
|
460
727
|
if (!current)
|
|
461
728
|
return 0;
|
|
462
|
-
const expiries = [
|
|
729
|
+
const expiries = [
|
|
730
|
+
current.globalUntil,
|
|
731
|
+
...[...current.modelUntil.values()].map(entries => cooldownEntries.latestUntil(entries)),
|
|
732
|
+
].filter(value => value > 0);
|
|
463
733
|
return expiries.length > 0 ? Math.min(...expiries) : 0;
|
|
464
734
|
}
|
|
465
735
|
selectEligible(candidates, activeSessions, context) {
|
|
@@ -542,13 +812,8 @@ export class TokenPool {
|
|
|
542
812
|
* poll loop so the UI reflects recovery without waiting for a new request.
|
|
543
813
|
*/
|
|
544
814
|
sweepExpiredCooldowns() {
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
clearExpiredRateLimitWindows(a, now, this.onCooldownExpired);
|
|
548
|
-
const state = this.cooldowns.get(a);
|
|
549
|
-
if (state)
|
|
550
|
-
this.clearExpiredCooldownState(a, state);
|
|
551
|
-
}
|
|
815
|
+
for (const a of this.accounts)
|
|
816
|
+
this.refreshBlockingState(a);
|
|
552
817
|
}
|
|
553
818
|
getAll() {
|
|
554
819
|
return this.accounts;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared policy for router-side upstream retries. Both providers consult the
|
|
3
|
+
* same rules so a 429 fails over and a 5xx retries identically whether the
|
|
4
|
+
* request came from Claude Code or the Codex CLI.
|
|
5
|
+
*
|
|
6
|
+
* The policy deliberately lives apart from either transport: it must only be
|
|
7
|
+
* applied BEFORE any response bytes have been relayed to a client, and the
|
|
8
|
+
* decision of when that is remains each transport's own.
|
|
9
|
+
*/
|
|
10
|
+
/** Total upstream attempts per client request, the first one included. */
|
|
11
|
+
export const MAX_UPSTREAM_ATTEMPTS = 3;
|
|
12
|
+
/**
|
|
13
|
+
* Pause before re-sending to the SAME account (a plain 5xx applies no
|
|
14
|
+
* cooldown and keeps the sticky binding, so re-acquisition returns the
|
|
15
|
+
* account that just failed). An immediate replay would hit whatever
|
|
16
|
+
* transient condition produced the 5xx still in progress; failovers to a
|
|
17
|
+
* different account skip the delay entirely.
|
|
18
|
+
*/
|
|
19
|
+
export const SAME_ACCOUNT_RETRY_DELAY_MS = 500;
|
|
20
|
+
/**
|
|
21
|
+
* Which upstream statuses the router may retry on its own. 429 fails over to
|
|
22
|
+
* a different account; 5xx (including Anthropic's 529 and Codex's 503
|
|
23
|
+
* overloads, whose cooldowns rebind the session elsewhere) retries per the
|
|
24
|
+
* pool's routing rules. 401 is deliberately absent: auth failures pass
|
|
25
|
+
* through while a background token refresh runs, exactly as before.
|
|
26
|
+
*/
|
|
27
|
+
export function isRetryableUpstreamStatus(status) {
|
|
28
|
+
return status === 429 || status >= 500;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Longest the retry loop will wait for a failover account's token refresh
|
|
32
|
+
* while an upstream failure response sits ready to relay. Refreshes normally
|
|
33
|
+
* settle within a couple of seconds; the refresh fetch itself carries no
|
|
34
|
+
* deadline, and the pre-response proxy timeout was already disarmed when the
|
|
35
|
+
* failure's headers arrived — so without this bound a stalled OAuth endpoint
|
|
36
|
+
* could withhold a ready 429/5xx for minutes. Generous on purpose: the
|
|
37
|
+
* fallback is relaying a failure the client may not recover from, so a slow
|
|
38
|
+
* but working refresh deserves the extra seconds.
|
|
39
|
+
*/
|
|
40
|
+
export const RETRY_REFRESH_TIMEOUT_MS = 15_000;
|
|
41
|
+
/**
|
|
42
|
+
* Await `work` for at most `ms`, resolving to `fallback` when the deadline
|
|
43
|
+
* passes or `signal` aborts first. `work` is NOT cancelled — a token refresh
|
|
44
|
+
* that settles late still updates its account for future requests — the
|
|
45
|
+
* caller merely stops waiting for it, and a late rejection is swallowed so
|
|
46
|
+
* abandoning the wait can never surface an unhandled rejection. A rejection
|
|
47
|
+
* inside the deadline also resolves to `fallback`; callers that need to tell
|
|
48
|
+
* failure from timeout map their promise to a value before waiting.
|
|
49
|
+
*/
|
|
50
|
+
export function boundedWait(work, ms, fallback, signal) {
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
let settled = false;
|
|
53
|
+
const finish = (value) => {
|
|
54
|
+
if (settled)
|
|
55
|
+
return;
|
|
56
|
+
settled = true;
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
signal?.removeEventListener("abort", onAbort);
|
|
59
|
+
resolve(value);
|
|
60
|
+
};
|
|
61
|
+
const onAbort = () => finish(fallback);
|
|
62
|
+
const timer = setTimeout(() => finish(fallback), ms);
|
|
63
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
onAbort();
|
|
66
|
+
work.then(finish, () => finish(fallback));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve after `ms`, or as soon as `signal` aborts — a client that hung up
|
|
71
|
+
* must not keep a retry pending. Never rejects.
|
|
72
|
+
*/
|
|
73
|
+
export function retryDelay(ms, signal) {
|
|
74
|
+
if (ms <= 0 || signal?.aborted)
|
|
75
|
+
return Promise.resolve();
|
|
76
|
+
return new Promise(resolve => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
signal?.removeEventListener("abort", onAbort);
|
|
79
|
+
resolve();
|
|
80
|
+
}, ms);
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
}
|