@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.
- package/CHANGELOG.md +106 -0
- package/README.md +4 -3
- package/dist/config/manager.js +9 -0
- package/dist/providers/anthropic/rate-limit-headers.js +44 -0
- package/dist/providers/anthropic/usage.js +24 -5
- package/dist/proxy/anthropic-messages-route.js +456 -0
- package/dist/proxy/anthropic-response-capture.js +40 -0
- package/dist/proxy/event-sequence.js +18 -0
- package/dist/proxy/lease-lifecycle.js +20 -13
- package/dist/proxy/messages-cross-route.js +7 -0
- package/dist/proxy/openai-ingress.js +207 -108
- package/dist/proxy/responses-server.js +9 -1
- package/dist/proxy/server.js +81 -109
- package/dist/proxy/stats.js +21 -2
- package/dist/proxy/token-pool.js +302 -37
- package/dist/proxy/upstream-retry.js +87 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { stats, boundModelId, createLocalRoutingErrorLog } from "./stats.js";
|
|
|
6
6
|
import { logError } from "./logger.js";
|
|
7
7
|
import { EmptyPoolError, NoEligibleAccountError } from "./account-pool.js";
|
|
8
8
|
import { acquireRequestRoute, routeReasonDetails, routeFailureDetails } from "./lease-lifecycle.js";
|
|
9
|
+
import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
|
|
9
10
|
/**
|
|
10
11
|
* Mirrors `anthropic-routing.ts`'s `requestTerminated` check. This ingress
|
|
11
12
|
* path never threads the raw `Request` through (only `Response`), so it
|
|
@@ -158,46 +159,64 @@ export async function runOpenAIIngress(opts) {
|
|
|
158
159
|
res.status(500).json(envelope.wrap("proxy_error", "Unexpected routing error"));
|
|
159
160
|
return;
|
|
160
161
|
}
|
|
161
|
-
const account = selected.route.account;
|
|
162
162
|
const startedAt = now();
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
stats.totalErrors++;
|
|
179
|
-
// Intentionally does not touch `account.healthy`: a single failed
|
|
180
|
-
// refresh fails only this request. Disabling the account here would
|
|
181
|
-
// hard-block it from every future request until a manual recovery, even
|
|
182
|
-
// though the very next request naturally retries the refresh.
|
|
183
|
-
//
|
|
184
|
-
// It must, however, break session affinity and cool the account down.
|
|
185
|
-
// A sticky binding survives this failure, so without both the session
|
|
186
|
-
// would re-acquire the same broken account on every retry and never fail
|
|
187
|
-
// over — 401ing forever while healthy accounts sit idle.
|
|
188
|
-
if (selected.route.sessionId !== undefined && selected.route.bindingGeneration !== undefined) {
|
|
189
|
-
openAIRouter.invalidate(selected.route.sessionId, account.id, selected.route.bindingGeneration);
|
|
163
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? MAX_UPSTREAM_ATTEMPTS);
|
|
164
|
+
const sameAccountDelayMs = opts.sameAccountRetryDelayMs ?? SAME_ACCOUNT_RETRY_DELAY_MS;
|
|
165
|
+
const retryRefreshTimeoutMs = opts.retryRefreshTimeoutMs ?? RETRY_REFRESH_TIMEOUT_MS;
|
|
166
|
+
/**
|
|
167
|
+
* Refresh a routed account's token if needed. On failure this applies the
|
|
168
|
+
* shared refresh-failure bookkeeping and returns false; what the caller
|
|
169
|
+
* sends instead is its own decision — the first attempt answers a local
|
|
170
|
+
* 401, a retry attempt relays the upstream failure it already holds.
|
|
171
|
+
*/
|
|
172
|
+
const prepareRoute = async (routed) => {
|
|
173
|
+
const account = routed.route.account;
|
|
174
|
+
const needed = needsOpenAIRefresh(account);
|
|
175
|
+
let ready;
|
|
176
|
+
try {
|
|
177
|
+
ready = await prepareOpenAIAccount(account);
|
|
190
178
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
catch (error) {
|
|
180
|
+
// A throwing refresh must behave exactly like a `false` return, never
|
|
181
|
+
// crash the request (or the daemon).
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
logError(account.id, 401, `openai token refresh threw: ${message}`);
|
|
184
|
+
ready = false;
|
|
185
|
+
}
|
|
186
|
+
if (!ready) {
|
|
187
|
+
routed.release();
|
|
188
|
+
account.errorCount++;
|
|
189
|
+
stats.totalErrors++;
|
|
190
|
+
// Intentionally does not touch `account.healthy`: a single failed
|
|
191
|
+
// refresh fails only this request. Disabling the account here would
|
|
192
|
+
// hard-block it from every future request until a manual recovery, even
|
|
193
|
+
// though the very next request naturally retries the refresh.
|
|
194
|
+
//
|
|
195
|
+
// It must, however, break session affinity and cool the account down.
|
|
196
|
+
// A sticky binding survives this failure, so without both the session
|
|
197
|
+
// would re-acquire the same broken account on every retry and never fail
|
|
198
|
+
// over — 401ing forever while healthy accounts sit idle.
|
|
199
|
+
if (routed.route.sessionId !== undefined && routed.route.bindingGeneration !== undefined) {
|
|
200
|
+
openAIRouter.invalidate(routed.route.sessionId, account.id, routed.route.bindingGeneration);
|
|
201
|
+
}
|
|
202
|
+
openAIPool.setGlobalCooldownForAccount(account, REFRESH_FAILURE_COOLDOWN_MS, "unavailable");
|
|
203
|
+
recordActivity({
|
|
204
|
+
ts: now(),
|
|
205
|
+
accountId: account.id,
|
|
206
|
+
model: requestedModel,
|
|
207
|
+
type: "error",
|
|
208
|
+
statusCode: 401,
|
|
209
|
+
path,
|
|
210
|
+
details: "openai token refresh failed",
|
|
211
|
+
});
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
account.healthy = true;
|
|
215
|
+
if (needed)
|
|
216
|
+
account.lastRefresh = now();
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
219
|
+
if (!(await prepareRoute(selected))) {
|
|
201
220
|
res.status(401).json(envelope.wrap("authentication_error", "OpenAI subscription token refresh failed"));
|
|
202
221
|
return;
|
|
203
222
|
}
|
|
@@ -210,88 +229,168 @@ export async function runOpenAIIngress(opts) {
|
|
|
210
229
|
selected.release();
|
|
211
230
|
return;
|
|
212
231
|
}
|
|
213
|
-
account.healthy = true;
|
|
214
|
-
if (needed)
|
|
215
|
-
account.lastRefresh = now();
|
|
216
232
|
let upstream;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
233
|
+
let upstreamFailed;
|
|
234
|
+
let details;
|
|
235
|
+
let accountFailureCounted;
|
|
236
|
+
for (let attempt = 1;; attempt++) {
|
|
237
|
+
const account = selected.route.account;
|
|
238
|
+
const attemptStartedAt = now();
|
|
239
|
+
try {
|
|
240
|
+
upstream = await forwardOpenAI({
|
|
241
|
+
account,
|
|
242
|
+
body: forwardBody,
|
|
243
|
+
stream: forwardBody.stream === true,
|
|
244
|
+
signal: clientGone.signal,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// A client that hung up mid-forward rejects this call through the abort
|
|
249
|
+
// above. That is a cancellation, not an upstream failure: the account did
|
|
250
|
+
// nothing wrong, so counting it would push a healthy account toward the
|
|
251
|
+
// unhealthy threshold and a cooldown for nothing more than a user pressing
|
|
252
|
+
// Ctrl-C, and there is no client left to receive a 502 or to whom an
|
|
253
|
+
// "upstream_error:network" entry would mean anything. Mirrors the
|
|
254
|
+
// pre-forward disconnect branch above, which also just releases and stops.
|
|
255
|
+
if (clientGone.signal.aborted || responseTerminated(res)) {
|
|
256
|
+
selected.release();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// A rejected forward call (network failure) must produce a local 502,
|
|
260
|
+
// never an unhandled rejection. The lease releases via the response's
|
|
261
|
+
// own finish/close lifecycle once this response is sent.
|
|
262
|
+
account.errorCount++;
|
|
263
|
+
stats.totalErrors++;
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
265
|
+
logError(account.id, 502, `openai request failed: ${message}`);
|
|
266
|
+
recordActivity({
|
|
267
|
+
ts: startedAt,
|
|
268
|
+
accountId: account.id,
|
|
269
|
+
model: requestedModel,
|
|
270
|
+
type: "error",
|
|
271
|
+
statusCode: 502,
|
|
272
|
+
path,
|
|
273
|
+
details: "upstream_error:network",
|
|
274
|
+
durationMs: now() - startedAt,
|
|
275
|
+
});
|
|
276
|
+
res.status(502).json(envelope.wrap("upstream_error", `OpenAI request failed: ${message}`));
|
|
235
277
|
return;
|
|
236
278
|
}
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
279
|
+
// Cooldown/eligibility react to the raw upstream signal — this must not
|
|
280
|
+
// change based on how the relay later renders the response to the client.
|
|
281
|
+
upstreamFailed = upstream.status === 401 || upstream.status === 429 || upstream.status >= 500;
|
|
282
|
+
details = routeReasonDetails(selected.route);
|
|
283
|
+
// Tracks whether `account.errorCount`/`consecutiveErrors` were already
|
|
284
|
+
// incremented for this request by the upstream-classification branch below,
|
|
285
|
+
// so a relay-synthesized failure (e.g. a byte-transparent stream that
|
|
286
|
+
// observed an upstream `response.failed`/`error` event on an otherwise-200
|
|
287
|
+
// response) can still increment them once further down without double
|
|
288
|
+
// counting an upstream 401/429/5xx that already did.
|
|
289
|
+
accountFailureCounted = false;
|
|
290
|
+
try {
|
|
291
|
+
// Header/rate-limit parsing and cooldown bookkeeping run on live upstream
|
|
292
|
+
// data between the two request-level try/catches above — a throw here
|
|
293
|
+
// (e.g. an unreadable header) must degrade to "skip this bookkeeping",
|
|
294
|
+
// never crash the daemon or leave the relay below un-reached.
|
|
295
|
+
const headerRecord = headersToRecord(upstream.headers);
|
|
296
|
+
applyCodexRateLimits(account, parseCodexRateLimits(headerRecord, now()), now());
|
|
297
|
+
if (upstreamFailed) {
|
|
298
|
+
account.errorCount++;
|
|
299
|
+
account.consecutiveErrors++;
|
|
300
|
+
accountFailureCounted = true;
|
|
301
|
+
const applied = applyCodexFailureRouting(upstream.status, headerRecord, selected.route, requestedModel, openAIRouter, openAIPool, now);
|
|
302
|
+
details = routeFailureDetails(selected.route, upstream.status === 401 ? "token-invalid"
|
|
303
|
+
: upstream.status === 429 ? "rate-limited"
|
|
304
|
+
// Only 503/529 are treated as upstream overload for cooldown
|
|
305
|
+
// purposes; labelling an isolated 500/502/504 "service-overloaded"
|
|
306
|
+
// would contradict the routing decision actually taken.
|
|
307
|
+
: upstream.status === 503 || upstream.status === 529 ? "service-overloaded"
|
|
308
|
+
: "upstream-error", applied.limitingScope);
|
|
309
|
+
if (upstream.status === 401)
|
|
310
|
+
onUpstreamAuthFailure?.(account);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
logError(account.id, upstream.status, `openai response classification failed: ${message}`);
|
|
316
|
+
}
|
|
317
|
+
// Retryable statuses (429 || >= 500) are a strict subset of
|
|
318
|
+
// `upstreamFailed`, so this predicate alone decides the loop.
|
|
319
|
+
if (!isRetryableUpstreamStatus(upstream.status)
|
|
320
|
+
|| attempt >= maxAttempts || clientGone.signal.aborted) {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
// Router-side failover/retry. The failure bookkeeping above already ran
|
|
324
|
+
// (cooldown, affinity break, error counters), and not a single response
|
|
325
|
+
// byte has been relayed, so the request can move to whichever account the
|
|
326
|
+
// pool would hand a brand-new request: a different one after a 429 or
|
|
327
|
+
// 503/529 cooldown, the same one after a plain 5xx. Everything is decided
|
|
328
|
+
// BEFORE the held failure response is abandoned — any dead end below
|
|
329
|
+
// still relays the original upstream failure unchanged.
|
|
330
|
+
let next;
|
|
331
|
+
try {
|
|
332
|
+
next = acquireRequestRoute(sessionKey, res, openAIRouter, { requestedModel });
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
// Nothing eligible to fail over to — pass the failure through. Only
|
|
336
|
+
// routing-level rejections are expected here; anything else is a bug
|
|
337
|
+
// worth a log line, though pass-through stays the safe outcome.
|
|
338
|
+
if (!(error instanceof NoEligibleAccountError) && !(error instanceof EmptyPoolError)) {
|
|
339
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
340
|
+
logError("proxy", 500, `unexpected routing failure during retry: ${message}`);
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
if (upstream.status === 429 && next.route.account.id === account.id) {
|
|
345
|
+
// Re-sending a 429 to the account that produced it would only
|
|
346
|
+
// reproduce the rate limit. The cooldown normally guarantees a
|
|
347
|
+
// different account here; if it ever does not, pass through instead.
|
|
348
|
+
next.release();
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
// Same bound as the Anthropic route: the held failure is ready to relay
|
|
352
|
+
// and the refresh fetch has no deadline of its own, so an unbounded wait
|
|
353
|
+
// here could withhold it for minutes. The refresh is not cancelled — a
|
|
354
|
+
// late outcome still runs prepareRoute's own bookkeeping in the
|
|
355
|
+
// background and readies (or cools down) the account for later requests.
|
|
356
|
+
const prepared = await boundedWait(prepareRoute(next), retryRefreshTimeoutMs, "still-pending", clientGone.signal);
|
|
357
|
+
if (prepared === "still-pending") {
|
|
358
|
+
if (!clientGone.signal.aborted) {
|
|
359
|
+
logError(next.route.account.id, 0, `failover token refresh still pending after ${retryRefreshTimeoutMs}ms — relaying held upstream failure`);
|
|
360
|
+
}
|
|
361
|
+
next.release();
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
if (!prepared)
|
|
365
|
+
break;
|
|
366
|
+
// Committed: record the failed attempt and abandon its response.
|
|
241
367
|
stats.totalErrors++;
|
|
242
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
243
|
-
logError(account.id, 502, `openai request failed: ${message}`);
|
|
244
368
|
recordActivity({
|
|
245
|
-
ts:
|
|
369
|
+
ts: attemptStartedAt,
|
|
246
370
|
accountId: account.id,
|
|
247
371
|
model: requestedModel,
|
|
248
372
|
type: "error",
|
|
249
|
-
statusCode:
|
|
373
|
+
statusCode: upstream.status,
|
|
250
374
|
path,
|
|
251
|
-
|
|
252
|
-
|
|
375
|
+
...(opts.method !== undefined ? { method: opts.method } : {}),
|
|
376
|
+
...(opts.source !== undefined ? { source: opts.source } : {}),
|
|
377
|
+
details: `${details}:will-retry`,
|
|
378
|
+
durationMs: now() - attemptStartedAt,
|
|
253
379
|
});
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// response) can still increment them once further down without double
|
|
266
|
-
// counting an upstream 401/429/5xx that already did.
|
|
267
|
-
let accountFailureCounted = false;
|
|
268
|
-
try {
|
|
269
|
-
// Header/rate-limit parsing and cooldown bookkeeping run on live upstream
|
|
270
|
-
// data between the two request-level try/catches above — a throw here
|
|
271
|
-
// (e.g. an unreadable header) must degrade to "skip this bookkeeping",
|
|
272
|
-
// never crash the daemon or leave the relay below un-reached.
|
|
273
|
-
const headerRecord = headersToRecord(upstream.headers);
|
|
274
|
-
applyCodexRateLimits(account, parseCodexRateLimits(headerRecord, now()), now());
|
|
275
|
-
if (upstreamFailed) {
|
|
276
|
-
account.errorCount++;
|
|
277
|
-
account.consecutiveErrors++;
|
|
278
|
-
accountFailureCounted = true;
|
|
279
|
-
const applied = applyCodexFailureRouting(upstream.status, headerRecord, selected.route, requestedModel, openAIRouter, openAIPool, now);
|
|
280
|
-
details = routeFailureDetails(selected.route, upstream.status === 401 ? "token-invalid"
|
|
281
|
-
: upstream.status === 429 ? "rate-limited"
|
|
282
|
-
// Only 503/529 are treated as upstream overload for cooldown
|
|
283
|
-
// purposes; labelling an isolated 500/502/504 "service-overloaded"
|
|
284
|
-
// would contradict the routing decision actually taken.
|
|
285
|
-
: upstream.status === 503 || upstream.status === 529 ? "service-overloaded"
|
|
286
|
-
: "upstream-error", applied.limitingScope);
|
|
287
|
-
if (upstream.status === 401)
|
|
288
|
-
onUpstreamAuthFailure?.(account);
|
|
380
|
+
void upstream.body?.cancel().catch(() => { });
|
|
381
|
+
selected.release();
|
|
382
|
+
const sameAccount = next.route.account.id === account.id;
|
|
383
|
+
selected = next;
|
|
384
|
+
// An immediate same-account replay would hit whatever transient condition
|
|
385
|
+
// produced the 5xx still in progress; a failover needs no pause.
|
|
386
|
+
if (sameAccount)
|
|
387
|
+
await retryDelay(sameAccountDelayMs, clientGone.signal);
|
|
388
|
+
if (clientGone.signal.aborted || responseTerminated(res)) {
|
|
389
|
+
selected.release();
|
|
390
|
+
return;
|
|
289
391
|
}
|
|
290
392
|
}
|
|
291
|
-
|
|
292
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
-
logError(account.id, upstream.status, `openai response classification failed: ${message}`);
|
|
294
|
-
}
|
|
393
|
+
const account = selected.route.account;
|
|
295
394
|
const entry = {
|
|
296
395
|
ts: startedAt,
|
|
297
396
|
accountId: account.id,
|
|
@@ -10,6 +10,7 @@ const RESPONSES_ENVELOPE = {
|
|
|
10
10
|
wrap: (type, message) => ({ error: { type, message } }),
|
|
11
11
|
sendNoEligible: (error, res, nowMs) => sendOpenAINoEligibleResponse(error, res, nowMs),
|
|
12
12
|
};
|
|
13
|
+
const RESPONSES_BODY_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
13
14
|
function isResponsesRequest(value) {
|
|
14
15
|
return (typeof value === "object" &&
|
|
15
16
|
value !== null &&
|
|
@@ -68,7 +69,7 @@ export function mountResponsesRoutes(app, opts) {
|
|
|
68
69
|
const prepareOpenAIAccount = opts.prepareOpenAIAccount ?? (async () => true);
|
|
69
70
|
const recordActivity = opts.recordActivity ?? ((entry) => stats.addLog(entry));
|
|
70
71
|
const now = opts.now ?? Date.now;
|
|
71
|
-
app.post("/v1/responses", express.json({ limit:
|
|
72
|
+
app.post("/v1/responses", express.json({ limit: RESPONSES_BODY_LIMIT_BYTES }), async (req, res) => {
|
|
72
73
|
if (!isResponsesRequest(req.body)) {
|
|
73
74
|
res.status(400).json({
|
|
74
75
|
error: {
|
|
@@ -136,6 +137,13 @@ export function mountResponsesRoutes(app, opts) {
|
|
|
136
137
|
now,
|
|
137
138
|
envelope: RESPONSES_ENVELOPE,
|
|
138
139
|
onUpstreamAuthFailure: opts.onUpstreamAuthFailure,
|
|
140
|
+
...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
|
|
141
|
+
...(opts.sameAccountRetryDelayMs !== undefined
|
|
142
|
+
? { sameAccountRetryDelayMs: opts.sameAccountRetryDelayMs }
|
|
143
|
+
: {}),
|
|
144
|
+
...(opts.retryRefreshTimeoutMs !== undefined
|
|
145
|
+
? { retryRefreshTimeoutMs: opts.retryRefreshTimeoutMs }
|
|
146
|
+
: {}),
|
|
139
147
|
relay: async (upstream, res, entry, report) => {
|
|
140
148
|
if (body.stream === true) {
|
|
141
149
|
const observer = createCodexUsageObserver();
|