@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.
@@ -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, onExpired) {
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 = 0;
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 = 0;
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
- clearExpiredRateLimitWindows(account, this.now(), this.onCooldownExpired);
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
- clearExpiredRateLimitWindows(account, this.now(), this.onCooldownExpired);
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
- /** Apply an account-global cooldown to the exact account incarnation routed. */
185
- setGlobalCooldownForAccount(account, durationMs) {
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
- state.definiteGlobalUntil = Math.max(state.definiteGlobalUntil, expiry);
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.set(family, Math.max(state.modelUntil.get(family) ?? 0, expiry));
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.set(family, Math.max(state.modelUntil.get(family) ?? 0, expiry));
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) ?? 0 : 0);
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
- .filter(([, untilMs]) => untilMs > 0)
287
- .sort(([left], [right]) => left.localeCompare(right))
288
- .slice(0, 12)
289
- .map(([modelFamily, untilMs]) => ({ modelFamily, untilMs })),
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" || usage.fetchedAt < account.rateLimits.lastUpdated) {
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
- definiteGlobalUntil: 0,
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
- if (state.definiteGlobalUntil <= now)
430
- state.definiteGlobalUntil = 0;
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
- this.recomputeGlobalUntil(state);
436
- for (const [family, expiry] of state.modelUntil) {
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 = state.definiteGlobalUntil;
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 = [current.globalUntil, ...current.modelUntil.values()].filter(value => value > 0);
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 now = this.now();
546
- for (const a of this.accounts) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {