@timo972/cc-router 0.9.0 → 0.10.0-rc.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.
@@ -0,0 +1,377 @@
1
+ import { ACCOUNT_USER_DEFAULTS, clampPercent } from "../../proxy/types.js";
2
+ import { DEFAULT_CODEX_LIMIT_ID, MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC, createEmptyCodexRateLimits, decodeOpenAIPlan, } from "./usage.js";
3
+ const MAX_MODEL_BUCKET_ENTRIES = 32;
4
+ const DEFAULT_OPENAI_SCOPES = ["openid", "profile", "email", "offline_access"];
5
+ // Fallback staleness window used when a rate-limit window is fully exhausted
6
+ // (utilization >= 1) but its resetAt is untrustworthy (0 — past, absent, or
7
+ // malformed per usage.ts's parseResetAtSeconds). Without a reset to wait out,
8
+ // such a window would otherwise block the account forever. Once the snapshot
9
+ // backing it hasn't been refreshed for longer than its own reported window
10
+ // length (or this default when no window length was reported), we treat it as
11
+ // stale and self-heal by clearing it on the next sweep.
12
+ const STALE_DEFAULT_WINDOW_MINUTES = 300;
13
+ // Named buckets are entirely upstream-controlled: every distinct limitId the
14
+ // Codex backend ever mentions gets its own entry. A cap bounds memory growth
15
+ // from a buggy or malicious upstream minting unbounded distinct ids. 16 is
16
+ // generous for any real deployment (the default bucket plus a small handful
17
+ // of per-model metered buckets) while still being a hard ceiling.
18
+ const MAX_CODEX_BUCKETS = 16;
19
+ // How long a named bucket can go completely unmentioned by upstream before
20
+ // the sweep reaps it outright, independent of its last-known window state.
21
+ // Reuses the same 8-day header-trust horizon already enforced when parsing
22
+ // reset-at/window-minutes values (usage.ts) and when trusting a reset in
23
+ // token-pool.ts: any resetAt a bucket last reported is itself capped to that
24
+ // horizon, so once a bucket has gone unmentioned for longer than it, every
25
+ // window it reported has necessarily already expired via the per-window
26
+ // check below — this only catches the residual case where a window's
27
+ // resetAt was untrustworthy (0) and it never reached full exhaustion, so
28
+ // nothing else would ever clear it. Deliberately much larger than
29
+ // STALE_DEFAULT_WINDOW_MINUTES above, which self-heals a single exhausted
30
+ // window on the timescale of that window's own length (hours), not the
31
+ // timescale of "upstream stopped mentioning this bucket at all" (days).
32
+ const UNMENTIONED_BUCKET_STALE_MS = MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC * 1_000;
33
+ export function createOpenAIAccount(record) {
34
+ const plan = decodeOpenAIPlan(record.accessToken);
35
+ const rateLimits = {
36
+ ...createEmptyCodexRateLimits(),
37
+ ...(plan ? { plan } : {}),
38
+ };
39
+ return {
40
+ ...record,
41
+ scopes: record.scopes ?? [...DEFAULT_OPENAI_SCOPES],
42
+ sessionLimitPercent: record.sessionLimitPercent !== undefined
43
+ ? clampPercent(record.sessionLimitPercent)
44
+ : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
45
+ weeklyLimitPercent: record.weeklyLimitPercent !== undefined
46
+ ? clampPercent(record.weeklyLimitPercent)
47
+ : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
48
+ healthy: true,
49
+ requestCount: 0,
50
+ errorCount: 0,
51
+ consecutiveErrors: 0,
52
+ lastUsed: 0,
53
+ lastRefresh: 0,
54
+ rateLimits,
55
+ modelBuckets: new Map(),
56
+ };
57
+ }
58
+ /**
59
+ * Evict the least-recently-seen named bucket to make room for a new one at
60
+ * the cap. Never touches the default bucket. Map insertion order does not
61
+ * track `lastSeenAt` (an existing entry re-touched via `.set()` keeps its
62
+ * original slot), so this scans for the minimum rather than relying on
63
+ * iteration order — unlike `learnModelBucket`'s LRU below, which can rely on
64
+ * insertion order because it never re-touches an existing key's position.
65
+ */
66
+ function evictLeastRecentlySeenBucket(limits) {
67
+ let oldestId;
68
+ let oldestSeenAt = Infinity;
69
+ for (const [limitId, bucket] of limits.buckets) {
70
+ if (limitId === DEFAULT_CODEX_LIMIT_ID)
71
+ continue;
72
+ const seenAt = bucket.lastSeenAt ?? 0;
73
+ if (seenAt < oldestSeenAt) {
74
+ oldestSeenAt = seenAt;
75
+ oldestId = limitId;
76
+ }
77
+ }
78
+ if (oldestId === undefined)
79
+ return;
80
+ limits.buckets.delete(oldestId);
81
+ // Deliberately leave `modelBuckets` mappings alone: this evicts a bucket
82
+ // upstream is still actively mentioning (there just isn't room for its
83
+ // snapshot), unlike the sweep's staleness reap below, which only fires once
84
+ // a bucket is confirmed abandoned. The mapping is already bounded at
85
+ // MAX_MODEL_BUCKET_ENTRIES, and `bucketIdForModel` resolves a cooldown from
86
+ // it alone even with no bucket snapshot present, so dropping it here would
87
+ // only lose a still-live cooldown mapping for no benefit.
88
+ }
89
+ /**
90
+ * Pick the window to keep for one slot of a bucket, and record when it was
91
+ * last actually reported. A freshly reported window is stamped `nowMs`; a
92
+ * window this response said nothing about keeps both its values and its older
93
+ * stamp, so its age reflects the last time upstream really mentioned it.
94
+ */
95
+ function stampWindow(reported, existing, nowMs) {
96
+ if (reported)
97
+ return { ...reported, lastSeenAt: nowMs };
98
+ return existing;
99
+ }
100
+ export function applyCodexRateLimits(account, update, nowMs) {
101
+ const limits = account.rateLimits;
102
+ for (const bucket of update.buckets) {
103
+ const existing = limits.buckets.get(bucket.limitId);
104
+ if (!existing && limits.buckets.size >= MAX_CODEX_BUCKETS) {
105
+ evictLeastRecentlySeenBucket(limits);
106
+ }
107
+ // Built field by field rather than spread over `existing` on purpose: a
108
+ // bucket upstream has just reported is live again, so any pending-reap
109
+ // mark the sweep left on it has to be dropped here.
110
+ const merged = { limitId: bucket.limitId };
111
+ const limitName = bucket.limitName ?? existing?.limitName;
112
+ if (limitName)
113
+ merged.limitName = limitName;
114
+ // Each window is stamped only when this response actually reported it. A
115
+ // retained window has not been heard from, however recently the bucket as
116
+ // a whole was: an upstream that keeps sending one window and not the other
117
+ // would otherwise keep resetting the age of the silent one, and an
118
+ // exhausted window with no reset time recovers *only* by ageing out — so
119
+ // it would sit at 100% forever, blocking its model on this account with
120
+ // nothing left to clear it.
121
+ const primary = stampWindow(bucket.primary, existing?.primary, nowMs);
122
+ if (primary)
123
+ merged.primary = primary;
124
+ const secondary = stampWindow(bucket.secondary, existing?.secondary, nowMs);
125
+ if (secondary)
126
+ merged.secondary = secondary;
127
+ // The bucket's own timestamp still tracks the bucket: it answers "is
128
+ // upstream still mentioning this bucket at all", which is what the
129
+ // unmentioned-bucket reap needs.
130
+ merged.lastSeenAt = nowMs;
131
+ limits.buckets.set(bucket.limitId, merged);
132
+ }
133
+ if (update.credits)
134
+ limits.credits = update.credits;
135
+ if (update.buckets.length > 0 || update.credits)
136
+ limits.lastUpdated = nowMs;
137
+ }
138
+ function normalizeModelSlug(model) {
139
+ const normalized = model?.trim().toLowerCase();
140
+ return normalized ? normalized.slice(0, 64) : undefined;
141
+ }
142
+ /**
143
+ * Record that `modelSlug` belongs to `limitId`, as of `learnedAtMs` — the
144
+ * moment the evidence was produced, not the moment this is called, so a
145
+ * mapping refreshed for LRU purposes does not pass itself off as newer than
146
+ * it is.
147
+ */
148
+ export function learnModelBucket(account, modelSlug, limitId, learnedAtMs) {
149
+ const model = normalizeModelSlug(modelSlug);
150
+ if (!model || limitId === DEFAULT_CODEX_LIMIT_ID)
151
+ return;
152
+ if (account.modelBuckets.has(model)) {
153
+ // A `Map.set` on an existing key keeps its original insertion position, so
154
+ // relearning a mapping would leave it first in line for eviction however
155
+ // recently it was used. Delete before re-adding to move it to the end and
156
+ // make the eviction below true LRU: otherwise a header-only 429 could
157
+ // learn a mapping and then have it evicted while its bucket cooldown is
158
+ // still live, which loses the model→bucket association `hardBlock` needs
159
+ // to keep that model off the cooling account.
160
+ account.modelBuckets.delete(model);
161
+ }
162
+ else if (account.modelBuckets.size >= MAX_MODEL_BUCKET_ENTRIES) {
163
+ const oldest = account.modelBuckets.keys().next().value;
164
+ if (oldest !== undefined)
165
+ account.modelBuckets.delete(oldest);
166
+ }
167
+ account.modelBuckets.set(model, { limitId, learnedAt: learnedAtMs });
168
+ }
169
+ /**
170
+ * Resolve the rate-limit bucket id a model is mapped to, without requiring a
171
+ * bucket snapshot to exist. A header-only 429 (no accompanying rate-limit
172
+ * snapshot for that bucket) still learns and keeps a model->limitId mapping
173
+ * via `learnModelBucket`, and callers that only need the id to look up an
174
+ * independently-tracked cooldown (e.g. `OpenAITokenPool.hardBlock`) must not
175
+ * lose that mapping just because no bucket snapshot has arrived yet.
176
+ */
177
+ export function bucketIdForModel(account, modelSlug) {
178
+ const model = normalizeModelSlug(modelSlug);
179
+ if (!model)
180
+ return undefined;
181
+ // A live bucket that names this model wins over the cached mapping. Upstream
182
+ // can move a model to a different limit id, and the freshly reported bucket
183
+ // is the one carrying current exhaustion data — trusting the cache first
184
+ // would keep consulting a superseded (or already-reaped) bucket, leaving an
185
+ // exhausted replacement eligible until a 429 happened to relearn it. When
186
+ // several buckets name the model, the most recently seen one is current.
187
+ //
188
+ // Recency cannot break every tie: one response stamps every bucket it
189
+ // carries with the same `lastSeenAt`, so two buckets naming the same model
190
+ // arrive indistinguishable. Falling back to Map insertion order there would
191
+ // silently outrank the cached mapping — and the cache is where a 429's
192
+ // `x-codex-active-limit` records which bucket upstream itself said was
193
+ // limiting. Prefer it on a tie, or a bucket cooldown keyed on the active
194
+ // limit stops being found and the rate-limited account goes back in rotation.
195
+ const cached = account.modelBuckets.get(model);
196
+ let live;
197
+ for (const bucket of account.rateLimits.buckets.values()) {
198
+ if (bucket.limitId === DEFAULT_CODEX_LIMIT_ID)
199
+ continue;
200
+ if (bucket.limitName?.trim().toLowerCase() !== model)
201
+ continue;
202
+ if (live === undefined) {
203
+ live = bucket;
204
+ continue;
205
+ }
206
+ const seenAt = bucket.lastSeenAt ?? 0;
207
+ const bestSeenAt = live.lastSeenAt ?? 0;
208
+ if (seenAt > bestSeenAt || (seenAt === bestSeenAt && bucket.limitId === cached?.limitId))
209
+ live = bucket;
210
+ }
211
+ if (live !== undefined) {
212
+ const liveSeenAt = live.lastSeenAt ?? 0;
213
+ // "Live wins" is really "the newer evidence wins", and a snapshot is not
214
+ // automatically the newer one. A header-only 429 carries no snapshot at
215
+ // all: it maps the model through `x-codex-active-limit` and leaves the
216
+ // previous bucket sitting in `rateLimits.buckets`, still naming the model
217
+ // from before the move. Relearning that older snapshot here would both
218
+ // resolve to the wrong bucket — missing the cooldown the 429 just set,
219
+ // sending the account straight back out for a model upstream told us to
220
+ // back off — and overwrite the mapping, so no later lookup could recover
221
+ // it either.
222
+ //
223
+ // Equal timestamps go to the mapping, for the same reason the loop above
224
+ // breaks its own ties that way. A 429 that carries rate-limit headers
225
+ // *and* `x-codex-active-limit` is applied and routed from one `now`, so
226
+ // the snapshot it refreshes and the mapping it learns are stamped
227
+ // identically — the common case, not a freak collision. Of the two, only
228
+ // the active-limit header is upstream naming the bucket that limited this
229
+ // request; the snapshot merely lists what exists.
230
+ if (cached !== undefined && cached.learnedAt >= liveSeenAt) {
231
+ // Re-learn at its own timestamp: this refreshes LRU recency without
232
+ // letting the mapping claim to be newer evidence than it is.
233
+ learnModelBucket(account, model, cached.limitId, cached.learnedAt);
234
+ return cached.limitId;
235
+ }
236
+ // Re-learning also refreshes the mapping's LRU recency, so a model that
237
+ // keeps routing stays mapped.
238
+ learnModelBucket(account, model, live.limitId, liveSeenAt);
239
+ return live.limitId;
240
+ }
241
+ // No live bucket names this model. The cached mapping is what keeps a
242
+ // header-only 429's bucket cooldown enforceable — that path never carries a
243
+ // bucket snapshot, so the cache is the only association available.
244
+ if (cached === undefined)
245
+ return undefined;
246
+ // Re-insert before returning, at its own evidence timestamp. Reading a Map
247
+ // does not move the key, so a mapping with no snapshot behind it would stay
248
+ // frozen at its original insertion position no matter how often it is
249
+ // consulted — and be evicted as "oldest" the moment a 33rd model is learned,
250
+ // even while the cooldown it exists to resolve is still live. Every other
251
+ // return path already relearns; this one has to as well.
252
+ learnModelBucket(account, model, cached.limitId, cached.learnedAt);
253
+ return cached.limitId;
254
+ }
255
+ export function bucketForModel(account, modelSlug) {
256
+ const limitId = bucketIdForModel(account, modelSlug);
257
+ return limitId === undefined ? undefined : account.rateLimits.buckets.get(limitId);
258
+ }
259
+ /**
260
+ * Give a reset-less exhausted window the retry time the upstream just
261
+ * advertised. A 429 reporting 100% utilization with a `Retry-After` but no
262
+ * reset header leaves `resetAt: 0`, which the pool can only read as "blocked
263
+ * indefinitely": it suppresses the `retryAtMs` the client would otherwise be
264
+ * told, and it outlives the cooldown derived from that very `Retry-After`,
265
+ * keeping the account unroutable until the multi-hour staleness sweep.
266
+ *
267
+ * The cooldown expiry is the best reset available — it is literally when the
268
+ * upstream said to come back — so recording it bounds recovery by what was
269
+ * advertised rather than by a fallback measured in hours. A later response
270
+ * carrying a real reset overwrites it.
271
+ */
272
+ export function boundResetlessExhaustedWindows(account, limitId, expiryMs) {
273
+ const bucket = account.rateLimits.buckets.get(limitId);
274
+ if (!bucket)
275
+ return;
276
+ const resetAt = Math.ceil(expiryMs / 1000);
277
+ if (!Number.isFinite(resetAt) || resetAt <= 0)
278
+ return;
279
+ for (const window of [bucket.primary, bucket.secondary]) {
280
+ if (window !== undefined && window.utilization >= 1 && window.resetAt === 0) {
281
+ window.resetAt = resetAt;
282
+ }
283
+ }
284
+ }
285
+ /**
286
+ * A window reporting `resetAt === 0` (untrustworthy — past, absent, or
287
+ * malformed, per parseResetAtSeconds) has no expiry to wait out, so the only
288
+ * thing that can retire its number is age. Once it has gone unreported for
289
+ * longer than its own window length, whatever utilization it claims describes
290
+ * a window that has since rolled over, and acting on it means acting on
291
+ * fiction.
292
+ *
293
+ * This deliberately does not require full exhaustion. A window stuck at, say,
294
+ * 80% excludes the account just as durably wherever a user cap sits below
295
+ * that — and worse, quietly: the account is skipped, so it receives no
296
+ * response to refresh the snapshot, so the stale number never changes. The
297
+ * traffic it would have taken goes to whichever accounts are left.
298
+ */
299
+ function isStaleResetlessWindow(window, lastSeenAtMs, nowMs) {
300
+ if (!window || window.resetAt !== 0 || window.utilization <= 0)
301
+ return false;
302
+ const staleAfterMs = (window.windowMinutes > 0 ? window.windowMinutes : STALE_DEFAULT_WINDOW_MINUTES) * 60_000;
303
+ return nowMs - lastSeenAtMs > staleAfterMs;
304
+ }
305
+ export function sweepCodexRateLimits(account, nowMs, options) {
306
+ const isRetained = options?.isRetained ?? (() => false);
307
+ const nowSec = Math.floor(nowMs / 1000);
308
+ let recovered = false;
309
+ for (const [limitId, bucket] of account.rateLimits.buckets) {
310
+ // Buckets recorded before lastSeenAt existed fall back to the
311
+ // account-wide timestamp.
312
+ const bucketSeenAt = bucket.lastSeenAt ?? account.rateLimits.lastUpdated;
313
+ const windows = [bucket.primary, bucket.secondary];
314
+ const expired = windows.map(window => window !== undefined
315
+ && ((window.resetAt > 0 && nowSec >= window.resetAt)
316
+ // Age each window by when it was last reported, not by when the bucket
317
+ // was: a window upstream has stopped mentioning is exactly the one
318
+ // this check exists to recover, and the bucket's timestamp keeps
319
+ // moving as long as the *other* window is still being sent.
320
+ || isStaleResetlessWindow(window, window.lastSeenAt ?? bucketSeenAt, nowMs)));
321
+ // Zero each individually-expired window in place — for both the default
322
+ // bucket and named buckets alike. A named bucket reporting a 5h primary
323
+ // window and a 6-day secondary window must recover the primary the
324
+ // moment it resets, not sit on it until the secondary also clears.
325
+ let anyWindowExpiredHere = false;
326
+ windows.forEach((window, index) => {
327
+ if (!window || !expired[index])
328
+ return;
329
+ anyWindowExpiredHere = true;
330
+ if (window.utilization >= 1)
331
+ recovered = true;
332
+ window.utilization = 0;
333
+ window.resetAt = 0;
334
+ });
335
+ if (limitId === DEFAULT_CODEX_LIMIT_ID)
336
+ continue;
337
+ // Named buckets (and their model mappings) are cleaned up entirely once
338
+ // every window the bucket still carries is at exactly zero utilization
339
+ // and a window expired this sweep — not merely "below 100%". A bucket
340
+ // reporting a zeroed 5h primary alongside a still-live 30% secondary is
341
+ // still meaningfully tracking usage and must be kept, snapshot and
342
+ // mapping intact, until the secondary clears too.
343
+ const allZero = windows.every(window => window === undefined || window.utilization === 0);
344
+ // Zeroing a window is a one-time observation: it leaves `utilization` and
345
+ // `resetAt` at 0, which no later sweep can tell apart from a bucket that
346
+ // has simply never been used, so `anyWindowExpiredHere` is false from here
347
+ // on. Record the verdict now, or a bucket that had to be held back below
348
+ // could never be reaped by this rule again.
349
+ if (anyWindowExpiredHere && allZero)
350
+ bucket.reapPending = true;
351
+ // A bucket the pool is actively cooling down on (a bucket-scoped
352
+ // cooldown learned independently of this snapshot, e.g. from a
353
+ // header-only 429) must keep both its snapshot and its model mapping for
354
+ // as long as that cooldown is live, even if every window it reports has
355
+ // just been zeroed above. The mark set above is what carries the reap
356
+ // across that wait: without it the bucket would linger, zeroed and
357
+ // unmentioned, until the eight-day stale reap — still naming its model in
358
+ // `bucketIdForModel`, where a live bucket outranks the cached mapping, so
359
+ // a header-only 429 that moved the model to a new bucket would keep
360
+ // resolving to this corpse and its long-expired cooldown.
361
+ if (isRetained(limitId))
362
+ continue;
363
+ // Independently, a bucket upstream has simply stopped mentioning at all
364
+ // for longer than the header-trust horizon is reaped outright — see
365
+ // UNMENTIONED_BUCKET_STALE_MS above for why this can't collide with the
366
+ // zero-utilization check just above.
367
+ const unmentionedTooLong = nowMs - bucketSeenAt > UNMENTIONED_BUCKET_STALE_MS;
368
+ if (bucket.reapPending === true || unmentionedTooLong) {
369
+ account.rateLimits.buckets.delete(limitId);
370
+ for (const [model, mapping] of account.modelBuckets) {
371
+ if (mapping.limitId === limitId)
372
+ account.modelBuckets.delete(model);
373
+ }
374
+ }
375
+ }
376
+ return recovered;
377
+ }
@@ -10,6 +10,7 @@ export async function forwardOpenAICodexResponse(opts) {
10
10
  accept: "text/event-stream",
11
11
  },
