@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.
@@ -0,0 +1,456 @@
1
+ import { request as httpRequest } from "node:http";
2
+ import { request as httpsRequest } from "node:https";
3
+ import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, extractClaudeSessionId, } from "./anthropic-routing.js";
4
+ import { acquireRequestRoute, applyUpstreamFailureRoutingDetailed, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
5
+ import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
6
+ import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
7
+ import { attachAnthropicResponseCapture } from "./anthropic-response-capture.js";
8
+ import { boundModelId, stats } from "./stats.js";
9
+ import { logError, logRoute } from "./logger.js";
10
+ import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
11
+ const OAUTH_BETA = "oauth-2025-04-20";
12
+ /**
13
+ * CRITICAL: api.anthropic.com requires the "oauth-2025-04-20" beta flag to
14
+ * accept OAuth tokens (sk-ant-oat01-*). Without it the request is rejected
15
+ * with "OAuth authentication is currently not supported." APPEND — do NOT
16
+ * replace — so existing betas (tools, computer-use, etc.) are preserved.
17
+ */
18
+ export function withOAuthBeta(existing) {
19
+ const betas = existing === undefined || existing === null || existing === ""
20
+ ? []
21
+ : String(existing).split(",").map(beta => beta.trim()).filter(Boolean);
22
+ if (!betas.includes(OAUTH_BETA))
23
+ betas.push(OAUTH_BETA);
24
+ return betas.join(",");
25
+ }
26
+ /** Join a target base path with the request's original URL (path + query). */
27
+ function joinTargetPath(target, originalUrl) {
28
+ const basePath = target.pathname.replace(/\/+$/, "");
29
+ return basePath === "" ? originalUrl : `${basePath}${originalUrl}`;
30
+ }
31
+ /**
32
+ * Build the upstream request headers from the client's. Mirrors the header
33
+ * behavior of the agent-less http-proxy transport the generic /v1 route
34
+ * uses (`connection: close`, `changeOrigin` host rewrite) plus this proxy's
35
+ * own auth handling: the placeholder bearer the client sent is replaced with
36
+ * the routed account's OAuth token, `x-api-key` is dropped because OAuth
37
+ * authentication uses Authorization Bearer and having both set can conflict
38
+ * at Anthropic's side, and the OAuth beta flag is appended.
39
+ */
40
+ function buildUpstreamHeaders(req, target, account, bodyLength) {
41
+ const headers = { ...req.headers };
42
+ // The buffered body is re-framed with an explicit length; the client's own
43
+ // framing headers no longer describe what is sent.
44
+ delete headers["content-length"];
45
+ delete headers["transfer-encoding"];
46
+ delete headers["x-api-key"];
47
+ headers["connection"] = "close";
48
+ headers["host"] = target.host;
49
+ headers["authorization"] = `Bearer ${account.tokens.accessToken}`;
50
+ headers["anthropic-beta"] = withOAuthBeta(req.headers["anthropic-beta"]);
51
+ headers["content-length"] = String(bodyLength);
52
+ return headers;
53
+ }
54
+ /**
55
+ * URL.hostname keeps the brackets on an IPv6 literal ("[::1]"), but
56
+ * node:http's host option expects them stripped — a bracketed value is
57
+ * treated as a DNS name and fails with ENOTFOUND. The Host *header* is
58
+ * unaffected: it is built from URL.host, where the brackets belong.
59
+ */
60
+ export function unbracketedHostname(target) {
61
+ const { hostname } = target;
62
+ return hostname.startsWith("[") && hostname.endsWith("]")
63
+ ? hostname.slice(1, -1)
64
+ : hostname;
65
+ }
66
+ function forwardAttempt(opts) {
67
+ const secure = opts.target.protocol === "https:";
68
+ const requestFn = secure ? httpsRequest : httpRequest;
69
+ const upstreamRequest = requestFn({
70
+ host: unbracketedHostname(opts.target),
71
+ port: opts.target.port !== "" ? Number(opts.target.port) : secure ? 443 : 80,
72
+ method: opts.method,
73
+ path: opts.path,
74
+ headers: opts.headers,
75
+ // Parity with the generic /v1 proxy: agent-less, one connection per
76
+ // request, closed by the `connection: close` request header.
77
+ agent: false,
78
+ timeout: opts.timeoutMs,
79
+ });
80
+ const response = new Promise((resolve, reject) => {
81
+ upstreamRequest.on("response", upstream => {
82
+ // The pre-response timeout must never cut a stream that has started.
83
+ upstreamRequest.setTimeout(0);
84
+ // The response may be HELD unconsumed while a retry decision resolves
85
+ // (re-acquire, token refresh) — a socket error in that window must not
86
+ // become an unhandled 'error' crash. The relay adds its own handler on
87
+ // top of this guard when the response is actually sent to the client.
88
+ upstream.on("error", () => { });
89
+ resolve(upstream);
90
+ });
91
+ upstreamRequest.on("error", reject);
92
+ upstreamRequest.on("timeout", () => {
93
+ upstreamRequest.destroy(new Error(`Upstream request timed out after ${opts.timeoutMs}ms`));
94
+ });
95
+ });
96
+ upstreamRequest.end(opts.body);
97
+ return { request: upstreamRequest, response };
98
+ }
99
+ /**
100
+ * Relay an upstream response to the client verbatim: status, status message,
101
+ * every header, and the raw (still possibly compressed) body bytes. Matches
102
+ * the observable behavior of http-proxy 1.18's outgoing passes, including
103
+ * its HTTP/1.0 accommodations, so moving /v1/messages off the generic proxy
104
+ * changes nothing about what a client receives.
105
+ */
106
+ function relayUpstreamResponse(upstream, req, res) {
107
+ if (req.httpVersion === "1.0") {
108
+ delete upstream.headers["transfer-encoding"];
109
+ upstream.headers["connection"] = req.headers["connection"] ?? "close";
110
+ }
111
+ else if (req.httpVersion !== "2.0" && !upstream.headers["connection"]) {
112
+ upstream.headers["connection"] = req.headers["connection"] ?? "keep-alive";
113
+ }
114
+ res.statusCode = upstream.statusCode ?? 502;
115
+ if (upstream.statusMessage)
116
+ res.statusMessage = upstream.statusMessage;
117
+ for (const [key, value] of Object.entries(upstream.headers)) {
118
+ if (value !== undefined)
119
+ res.setHeader(key, value);
120
+ }
121
+ // A mid-body upstream failure cannot be recovered into a valid response —
122
+ // tear the client connection down rather than ending it cleanly, so the
123
+ // client sees a broken transfer instead of a silently truncated body.
124
+ upstream.once("error", () => {
125
+ if (!res.writableEnded)
126
+ res.destroy();
127
+ });
128
+ upstream.pipe(res);
129
+ }
130
+ /**
131
+ * Claude-bound POST /v1/messages with router-side failover: a 429 or 5xx
132
+ * received before any response byte is relayed retries on whichever account
133
+ * the pool would hand a brand-new request (a different one after a 429's
134
+ * cooldown, the same one after a plain 5xx), bounded by the shared attempt
135
+ * budget. When nothing is eligible the failed upstream response is relayed
136
+ * unchanged — the pass-through contract this route inherits is the fallback,
137
+ * not the default. Streaming stays byte-transparent; the route never
138
+ * synthesizes or transforms response bytes.
139
+ */
140
+ export function mountAnthropicMessagesRoute(app, opts) {
141
+ const target = new URL(opts.target);
142
+ const recordActivity = opts.recordActivity ?? ((entry) => stats.addLog(entry));
143
+ const now = opts.now ?? Date.now;
144
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? MAX_UPSTREAM_ATTEMPTS);
145
+ const sameAccountDelayMs = opts.sameAccountRetryDelayMs ?? SAME_ACCOUNT_RETRY_DELAY_MS;
146
+ const retryRefreshTimeoutMs = opts.retryRefreshTimeoutMs ?? RETRY_REFRESH_TIMEOUT_MS;
147
+ // Only requests whose raw body was buffered by the cross-provider dispatch
148
+ // can be retried (and re-sent at all) — anything else falls through to the
149
+ // generic /v1 proxy exactly as before this route existed.
150
+ const requireBufferedBody = (req, _res, next) => {
151
+ if (!req._ccRawBody) {
152
+ next("route");
153
+ return;
154
+ }
155
+ next();
156
+ };
157
+ const handler = async (req, res) => {
158
+ const rawBody = req._ccRawBody;
159
+ const context = req._ccRouteContext;
160
+ const sessionHeader = extractClaudeSessionId(req);
161
+ const path = joinTargetPath(target, req.originalUrl);
162
+ let route = req._ccRoute;
163
+ let release = req._ccReleaseLease;
164
+ const source = route.sessionId !== undefined
165
+ ? "cli"
166
+ : req.headers["x-api-key"]
167
+ ? "desktop"
168
+ : "api";
169
+ const model = boundModelId(context?.requestedModel ?? "-");
170
+ const startedAt = now();
171
+ stats.totalRequests++;
172
+ // A client that hangs up takes the in-flight upstream attempt (and any
173
+ // pending retry) with it. `writableEnded` guards the normal-completion
174
+ // close; only a premature close is a disconnect.
175
+ const clientGone = new AbortController();
176
+ let inFlight;
177
+ res.once("close", () => {
178
+ if (!res.writableEnded)
179
+ clientGone.abort();
180
+ });
181
+ clientGone.signal.addEventListener("abort", () => {
182
+ inFlight?.destroy(new Error("client disconnected"));
183
+ });
184
+ // Parity with the generic proxy's incoming-socket timeout: armed until
185
+ // the first upstream response headers arrive, then cleared for good so a
186
+ // long-lived stream is never cut (see anthropic-proxy.ts).
187
+ req.socket.setTimeout(opts.timeoutMs);
188
+ for (let attempt = 1;; attempt++) {
189
+ const account = route.account;
190
+ const attemptStartedAt = now();
191
+ req._ccAccount = account;
192
+ logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - now()) / 60_000));
193
+ const forwarded = forwardAttempt({
194
+ target,
195
+ path,
196
+ method: req.method,
197
+ headers: buildUpstreamHeaders(req, target, account, rawBody.byteLength),
198
+ body: rawBody,
199
+ timeoutMs: opts.timeoutMs,
200
+ });
201
+ inFlight = forwarded.request;
202
+ let upstream;
203
+ try {
204
+ upstream = await forwarded.response;
205
+ }
206
+ catch (error) {
207
+ release();
208
+ // A hung-up client rejects this await through the abort above. That
209
+ // is a cancellation, not an upstream failure — there is no client
210
+ // left to receive a 502, and the generic proxy does not log client
211
+ // resets either.
212
+ if (clientGone.signal.aborted || res.writableEnded)
213
+ return;
214
+ const message = error instanceof Error ? error.message : String(error);
215
+ stats.totalErrors++;
216
+ logError("proxy", 0, message);
217
+ recordActivity({
218
+ ts: attemptStartedAt,
219
+ accountId: account.id,
220
+ model,
221
+ type: "error",
222
+ statusCode: 0,
223
+ method: req.method,
224
+ path: req.path,
225
+ source,
226
+ details: routeFailureDetails(route, "proxy-error"),
227
+ durationMs: now() - attemptStartedAt,
228
+ });
229
+ if (!res.headersSent) {
230
+ // Match Anthropic's error response format so Claude Code handles it
231
+ // gracefully — same shape the generic proxy's error handler sends.
232
+ res.status(502).json({
233
+ type: "error",
234
+ error: { type: "proxy_error", message },
235
+ });
236
+ }
237
+ return;
238
+ }
239
+ req.socket.setTimeout(0);
240
+ const status = upstream.statusCode ?? 0;
241
+ // Routing state changes implied by the failure — cooldowns and sticky
242
+ // binding invalidation — run before any retry decision, so the
243
+ // re-acquisition below already sees the failed account excluded.
244
+ const failureRouting = applyUpstreamFailureRoutingDetailed(status, upstream.headers, route, opts.sessionRouter, opts.pool, now);
245
+ const entry = {
246
+ ts: attemptStartedAt,
247
+ accountId: account.id,
248
+ model,
249
+ type: "route",
250
+ statusCode: status,
251
+ method: req.method,
252
+ path: req.path,
253
+ source,
254
+ details: routeReasonDetails(route),
255
+ durationMs: now() - attemptStartedAt,
256
+ };
257
+ if (status === 401) {
258
+ // Token invalid or expired mid-request. Forward the 401 to the client
259
+ // (Claude Code will retry on 401) and schedule a background refresh
260
+ // so the next request succeeds.
261
+ stats.totalErrors++;
262
+ account.errorCount++;
263
+ entry.type = "error";
264
+ entry.details = routeFailureDetails(route, "token-invalid");
265
+ logError(account.id, 401, "Token invalid — scheduling background refresh");
266
+ opts.onUpstream401?.(account);
267
+ }
268
+ else if (status === 429) {
269
+ stats.totalErrors++;
270
+ account.errorCount++;
271
+ entry.type = "error";
272
+ entry.details = routeFailureDetails(route, "rate-limited", failureRouting.limitingScope);
273
+ logError(account.id, 429, `Rate limited — cooldown ${failureRouting.cooldownSeconds ?? 60}s`);
274
+ // Lets the caller refresh usage in the background and narrow only
275
+ // ambiguity-owned global state when fresh usage proves a
276
+ // requested-model exhaustion.
277
+ opts.onRateLimited?.(route, failureRouting.ambiguousCooldownToken);
278
+ }
279
+ else if (status === 529) {
280
+ // Anthropic service overloaded — short cooldown on this account.
281
+ stats.totalErrors++;
282
+ account.errorCount++;
283
+ entry.type = "error";
284
+ entry.details = routeFailureDetails(route, "service-overloaded");
285
+ logError(account.id, 529, "Service overloaded — cooldown 30s");
286
+ }
287
+ else if (status >= 500) {
288
+ // A plain 5xx takes no cooldown: it says nothing about the account's
289
+ // capacity and can even be request-specific, so cooling the account
290
+ // down would punish it for upstream's (or the request's) problem.
291
+ stats.totalErrors++;
292
+ account.errorCount++;
293
+ entry.type = "error";
294
+ entry.details = routeFailureDetails(route, "upstream-error");
295
+ logError(account.id, status, "Upstream server error");
296
+ }
297
+ // Capture rate limit utilization from response headers — failed
298
+ // attempts carry them too.
299
+ applyRateLimitHeaders(account, upstream.headers);
300
+ // ── Router-side failover/retry ────────────────────────────────────────
301
+ // Decided at response headers: not a single byte has been relayed yet.
302
+ // Everything below resolves BEFORE the held failure response is
303
+ // abandoned, so any dead end still relays it unchanged.
304
+ if (isRetryableUpstreamStatus(status) && attempt < maxAttempts && !clientGone.signal.aborted) {
305
+ let next;
306
+ try {
307
+ next = acquireRequestRoute(sessionHeader, res, opts.sessionRouter, context);
308
+ }
309
+ catch (error) {
310
+ // Nothing eligible to fail over to — pass the failure through.
311
+ // Only routing-level rejections are expected here; anything else is
312
+ // a bug worth a log line, though pass-through stays the safe outcome.
313
+ if (!(error instanceof NoEligibleAccountError) && !(error instanceof EmptyPoolError)) {
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ logError("proxy", 0, `unexpected routing failure during retry: ${message}`);
316
+ }
317
+ next = undefined;
318
+ }
319
+ if (next && status === 429 && next.route.account.id === account.id) {
320
+ // Re-sending a 429 to the account that produced it would only
321
+ // reproduce the rate limit. The cooldown normally guarantees a
322
+ // different account here; if it ever does not, pass through.
323
+ next.release();
324
+ next = undefined;
325
+ }
326
+ if (next && opts.needsRefresh(next.route.account)) {
327
+ // The held failure is ready to relay RIGHT NOW; preparing a better
328
+ // answer must not hold it hostage. The refresh fetch carries no
329
+ // deadline of its own and the pre-response socket timeout was
330
+ // disarmed when the failure's headers arrived, so an unbounded
331
+ // await here could withhold a ready 429/5xx for minutes — past a
332
+ // client disconnect, even. The refresh is not cancelled: a late
333
+ // success still readies the account for the next request.
334
+ const failoverAccount = next.route.account;
335
+ const refreshOutcome = opts.refresh(failoverAccount).then(ok => ok ? "refreshed" : "failed", () => "failed");
336
+ // The failure booking hangs off the refresh's own settlement, not
337
+ // off the bounded wait below: a refresh that fails AFTER the
338
+ // deadline is the same operational failure as one that fails
339
+ // inside it, and this single continuation on a once-settling
340
+ // promise can neither miss it nor report it twice. The callback
341
+ // owns error stats/logging, exactly as it does for the refresh
342
+ // middleware on the first attempt; a refresh that eventually
343
+ // succeeds books nothing.
344
+ void refreshOutcome.then(settled => {
345
+ if (settled === "failed")
346
+ opts.onRefreshFailure(failoverAccount);
347
+ });
348
+ const outcome = await boundedWait(refreshOutcome, retryRefreshTimeoutMs, "still-pending", clientGone.signal);
349
+ if (outcome === "still-pending" && !clientGone.signal.aborted) {
350
+ logError(failoverAccount.id, 0, `failover token refresh still pending after ${retryRefreshTimeoutMs}ms — relaying held upstream failure`);
351
+ }
352
+ if (outcome !== "refreshed") {
353
+ next.release();
354
+ next = undefined;
355
+ }
356
+ }
357
+ if (clientGone.signal.aborted || res.writableEnded) {
358
+ next?.release();
359
+ release();
360
+ upstream.destroy();
361
+ return;
362
+ }
363
+ if (next) {
364
+ // Committed: record the failed attempt and abandon its response.
365
+ entry.details = `${entry.details}:will-retry`;
366
+ recordActivity(entry);
367
+ upstream.destroy();
368
+ release();
369
+ const sameAccount = next.route.account.id === account.id;
370
+ route = next.route;
371
+ release = next.release;
372
+ req._ccRoute = route;
373
+ req._ccReleaseLease = release;
374
+ // An immediate same-account replay would hit whatever transient
375
+ // condition produced the 5xx still in progress; a failover to a
376
+ // different account needs no pause.
377
+ if (sameAccount) {
378
+ await retryDelay(sameAccountDelayMs, clientGone.signal);
379
+ if (clientGone.signal.aborted || res.writableEnded) {
380
+ release();
381
+ return;
382
+ }
383
+ }
384
+ continue;
385
+ }
386
+ }
387
+ // The client may have left while the response headers (or the retry
388
+ // decision) were in flight — the abort listener has already torn the
389
+ // upstream request down, so there is nothing to relay and no reader to
390
+ // relay it to. The attempt's bookkeeping above still stands; only a
391
+ // response that upstream itself answered cleanly gets the cancellation
392
+ // marker, mirroring the OpenAI ingress.
393
+ // The final attempt's entry describes the whole client request: it
394
+ // starts at the request and spans every attempt (and retry delay),
395
+ // exactly like the OpenAI ingress — so ts + durationMs always equals
396
+ // the moment the entry was finalized. Failed :will-retry entries keep
397
+ // their own per-attempt window.
398
+ entry.ts = startedAt;
399
+ entry.durationMs = now() - startedAt;
400
+ if (clientGone.signal.aborted || res.writableEnded) {
401
+ if (entry.type === "route") {
402
+ entry.details = entry.details ? `${entry.details} client-cancelled` : "client-cancelled";
403
+ }
404
+ recordActivity(entry);
405
+ upstream.destroy();
406
+ release();
407
+ return;
408
+ }
409
+ // The held response can die while a retry decision is pending — its
410
+ // socket erroring during the failover account's token refresh is the
411
+ // concrete case. The forward-time error guard keeps that from crashing
412
+ // the daemon, but a destroyed body can no longer be relayed: piping it
413
+ // emits neither data nor end and would leave the client waiting
414
+ // forever. Nothing has been written yet, so answer with the same local
415
+ // 502 any other transport failure produces.
416
+ if (upstream.destroyed) {
417
+ if (entry.type === "route")
418
+ stats.totalErrors++;
419
+ entry.type = "error";
420
+ entry.statusCode = 502;
421
+ entry.details = `${entry.details}:held-response-lost`;
422
+ recordActivity(entry);
423
+ release();
424
+ logError(account.id, 502, `upstream ${status} response was lost before it could be relayed`);
425
+ res.status(502).json({
426
+ type: "error",
427
+ error: {
428
+ type: "proxy_error",
429
+ message: `Upstream ${status} response was lost before it could be relayed`,
430
+ },
431
+ });
432
+ return;
433
+ }
434
+ // ── Final: relay this response byte-transparently ─────────────────────
435
+ // The entry is recorded now (headers time) and mutated in place by the
436
+ // usage capture; the dashboard picks the values up on its next poll —
437
+ // same contract as the generic proxy path.
438
+ recordActivity(entry);
439
+ attachAnthropicResponseCapture(upstream, res, entry, startedAt);
440
+ relayUpstreamResponse(upstream, req, res);
441
+ return;
442
+ }
443
+ };
444
+ app.post("/v1/messages", requireBufferedBody, createAnthropicRoutingMiddleware({
445
+ sessionRouter: opts.sessionRouter,
446
+ ...(opts.onEmptyPool ? { onEmptyPool: opts.onEmptyPool } : {}),
447
+ ...(opts.onNoEligibleAccount ? { onNoEligibleAccount: opts.onNoEligibleAccount } : {}),
448
+ ...(opts.now ? { now: opts.now } : {}),
449
+ }), createAnthropicRefreshMiddleware({
450
+ needsRefresh: opts.needsRefresh,
451
+ refresh: opts.refresh,
452
+ onRefreshFailure: opts.onRefreshFailure,
453
+ }), (req, res, next) => {
454
+ void handler(req, res).catch(next);
455
+ });
456
+ }
@@ -0,0 +1,40 @@
1
+ import { applyAnthropicInputUsage, applyAnthropicOutputUsage } from "./stats.js";
2
+ import { createAnthropicUsageCapture } from "./usage-capture.js";
3
+ import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
4
+ /**
5
+ * Attach the passive observability taps to a relayed Anthropic response:
6
+ * stream-lifecycle tracking and token-usage capture, both mutating the
7
+ * already-recorded activity entry in place (the dashboard picks the values
8
+ * up on its next poll).
9
+ *
10
+ * SSE streams carry usage across two events (message_start → input/cache
11
+ * tokens, message_delta → output tokens); non-streaming JSON carries all
12
+ * fields in one usage object. The proxy is byte-transparent and the client's
13
+ * accept-encoding makes upstream compress, so the capture decompresses its
14
+ * own copy of the stream (see usage-capture.ts) — previously compressed
15
+ * responses were skipped, which in practice was EVERY response.
16
+ *
17
+ * Shared by the generic /v1 proxy and the retrying /v1/messages transport so
18
+ * the two Anthropic relays can never drift on what they observe. Must run
19
+ * BEFORE the response is piped to the client, in the same synchronous block,
20
+ * so no data event can slip past the taps.
21
+ */
22
+ export function attachAnthropicResponseCapture(upstream, downstream, entry, startedAt) {
23
+ const contentType = String(upstream.headers["content-type"] ?? "");
24
+ const encoding = String(upstream.headers["content-encoding"] ?? "");
25
+ const isCompressed = /gzip|br|deflate/.test(encoding);
26
+ const streamTracker = createStreamLifecycleTracker(startedAt, !isCompressed && contentType.includes("text/event-stream"));
27
+ entry.streamLifecycle = streamTracker.state;
28
+ streamTracker.attach(upstream, downstream);
29
+ upstream.on("data", (chunk) => streamTracker.observeChunk(chunk));
30
+ const usageCapture = createAnthropicUsageCapture({
31
+ contentType,
32
+ contentEncoding: encoding,
33
+ onInputUsage: usage => applyAnthropicInputUsage(entry, usage),
34
+ onOutputUsage: usage => applyAnthropicOutputUsage(entry, usage),
35
+ });
36
+ if (usageCapture) {
37
+ upstream.on("data", (chunk) => usageCapture.write(chunk));
38
+ upstream.on("end", () => usageCapture.end());
39
+ }
40
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A process-wide monotonic counter for ordering routing events against each
3
+ * other.
4
+ *
5
+ * Wall-clock milliseconds cannot do this job. A 429 response, the header
6
+ * snapshot taken from it, and the usage refresh the router starts in response
7
+ * all happen inside one event-loop turn, so `Date.now()` returns the same value
8
+ * for all three — measured at ~199 ties in 200 runs. Anything deciding whether
9
+ * one of those events came after another needs an ordering that does not
10
+ * collapse at millisecond resolution.
11
+ *
12
+ * Values are meaningless as timestamps and are never persisted or reported:
13
+ * only their relative order carries information, and only within one process.
14
+ */
15
+ let sequence = 0;
16
+ export function nextEventSequence() {
17
+ return ++sequence;
18
+ }
@@ -93,21 +93,33 @@ function matchingModelLimit(account, modelFamily) {
93
93
  return undefined;
94
94
  return usage.modelLimits.find(limit => normalizeModelFamily(limit.modelFamily) === modelFamily);
95
95
  }
96
+ /** A reported figure strictly inside the normalized headroom range. */
97
+ function withinHeadroomRange(value) {
98
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value < 1;
99
+ }
100
+ /** A reported figure at or above its limit. Unreported is never "at limit". */
101
+ function atReportedLimit(value) {
102
+ return typeof value === "number" && Number.isFinite(value) && value >= 1;
103
+ }
96
104
  function classifyCooldown(claimValue, route) {
97
105
  const claim = typeof claimValue === "string" ? claimValue.trim().toLowerCase() : "";
98
106
  const requestedFamily = normalizeModelFamily(route.modelFamily);
99
107
  const usage = route.account.rateLimits?.usage;
100
108
  if (claim === "five_hour" || claim === "seven_day" || claim === "seven_day_oauth_apps") {
101
- const usageWindow = claim === "five_hour" ? usage?.fiveHour : claim === "seven_day" ? usage?.sevenDay : undefined;
109
+ // seven_day_oauth_apps is a distinct quota the usage endpoint does not
110
+ // report, so it gets no window and can never be superseded by one.
111
+ const scope = claim === "five_hour" ? "five_hour" : claim === "seven_day" ? "seven_day" : undefined;
112
+ const usageWindow = scope === "five_hour" ? usage?.fiveHour : scope === "seven_day" ? usage?.sevenDay : undefined;
102
113
  return {
103
114
  kind: "global",
104
115
  ambiguous: false,
116
+ ...(scope ? { usageWindow: scope } : {}),
105
117
  ...(usageWindow ? { usageResetAtMs: usageWindow.resetAt * 1_000 } : {}),
106
118
  };
107
119
  }
108
120
  if (claim === "seven_day_overage_included") {
109
121
  const matching = matchingModelLimit(route.account, requestedFamily);
110
- if (requestedFamily && matching?.active === true && matching.utilization >= 1) {
122
+ if (requestedFamily && matching?.active === true && atReportedLimit(matching.utilization)) {
111
123
  return {
112
124
  kind: "model",
113
125
  ambiguous: false,
@@ -143,12 +155,12 @@ function cooldownDurationMs(headers, classification, nowMs) {
143
155
  ? Math.max(...expiries) - nowMs
144
156
  : DEFAULT_RATE_LIMIT_COOLDOWN_MS;
145
157
  }
146
- function setGlobalCooldown(pool, account, durationMs, ambiguous, modelFamily) {
158
+ function setGlobalCooldown(pool, account, durationMs, ambiguous, modelFamily, usageWindow) {
147
159
  if (ambiguous && pool.setAmbiguousGlobalCooldownForAccount) {
148
160
  return pool.setAmbiguousGlobalCooldownForAccount(account, durationMs, modelFamily);
149
161
  }
150
162
  else if (pool.setGlobalCooldownForAccount) {
151
- pool.setGlobalCooldownForAccount(account, durationMs);
163
+ pool.setGlobalCooldownForAccount(account, durationMs, usageWindow);
152
164
  }
153
165
  else {
154
166
  pool.setCooldownForAccount(account, durationMs);
@@ -163,19 +175,14 @@ export function reconcileAmbiguousRateLimitCooldown(route, pool, token, now = Da
163
175
  return false;
164
176
  if (!usage.fiveHour || !usage.sevenDay)
165
177
  return false;
166
- if (!Number.isFinite(usage.fiveHour.utilization) ||
167
- !Number.isFinite(usage.sevenDay.utilization) ||
168
- usage.fiveHour.utilization < 0 ||
169
- usage.sevenDay.utilization < 0 ||
170
- usage.fiveHour.utilization >= 1 ||
171
- usage.sevenDay.utilization >= 1)
178
+ if (!withinHeadroomRange(usage.fiveHour.utilization) ||
179
+ !withinHeadroomRange(usage.sevenDay.utilization))
172
180
  return false;
173
181
  if (canUseExtraUsage(usage.extraUsage))
174
182
  return false;
175
183
  const matching = matchingModelLimit(route.account, family);
176
- if (!matching || !matching.active || !Number.isFinite(matching.utilization) || matching.utilization < 1) {
184
+ if (!matching || !matching.active || !atReportedLimit(matching.utilization))
177
185
  return false;
178
- }
179
186
  if (!pool.reconcileAmbiguousGlobalCooldownForAccount)
180
187
  return false;
181
188
  const nowMs = now();
@@ -207,7 +214,7 @@ export function applyUpstreamFailureRoutingDetailed(status, failureHeaders, rout
207
214
  }
208
215
  }
209
216
  else {
210
- ambiguousCooldownToken = setGlobalCooldown(pool, route.account, durationMs, classification.ambiguous, route.modelFamily);
217
+ ambiguousCooldownToken = setGlobalCooldown(pool, route.account, durationMs, classification.ambiguous, route.modelFamily, classification.usageWindow);
211
218
  }
212
219
  return {
213
220
  cooldownSeconds: durationMs / 1_000,
@@ -361,6 +361,13 @@ export function mountMessagesCrossProviderRoute(app, opts) {
361
361
  now,
362
362
  envelope: MESSAGES_ENVELOPE,
363
363
  onUpstreamAuthFailure: opts.onUpstreamAuthFailure,
364
+ ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
365
+ ...(opts.sameAccountRetryDelayMs !== undefined
366
+ ? { sameAccountRetryDelayMs: opts.sameAccountRetryDelayMs }
367
+ : {}),
368
+ ...(opts.retryRefreshTimeoutMs !== undefined
369
+ ? { retryRefreshTimeoutMs: opts.retryRefreshTimeoutMs }
370
+ : {}),
364
371
  relay: (upstream, res, entry, report) => sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, report),
365
372
  });
366
373
  });