@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.
- package/CHANGELOG.md +88 -2
- package/README.md +2 -0
- package/dist/config/manager.js +15 -4
- package/dist/protocol/openai-response-to-anthropic.js +16 -1
- package/dist/protocol/openai-responses-collect.js +190 -11
- package/dist/protocol/openai-stream-to-anthropic.js +20 -2
- package/dist/protocol/sse.js +10 -1
- package/dist/providers/openai/account-state.js +377 -0
- package/dist/providers/openai/codex-transport.js +8 -0
- package/dist/providers/openai/failure-routing.js +141 -0
- package/dist/providers/openai/token-pool.js +401 -0
- package/dist/providers/openai/token-refresher.js +144 -11
- package/dist/providers/openai/usage.js +160 -0
- package/dist/proxy/account-add.js +10 -7
- package/dist/proxy/account-deletion.js +5 -1
- package/dist/proxy/account-patch.js +64 -0
- package/dist/proxy/account-pool.js +19 -0
- package/dist/proxy/anthropic-routing.js +2 -2
- package/dist/proxy/lease-lifecycle.js +6 -4
- package/dist/proxy/messages-cross-route.js +243 -62
- package/dist/proxy/openai-ingress.js +379 -0
- package/dist/proxy/openai-routing.js +61 -0
- package/dist/proxy/provider-routing.js +8 -4
- package/dist/proxy/responses-server.js +109 -48
- package/dist/proxy/server.js +241 -65
- package/dist/proxy/stats.js +30 -1
- package/dist/proxy/token-pool.js +3 -19
- package/dist/ui/Dashboard.js +93 -17
- package/package.json +1 -1
- package/dist/providers/openai/account-pool.js +0 -11
|
@@ -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
|
+
}
|