12
12
  body: JSON.stringify(body),
13
+ ...(opts.signal ? { signal: opts.signal } : {}),
13
14
  });
14
15
  return ensureEventStreamContentType(upstream);
15
16
  }
@@ -26,6 +27,13 @@ function ensureEventStreamContentType(upstream) {
26
27
  const contentType = upstream.headers.get("content-type");
27
28
  if (contentType?.includes("text/event-stream"))
28
29
  return upstream;
30
+ // Only a successful response is actually an event stream that lost its
31
+ // content-type header. A non-OK response (401/429/5xx) is typically a
32
+ // plain JSON or text error body — rewriting its content-type would make
33
+ // callers parse it as SSE and misreport a real upstream failure as an
34
+ // empty success. Let it pass through with whatever content-type it has.
35
+ if (!upstream.ok)
36
+ return upstream;
29
37
  const headers = new Headers(upstream.headers);
30
38
  headers.set("content-type", "text/event-stream");
31
39
  return new Response(upstream.body, {
@@ -0,0 +1,141 @@
1
+ import { futureExpiry, resetHeaderExpiry, retryAfterExpiry, } from "../../proxy/lease-lifecycle.js";
2
+ import { boundResetlessExhaustedWindows, learnModelBucket } from "./account-state.js";
3
+ import { DEFAULT_CODEX_LIMIT_ID, resolveActiveLimit } from "./usage.js";
4
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
5
+ const AUTH_FAILURE_COOLDOWN_MS = 30_000;
6
+ const OVERLOAD_COOLDOWN_MS = 30_000;
7
+ function header(headers, name) {
8
+ for (const [key, value] of Object.entries(headers)) {
9
+ if (key.toLowerCase() === name)
10
+ return value;
11
+ }
12
+ return undefined;
13
+ }
14
+ function headerNumber(headers, name) {
15
+ const raw = header(headers, name);
16
+ if (typeof raw !== "string" && typeof raw !== "number")
17
+ return undefined;
18
+ const parsed = Number(raw);
19
+ return Number.isFinite(parsed) ? parsed : undefined;
20
+ }
21
+ function headerWindowCandidates(headers, prefix, kind, nowMs) {
22
+ const usedPercent = headerNumber(headers, `${prefix}-${kind}-used-percent`);
23
+ const exhausted = usedPercent !== undefined && usedPercent >= 100;
24
+ // One window, one candidate: absolute first, relative only when the absolute
25
+ // is unusable — the precedence `parseResetAtSeconds` already applies when it
26
+ // builds the snapshot. Offering both as independent candidates let the
27
+ // exhausted-window `max` below pick whichever was larger, so a stale
28
+ // `reset-after-seconds` could outrank the authoritative `reset-at` and hold
29
+ // the account out long past the moment upstream said it would be free.
30
+ const expiry = resetHeaderExpiry(header(headers, `${prefix}-${kind}-reset-at`), nowMs)
31
+ ?? retryAfterExpiry(header(headers, `${prefix}-${kind}-reset-after-seconds`), nowMs);
32
+ return expiry === undefined ? [] : [{ expiry, exhausted }];
33
+ }
34
+ function bucketWindowCandidates(account, limitId, nowMs) {
35
+ const bucket = account.rateLimits.buckets.get(limitId);
36
+ return [bucket?.primary, bucket?.secondary]
37
+ .filter((window) => window !== undefined && window.resetAt > 0)
38
+ .flatMap(window => {
39
+ const expiry = futureExpiry(window.resetAt * 1_000, nowMs);
40
+ return expiry === undefined ? [] : [{ expiry, exhausted: window.utilization >= 1 }];
41
+ });
42
+ }
43
+ /**
44
+ * A 429's cooldown must wait out every window that's actually exhausted
45
+ * (both a 5h and a 7d/weekly window are usually reported together, but only
46
+ * one may have actually run out) — never the furthest-out window merely
47
+ * because it was mentioned. In priority order:
48
+ * 1. Retry-After, when present, is always a candidate (a floor the server
49
+ * asked us to respect).
50
+ * 2. Resets of windows known to be exhausted (used-percent/utilization
51
+ * >= 100%), from both the failure headers and the account's own bucket
52
+ * snapshot. If Retry-After or an exhausted-window reset exists, the
53
+ * cooldown is the max of these — every exhausted window must clear.
54
+ * 3. Otherwise (nothing is known to be exhausted), fall back to the
55
+ * *soonest* known future window reset, header or snapshot — the
56
+ * shortest window is the likeliest limiter, and erring short just
57
+ * retries sooner rather than blocking for a week on a guess.
58
+ * 4. Nothing known at all -> the default cooldown.
59
+ */
60
+ function cooldownCandidates(headers, account, limitId, nowMs) {
61
+ const prefix = `x-${limitId.replace(/_/g, "-")}`;
62
+ const retryAfter = retryAfterExpiry(header(headers, "retry-after"), nowMs);
63
+ const windowCandidates = [
64
+ ...headerWindowCandidates(headers, prefix, "primary", nowMs),
65
+ ...headerWindowCandidates(headers, prefix, "secondary", nowMs),
66
+ ...bucketWindowCandidates(account, limitId, nowMs),
67
+ ];
68
+ const exhaustedExpiries = windowCandidates.filter(candidate => candidate.exhausted).map(candidate => candidate.expiry);
69
+ return {
70
+ known: retryAfter !== undefined ? [retryAfter, ...exhaustedExpiries] : exhaustedExpiries,
71
+ all: windowCandidates.map(candidate => candidate.expiry),
72
+ };
73
+ }
74
+ function rateLimitCooldownMs(headers, account, limitId, nowMs) {
75
+ const { known, all } = cooldownCandidates(headers, account, limitId, nowMs);
76
+ if (known.length > 0)
77
+ return Math.max(...known) - nowMs;
78
+ if (all.length > 0)
79
+ return Math.min(...all) - nowMs;
80
+ return DEFAULT_RATE_LIMIT_COOLDOWN_MS;
81
+ }
82
+ /**
83
+ * How long to keep an account out after a 503/529.
84
+ *
85
+ * Upstream asking for a specific backoff is the whole point of `Retry-After`,
86
+ * and a flat 30s brings the account back early to hit a service that said it
87
+ * needed longer. An exhausted window counts too — it would keep this account
88
+ * out regardless, so a shorter overload cooldown would only produce a request
89
+ * that fails again.
90
+ *
91
+ * What is deliberately *not* consulted is the "soonest future reset" fallback
92
+ * the rate-limit path ends on. An overload is an availability event, not a
93
+ * quota one: a 5h window resetting three hours from now says nothing about
94
+ * how long a blip lasts, and treating it as a floor would take a healthy
95
+ * account out for hours over one 503.
96
+ */
97
+ function overloadCooldownMs(headers, account, nowMs) {
98
+ const { known } = cooldownCandidates(headers, account, DEFAULT_CODEX_LIMIT_ID, nowMs);
99
+ return known.length > 0 ? Math.max(...known) - nowMs : OVERLOAD_COOLDOWN_MS;
100
+ }
101
+ /**
102
+ * Apply only routing state changes implied by an upstream Codex failure. The
103
+ * failed response itself is relayed byte-for-byte by the caller.
104
+ */
105
+ export function applyCodexFailureRouting(status, failureHeaders, route, requestedModel, router, pool, now = Date.now) {
106
+ // Only 503/529 signal the *upstream service* is overloaded — worth cooling
107
+ // the whole account down and rebinding elsewhere. Other 5xx (500, 502,
108
+ // 504, ...) are far more often an isolated per-request hiccup; blacking
109
+ // out the account as "rate limited" for them would take a healthy account
110
+ // out of rotation for something that has nothing to do with its rate
111
+ // limit. The ingress still counts them as errors for stats regardless.
112
+ const isOverload = status === 503 || status === 529;
113
+ if (status !== 401 && status !== 429 && !isOverload)
114
+ return {};
115
+ if (route.sessionId !== undefined && route.bindingGeneration !== undefined) {
116
+ router.invalidate(route.sessionId, route.account.id, route.bindingGeneration);
117
+ }
118
+ const nowMs = now();
119
+ if (status === 429) {
120
+ const activeLimit = resolveActiveLimit(failureHeaders);
121
+ if (activeLimit !== undefined && activeLimit !== DEFAULT_CODEX_LIMIT_ID) {
122
+ learnModelBucket(route.account, requestedModel, activeLimit, nowMs);
123
+ const durationMs = rateLimitCooldownMs(failureHeaders, route.account, activeLimit, nowMs);
124
+ pool.setBucketCooldownForAccount(route.account, activeLimit, durationMs);
125
+ boundResetlessExhaustedWindows(route.account, activeLimit, nowMs + durationMs);
126
+ return { cooldownSeconds: durationMs / 1_000, limitingScope: `bucket:${activeLimit}` };
127
+ }
128
+ const durationMs = rateLimitCooldownMs(failureHeaders, route.account, DEFAULT_CODEX_LIMIT_ID, nowMs);
129
+ pool.setGlobalCooldownForAccount(route.account, durationMs, "rate_limit");
130
+ boundResetlessExhaustedWindows(route.account, DEFAULT_CODEX_LIMIT_ID, nowMs + durationMs);
131
+ route.account.rateLimits.status = "rate_limited";
132
+ return { cooldownSeconds: durationMs / 1_000, limitingScope: "global" };
133
+ }
134
+ const durationMs = status === 401
135
+ ? AUTH_FAILURE_COOLDOWN_MS
136
+ : overloadCooldownMs(failureHeaders, route.account, nowMs);
137
+ // Neither an auth failure nor an upstream overload is a spent quota, so
138
+ // neither may surface to a client as a rate limit.
139
+ pool.setGlobalCooldownForAccount(route.account, durationMs, "unavailable");
140
+ return { cooldownSeconds: durationMs / 1_000, limitingScope: "global" };
141
+ }