@timo972/cc-router 0.12.1 → 0.12.2-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 +18 -0
- package/Dockerfile +1 -0
- package/README.md +1 -1
- package/dist/cli/cmd-accounts.js +116 -65
- package/dist/cli/cmd-setup.js +100 -30
- package/dist/cli/cmd-status.js +14 -2
- package/dist/cli/cmd-telemetry.js +43 -32
- package/dist/cli/index.js +15 -1
- package/dist/config/directory.js +14 -0
- package/dist/config/telemetry.js +192 -41
- package/dist/providers/anthropic/usage-refresher.js +35 -1
- package/dist/providers/model-discovery.js +17 -11
- package/dist/providers/openai/device-oauth.js +88 -31
- package/dist/providers/openai/token-refresher.js +12 -2
- package/dist/providers/openai/usage-fetch.js +19 -2
- package/dist/proxy/anthropic-messages-route.js +144 -5
- package/dist/proxy/anthropic-proxy.js +10 -0
- package/dist/proxy/anthropic-response-capture.js +6 -19
- package/dist/proxy/openai-ingress.js +98 -1
- package/dist/proxy/server.js +35 -22
- package/dist/proxy/token-refresher.js +12 -2
- package/dist/proxy/usage-capture.js +41 -4
- package/dist/telemetry/contracts.js +129 -0
- package/dist/telemetry/facade.js +654 -0
- package/dist/telemetry/otel-exporters.js +289 -0
- package/dist/telemetry/posthog-client.js +398 -0
- package/dist/telemetry/privacy.js +567 -0
- package/dist/telemetry/runtime.js +306 -0
- package/dist/telemetry/setup-diagnostics.js +239 -0
- package/dist/utils/token-extractor.js +79 -11
- package/dist/utils/token-validator.js +25 -9
- package/docs/README.md +34 -0
- package/docs/telemetry.md +167 -0
- package/package.json +12 -2
- package/dist/utils/telemetry.js +0 -88
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyCodexRateLimits } from "./account-state.js";
|
|
2
2
|
import { parseCodexUsagePayload } from "./usage.js";
|
|
3
3
|
import { UsageRefresher } from "../../proxy/usage-refresher.js";
|
|
4
|
+
import { classifyExpectedRuntimeFailure, httpOutcome, recordRuntimeError, recordUpstreamStatus, withTelemetrySpan, } from "../../telemetry/facade.js";
|
|
4
5
|
/**
|
|
5
6
|
* The endpoint the Codex CLI's own backend client reads rate limits from
|
|
6
7
|
* (codex-rs/backend-client, ChatGPT path style). Answers a bearer-only GET
|
|
@@ -8,7 +9,17 @@ import { UsageRefresher } from "../../proxy/usage-refresher.js";
|
|
|
8
9
|
* makes usage visible without burning a request through /codex/responses.
|
|
9
10
|
*/
|
|
10
11
|
export const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
11
|
-
export
|
|
12
|
+
export function fetchCodexUsage(account, options = {}) {
|
|
13
|
+
return withTelemetrySpan("provider.usage_refresh", { provider: "openai" }, async (span) => {
|
|
14
|
+
const result = await runCodexUsageFetch(account, options, span);
|
|
15
|
+
// Failure paths classify the span where the status or error is known;
|
|
16
|
+
// this only covers the ones that could not (e.g. a malformed body).
|
|
17
|
+
if (!result.ok)
|
|
18
|
+
span.fail();
|
|
19
|
+
return result;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
async function runCodexUsageFetch(account, options, span) {
|
|
12
23
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
13
24
|
const now = options.now ?? Date.now;
|
|
14
25
|
let response;
|
|
@@ -18,9 +29,15 @@ export async function fetchCodexUsage(account, options = {}) {
|
|
|
18
29
|
signal: AbortSignal.timeout(10_000),
|
|
19
30
|
});
|
|
20
31
|
}
|
|
21
|
-
catch {
|
|
32
|
+
catch (error) {
|
|
33
|
+
recordRuntimeError(error, { operation: "provider.usage_refresh", provider: "openai" });
|
|
34
|
+
span.fail({ outcome: classifyExpectedRuntimeFailure(error) === "timeout" ? "timeout" : "upstream_error" });
|
|
22
35
|
return { ok: false, reason: "network" };
|
|
23
36
|
}
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
recordUpstreamStatus("provider.usage_refresh", "openai", response.status);
|
|
39
|
+
span.fail({ httpStatusCode: response.status, outcome: httpOutcome(response.status) });
|
|
40
|
+
}
|
|
24
41
|
if (response.status === 401 || response.status === 403)
|
|
25
42
|
return { ok: false, reason: "auth" };
|
|
26
43
|
if (!response.ok)
|
|
@@ -5,8 +5,12 @@ import { acquireRequestRoute, applyUpstreamFailureRoutingDetailed, routeFailureD
|
|
|
5
5
|
import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
|
|
6
6
|
import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
|
|
7
7
|
import { attachAnthropicResponseCapture } from "./anthropic-response-capture.js";
|
|
8
|
+
import { TRACE_CONTEXT_HEADERS } from "./anthropic-proxy.js";
|
|
8
9
|
import { boundModelId, stats } from "./stats.js";
|
|
9
10
|
import { logError, logRoute } from "./logger.js";
|
|
11
|
+
import { annotateActiveSpan, classifyExpectedRuntimeFailure, modelFamilyOf, recordRuntimeError, recordUpstreamStatus, settleProxyRequestSpan, startTelemetrySpan, } from "../telemetry/facade.js";
|
|
12
|
+
/** How long a provider span may wait for a compressed body's usage after the response closed. */
|
|
13
|
+
const USAGE_SETTLE_GRACE_MS = 1_000;
|
|
10
14
|
import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
|
|
11
15
|
const OAUTH_BETA = "oauth-2025-04-20";
|
|
12
16
|
/**
|
|
@@ -44,6 +48,8 @@ function buildUpstreamHeaders(req, target, account, bodyLength) {
|
|
|
44
48
|
delete headers["content-length"];
|
|
45
49
|
delete headers["transfer-encoding"];
|
|
46
50
|
delete headers["x-api-key"];
|
|
51
|
+
for (const header of TRACE_CONTEXT_HEADERS)
|
|
52
|
+
delete headers[header];
|
|
47
53
|
headers["connection"] = "close";
|
|
48
54
|
headers["host"] = target.host;
|
|
49
55
|
headers["authorization"] = `Bearer ${account.tokens.accessToken}`;
|
|
@@ -90,7 +96,9 @@ function forwardAttempt(opts) {
|
|
|
90
96
|
});
|
|
91
97
|
upstreamRequest.on("error", reject);
|
|
92
98
|
upstreamRequest.on("timeout", () => {
|
|
93
|
-
|
|
99
|
+
// The `code` lets telemetry classify this as an expected timeout; the
|
|
100
|
+
// message and the client-facing response are unchanged.
|
|
101
|
+
upstreamRequest.destroy(Object.assign(new Error(`Upstream request timed out after ${opts.timeoutMs}ms`), { code: "ETIMEDOUT" }));
|
|
94
102
|
});
|
|
95
103
|
});
|
|
96
104
|
upstreamRequest.end(opts.body);
|
|
@@ -103,7 +111,7 @@ function forwardAttempt(opts) {
|
|
|
103
111
|
* its HTTP/1.0 accommodations, so moving /v1/messages off the generic proxy
|
|
104
112
|
* changes nothing about what a client receives.
|
|
105
113
|
*/
|
|
106
|
-
function relayUpstreamResponse(upstream, req, res) {
|
|
114
|
+
function relayUpstreamResponse(upstream, req, res, onUpstreamFailure) {
|
|
107
115
|
if (req.httpVersion === "1.0") {
|
|
108
116
|
delete upstream.headers["transfer-encoding"];
|
|
109
117
|
upstream.headers["connection"] = req.headers["connection"] ?? "close";
|
|
@@ -122,6 +130,9 @@ function relayUpstreamResponse(upstream, req, res) {
|
|
|
122
130
|
// tear the client connection down rather than ending it cleanly, so the
|
|
123
131
|
// client sees a broken transfer instead of a silently truncated body.
|
|
124
132
|
upstream.once("error", () => {
|
|
133
|
+
// Recorded before the downstream teardown, which would otherwise look
|
|
134
|
+
// like a client hang-up to the response's own close listener.
|
|
135
|
+
onUpstreamFailure?.();
|
|
125
136
|
if (!res.writableEnded)
|
|
126
137
|
res.destroy();
|
|
127
138
|
});
|
|
@@ -167,8 +178,22 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
167
178
|
? "desktop"
|
|
168
179
|
: "api";
|
|
169
180
|
const model = boundModelId(context?.requestedModel ?? "-");
|
|
181
|
+
const modelFamily = modelFamilyOf(model);
|
|
182
|
+
const streaming = req.body?.stream === true;
|
|
170
183
|
const startedAt = now();
|
|
171
184
|
stats.totalRequests++;
|
|
185
|
+
annotateActiveSpan("proxy.request", {
|
|
186
|
+
provider: "anthropic",
|
|
187
|
+
route: "messages",
|
|
188
|
+
modelFamily,
|
|
189
|
+
requestSource: source,
|
|
190
|
+
streaming,
|
|
191
|
+
accountPoolSize: opts.pool.getAll().length,
|
|
192
|
+
});
|
|
193
|
+
/** The request span's final verdict; the middleware ends the span from it. */
|
|
194
|
+
const settleRequest = (outcome, extra = {}) => {
|
|
195
|
+
settleProxyRequestSpan(res, { ...extra, outcome, operationDurationMs: now() - startedAt });
|
|
196
|
+
};
|
|
172
197
|
// A client that hangs up takes the in-flight upstream attempt (and any
|
|
173
198
|
// pending retry) with it. `writableEnded` guards the normal-completion
|
|
174
199
|
// close; only a premature close is a disconnect.
|
|
@@ -188,6 +213,23 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
188
213
|
for (let attempt = 1;; attempt++) {
|
|
189
214
|
const account = route.account;
|
|
190
215
|
const attemptStartedAt = now();
|
|
216
|
+
const attemptSpan = startTelemetrySpan("provider.inference", {
|
|
217
|
+
provider: "anthropic",
|
|
218
|
+
route: "messages",
|
|
219
|
+
modelFamily,
|
|
220
|
+
streaming,
|
|
221
|
+
attempt,
|
|
222
|
+
});
|
|
223
|
+
/** Close this attempt's span exactly once, on the outcome it ended with. */
|
|
224
|
+
const endAttempt = (outcome, extra = {}) => {
|
|
225
|
+
attemptSpan.annotate({
|
|
226
|
+
...extra,
|
|
227
|
+
outcome,
|
|
228
|
+
attempt,
|
|
229
|
+
operationDurationMs: now() - attemptStartedAt,
|
|
230
|
+
});
|
|
231
|
+
attemptSpan.end(outcome === "complete" ? "ok" : "error");
|
|
232
|
+
};
|
|
191
233
|
req._ccAccount = account;
|
|
192
234
|
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - now()) / 60_000));
|
|
193
235
|
const forwarded = forwardAttempt({
|
|
@@ -209,10 +251,20 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
209
251
|
// is a cancellation, not an upstream failure — there is no client
|
|
210
252
|
// left to receive a 502, and the generic proxy does not log client
|
|
211
253
|
// resets either.
|
|
212
|
-
if (clientGone.signal.aborted || res.writableEnded)
|
|
254
|
+
if (clientGone.signal.aborted || res.writableEnded) {
|
|
255
|
+
endAttempt("cancelled", { streamOutcome: "cancelled" });
|
|
256
|
+
settleRequest("cancelled", { streamOutcome: "cancelled", attempt });
|
|
213
257
|
return;
|
|
258
|
+
}
|
|
214
259
|
const message = error instanceof Error ? error.message : String(error);
|
|
215
260
|
stats.totalErrors++;
|
|
261
|
+
recordRuntimeError(error, { operation: "provider.inference", provider: "anthropic" }, {
|
|
262
|
+
attempt,
|
|
263
|
+
durationMs: now() - attemptStartedAt,
|
|
264
|
+
});
|
|
265
|
+
const forwardOutcome = classifyExpectedRuntimeFailure(error) === "timeout"
|
|
266
|
+
? "timeout"
|
|
267
|
+
: "upstream_error";
|
|
216
268
|
logError("proxy", 0, message);
|
|
217
269
|
recordActivity({
|
|
218
270
|
ts: attemptStartedAt,
|
|
@@ -234,10 +286,18 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
234
286
|
error: { type: "proxy_error", message },
|
|
235
287
|
});
|
|
236
288
|
}
|
|
289
|
+
endAttempt(forwardOutcome, { httpStatusCode: 502, streamOutcome: forwardOutcome });
|
|
290
|
+
settleRequest(forwardOutcome, { httpStatusCode: 502, attempt });
|
|
237
291
|
return;
|
|
238
292
|
}
|
|
239
293
|
req.socket.setTimeout(0);
|
|
240
294
|
const status = upstream.statusCode ?? 0;
|
|
295
|
+
if (status === 401 || status === 403 || status === 429 || status >= 500) {
|
|
296
|
+
recordUpstreamStatus("provider.inference", "anthropic", status, {
|
|
297
|
+
attempt,
|
|
298
|
+
durationMs: now() - attemptStartedAt,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
241
301
|
// Routing state changes implied by the failure — cooldowns and sticky
|
|
242
302
|
// binding invalidation — run before any retry decision, so the
|
|
243
303
|
// re-acquisition below already sees the failed account excluded.
|
|
@@ -358,10 +418,16 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
358
418
|
next?.release();
|
|
359
419
|
release();
|
|
360
420
|
upstream.destroy();
|
|
421
|
+
endAttempt("cancelled", { httpStatusCode: status, streamOutcome: "cancelled" });
|
|
422
|
+
settleRequest("cancelled", { httpStatusCode: status, streamOutcome: "cancelled", attempt });
|
|
361
423
|
return;
|
|
362
424
|
}
|
|
363
425
|
if (next) {
|
|
364
426
|
// Committed: record the failed attempt and abandon its response.
|
|
427
|
+
endAttempt(status === 429 ? "rate_limited" : "upstream_error", {
|
|
428
|
+
httpStatusCode: status,
|
|
429
|
+
streamOutcome: "upstream_error",
|
|
430
|
+
});
|
|
365
431
|
entry.details = `${entry.details}:will-retry`;
|
|
366
432
|
recordActivity(entry);
|
|
367
433
|
upstream.destroy();
|
|
@@ -404,6 +470,8 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
404
470
|
recordActivity(entry);
|
|
405
471
|
upstream.destroy();
|
|
406
472
|
release();
|
|
473
|
+
endAttempt("cancelled", { httpStatusCode: status, streamOutcome: "cancelled" });
|
|
474
|
+
settleRequest("cancelled", { httpStatusCode: status, streamOutcome: "cancelled", attempt });
|
|
407
475
|
return;
|
|
408
476
|
}
|
|
409
477
|
// The held response can die while a retry decision is pending — its
|
|
@@ -421,6 +489,8 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
421
489
|
entry.details = `${entry.details}:held-response-lost`;
|
|
422
490
|
recordActivity(entry);
|
|
423
491
|
release();
|
|
492
|
+
endAttempt("upstream_error", { httpStatusCode: 502, streamOutcome: "upstream_error" });
|
|
493
|
+
settleRequest("upstream_error", { httpStatusCode: 502, attempt });
|
|
424
494
|
logError(account.id, 502, `upstream ${status} response was lost before it could be relayed`);
|
|
425
495
|
res.status(502).json({
|
|
426
496
|
type: "error",
|
|
@@ -436,8 +506,77 @@ export function mountAnthropicMessagesRoute(app, opts) {
|
|
|
436
506
|
// usage capture; the dashboard picks the values up on its next poll —
|
|
437
507
|
// same contract as the generic proxy path.
|
|
438
508
|
recordActivity(entry);
|
|
439
|
-
|
|
440
|
-
|
|
509
|
+
const outcome = status === 429 ? "rate_limited"
|
|
510
|
+
: status >= 400 ? "upstream_error"
|
|
511
|
+
: "complete";
|
|
512
|
+
annotateActiveSpan("proxy.request", {
|
|
513
|
+
httpStatusCode: status,
|
|
514
|
+
outcome,
|
|
515
|
+
attempt,
|
|
516
|
+
operationDurationMs: now() - startedAt,
|
|
517
|
+
});
|
|
518
|
+
// Tokens and the stream verdict are only known once the relayed body
|
|
519
|
+
// settles. The span waits for both the response's close and the passive
|
|
520
|
+
// usage capture (a compressed body decodes after close), bounded so a
|
|
521
|
+
// decoder that never finishes cannot keep the span open.
|
|
522
|
+
const contentType = String(upstream.headers["content-type"] ?? "");
|
|
523
|
+
const encoding = String(upstream.headers["content-encoding"] ?? "");
|
|
524
|
+
const isSse = contentType.includes("text/event-stream");
|
|
525
|
+
// Set by the relay when the provider connection failed first; a client
|
|
526
|
+
// hang-up also destroys the upstream, so the order of events matters.
|
|
527
|
+
let upstreamFailedFirst = false;
|
|
528
|
+
// The lifecycle tracker only reads uncompressed SSE; the usage capture's
|
|
529
|
+
// decoded copy reports the terminal event for compressed streams.
|
|
530
|
+
let decodedMessageStop = false;
|
|
531
|
+
let responseClosed = false;
|
|
532
|
+
let usageSettled = false;
|
|
533
|
+
let usageDeadline;
|
|
534
|
+
const finishAttempt = () => {
|
|
535
|
+
if (usageDeadline !== undefined)
|
|
536
|
+
clearTimeout(usageDeadline);
|
|
537
|
+
const lifecycle = entry.streamLifecycle;
|
|
538
|
+
// A client hang-up destroys the upstream request too, so the client's
|
|
539
|
+
// own signal must win over the resulting upstream abort.
|
|
540
|
+
const streamOutcome = status >= 400 ? "upstream_error"
|
|
541
|
+
: upstreamFailedFirst ? "upstream_error"
|
|
542
|
+
: clientGone.signal.aborted ? "cancelled"
|
|
543
|
+
: lifecycle?.upstreamAborted ? "upstream_error"
|
|
544
|
+
: !res.writableEnded ? "cancelled"
|
|
545
|
+
: isSse && !lifecycle?.sawMessageStop && !decodedMessageStop ? "upstream_error"
|
|
546
|
+
: "complete";
|
|
547
|
+
const attemptOutcome = status >= 400 ? outcome
|
|
548
|
+
: streamOutcome === "complete" ? outcome
|
|
549
|
+
: streamOutcome;
|
|
550
|
+
const tokens = {
|
|
551
|
+
...(entry.inputTokens !== undefined ? { inputTokens: entry.inputTokens } : {}),
|
|
552
|
+
...(entry.outputTokens !== undefined ? { outputTokens: entry.outputTokens } : {}),
|
|
553
|
+
};
|
|
554
|
+
endAttempt(attemptOutcome, { httpStatusCode: status, streamOutcome, ...tokens });
|
|
555
|
+
settleRequest(attemptOutcome, { httpStatusCode: status, streamOutcome, attempt, ...tokens });
|
|
556
|
+
};
|
|
557
|
+
const maybeFinishAttempt = () => {
|
|
558
|
+
if (responseClosed && usageSettled)
|
|
559
|
+
finishAttempt();
|
|
560
|
+
};
|
|
561
|
+
res.once("close", () => {
|
|
562
|
+
responseClosed = true;
|
|
563
|
+
if (!usageSettled) {
|
|
564
|
+
usageDeadline = setTimeout(finishAttempt, USAGE_SETTLE_GRACE_MS);
|
|
565
|
+
usageDeadline.unref?.();
|
|
566
|
+
}
|
|
567
|
+
maybeFinishAttempt();
|
|
568
|
+
});
|
|
569
|
+
attachAnthropicResponseCapture(upstream, res, entry, startedAt, {
|
|
570
|
+
onMessageStop: () => { decodedMessageStop = true; },
|
|
571
|
+
onUsageSettled: () => {
|
|
572
|
+
usageSettled = true;
|
|
573
|
+
maybeFinishAttempt();
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
relayUpstreamResponse(upstream, req, res, () => {
|
|
577
|
+
if (!clientGone.signal.aborted)
|
|
578
|
+
upstreamFailedFirst = true;
|
|
579
|
+
});
|
|
441
580
|
return;
|
|
442
581
|
}
|
|
443
582
|
};
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
2
|
+
/**
|
|
3
|
+
* Inbound trace context is stripped rather than forwarded, by every Anthropic
|
|
4
|
+
* relay: telemetry never joins a client's distributed trace, and a client's
|
|
5
|
+
* context headers must not reach the upstream provider through this proxy.
|
|
6
|
+
*/
|
|
7
|
+
export const TRACE_CONTEXT_HEADERS = ["traceparent", "tracestate", "baggage"];
|
|
2
8
|
/**
|
|
3
9
|
* Construct the Anthropic transport with http-proxy-middleware's native
|
|
4
10
|
* response piping. In particular, this deliberately does not self-handle,
|
|
@@ -15,6 +21,10 @@ export function createAnthropicProxy(options) {
|
|
|
15
21
|
on: {
|
|
16
22
|
...options.on,
|
|
17
23
|
proxyReq: (proxyRequest, request, response, proxyOptions) => {
|
|
24
|
+
// Telemetry never joins a distributed trace: a client's context headers
|
|
25
|
+
// must not reach the upstream provider through this proxy.
|
|
26
|
+
for (const header of TRACE_CONTEXT_HEADERS)
|
|
27
|
+
proxyRequest.removeHeader(header);
|
|
18
28
|
proxyRequest.once("response", () => {
|
|
19
29
|
proxyRequest.setTimeout(0);
|
|
20
30
|
request.socket.setTimeout(0);
|
|
@@ -1,25 +1,7 @@
|
|
|
1
1
|
import { applyAnthropicInputUsage, applyAnthropicOutputUsage } from "./stats.js";
|
|
2
2
|
import { createAnthropicUsageCapture } from "./usage-capture.js";
|
|
3
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) {
|
|
4
|
+
export function attachAnthropicResponseCapture(upstream, downstream, entry, startedAt, hooks = {}) {
|
|
23
5
|
const contentType = String(upstream.headers["content-type"] ?? "");
|
|
24
6
|
const encoding = String(upstream.headers["content-encoding"] ?? "");
|
|
25
7
|
const isCompressed = /gzip|br|deflate/.test(encoding);
|
|
@@ -32,9 +14,14 @@ export function attachAnthropicResponseCapture(upstream, downstream, entry, star
|
|
|
32
14
|
contentEncoding: encoding,
|
|
33
15
|
onInputUsage: usage => applyAnthropicInputUsage(entry, usage),
|
|
34
16
|
onOutputUsage: usage => applyAnthropicOutputUsage(entry, usage),
|
|
17
|
+
onSettled: () => hooks.onUsageSettled?.(),
|
|
18
|
+
...(hooks.onMessageStop ? { onMessageStop: hooks.onMessageStop } : {}),
|
|
35
19
|
});
|
|
36
20
|
if (usageCapture) {
|
|
37
21
|
upstream.on("data", (chunk) => usageCapture.write(chunk));
|
|
38
22
|
upstream.on("end", () => usageCapture.end());
|
|
39
23
|
}
|
|
24
|
+
else {
|
|
25
|
+
hooks.onUsageSettled?.();
|
|
26
|
+
}
|
|
40
27
|
}
|
|
@@ -7,6 +7,7 @@ import { logError, logRoute } from "./logger.js";
|
|
|
7
7
|
import { EmptyPoolError, NoEligibleAccountError } from "./account-pool.js";
|
|
8
8
|
import { acquireRequestRoute, routeReasonDetails, routeFailureDetails } from "./lease-lifecycle.js";
|
|
9
9
|
import { createCorrelationId, formatTransportDiagnostic, safeCauseCode } from "./transport-diagnostics.js";
|
|
10
|
+
import { annotateActiveSpan, modelFamilyOf, recordRuntimeError, recordUpstreamStatus, settleProxyRequestSpan, startTelemetrySpan, } from "../telemetry/facade.js";
|
|
10
11
|
import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
|
|
11
12
|
/**
|
|
12
13
|
* Mirrors `anthropic-routing.ts`'s `requestTerminated` check. This ingress
|
|
@@ -91,6 +92,16 @@ export function mirrorUpstreamHeaders(source, apply) {
|
|
|
91
92
|
apply(key, value);
|
|
92
93
|
});
|
|
93
94
|
}
|
|
95
|
+
/** Map the activity-log client label onto the closed telemetry request source. */
|
|
96
|
+
function requestSourceOf(source) {
|
|
97
|
+
switch (source) {
|
|
98
|
+
case "codex":
|
|
99
|
+
case "cli": return "cli";
|
|
100
|
+
case "desktop": return "desktop";
|
|
101
|
+
case "api": return "api";
|
|
102
|
+
default: return "other";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
94
105
|
/**
|
|
95
106
|
* Shared OpenAI/Codex ingress lifecycle: acquire a sticky account lease,
|
|
96
107
|
* refresh its token if needed, forward the request, classify the upstream
|
|
@@ -109,6 +120,18 @@ export async function runOpenAIIngress(opts) {
|
|
|
109
120
|
// it still carries whatever model the caller asked for.
|
|
110
121
|
const requestedModel = boundModelId(opts.requestedModel);
|
|
111
122
|
const correlationId = createCorrelationId();
|
|
123
|
+
const route = path === "/v1/messages" ? "messages" : "responses";
|
|
124
|
+
const modelFamily = modelFamilyOf(requestedModel);
|
|
125
|
+
const streaming = forwardBody.stream === true;
|
|
126
|
+
const telemetryStartedAt = now();
|
|
127
|
+
annotateActiveSpan("proxy.request", {
|
|
128
|
+
provider: "openai",
|
|
129
|
+
route,
|
|
130
|
+
modelFamily,
|
|
131
|
+
requestSource: opts.requestSource ?? requestSourceOf(opts.source),
|
|
132
|
+
streaming,
|
|
133
|
+
accountPoolSize: openAIPool.getAll().length,
|
|
134
|
+
});
|
|
112
135
|
// A client that hangs up must take the upstream request with it. Releasing
|
|
113
136
|
// the lease (which the response's own close listener does) only returns the
|
|
114
137
|
// account's *local* capacity — without this the Codex request keeps
|
|
@@ -259,9 +282,30 @@ export async function runOpenAIIngress(opts) {
|
|
|
259
282
|
let upstreamFailed;
|
|
260
283
|
let details;
|
|
261
284
|
let accountFailureCounted;
|
|
285
|
+
let attemptSpan;
|
|
286
|
+
let attemptStartedAt = now();
|
|
287
|
+
let attemptCount = 0;
|
|
288
|
+
/** Close the attempt's span exactly once, on the outcome it ended with. */
|
|
289
|
+
const endAttemptSpan = (outcome, extra = {}) => {
|
|
290
|
+
attemptSpan?.annotate({
|
|
291
|
+
...extra,
|
|
292
|
+
outcome,
|
|
293
|
+
attempt: attemptCount,
|
|
294
|
+
operationDurationMs: now() - attemptStartedAt,
|
|
295
|
+
});
|
|
296
|
+
attemptSpan?.end(outcome === "complete" ? "ok" : "error");
|
|
297
|
+
};
|
|
262
298
|
for (let attempt = 1;; attempt++) {
|
|
263
299
|
const account = selected.route.account;
|
|
264
|
-
|
|
300
|
+
attemptStartedAt = now();
|
|
301
|
+
attemptCount = attempt;
|
|
302
|
+
attemptSpan = startTelemetrySpan("provider.inference", {
|
|
303
|
+
provider: "openai",
|
|
304
|
+
route,
|
|
305
|
+
modelFamily,
|
|
306
|
+
streaming,
|
|
307
|
+
attempt,
|
|
308
|
+
});
|
|
265
309
|
try {
|
|
266
310
|
upstream = await forwardOpenAI({
|
|
267
311
|
account,
|
|
@@ -281,6 +325,7 @@ export async function runOpenAIIngress(opts) {
|
|
|
281
325
|
// pre-forward disconnect branch above, which also just releases and stops.
|
|
282
326
|
if (clientGone.signal.aborted || responseTerminated(res)) {
|
|
283
327
|
selected.release();
|
|
328
|
+
endAttemptSpan("cancelled", { streamOutcome: "cancelled" });
|
|
284
329
|
return;
|
|
285
330
|
}
|
|
286
331
|
// A rejected forward call (network failure) must produce a local 502,
|
|
@@ -307,9 +352,30 @@ export async function runOpenAIIngress(opts) {
|
|
|
307
352
|
headerDurationMs: now() - attemptStartedAt,
|
|
308
353
|
});
|
|
309
354
|
res.status(timeout ? 504 : 502).json(envelope.wrap("upstream_error", timeout ? "OpenAI request timed out" : "OpenAI request failed"));
|
|
355
|
+
recordRuntimeError(error, { operation: "provider.inference", provider: "openai" }, {
|
|
356
|
+
attempt,
|
|
357
|
+
durationMs: now() - attemptStartedAt,
|
|
358
|
+
});
|
|
359
|
+
endAttemptSpan(timeout ? "timeout" : "upstream_error", {
|
|
360
|
+
httpStatusCode: timeout ? 504 : 502,
|
|
361
|
+
streamOutcome: timeout ? "timeout" : "upstream_error",
|
|
362
|
+
});
|
|
363
|
+
settleProxyRequestSpan(res, {
|
|
364
|
+
httpStatusCode: timeout ? 504 : 502,
|
|
365
|
+
outcome: timeout ? "timeout" : "upstream_error",
|
|
366
|
+
attempt,
|
|
367
|
+
operationDurationMs: now() - telemetryStartedAt,
|
|
368
|
+
});
|
|
310
369
|
return;
|
|
311
370
|
}
|
|
312
371
|
headerDurationMs = now() - attemptStartedAt;
|
|
372
|
+
if (upstream.status === 401 || upstream.status === 403
|
|
373
|
+
|| upstream.status === 429 || upstream.status >= 500) {
|
|
374
|
+
recordUpstreamStatus("provider.inference", "openai", upstream.status, {
|
|
375
|
+
attempt,
|
|
376
|
+
durationMs: headerDurationMs,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
313
379
|
// Cooldown/eligibility react to the raw upstream signal — this must not
|
|
314
380
|
// change based on how the relay later renders the response to the client.
|
|
315
381
|
upstreamFailed = upstream.status === 401 || upstream.status === 429 || upstream.status >= 500;
|
|
@@ -402,6 +468,10 @@ export async function runOpenAIIngress(opts) {
|
|
|
402
468
|
if (!prepared)
|
|
403
469
|
break;
|
|
404
470
|
// Committed: record the failed attempt and abandon its response.
|
|
471
|
+
endAttemptSpan(upstream.status === 429 ? "rate_limited" : "upstream_error", {
|
|
472
|
+
httpStatusCode: upstream.status,
|
|
473
|
+
streamOutcome: "upstream_error",
|
|
474
|
+
});
|
|
405
475
|
stats.totalErrors++;
|
|
406
476
|
recordActivity({
|
|
407
477
|
ts: attemptStartedAt,
|
|
@@ -428,6 +498,12 @@ export async function runOpenAIIngress(opts) {
|
|
|
428
498
|
await retryDelay(sameAccountDelayMs, clientGone.signal);
|
|
429
499
|
if (clientGone.signal.aborted || responseTerminated(res)) {
|
|
430
500
|
selected.release();
|
|
501
|
+
settleProxyRequestSpan(res, {
|
|
502
|
+
outcome: "cancelled",
|
|
503
|
+
streamOutcome: "cancelled",
|
|
504
|
+
attempt,
|
|
505
|
+
operationDurationMs: now() - telemetryStartedAt,
|
|
506
|
+
});
|
|
431
507
|
return;
|
|
432
508
|
}
|
|
433
509
|
}
|
|
@@ -546,4 +622,25 @@ export async function runOpenAIIngress(opts) {
|
|
|
546
622
|
entry.statusCode = finalStatus;
|
|
547
623
|
entry.durationMs = now() - startedAt;
|
|
548
624
|
recordActivity(entry);
|
|
625
|
+
const outcome = clientCancelled ? "cancelled"
|
|
626
|
+
: finalStatus === 429 ? "rate_limited"
|
|
627
|
+
: failedFinal ? "upstream_error"
|
|
628
|
+
: "complete";
|
|
629
|
+
const streamOutcome = clientCancelled ? "cancelled"
|
|
630
|
+
: failedFinal ? "upstream_error"
|
|
631
|
+
: "complete";
|
|
632
|
+
const tokens = {
|
|
633
|
+
...(entry.inputTokens !== undefined ? { inputTokens: entry.inputTokens } : {}),
|
|
634
|
+
...(entry.outputTokens !== undefined ? { outputTokens: entry.outputTokens } : {}),
|
|
635
|
+
};
|
|
636
|
+
attemptSpan?.annotate(tokens);
|
|
637
|
+
endAttemptSpan(outcome, { httpStatusCode: finalStatus, streamOutcome });
|
|
638
|
+
settleProxyRequestSpan(res, {
|
|
639
|
+
...tokens,
|
|
640
|
+
httpStatusCode: finalStatus,
|
|
641
|
+
outcome,
|
|
642
|
+
streamOutcome,
|
|
643
|
+
attempt: attemptCount,
|
|
644
|
+
operationDurationMs: now() - telemetryStartedAt,
|
|
645
|
+
});
|
|
549
646
|
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -9,8 +9,8 @@ import { needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccount
|
|
|
9
9
|
import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
|
|
10
10
|
import { createRefreshAllRunner, describeRefreshAll, refreshAllAccounts } from "./pool-refresh.js";
|
|
11
11
|
import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { annotateActiveSpan, httpOutcome, modelFamilyOf, recordProxyStarted, runtimeMode, shutdownTelemetryWithin, startProxyHeartbeat, telemetryRequestMiddleware, } from "../telemetry/facade.js";
|
|
13
|
+
import { startTelemetryRuntime } from "../telemetry/runtime.js";
|
|
14
14
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
15
15
|
import { createLocalRoutingErrorLog, stats } from "./stats.js";
|
|
16
16
|
import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
|
|
@@ -40,6 +40,8 @@ import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpe
|
|
|
40
40
|
import { addOpenAIAccountTransaction } from "./account-add.js";
|
|
41
41
|
import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
|
|
42
42
|
import { createAllowanceView } from "./allowance.js";
|
|
43
|
+
/** Upper bound on how long a shutdown may wait for telemetry to drain. */
|
|
44
|
+
const TELEMETRY_SHUTDOWN_DEADLINE_MS = 1_000;
|
|
43
45
|
const zeroRoutingMetrics = () => ({
|
|
44
46
|
inFlightRequests: 0,
|
|
45
47
|
activeSessions: 0,
|
|
@@ -334,6 +336,9 @@ export function createOpenAIPersister(accountsPath) {
|
|
|
334
336
|
};
|
|
335
337
|
}
|
|
336
338
|
export async function startServer(opts = {}) {
|
|
339
|
+
// Best effort and non-throwing: a telemetry runtime that cannot start just
|
|
340
|
+
// leaves every later facade call inert.
|
|
341
|
+
startTelemetryRuntime({ tracing: true, runtimeMode: runtimeMode() });
|
|
337
342
|
const port = opts.port ?? PROXY_PORT;
|
|
338
343
|
// Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
|
|
339
344
|
// Priority: explicit option > LITELLM_URL env var > direct to Anthropic
|
|
@@ -1049,6 +1054,9 @@ export async function startServer(opts = {}) {
|
|
|
1049
1054
|
const onOpenAIUpstreamAuthFailure = (account) => {
|
|
1050
1055
|
refreshAndPersistOpenAIAccount(account, openAIAccounts, persistOpenAIAccounts).catch(() => { });
|
|
1051
1056
|
};
|
|
1057
|
+
// Wraps only /v1/messages and /v1/responses in a server span; every other
|
|
1058
|
+
// route passes straight through.
|
|
1059
|
+
app.use(telemetryRequestMiddleware());
|
|
1052
1060
|
mountResponsesRoutes(app, {
|
|
1053
1061
|
openAIRouter,
|
|
1054
1062
|
openAIPool,
|
|
@@ -1160,6 +1168,11 @@ export async function startServer(opts = {}) {
|
|
|
1160
1168
|
const durationMs = req._startTime
|
|
1161
1169
|
? Date.now() - req._startTime
|
|
1162
1170
|
: undefined;
|
|
1171
|
+
annotateActiveSpan("proxy.request", {
|
|
1172
|
+
httpStatusCode: status,
|
|
1173
|
+
outcome: httpOutcome(status),
|
|
1174
|
+
...(durationMs !== undefined ? { operationDurationMs: durationMs } : {}),
|
|
1175
|
+
});
|
|
1163
1176
|
// Complete the pending log entry with response info
|
|
1164
1177
|
const pendingLog = req._pendingLog ?? {
|
|
1165
1178
|
ts: Date.now(),
|
|
@@ -1296,6 +1309,14 @@ export async function startServer(opts = {}) {
|
|
|
1296
1309
|
: req.headers["x-api-key"]
|
|
1297
1310
|
? "desktop"
|
|
1298
1311
|
: "api";
|
|
1312
|
+
annotateActiveSpan("proxy.request", {
|
|
1313
|
+
provider: "anthropic",
|
|
1314
|
+
route: "messages",
|
|
1315
|
+
modelFamily: modelFamilyOf(req._ccRouteContext?.requestedModel ?? "-"),
|
|
1316
|
+
requestSource: source,
|
|
1317
|
+
accountPoolSize: pool.getAll().length,
|
|
1318
|
+
concurrency: pool.getInFlight(account.id),
|
|
1319
|
+
});
|
|
1299
1320
|
req._pendingLog = {
|
|
1300
1321
|
ts: Date.now(),
|
|
1301
1322
|
accountId: account.id,
|
|
@@ -1316,7 +1337,13 @@ export async function startServer(opts = {}) {
|
|
|
1316
1337
|
changeOrigin: true,
|
|
1317
1338
|
}));
|
|
1318
1339
|
// ─── Graceful shutdown ────────────────────────────────────────────────────
|
|
1319
|
-
|
|
1340
|
+
let shuttingDown = false;
|
|
1341
|
+
const shutdown = async () => {
|
|
1342
|
+
// A second signal while the bounded telemetry flush runs must not repeat
|
|
1343
|
+
// the persistence work below.
|
|
1344
|
+
if (shuttingDown)
|
|
1345
|
+
return;
|
|
1346
|
+
shuttingDown = true;
|
|
1320
1347
|
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
1321
1348
|
usageRefresher.stop();
|
|
1322
1349
|
openAIUsageRefresher.stop();
|
|
@@ -1324,10 +1351,11 @@ export async function startServer(opts = {}) {
|
|
|
1324
1351
|
if (managesPidFile()) {
|
|
1325
1352
|
removePid();
|
|
1326
1353
|
}
|
|
1354
|
+
await shutdownTelemetryWithin(TELEMETRY_SHUTDOWN_DEADLINE_MS);
|
|
1327
1355
|
process.exit(0);
|
|
1328
1356
|
};
|
|
1329
|
-
process.on("SIGTERM", shutdown);
|
|
1330
|
-
process.on("SIGINT", shutdown);
|
|
1357
|
+
process.on("SIGTERM", () => { void shutdown(); });
|
|
1358
|
+
process.on("SIGINT", () => { void shutdown(); });
|
|
1331
1359
|
// ─── Update handling ──────────────────────────────────────────────────────
|
|
1332
1360
|
// Auto-update is OFF by default: installing code unattended from the npm
|
|
1333
1361
|
// registry (no signature/provenance check) turns any publish-channel
|
|
@@ -1402,22 +1430,7 @@ export async function startServer(opts = {}) {
|
|
|
1402
1430
|
console.log(autoFailover
|
|
1403
1431
|
? chalk.gray(" Auto-failover: on — 429/5xx retried across accounts before the first relayed byte")
|
|
1404
1432
|
: chalk.gray(" Auto-failover: off — upstream failures pass through; clients own retries"));
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
const telemetryState = loadTelemetryState();
|
|
1408
|
-
// First-run detection: if the install is brand new, emit app_started too
|
|
1409
|
-
const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
|
|
1410
|
-
if (firstRunAge < 5 * 60 * 1000) {
|
|
1411
|
-
void trackEvent("app_started", { first_run: true });
|
|
1412
|
-
}
|
|
1413
|
-
void trackEvent("proxy_started", {
|
|
1414
|
-
account_count: totalAccountCount,
|
|
1415
|
-
mode,
|
|
1416
|
-
});
|
|
1417
|
-
startHeartbeat(totalAccountCount);
|
|
1418
|
-
}
|
|
1419
|
-
catch {
|
|
1420
|
-
// never let telemetry break the proxy
|
|
1421
|
-
}
|
|
1433
|
+
recordProxyStarted(totalAccountCount);
|
|
1434
|
+
startProxyHeartbeat(() => pool.getAll().length + openAIPool.getAll().length);
|
|
1422
1435
|
});
|
|
1423
1436
|
}
|