@timo972/cc-router 0.7.0 → 0.9.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 +139 -0
- package/README.md +12 -87
- package/dist/cli/cmd-accounts.js +114 -11
- package/dist/cli/index.js +0 -0
- package/dist/protocol/openai-responses-collect.js +71 -0
- package/dist/providers/anthropic/usage-refresher.js +195 -0
- package/dist/providers/anthropic/usage.js +217 -0
- package/dist/proxy/account-add.js +30 -0
- package/dist/proxy/account-deletion.js +16 -0
- package/dist/proxy/anthropic-routing.js +31 -2
- package/dist/proxy/lease-lifecycle.js +182 -22
- package/dist/proxy/logger.js +3 -0
- package/dist/proxy/messages-cross-route.js +4 -1
- package/dist/proxy/request-model.js +17 -0
- package/dist/proxy/responses-server.js +43 -1
- package/dist/proxy/server.js +198 -24
- package/dist/proxy/session-router.js +12 -8
- package/dist/proxy/stats.js +11 -0
- package/dist/proxy/token-pool.js +379 -108
- package/dist/ui/Dashboard.js +90 -4
- package/dist/ui/accountsApi.js +136 -20
- package/package.json +12 -11
package/dist/proxy/token-pool.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "./types.js";
|
|
2
|
+
import { canUseExtraUsage, normalizeModelFamily } from "../providers/anthropic/usage.js";
|
|
2
3
|
export class EmptyPoolError extends Error {
|
|
3
4
|
constructor(message) {
|
|
4
5
|
super(message);
|
|
5
6
|
this.name = "EmptyPoolError";
|
|
6
7
|
}
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
export class NoEligibleAccountError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
retryAtMs;
|
|
12
|
+
blockedAccounts;
|
|
13
|
+
constructor(reason, blockedAccounts, retryAtMs) {
|
|
14
|
+
super("no account is currently eligible for routing");
|
|
15
|
+
this.name = "NoEligibleAccountError";
|
|
16
|
+
this.reason = reason;
|
|
17
|
+
this.blockedAccounts = blockedAccounts;
|
|
18
|
+
if (retryAtMs !== undefined)
|
|
19
|
+
this.retryAtMs = retryAtMs;
|
|
20
|
+
}
|
|
14
21
|
}
|
|
22
|
+
const MAX_TRUSTED_RATE_LIMIT_RESET_MS = 8 * 24 * 60 * 60 * 1_000;
|
|
15
23
|
/**
|
|
16
24
|
* Returns the reset timestamp (seconds) that must pass before the account
|
|
17
25
|
* stops being rate_limited. Prefers the `claim` window (the one Anthropic
|
|
18
|
-
* said was actually limiting);
|
|
26
|
+
* said was actually limiting); when the claim is absent, all known windows
|
|
27
|
+
* must reset, so the latest non-zero reset is the complete unblock time.
|
|
19
28
|
* Returns 0 when no reset is known.
|
|
20
29
|
*/
|
|
21
30
|
function limitingReset(a) {
|
|
@@ -24,8 +33,22 @@ function limitingReset(a) {
|
|
|
24
33
|
return r.fiveHourReset;
|
|
25
34
|
if (r.claim === "seven_day" && r.sevenDayReset)
|
|
26
35
|
return r.sevenDayReset;
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
return Math.max(r.fiveHourReset, r.sevenDayReset, 0);
|
|
37
|
+
}
|
|
38
|
+
function modelScopedClaim(claim) {
|
|
39
|
+
return claim.startsWith("seven_day_") &&
|
|
40
|
+
claim !== "seven_day_oauth_apps" &&
|
|
41
|
+
claim !== "seven_day_overage_included";
|
|
42
|
+
}
|
|
43
|
+
/** Accept only reset timestamps within the same bounded horizon as cooldown evidence. */
|
|
44
|
+
function trustworthyResetMs(resetAtSeconds, nowMs) {
|
|
45
|
+
if (typeof resetAtSeconds !== "number" || !Number.isFinite(resetAtSeconds) || resetAtSeconds <= 0) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const resetAtMs = Math.floor(resetAtSeconds) * 1_000;
|
|
49
|
+
if (!Number.isFinite(resetAtMs) || resetAtMs <= nowMs)
|
|
50
|
+
return undefined;
|
|
51
|
+
return resetAtMs - nowMs <= MAX_TRUSTED_RATE_LIMIT_RESET_MS ? resetAtMs : undefined;
|
|
29
52
|
}
|
|
30
53
|
/**
|
|
31
54
|
* Roll over any rate-limit window whose reset timestamp has passed.
|
|
@@ -48,17 +71,29 @@ function limitingReset(a) {
|
|
|
48
71
|
function clearExpiredRateLimitWindows(a, nowMs, onExpired) {
|
|
49
72
|
const nowSec = Math.floor(nowMs / 1000);
|
|
50
73
|
const r = a.rateLimits;
|
|
51
|
-
let changed = false;
|
|
52
74
|
let recovered = false;
|
|
53
75
|
if (r.fiveHourReset > 0 && nowSec >= r.fiveHourReset) {
|
|
54
76
|
r.fiveHourUtil = 0;
|
|
55
77
|
r.fiveHourReset = 0;
|
|
56
|
-
changed = true;
|
|
57
78
|
}
|
|
58
79
|
if (r.sevenDayReset > 0 && nowSec >= r.sevenDayReset) {
|
|
59
80
|
r.sevenDayUtil = 0;
|
|
60
81
|
r.sevenDayReset = 0;
|
|
61
|
-
|
|
82
|
+
}
|
|
83
|
+
const usage = r.usage;
|
|
84
|
+
if (usage) {
|
|
85
|
+
for (const window of [usage.fiveHour, usage.sevenDay]) {
|
|
86
|
+
if (window && window.resetAt > 0 && nowSec >= window.resetAt) {
|
|
87
|
+
window.utilization = 0;
|
|
88
|
+
window.resetAt = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const limit of usage.modelLimits) {
|
|
92
|
+
if (limit.resetAt > 0 && nowSec >= limit.resetAt) {
|
|
93
|
+
limit.utilization = 0;
|
|
94
|
+
limit.resetAt = 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
62
97
|
}
|
|
63
98
|
// If the account was rate_limited and its claimed window just reset,
|
|
64
99
|
// return it to rotation. If we can't tell which window was limiting
|
|
@@ -70,56 +105,27 @@ function clearExpiredRateLimitWindows(a, nowMs, onExpired) {
|
|
|
70
105
|
if (!stillBlocked) {
|
|
71
106
|
r.status = "allowed";
|
|
72
107
|
recovered = true;
|
|
73
|
-
changed = true;
|
|
74
108
|
}
|
|
75
109
|
}
|
|
76
|
-
if (changed)
|
|
77
|
-
r.lastUpdated = nowMs;
|
|
78
110
|
if (recovered && onExpired)
|
|
79
111
|
onExpired(a);
|
|
80
112
|
}
|
|
81
|
-
/** True when the account's user-defined caps have been reached. */
|
|
82
|
-
function overUserCap(a) {
|
|
83
|
-
return (a.rateLimits.fiveHourUtil * 100 >= a.sessionLimitPercent ||
|
|
84
|
-
a.rateLimits.sevenDayUtil * 100 >= a.weeklyLimitPercent);
|
|
85
|
-
}
|
|
86
|
-
/** Filter out accounts the user has taken out of the rotation. */
|
|
87
|
-
function isUsable(a) {
|
|
88
|
-
return a.enabled && !overUserCap(a);
|
|
89
|
-
}
|
|
90
113
|
export class TokenPool {
|
|
91
114
|
accounts;
|
|
92
115
|
inFlight = new Map();
|
|
93
|
-
|
|
116
|
+
cooldowns = new Map();
|
|
94
117
|
now;
|
|
95
118
|
currentIndex = 0;
|
|
119
|
+
nextAmbiguousCooldownToken = 1;
|
|
96
120
|
constructor(accounts, options = {}) {
|
|
97
121
|
this.accounts = accounts;
|
|
98
122
|
this.now = options.now ?? Date.now;
|
|
99
123
|
}
|
|
100
124
|
/**
|
|
101
125
|
* Compatibility wrapper for request sites that do not yet retain leases.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* • not rate-limited by Anthropic
|
|
106
|
-
* • enabled (user toggle)
|
|
107
|
-
* • under the user-configured 5h/7d caps
|
|
108
|
-
*
|
|
109
|
-
* Fallback chain when nothing is available:
|
|
110
|
-
* 1. Any healthy+usable (enabled & under caps) account — pick earliest reset.
|
|
111
|
-
* 2. Any healthy account — pick earliest reset. This intentionally ignores
|
|
112
|
-
* user caps when every option is capped; limits are advisory, not a hard
|
|
113
|
-
* ban that would leave Claude Code with no working account. The fallback
|
|
114
|
-
* is logged via the optional onCapBypass callback so the dashboard can
|
|
115
|
-
* surface it instead of silently exceeding the cap.
|
|
116
|
-
* 3. Any account as a last resort (only if every account is unhealthy).
|
|
117
|
-
*
|
|
118
|
-
* Fallback sets prefer the lowest in-flight load, then the earliest reset.
|
|
119
|
-
*
|
|
120
|
-
* Throws `EmptyPoolError` when there are no accounts at all — callers in
|
|
121
|
-
* the request path should map this to a 503. The DELETE endpoint guards
|
|
122
|
-
* against this state by refusing to remove the last account.
|
|
126
|
+
* User caps are advisory and may be bypassed when every hard-eligible
|
|
127
|
+
* account is capped. Upstream exhaustion, cooldown, disabled state, and
|
|
128
|
+
* unhealthy state are hard blocks and are never bypassed.
|
|
123
129
|
*/
|
|
124
130
|
getNext() {
|
|
125
131
|
const lease = this.acquireBest(new Map());
|
|
@@ -130,46 +136,53 @@ export class TokenPool {
|
|
|
130
136
|
* Acquire the best eligible account using load, session affinity pressure,
|
|
131
137
|
* rate-limit headroom, and a rotating tie-break, in that order.
|
|
132
138
|
*/
|
|
133
|
-
acquireBest(activeSessions) {
|
|
139
|
+
acquireBest(activeSessions, context) {
|
|
134
140
|
if (this.accounts.length === 0) {
|
|
135
141
|
throw new EmptyPoolError("token pool is empty — add an account first");
|
|
136
142
|
}
|
|
137
143
|
this.sweepExpiredCooldowns();
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
const
|
|
144
|
+
const hardBlocks = new Map();
|
|
145
|
+
const hardEligible = this.accounts.filter(account => {
|
|
146
|
+
const block = this.hardBlock(account, context);
|
|
147
|
+
if (block)
|
|
148
|
+
hardBlocks.set(account, block);
|
|
149
|
+
return block === null;
|
|
150
|
+
});
|
|
151
|
+
const withinUserCaps = hardEligible.filter(account => !this.overUserCap(account));
|
|
152
|
+
if (withinUserCaps.length > 0) {
|
|
153
|
+
const account = this.selectEligible(withinUserCaps, activeSessions, context);
|
|
141
154
|
this.advanceCursor(account);
|
|
142
155
|
return this.createLease(account, false);
|
|
143
156
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
? healthyUsable
|
|
148
|
-
: healthy.length > 0
|
|
149
|
-
? healthy
|
|
150
|
-
: this.accounts;
|
|
151
|
-
const account = this.selectFallback(fallbackCandidates);
|
|
152
|
-
this.advanceCursor(account);
|
|
153
|
-
if (overUserCap(account))
|
|
157
|
+
if (hardEligible.length > 0) {
|
|
158
|
+
const account = this.selectEligible(hardEligible, activeSessions, context);
|
|
159
|
+
this.advanceCursor(account);
|
|
154
160
|
this.onCapBypass?.(account);
|
|
155
|
-
|
|
161
|
+
return this.createLease(account, true);
|
|
162
|
+
}
|
|
163
|
+
const rateLimited = [...hardBlocks.values()].filter(block => block.reason === "rate_limited");
|
|
164
|
+
const retryTimes = rateLimited
|
|
165
|
+
.map(block => block.retryAtMs)
|
|
166
|
+
.filter((retryAtMs) => retryAtMs !== undefined);
|
|
167
|
+
const retryAtMs = retryTimes.length > 0 ? Math.min(...retryTimes) : undefined;
|
|
168
|
+
throw new NoEligibleAccountError(rateLimited.length > 0 ? "rate_limited" : "unavailable", this.accounts.length, retryAtMs);
|
|
156
169
|
}
|
|
157
170
|
/** Acquire a specific account for an existing sticky session. */
|
|
158
|
-
tryAcquire(accountId) {
|
|
171
|
+
tryAcquire(accountId, context) {
|
|
159
172
|
const account = this.findById(accountId);
|
|
160
173
|
if (!account)
|
|
161
174
|
return null;
|
|
162
175
|
clearExpiredRateLimitWindows(account, this.now(), this.onCooldownExpired);
|
|
163
|
-
if (
|
|
176
|
+
if (this.hardBlock(account, context) || this.overUserCap(account))
|
|
164
177
|
return null;
|
|
165
178
|
return this.createLease(account, false);
|
|
166
179
|
}
|
|
167
|
-
isEligible(accountId) {
|
|
180
|
+
isEligible(accountId, context) {
|
|
168
181
|
const account = this.findById(accountId);
|
|
169
182
|
if (!account)
|
|
170
183
|
return false;
|
|
171
184
|
clearExpiredRateLimitWindows(account, this.now(), this.onCooldownExpired);
|
|
172
|
-
return this.
|
|
185
|
+
return this.hardBlock(account, context) === null && !this.overUserCap(account);
|
|
173
186
|
}
|
|
174
187
|
getInFlight(accountId) {
|
|
175
188
|
return this.inFlight.get(accountId) ?? 0;
|
|
@@ -178,66 +191,320 @@ export class TokenPool {
|
|
|
178
191
|
const account = this.findById(accountId);
|
|
179
192
|
if (!account)
|
|
180
193
|
return;
|
|
181
|
-
this.
|
|
194
|
+
this.setGlobalCooldownForAccount(account, durationMs);
|
|
182
195
|
}
|
|
183
|
-
/**
|
|
196
|
+
/** Compatibility alias retained for callers that have not adopted scopes. */
|
|
184
197
|
setCooldownForAccount(account, durationMs) {
|
|
185
|
-
|
|
198
|
+
this.setGlobalCooldownForAccount(account, durationMs);
|
|
199
|
+
}
|
|
200
|
+
/** Apply an account-global cooldown to the exact account incarnation routed. */
|
|
201
|
+
setGlobalCooldownForAccount(account, durationMs) {
|
|
202
|
+
const expiry = this.proposedExpiry(account, durationMs);
|
|
203
|
+
if (expiry === undefined)
|
|
186
204
|
return;
|
|
187
|
-
|
|
205
|
+
const state = this.cooldownsFor(account);
|
|
206
|
+
state.definiteGlobalUntil = Math.max(state.definiteGlobalUntil, expiry);
|
|
207
|
+
this.recomputeGlobalUntil(state);
|
|
208
|
+
}
|
|
209
|
+
/** Apply a model-family cooldown to the exact account incarnation routed. */
|
|
210
|
+
setModelCooldownForAccount(account, modelFamily, durationMs) {
|
|
211
|
+
const expiry = this.proposedExpiry(account, durationMs);
|
|
212
|
+
const family = normalizeModelFamily(modelFamily);
|
|
213
|
+
if (expiry === undefined || family === undefined)
|
|
188
214
|
return;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
215
|
+
const state = this.cooldownsFor(account);
|
|
216
|
+
state.modelUntil.set(family, Math.max(state.modelUntil.get(family) ?? 0, expiry));
|
|
217
|
+
}
|
|
218
|
+
/** Mark a conservative global cooldown as eligible for later narrowing. */
|
|
219
|
+
setAmbiguousGlobalCooldownForAccount(account, durationMs, modelFamily) {
|
|
220
|
+
const expiry = this.proposedExpiry(account, durationMs);
|
|
221
|
+
if (expiry === undefined)
|
|
222
|
+
return undefined;
|
|
223
|
+
const state = this.cooldownsFor(account);
|
|
224
|
+
const token = this.nextAmbiguousCooldownToken++;
|
|
225
|
+
const family = normalizeModelFamily(modelFamily);
|
|
226
|
+
state.pendingAmbiguous.set(token, {
|
|
227
|
+
until: expiry,
|
|
228
|
+
...(family ? { modelFamily: family } : {}),
|
|
229
|
+
});
|
|
230
|
+
this.recomputeGlobalUntil(state);
|
|
231
|
+
return token;
|
|
192
232
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (
|
|
233
|
+
/** Narrow only ambiguity-owned global state after a successful usage refresh. */
|
|
234
|
+
reconcileAmbiguousGlobalCooldownForAccount(account, token, modelFamily, durationMs) {
|
|
235
|
+
if (this.findById(account.id) !== account)
|
|
236
|
+
return false;
|
|
237
|
+
const state = this.cooldowns.get(account);
|
|
238
|
+
if (!state)
|
|
196
239
|
return false;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
const pending = state.pendingAmbiguous.get(token);
|
|
241
|
+
if (!pending || pending.until <= this.now())
|
|
242
|
+
return false;
|
|
243
|
+
const family = normalizeModelFamily(modelFamily);
|
|
244
|
+
if (!family || pending.modelFamily !== family)
|
|
245
|
+
return false;
|
|
246
|
+
const proposed = this.proposedExpiry(account, durationMs) ?? 0;
|
|
247
|
+
const expiry = Math.max(pending.until, proposed);
|
|
248
|
+
state.modelUntil.set(family, Math.max(state.modelUntil.get(family) ?? 0, expiry));
|
|
249
|
+
state.pendingAmbiguous.delete(token);
|
|
250
|
+
this.recomputeGlobalUntil(state);
|
|
251
|
+
if (state.globalUntil <= this.now()) {
|
|
252
|
+
account.rateLimits.status = "allowed";
|
|
253
|
+
}
|
|
254
|
+
this.deleteEmptyCooldowns(account, state);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
getApplicableCooldownUntil(accountId, context) {
|
|
258
|
+
const account = this.findById(accountId);
|
|
259
|
+
if (!account)
|
|
260
|
+
return 0;
|
|
261
|
+
const state = this.cooldowns.get(account);
|
|
262
|
+
if (!state)
|
|
263
|
+
return 0;
|
|
264
|
+
this.clearExpiredCooldownState(account, state);
|
|
265
|
+
const current = this.cooldowns.get(account);
|
|
266
|
+
if (!current)
|
|
267
|
+
return 0;
|
|
268
|
+
const family = normalizeModelFamily(context?.modelFamily ?? context?.requestedModel);
|
|
269
|
+
return Math.max(current.globalUntil, family ? current.modelUntil.get(family) ?? 0 : 0);
|
|
270
|
+
}
|
|
271
|
+
isCoolingDown(accountId, context) {
|
|
272
|
+
if (context !== undefined)
|
|
273
|
+
return this.getApplicableCooldownUntil(accountId, context) > 0;
|
|
274
|
+
const account = this.findById(accountId);
|
|
275
|
+
if (!account)
|
|
276
|
+
return false;
|
|
277
|
+
return this.earliestCooldownUntil(account) > 0;
|
|
278
|
+
}
|
|
279
|
+
/** Earliest active scope expiry for aggregate health reporting. */
|
|
280
|
+
getEarliestCooldownUntil(accountId) {
|
|
281
|
+
const account = this.findById(accountId);
|
|
282
|
+
return account ? this.earliestCooldownUntil(account) : 0;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Return only aggregate, normalized cooldown scopes. Session bindings and
|
|
286
|
+
* ambiguity tokens deliberately remain internal to the router.
|
|
287
|
+
*/
|
|
288
|
+
getCooldownSummary(accountId) {
|
|
289
|
+
const account = this.findById(accountId);
|
|
290
|
+
if (!account)
|
|
291
|
+
return { globalUntilMs: 0, modelCooldowns: [] };
|
|
292
|
+
const state = this.cooldowns.get(account);
|
|
293
|
+
if (!state)
|
|
294
|
+
return { globalUntilMs: 0, modelCooldowns: [] };
|
|
295
|
+
this.clearExpiredCooldownState(account, state);
|
|
296
|
+
const current = this.cooldowns.get(account);
|
|
297
|
+
if (!current)
|
|
298
|
+
return { globalUntilMs: 0, modelCooldowns: [] };
|
|
299
|
+
return {
|
|
300
|
+
globalUntilMs: current.globalUntil,
|
|
301
|
+
modelCooldowns: [...current.modelUntil]
|
|
302
|
+
.filter(([, untilMs]) => untilMs > 0)
|
|
303
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
304
|
+
.slice(0, 12)
|
|
305
|
+
.map(([modelFamily, untilMs]) => ({ modelFamily, untilMs })),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
hardBlock(account, context) {
|
|
309
|
+
if (!account.enabled || !account.healthy)
|
|
310
|
+
return { reason: "unavailable" };
|
|
311
|
+
const nowMs = this.now();
|
|
312
|
+
const timedBlockers = [];
|
|
313
|
+
let hasIndefiniteBlocker = false;
|
|
314
|
+
let rateLimited = false;
|
|
315
|
+
const cooldownExpiry = this.getApplicableCooldownUntil(account.id, context);
|
|
316
|
+
if (cooldownExpiry > 0) {
|
|
317
|
+
rateLimited = true;
|
|
318
|
+
timedBlockers.push(cooldownExpiry);
|
|
319
|
+
}
|
|
320
|
+
if (account.rateLimits.status === "rate_limited" && !modelScopedClaim(account.rateLimits.claim)) {
|
|
321
|
+
rateLimited = true;
|
|
322
|
+
const resetAt = trustworthyResetMs(limitingReset(account), nowMs);
|
|
323
|
+
if (resetAt !== undefined)
|
|
324
|
+
timedBlockers.push(resetAt);
|
|
325
|
+
else
|
|
326
|
+
hasIndefiniteBlocker = true;
|
|
327
|
+
}
|
|
328
|
+
const exhausted = [
|
|
329
|
+
...this.globalWindows(account),
|
|
330
|
+
...this.matchingModelWindows(account, context),
|
|
331
|
+
].filter(window => window.utilization >= 1);
|
|
332
|
+
if (exhausted.length > 0 && !this.canUsePaidExtra(account)) {
|
|
333
|
+
rateLimited = true;
|
|
334
|
+
for (const window of exhausted) {
|
|
335
|
+
const resetAt = trustworthyResetMs(window.resetAt, nowMs);
|
|
336
|
+
if (resetAt !== undefined)
|
|
337
|
+
timedBlockers.push(resetAt);
|
|
338
|
+
else
|
|
339
|
+
hasIndefiniteBlocker = true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!rateLimited)
|
|
343
|
+
return null;
|
|
344
|
+
const retryAtMs = !hasIndefiniteBlocker && timedBlockers.length > 0
|
|
345
|
+
? Math.max(...timedBlockers)
|
|
346
|
+
: undefined;
|
|
347
|
+
return retryAtMs === undefined
|
|
348
|
+
? { reason: "rate_limited" }
|
|
349
|
+
: { reason: "rate_limited", retryAtMs };
|
|
350
|
+
}
|
|
351
|
+
overUserCap(account) {
|
|
352
|
+
const [fiveHour, sevenDay] = this.globalWindows(account);
|
|
353
|
+
return (account.sessionLimitPercent < 100 &&
|
|
354
|
+
fiveHour.utilization * 100 >= account.sessionLimitPercent) ||
|
|
355
|
+
(account.weeklyLimitPercent < 100 &&
|
|
356
|
+
sevenDay.utilization * 100 >= account.weeklyLimitPercent);
|
|
357
|
+
}
|
|
358
|
+
globalWindows(account) {
|
|
359
|
+
const headers = [
|
|
360
|
+
{
|
|
361
|
+
utilization: this.safeUtilization(account.rateLimits.fiveHourUtil),
|
|
362
|
+
resetAt: this.safeResetAt(account.rateLimits.fiveHourReset),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
utilization: this.safeUtilization(account.rateLimits.sevenDayUtil),
|
|
366
|
+
resetAt: this.safeResetAt(account.rateLimits.sevenDayReset),
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
const usage = account.rateLimits.usage;
|
|
370
|
+
if (!usage || usage.fetchStatus === "unavailable" || usage.fetchedAt < account.rateLimits.lastUpdated) {
|
|
371
|
+
return headers;
|
|
372
|
+
}
|
|
373
|
+
return [
|
|
374
|
+
usage.fiveHour
|
|
375
|
+
? {
|
|
376
|
+
utilization: this.safeUtilization(usage.fiveHour.utilization),
|
|
377
|
+
resetAt: this.safeResetAt(usage.fiveHour.resetAt),
|
|
378
|
+
}
|
|
379
|
+
: headers[0],
|
|
380
|
+
usage.sevenDay
|
|
381
|
+
? {
|
|
382
|
+
utilization: this.safeUtilization(usage.sevenDay.utilization),
|
|
383
|
+
resetAt: this.safeResetAt(usage.sevenDay.resetAt),
|
|
384
|
+
}
|
|
385
|
+
: headers[1],
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
matchingModelWindows(account, context) {
|
|
389
|
+
const usage = account.rateLimits.usage;
|
|
390
|
+
if (!usage || usage.fetchStatus === "unavailable")
|
|
391
|
+
return [];
|
|
392
|
+
const requestedModel = context?.requestedModel;
|
|
393
|
+
const modelFamily = context?.modelFamily;
|
|
394
|
+
if (!requestedModel && !modelFamily)
|
|
395
|
+
return [];
|
|
396
|
+
return usage.modelLimits
|
|
397
|
+
.filter(limit => (modelFamily !== undefined && limit.modelFamily === modelFamily) ||
|
|
398
|
+
(requestedModel !== undefined && limit.modelId === requestedModel))
|
|
399
|
+
.map(limit => ({
|
|
400
|
+
utilization: this.safeUtilization(limit.utilization),
|
|
401
|
+
resetAt: this.safeResetAt(limit.resetAt),
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
canUsePaidExtra(account) {
|
|
405
|
+
const usage = account.rateLimits.usage;
|
|
406
|
+
return usage?.fetchStatus === "fresh" && canUseExtraUsage(usage.extraUsage);
|
|
407
|
+
}
|
|
408
|
+
usesPaidExtra(account, context) {
|
|
409
|
+
if (!this.canUsePaidExtra(account))
|
|
410
|
+
return false;
|
|
411
|
+
return [...this.globalWindows(account), ...this.matchingModelWindows(account, context)]
|
|
412
|
+
.some(window => window.utilization >= 1);
|
|
413
|
+
}
|
|
414
|
+
safeUtilization(value) {
|
|
415
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
416
|
+
}
|
|
417
|
+
safeResetAt(value) {
|
|
418
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
419
|
+
? Math.floor(value)
|
|
420
|
+
: 0;
|
|
421
|
+
}
|
|
422
|
+
proposedExpiry(account, durationMs) {
|
|
423
|
+
if (this.findById(account.id) !== account)
|
|
424
|
+
return undefined;
|
|
425
|
+
if (!Number.isFinite(durationMs) || durationMs <= 0)
|
|
426
|
+
return undefined;
|
|
427
|
+
const expiry = this.now() + durationMs;
|
|
428
|
+
return Number.isFinite(expiry) ? expiry : undefined;
|
|
429
|
+
}
|
|
430
|
+
cooldownsFor(account) {
|
|
431
|
+
let state = this.cooldowns.get(account);
|
|
432
|
+
if (!state) {
|
|
433
|
+
state = {
|
|
434
|
+
globalUntil: 0,
|
|
435
|
+
modelUntil: new Map(),
|
|
436
|
+
definiteGlobalUntil: 0,
|
|
437
|
+
pendingAmbiguous: new Map(),
|
|
438
|
+
};
|
|
439
|
+
this.cooldowns.set(account, state);
|
|
440
|
+
}
|
|
441
|
+
return state;
|
|
442
|
+
}
|
|
443
|
+
clearExpiredCooldownState(account, state) {
|
|
444
|
+
const now = this.now();
|
|
445
|
+
if (state.definiteGlobalUntil <= now)
|
|
446
|
+
state.definiteGlobalUntil = 0;
|
|
447
|
+
for (const [token, pending] of state.pendingAmbiguous) {
|
|
448
|
+
if (pending.until <= now)
|
|
449
|
+
state.pendingAmbiguous.delete(token);
|
|
450
|
+
}
|
|
451
|
+
this.recomputeGlobalUntil(state);
|
|
452
|
+
for (const [family, expiry] of state.modelUntil) {
|
|
453
|
+
if (expiry <= now)
|
|
454
|
+
state.modelUntil.delete(family);
|
|
455
|
+
}
|
|
456
|
+
this.deleteEmptyCooldowns(account, state);
|
|
457
|
+
}
|
|
458
|
+
deleteEmptyCooldowns(account, state) {
|
|
459
|
+
if (state.globalUntil === 0 && state.modelUntil.size === 0) {
|
|
460
|
+
this.cooldowns.delete(account);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
recomputeGlobalUntil(state) {
|
|
464
|
+
let globalUntil = state.definiteGlobalUntil;
|
|
465
|
+
for (const pending of state.pendingAmbiguous.values()) {
|
|
466
|
+
globalUntil = Math.max(globalUntil, pending.until);
|
|
467
|
+
}
|
|
468
|
+
state.globalUntil = globalUntil;
|
|
469
|
+
}
|
|
470
|
+
earliestCooldownUntil(account) {
|
|
471
|
+
const state = this.cooldowns.get(account);
|
|
472
|
+
if (!state)
|
|
473
|
+
return 0;
|
|
474
|
+
this.clearExpiredCooldownState(account, state);
|
|
475
|
+
const current = this.cooldowns.get(account);
|
|
476
|
+
if (!current)
|
|
477
|
+
return 0;
|
|
478
|
+
const expiries = [current.globalUntil, ...current.modelUntil.values()].filter(value => value > 0);
|
|
479
|
+
return expiries.length > 0 ? Math.min(...expiries) : 0;
|
|
480
|
+
}
|
|
481
|
+
selectEligible(candidates, activeSessions, context) {
|
|
210
482
|
return candidates.reduce((best, account) => {
|
|
211
483
|
const comparison = this.compareTuple([
|
|
212
484
|
this.getInFlight(account.id),
|
|
213
485
|
activeSessions.get(account.id) ?? 0,
|
|
214
|
-
this.
|
|
486
|
+
this.usesPaidExtra(account, context) ? 1 : 0,
|
|
487
|
+
this.headroomScore(account, context),
|
|
215
488
|
this.circularDistance(account),
|
|
216
489
|
], [
|
|
217
490
|
this.getInFlight(best.id),
|
|
218
491
|
activeSessions.get(best.id) ?? 0,
|
|
219
|
-
this.
|
|
492
|
+
this.usesPaidExtra(best, context) ? 1 : 0,
|
|
493
|
+
this.headroomScore(best, context),
|
|
220
494
|
this.circularDistance(best),
|
|
221
495
|
]);
|
|
222
496
|
return comparison < 0 ? account : best;
|
|
223
497
|
});
|
|
224
498
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
499
|
+
headroomScore(account, context) {
|
|
500
|
+
const [fiveHour, sevenDay] = this.globalWindows(account);
|
|
501
|
+
const modelUtilization = this.matchingModelWindows(account, context)
|
|
502
|
+
.map(window => window.utilization);
|
|
503
|
+
return Math.max(this.capNormalizedUtilization(fiveHour.utilization, account.sessionLimitPercent), this.capNormalizedUtilization(sevenDay.utilization, account.weeklyLimitPercent), ...modelUtilization);
|
|
230
504
|
}
|
|
231
|
-
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
const fiveHourUtil = Number.isFinite(account.rateLimits.fiveHourUtil)
|
|
235
|
-
? Math.max(0, account.rateLimits.fiveHourUtil)
|
|
236
|
-
: 0;
|
|
237
|
-
const sevenDayUtil = Number.isFinite(account.rateLimits.sevenDayUtil)
|
|
238
|
-
? Math.max(0, account.rateLimits.sevenDayUtil)
|
|
239
|
-
: 0;
|
|
240
|
-
return Math.max(fiveHourUtil / fiveHourCap, sevenDayUtil / sevenDayCap);
|
|
505
|
+
capNormalizedUtilization(utilization, capPercent) {
|
|
506
|
+
const cap = Number.isFinite(capPercent) ? Math.max(0, capPercent / 100) : 1;
|
|
507
|
+
return cap === 0 ? Number.POSITIVE_INFINITY : utilization / cap;
|
|
241
508
|
}
|
|
242
509
|
circularDistance(account) {
|
|
243
510
|
const index = this.accounts.indexOf(account);
|
|
@@ -294,7 +561,9 @@ export class TokenPool {
|
|
|
294
561
|
const now = this.now();
|
|
295
562
|
for (const a of this.accounts) {
|
|
296
563
|
clearExpiredRateLimitWindows(a, now, this.onCooldownExpired);
|
|
297
|
-
this.
|
|
564
|
+
const state = this.cooldowns.get(a);
|
|
565
|
+
if (state)
|
|
566
|
+
this.clearExpiredCooldownState(a, state);
|
|
298
567
|
}
|
|
299
568
|
}
|
|
300
569
|
getAll() {
|
|
@@ -310,6 +579,7 @@ export class TokenPool {
|
|
|
310
579
|
busy: a.busy,
|
|
311
580
|
inFlightRequests: this.getInFlight(a.id),
|
|
312
581
|
coolingDown: this.isCoolingDown(a.id),
|
|
582
|
+
cooldownUntilMs: this.earliestCooldownUntil(a),
|
|
313
583
|
requestCount: a.requestCount,
|
|
314
584
|
errorCount: a.errorCount,
|
|
315
585
|
expiresInMs: a.tokens.expiresAt - Date.now(),
|
|
@@ -393,9 +663,10 @@ export class TokenPool {
|
|
|
393
663
|
const idx = this.accounts.findIndex(a => a.id === id);
|
|
394
664
|
if (idx === -1)
|
|
395
665
|
return false;
|
|
396
|
-
this.accounts.splice(idx, 1);
|
|
666
|
+
const [removed] = this.accounts.splice(idx, 1);
|
|
397
667
|
this.inFlight.delete(id);
|
|
398
|
-
|
|
668
|
+
if (removed)
|
|
669
|
+
this.cooldowns.delete(removed);
|
|
399
670
|
if (this.accounts.length > 0) {
|
|
400
671
|
this.currentIndex = this.currentIndex % this.accounts.length;
|
|
401
672
|
}
|