@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.
@@ -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
- /** Returns the earliest non-zero reset timestamp (seconds) for an account. */
9
- function earliestReset(a) {
10
- const r = a.rateLimits;
11
- if (r.fiveHourReset && r.sevenDayReset)
12
- return Math.min(r.fiveHourReset, r.sevenDayReset);
13
- return r.fiveHourReset || r.sevenDayReset || Infinity;
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); falls back to the earliest non-zero reset.
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
- const earliest = earliestReset(a);
28
- return Number.isFinite(earliest) ? earliest : 0;
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
- changed = true;
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
- cooldownUntil = new Map();
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
- * Selection considers accounts that are:
103
- * healthy
104
- * not busy
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 eligible = this.accounts.filter(account => this.isEligibleWithoutSweep(account));
139
- if (eligible.length > 0) {
140
- const account = this.selectEligible(eligible, activeSessions);
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
- const healthyUsable = this.accounts.filter(account => account.healthy && isUsable(account));
145
- const healthy = this.accounts.filter(account => account.healthy);
146
- const fallbackCandidates = healthyUsable.length > 0
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
- return this.createLease(account, true);
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 (!this.isEligibleWithoutSweep(account))
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.isEligibleWithoutSweep(account);
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.setCooldownForAccount(account, durationMs);
194
+ this.setGlobalCooldownForAccount(account, durationMs);
182
195
  }
183
- /** Apply cooldown only to the exact account incarnation that was routed. */
196
+ /** Compatibility alias retained for callers that have not adopted scopes. */
184
197
  setCooldownForAccount(account, durationMs) {
185
- if (this.findById(account.id) !== account)
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
- if (!Number.isFinite(durationMs) || durationMs <= 0)
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 proposedExpiry = this.now() + durationMs;
190
- const existingExpiry = this.cooldownUntil.get(account.id) ?? 0;
191
- this.cooldownUntil.set(account.id, Math.max(existingExpiry, proposedExpiry));
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
- isCoolingDown(accountId) {
194
- const until = this.cooldownUntil.get(accountId);
195
- if (until === undefined)
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
- if (this.now() < until)
198
- return true;
199
- this.cooldownUntil.delete(accountId);
200
- return false;
201
- }
202
- isEligibleWithoutSweep(account) {
203
- return account.healthy &&
204
- !account.busy &&
205
- !this.isCoolingDown(account.id) &&
206
- account.rateLimits.status !== "rate_limited" &&
207
- isUsable(account);
208
- }
209
- selectEligible(candidates, activeSessions) {
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.headroomScore(account),
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.headroomScore(best),
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
- selectFallback(candidates) {
226
- return candidates.reduce((best, account) => {
227
- const comparison = this.compareTuple([this.getInFlight(account.id), earliestReset(account), this.circularDistance(account)], [this.getInFlight(best.id), earliestReset(best), this.circularDistance(best)]);
228
- return comparison < 0 ? account : best;
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
- headroomScore(account) {
232
- const fiveHourCap = account.sessionLimitPercent / 100;
233
- const sevenDayCap = account.weeklyLimitPercent / 100;
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.isCoolingDown(a.id);
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
- this.cooldownUntil.delete(id);
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
  }