@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
|
@@ -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
|
-
|
|
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
|
|
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 (!
|
|
167
|
-
!
|
|
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 || !
|
|
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
|
});
|