@timo972/cc-router 0.13.0-rc.2 → 0.13.0-rc.3

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.
@@ -1,4 +1,4 @@
1
- import { applyAnthropicInputUsage, applyAnthropicOutputUsage } from "./stats.js";
1
+ import { applyAnthropicInputUsage, applyAnthropicOutputUsage, setUsageModel, finishUsageAttempt } from "./stats.js";
2
2
  import { createAnthropicUsageCapture } from "./usage-capture.js";
3
3
  import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
4
4
  export function attachAnthropicResponseCapture(upstream, downstream, entry, startedAt, hooks = {}) {
@@ -9,19 +9,30 @@ export function attachAnthropicResponseCapture(upstream, downstream, entry, star
9
9
  entry.streamLifecycle = streamTracker.state;
10
10
  streamTracker.attach(upstream, downstream);
11
11
  upstream.on("data", (chunk) => streamTracker.observeChunk(chunk));
12
+ let terminal = false;
13
+ let ended = false;
12
14
  const usageCapture = createAnthropicUsageCapture({
13
15
  contentType,
14
16
  contentEncoding: encoding,
15
17
  onInputUsage: usage => applyAnthropicInputUsage(entry, usage),
16
18
  onOutputUsage: usage => applyAnthropicOutputUsage(entry, usage),
17
- onSettled: () => hooks.onUsageSettled?.(),
18
- ...(hooks.onMessageStop ? { onMessageStop: hooks.onMessageStop } : {}),
19
+ onModel: model => setUsageModel(entry, model),
20
+ onJSONComplete: () => { terminal = true; },
21
+ onSettled: () => { finishUsageAttempt(entry, terminal); hooks.onUsageSettled?.(); },
22
+ onMessageStop: () => { terminal = true; hooks.onMessageStop?.(); },
19
23
  });
20
24
  if (usageCapture) {
21
25
  upstream.on("data", (chunk) => usageCapture.write(chunk));
22
- upstream.on("end", () => usageCapture.end());
26
+ upstream.on("end", () => { ended = true; usageCapture.end(); });
27
+ upstream.once("close", () => { if (!ended)
28
+ usageCapture.abort(); });
29
+ upstream.once("error", () => { if (!ended)
30
+ usageCapture.abort(); });
31
+ downstream.once("close", () => { if (!ended)
32
+ usageCapture.abort(); });
23
33
  }
24
34
  else {
35
+ finishUsageAttempt(entry, false);
25
36
  hooks.onUsageSettled?.();
26
37
  }
27
38
  }
@@ -1,3 +1,4 @@
1
+ import { boundModelId, discardUsageAttempt } from "./stats.js";
1
2
  import { acquireRequestRoute } from "./lease-lifecycle.js";
2
3
  import { normalizeSessionId } from "./session-router.js";
3
4
  import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
@@ -69,6 +70,13 @@ export function createAnthropicRoutingMiddleware(options) {
69
70
  routedRequest._ccRoute = selected.route;
70
71
  routedRequest._ccReleaseLease = selected.release;
71
72
  routedRequest._ccAccount = selected.route.account;
73
+ // The generic /v1 transport also handles token-count preflights. They are
74
+ // not inference attempts, even though they borrow an account/lease.
75
+ if (options.usageRuntime && request.method === "POST" && /^\/v1\/messages\/?(?:\?|$)/i.test(request.originalUrl)) {
76
+ const entry = { ts: Date.now(), accountId: selected.route.account.id, model: boundModelId(request._ccRouteContext?.requestedModel ?? "-"), type: "route" };
77
+ options.usageRuntime.bind(entry, "anthropic_subscription");
78
+ request._ccUsageEntry = entry;
79
+ }
72
80
  next();
73
81
  }
74
82
  catch (error) {
@@ -102,10 +110,14 @@ export function createAnthropicRefreshMiddleware(options) {
102
110
  if (options.needsRefresh(account)) {
103
111
  const ok = await options.refresh(account);
104
112
  if (requestTerminated(request, response)) {
113
+ if (request._ccUsageEntry)
114
+ discardUsageAttempt(request._ccUsageEntry);
105
115
  release();
106
116
  return;
107
117
  }
108
118
  if (!ok) {
119
+ if (request._ccUsageEntry)
120
+ discardUsageAttempt(request._ccUsageEntry);
109
121
  release();
110
122
  options.onRefreshFailure(account);
111
123
  response.status(401).json({
@@ -119,12 +131,16 @@ export function createAnthropicRefreshMiddleware(options) {
119
131
  }
120
132
  }
121
133
  if (requestTerminated(request, response)) {
134
+ if (request._ccUsageEntry)
135
+ discardUsageAttempt(request._ccUsageEntry);
122
136
  release();
123
137
  return;
124
138
  }
125
139
  next();
126
140
  }
127
141
  catch (error) {
142
+ if (request._ccUsageEntry)
143
+ discardUsageAttempt(request._ccUsageEntry);
128
144
  release();
129
145
  if (requestTerminated(request, response))
130
146
  return;
@@ -8,7 +8,7 @@ import { encodeSseEvent, parseSseLines } from "../protocol/sse.js";
8
8
  import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
9
9
  import { terminalResponsePayload, usageFromTerminalEvent, usageFromResponseBody, } from "../protocol/openai-responses-collect.js";
10
10
  import { extractAnthropicRouteContext } from "./request-model.js";
11
- import { stats, applyCodexUsage } from "./stats.js";
11
+ import { stats, applyCodexUsage, captureCodexResponse } from "./stats.js";
12
12
  import { extractCodexSessionKey } from "./openai-routing.js";
13
13
  import { sendAnthropicNoEligibleResponse, detectAnthropicClientSource } from "./anthropic-routing.js";
14
14
  import { mirrorUpstreamHeaders, runOpenAIIngress, } from "./openai-ingress.js";
@@ -101,7 +101,7 @@ async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, repo
101
101
  const failure = await sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report, now);
102
102
  return { statusCode: failure === undefined ? upstream.status : 502 };
103
103
  }
104
- const collected = await collectOpenAIStreamAsAnthropicMessage(upstream, report);
104
+ const collected = await collectOpenAIStreamAsAnthropicMessage(upstream, report, entry);
105
105
  onUsage(collected.usage);
106
106
  if (collected.failure !== undefined) {
107
107
  // Mirrors collectCodexResponseStream on the /v1/responses path: a stream
@@ -122,11 +122,12 @@ async function sendOpenAIAsAnthropic(upstream, res, requestedStream, entry, repo
122
122
  return { statusCode: upstream.status };
123
123
  }
124
124
  const json = await upstream.json();
125
+ captureCodexResponse(entry, json);
125
126
  onUsage(usageFromResponseBody(json));
126
127
  res.status(upstream.status).json(openAIResponseToAnthropicMessage(json));
127
128
  return { statusCode: upstream.status };
128
129
  }
129
- async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
130
+ async function collectOpenAIStreamAsAnthropicMessage(upstream, report, entry) {
130
131
  const reader = upstream.body?.getReader();
131
132
  if (!reader) {
132
133
  // An event-stream response with no body cannot have reached a terminal
@@ -156,6 +157,8 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
156
157
  const pendingCallsByIndex = new Map();
157
158
  const callsByIndex = new Map();
158
159
  const applyEvent = (event) => {
160
+ if (event && typeof event === "object")
161
+ captureCodexResponse(entry, event.response);
159
162
  if (typeof event !== "object" || event === null)
160
163
  return;
161
164
  const openAIEvent = event;
@@ -340,6 +343,8 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, entry, onUsage, report
340
343
  }));
341
344
  };
342
345
  const inspect = (event) => {
346
+ if (event && typeof event === "object")
347
+ captureCodexResponse(entry, event.response);
343
348
  totals = usageFromTerminalEvent(event) ?? totals;
344
349
  if (typeof event !== "object" || event === null)
345
350
  return;
@@ -480,6 +485,7 @@ export function mountMessagesCrossProviderRoute(app, opts) {
480
485
  source: detectAnthropicClientSource(req.headers),
481
486
  openAIRouter: opts.openAIRouter,
482
487
  openAIPool: opts.openAIPool,
488
+ usageRuntime: opts.usageRuntime,
483
489
  prepareOpenAIAccount,
484
490
  forwardOpenAI,
485
491
  forwardBody: body,
@@ -2,7 +2,7 @@ import { applyCodexRateLimits } from "../providers/openai/account-state.js";
2
2
  import { headersToRecord, parseCodexRateLimits } from "../providers/openai/usage.js";
3
3
  import { applyCodexFailureRouting } from "../providers/openai/failure-routing.js";
4
4
  import { needsOpenAIRefresh } from "../providers/openai/token-refresher.js";
5
- import { stats, boundModelId, createLocalRoutingErrorLog } from "./stats.js";
5
+ import { stats, boundModelId, createLocalRoutingErrorLog, finishUsageAttempt, discardUsageAttempt } from "./stats.js";
6
6
  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";
@@ -206,7 +206,18 @@ export async function runOpenAIIngress(opts) {
206
206
  * sends instead is its own decision — the first attempt answers a local
207
207
  * 401, a retry attempt relays the upstream failure it already holds.
208
208
  */
209
+ const attemptEntries = new WeakMap();
210
+ const entryFor = (routed) => {
211
+ let entry = attemptEntries.get(routed);
212
+ if (!entry) {
213
+ entry = { ts: now(), accountId: routed.route.account.id, model: requestedModel, type: "route", path };
214
+ opts.usageRuntime?.bind(entry, "openai_subscription");
215
+ attemptEntries.set(routed, entry);
216
+ }
217
+ return entry;
218
+ };
209
219
  const prepareRoute = async (routed) => {
220
+ entryFor(routed); // Bind immutable identity before token refresh can yield to account rename.
210
221
  const account = routed.route.account;
211
222
  const needed = needsOpenAIRefresh(account);
212
223
  const refreshStartedAt = now();
@@ -224,6 +235,7 @@ export async function runOpenAIIngress(opts) {
224
235
  const refreshDurationMs = now() - refreshStartedAt;
225
236
  refreshDurations.set(account, refreshDurationMs);
226
237
  if (!ready) {
238
+ discardUsageAttempt(entryFor(routed));
227
239
  routed.release();
228
240
  // Intentionally does not touch `account.healthy`: a single failed
229
241
  // refresh fails only this request. Disabling the account here would
@@ -275,6 +287,7 @@ export async function runOpenAIIngress(opts) {
275
287
  // Just release the lease and stop — no response to send, and it is safe to
276
288
  // release again even if the response's own close/finish listener already
277
289
  // did so (`attachLeaseLifecycle`'s release() is idempotent).
290
+ discardUsageAttempt(entryFor(selected));
278
291
  selected.release();
279
292
  return;
280
293
  }
@@ -287,6 +300,7 @@ export async function runOpenAIIngress(opts) {
287
300
  let attemptCount = 0;
288
301
  /** Close the attempt's span exactly once, on the outcome it ended with. */
289
302
  const endAttemptSpan = (outcome, extra = {}) => {
303
+ finishUsageAttempt(entryFor(selected), false);
290
304
  attemptSpan?.annotate({
291
305
  ...extra,
292
306
  outcome,
@@ -459,6 +473,7 @@ export async function runOpenAIIngress(opts) {
459
473
  // background and readies (or cools down) the account for later requests.
460
474
  const prepared = await boundedWait(prepareRoute(next), retryRefreshTimeoutMs, "still-pending", clientGone.signal);
461
475
  if (prepared === "still-pending") {
476
+ discardUsageAttempt(entryFor(next));
462
477
  if (!clientGone.signal.aborted) {
463
478
  logError(next.route.account.id, 0, `failover token refresh still pending after ${retryRefreshTimeoutMs}ms — relaying held upstream failure`);
464
479
  }
@@ -497,6 +512,7 @@ export async function runOpenAIIngress(opts) {
497
512
  if (sameAccount)
498
513
  await retryDelay(sameAccountDelayMs, clientGone.signal);
499
514
  if (clientGone.signal.aborted || responseTerminated(res)) {
515
+ discardUsageAttempt(entryFor(selected));
500
516
  selected.release();
501
517
  settleProxyRequestSpan(res, {
502
518
  outcome: "cancelled",
@@ -508,7 +524,8 @@ export async function runOpenAIIngress(opts) {
508
524
  }
509
525
  }
510
526
  const account = selected.route.account;
511
- const entry = {
527
+ const entry = entryFor(selected);
528
+ Object.assign(entry, {
512
529
  ts: startedAt,
513
530
  accountId: account.id,
514
531
  model: requestedModel,
@@ -520,7 +537,7 @@ export async function runOpenAIIngress(opts) {
520
537
  headerDurationMs,
521
538
  correlationId,
522
539
  details,
523
- };
540
+ });
524
541
  let finalStatus = upstream.status;
525
542
  let relayFailed = false;
526
543
  let relayFailureMessage = "";
@@ -618,6 +635,7 @@ export async function runOpenAIIngress(opts) {
618
635
  account.consecutiveErrors = 0;
619
636
  stats.totalRequests++;
620
637
  }
638
+ finishUsageAttempt(entry, !relayFailed && !clientCancelled && finalStatus < 400);
621
639
  entry.type = failedFinal ? "error" : "route";
622
640
  entry.statusCode = finalStatus;
623
641
  entry.durationMs = now() - startedAt;
@@ -2,7 +2,7 @@ import express from "express";
2
2
  import { selectRoute } from "../providers/route-selector.js";
3
3
  import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
4
4
  import { collectCodexResponseStream, createCodexUsageObserver, usageFromResponseBody, } from "../protocol/openai-responses-collect.js";
5
- import { stats, applyCodexUsage, boundModelId } from "./stats.js";
5
+ import { stats, applyCodexUsage, boundModelId, captureCodexResponse } from "./stats.js";
6
6
  import { logWarn } from "./logger.js";
7
7
  import { extractCodexSessionKey, sendOpenAINoEligibleResponse } from "./openai-routing.js";
8
8
  import { runOpenAIIngress, mirrorUpstreamHeaders, } from "./openai-ingress.js";
@@ -146,6 +146,7 @@ export function mountResponsesRoutes(app, opts) {
146
146
  source: "codex",
147
147
  openAIRouter: opts.openAIRouter,
148
148
  openAIPool: opts.openAIPool,
149
+ usageRuntime: opts.usageRuntime,
149
150
  prepareOpenAIAccount,
150
151
  forwardOpenAI,
151
152
  forwardBody: body,
@@ -163,7 +164,7 @@ export function mountResponsesRoutes(app, opts) {
163
164
  : {}),
164
165
  relay: async (upstream, res, entry, report) => {
165
166
  if (body.stream === true) {
166
- const observer = createCodexUsageObserver();
167
+ const observer = createCodexUsageObserver(body => captureCodexResponse(entry, body), upstream.headers.get("content-type") ?? "");
167
168
  // Only an upstream that actually promised a successful event stream
168
169
  // can be judged on whether that stream completed. A non-OK response
169
170
  // (a plain 401/429/5xx body) has no SSE events to observe, so
@@ -176,14 +177,18 @@ export function mountResponsesRoutes(app, opts) {
176
177
  // Reported per chunk, not once at the end: this relay can throw
177
178
  // (or be cut short) after upstream has already announced a failure,
178
179
  // and the verdict has to survive that.
179
- await sendUpstreamResponse(upstream, res, chunk => {
180
- if (entry.firstByteDurationMs === undefined)
181
- entry.firstByteDurationMs = now() - entry.ts;
182
- observer.push(chunk);
183
- if (observer.explicitFailure() !== undefined)
184
- report.upstreamReportedFailure = true;
185
- });
186
- applyCodexUsage(entry, observer.finish());
180
+ try {
181
+ await sendUpstreamResponse(upstream, res, chunk => {
182
+ if (entry.firstByteDurationMs === undefined)
183
+ entry.firstByteDurationMs = now() - entry.ts;
184
+ observer.push(chunk);
185
+ if (observer.explicitFailure() !== undefined)
186
+ report.upstreamReportedFailure = true;
187
+ });
188
+ }
189
+ finally {
190
+ applyCodexUsage(entry, observer.finish());
191
+ }
187
192
  // Bytes already written to the client are untouched — this only
188
193
  // changes the REPORTED status (used for stats/activity/cooldown),
189
194
  // matching a stream that upstream answered `200` but that ended in
@@ -197,7 +202,7 @@ export function mountResponsesRoutes(app, opts) {
197
202
  }
198
203
  const collected = await collectCodexResponseStream(upstream, () => {
199
204
  report.upstreamReportedFailure = true;
200
- });
205
+ }, body => captureCodexResponse(entry, body));
201
206
  // Mirror upstream headers (e.g. Retry-After, x-codex-*) before sending the
202
207
  // collected body, so failure responses reach the client unchanged per the
203
208
  // same contract the streaming path already honors via sendUpstreamResponse.
@@ -1,3 +1,7 @@
1
+ import { finishUsageAttempt } from "./stats.js";
2
+ import { startUsageRuntime } from "../usage/runtime.js";
3
+ import { createUsageRouter } from "../usage/http.js";
4
+ import { usageDirectoryForAccounts, withUsageRename } from "../usage/account-lifecycle.js";
1
5
  import { consumeCodexResetCredit } from "../providers/openai/usage-reset.js";
2
6
  import { createUsageResetHandler } from "./account-usage-reset.js";
3
7
  import express from "express";
@@ -372,6 +376,7 @@ export async function startServer(opts = {}) {
372
376
  const target = litellmUrl ?? "https://api.anthropic.com";
373
377
  const mode = litellmUrl ? "litellm" : "standalone";
374
378
  const accountsPath = opts.accountsPath;
379
+ const accountsFile = accountsPath ?? ACCOUNTS_PATH;
375
380
  const persistOpenAIAccounts = createOpenAIPersister(accountsPath);
376
381
  // Every Anthropic write goes through this, never `saveAccounts` directly, so
377
382
  // a server started with `--accounts <path>` writes back to the file it read.
@@ -389,6 +394,11 @@ export async function startServer(opts = {}) {
389
394
  console.error(chalk.yellow(" Run: cc-router setup\n"));
390
395
  process.exit(1);
391
396
  }
397
+ const usageRuntime = startUsageRuntime(usageDirectoryForAccounts(accountsFile), [
398
+ ...accounts.map(a => ({ id: a.id, provider: "anthropic_subscription" })),
399
+ ...openAIAccounts,
400
+ ...loadXaiAccounts(accountsPath),
401
+ ], { unmeasuredProviders: litellmUrl ? ["anthropic_subscription"] : [] });
392
402
  const pool = new TokenPool(accounts);
393
403
  const sessionRouter = new SessionRouter(pool);
394
404
  const createRoutingMetricsResolver = () => {
@@ -528,6 +538,7 @@ export async function startServer(opts = {}) {
528
538
  next();
529
539
  });
530
540
  }
541
+ app.use("/cc-router/usage", createUsageRouter(usageRuntime));
531
542
  // ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
532
543
  // Always reachable without auth so PM2/monitoring liveness checks keep
533
544
  // working — but the DETAILED payload (account IDs, plan tier, usage, recent
@@ -877,12 +888,12 @@ export async function startServer(opts = {}) {
877
888
  ? {
878
889
  rename: (oldId, nextId) => pool.renameAccount(oldId, nextId) !== null,
879
890
  renameSessions: (oldId, nextId) => { sessionRouter.renameAccount(oldId, nextId); },
880
- persist: () => persistAnthropicAccounts(pool.getAll()),
891
+ persist: () => withUsageRename(accountsFile, id, newId, () => persistAnthropicAccounts(pool.getAll())),
881
892
  }
882
893
  : {
883
894
  rename: (oldId, nextId) => openAIPool.renameAccount(oldId, nextId) !== null,
884
895
  renameSessions: (oldId, nextId) => { openAIRouter.renameAccount(oldId, nextId); },
885
- persist: () => persistOpenAIAccounts(openAIAccounts),
896
+ persist: () => withUsageRename(accountsFile, id, newId, () => persistOpenAIAccounts(openAIAccounts)),
886
897
  });
887
898
  }
888
899
  catch (err) {
@@ -1060,7 +1071,7 @@ export async function startServer(opts = {}) {
1060
1071
  expiresAt: body.expiresAt,
1061
1072
  scopes: Array.isArray(body.scopes) ? body.scopes : [],
1062
1073
  enabled: body.enabled !== false,
1063
- });
1074
+ }, accountsFile);
1064
1075
  }
1065
1076
  catch (err) {
1066
1077
  const message = err instanceof Error ? err.message : String(err);
@@ -1233,6 +1244,7 @@ export async function startServer(opts = {}) {
1233
1244
  mountResponsesRoutes(app, {
1234
1245
  openAIRouter,
1235
1246
  openAIPool,
1247
+ usageRuntime,
1236
1248
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
1237
1249
  modelRouting,
1238
1250
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
@@ -1242,6 +1254,7 @@ export async function startServer(opts = {}) {
1242
1254
  mountMessagesCrossProviderRoute(app, {
1243
1255
  openAIRouter,
1244
1256
  openAIPool,
1257
+ usageRuntime,
1245
1258
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
1246
1259
  modelRouting,
1247
1260
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
@@ -1276,6 +1289,7 @@ export async function startServer(opts = {}) {
1276
1289
  timeoutMs: proxyRequestTimeoutMs,
1277
1290
  pool,
1278
1291
  sessionRouter,
1292
+ usageRuntime,
1279
1293
  ...upstreamAttempts,
1280
1294
  needsRefresh,
1281
1295
  refresh: account => refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts }),
@@ -1434,6 +1448,8 @@ export async function startServer(opts = {}) {
1434
1448
  },
1435
1449
  error: (err, _req, res) => {
1436
1450
  const request = _req;
1451
+ if (request._ccUsageEntry)
1452
+ finishUsageAttempt(request._ccUsageEntry, false);
1437
1453
  request._ccReleaseLease?.();
1438
1454
  stats.totalErrors++;
1439
1455
  logError("proxy", 0, err.message);
@@ -1466,6 +1482,7 @@ export async function startServer(opts = {}) {
1466
1482
  // CRITICAL: Do NOT use express.json() here — it consumes the body stream
1467
1483
  // and breaks SSE streaming passthrough.
1468
1484
  app.use("/v1", createAnthropicRoutingMiddleware({
1485
+ usageRuntime,
1469
1486
  sessionRouter,
1470
1487
  onEmptyPool: onAnthropicEmptyPool,
1471
1488
  onNoEligibleAccount: onAnthropicNoEligibleAccount,
@@ -1521,15 +1538,18 @@ export async function startServer(opts = {}) {
1521
1538
  usageRefresher.stop();
1522
1539
  openAIUsageRefresher.stop();
1523
1540
  accountInfoCache.stop();
1524
- persistAnthropicAccounts(pool.getAll());
1541
+ try {
1542
+ persistAnthropicAccounts(pool.getAll());
1543
+ }
1544
+ finally {
1545
+ usageRuntime.close();
1546
+ }
1525
1547
  if (managesPidFile()) {
1526
1548
  removePid();
1527
1549
  }
1528
1550
  await shutdownTelemetryWithin(TELEMETRY_SHUTDOWN_DEADLINE_MS);
1529
1551
  process.exit(0);
1530
1552
  };
1531
- process.on("SIGTERM", () => { void shutdown(); });
1532
- process.on("SIGINT", () => { void shutdown(); });
1533
1553
  // ─── Update handling ──────────────────────────────────────────────────────
1534
1554
  // Auto-update is OFF by default: installing code unattended from the npm
1535
1555
  // registry (no signature/provenance check) turns any publish-channel
@@ -1551,6 +1571,7 @@ export async function startServer(opts = {}) {
1551
1571
  if (ok) {
1552
1572
  console.log(chalk.green("[auto-update] Restarting with new version..."));
1553
1573
  persistAnthropicAccounts(pool.getAll());
1574
+ usageRuntime.close();
1554
1575
  restartSelf();
1555
1576
  }
1556
1577
  }
@@ -1588,7 +1609,26 @@ export async function startServer(opts = {}) {
1588
1609
  ` unset HOST (or set HOST=127.0.0.1).\n`));
1589
1610
  process.exit(1);
1590
1611
  }
1591
- app.listen(port, host, () => {
1612
+ const server = app.listen(port, host);
1613
+ await new Promise((resolve, reject) => {
1614
+ const onError = (error) => {
1615
+ server.off("listening", onListening);
1616
+ usageRefresher.stop();
1617
+ openAIUsageRefresher.stop();
1618
+ accountInfoCache.stop();
1619
+ usageRuntime.close();
1620
+ reject(error);
1621
+ };
1622
+ const onListening = () => {
1623
+ server.off("error", onError);
1624
+ resolve();
1625
+ };
1626
+ server.once("error", onError);
1627
+ server.once("listening", onListening);
1628
+ });
1629
+ process.on("SIGTERM", () => { void shutdown(); });
1630
+ process.on("SIGINT", () => { void shutdown(); });
1631
+ {
1592
1632
  // Write PID for daemon/service process management
1593
1633
  if (managesPidFile()) {
1594
1634
  writePid(process.pid);
@@ -1606,5 +1646,6 @@ export async function startServer(opts = {}) {
1606
1646
  : chalk.gray(" Auto-failover: off — upstream failures pass through; clients own retries"));
1607
1647
  recordProxyStarted(totalAccountCount);
1608
1648
  startProxyHeartbeat(() => pool.getAll().length + openAIPool.getAll().length);
1609
- });
1649
+ }
1650
+ server.once("close", () => usageRuntime.close());
1610
1651
  }
@@ -58,23 +58,36 @@ export const stats = new ProxyStats();
58
58
  * entry that is typically already stored — the dashboard picks the values up
59
59
  * on its next poll.
60
60
  */
61
+ function applyCumulativeAnthropicCount(entry, field, total, value) {
62
+ // Usage snapshots are cumulative. Absent or malformed fields are not new zeroes.
63
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
64
+ return;
65
+ const previous = entry[field] ?? 0;
66
+ if (value < previous)
67
+ return;
68
+ stats[total] += value - previous;
69
+ entry[field] = value;
70
+ }
71
+ function applyAnthropicUsageSnapshot(entry, usage) {
72
+ applyCumulativeAnthropicCount(entry, "inputTokens", "totalInputTokens", usage.input_tokens);
73
+ applyCumulativeAnthropicCount(entry, "cacheReadTokens", "totalCacheReadTokens", usage.cache_read_input_tokens);
74
+ applyCumulativeAnthropicCount(entry, "cacheCreationTokens", "totalCacheCreationTokens", usage.cache_creation_input_tokens);
75
+ applyCumulativeAnthropicCount(entry, "outputTokens", "totalOutputTokens", usage.output_tokens);
76
+ }
61
77
  export function applyAnthropicInputUsage(entry, usage) {
62
- entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
63
- entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
64
- entry.inputTokens = usage["input_tokens"] ?? 0;
65
- stats.totalCacheReadTokens += entry.cacheReadTokens;
66
- stats.totalCacheCreationTokens += entry.cacheCreationTokens;
67
- stats.totalInputTokens += entry.inputTokens;
78
+ notifyUsage(entry, { kind: "input", usage });
79
+ applyAnthropicUsageSnapshot(entry, usage);
68
80
  }
69
- /** Record Anthropic output-side usage (message_delta) — see input counterpart. */
81
+ /** Record the positive difference from the preceding cumulative output snapshot. */
70
82
  export function applyAnthropicOutputUsage(entry, usage) {
71
- entry.outputTokens = usage["output_tokens"] ?? 0;
72
- stats.totalOutputTokens += entry.outputTokens;
83
+ notifyUsage(entry, { kind: "output", usage });
84
+ applyAnthropicUsageSnapshot(entry, usage);
73
85
  }
74
86
  /** Record Codex token usage on both the request's log entry and the running totals. */
75
87
  export function applyCodexUsage(entry, usage) {
76
88
  if (!usage)
77
89
  return;
90
+ notifyUsage(entry, { kind: "codex", usage });
78
91
  entry.inputTokens = Math.max(0, usage.inputTokens - usage.cachedInputTokens);
79
92
  entry.outputTokens = usage.outputTokens;
80
93
  entry.cacheReadTokens = usage.cachedInputTokens;
@@ -82,3 +95,19 @@ export function applyCodexUsage(entry, usage) {
82
95
  stats.totalOutputTokens += usage.outputTokens;
83
96
  stats.totalCacheReadTokens += usage.cachedInputTokens;
84
97
  }
98
+ const usageObservers = new WeakMap();
99
+ export function observeEntryUsage(entry, observer) { usageObservers.set(entry, observer); }
100
+ function notifyUsage(entry, event) {
101
+ try {
102
+ usageObservers.get(entry)?.(event);
103
+ }
104
+ catch { /* Persistence must never change routing/response bytes. */ }
105
+ }
106
+ export function setUsageModel(entry, model) { notifyUsage(entry, { kind: "model", model }); }
107
+ export function captureCodexResponse(entry, body) { notifyUsage(entry, { kind: "codex-response", body }); }
108
+ export function finishUsageAttempt(entry, complete) { notifyUsage(entry, { kind: "finish", complete }); }
109
+ /** A bound candidate was never forwarded: release capture without inventing missing usage. */
110
+ export function discardUsageAttempt(entry) {
111
+ notifyUsage(entry, { kind: "discard" });
112
+ usageObservers.delete(entry);
113
+ }
@@ -1,3 +1,4 @@
1
+ import { StringDecoder } from "node:string_decoder";
1
2
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
2
3
  import { MAX_RETAINED_SSE_LINE_BYTES } from "./stream-lifecycle.js";
3
4
  /** Non-streaming bodies are buffered for one parse at end-of-stream; a body
@@ -26,6 +27,7 @@ export function createAnthropicUsageCapture(options) {
26
27
  const decoder = createDecoder(options.contentEncoding);
27
28
  if (decoder === undefined)
28
29
  return null;
30
+ const textDecoder = new StringDecoder("utf8");
29
31
  let dead = false;
30
32
  const die = () => {
31
33
  if (dead)
@@ -70,15 +72,17 @@ export function createAnthropicUsageCapture(options) {
70
72
  for (const line of lines) {
71
73
  if (dead)
72
74
  return;
73
- if (!line.startsWith("data: "))
75
+ if (line.length > MAX_RETAINED_SSE_LINE_BYTES || !line.startsWith("data:"))
74
76
  continue;
75
77
  try {
76
- const evt = JSON.parse(line.slice(6));
78
+ const evt = JSON.parse(line.slice(5).trimStart());
79
+ if (evt.type === "message_start")
80
+ options.onModel?.(evt.message?.model);
77
81
  if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
78
82
  options.onInputUsage(evt.message.usage);
79
83
  gotInput = true;
80
84
  }
81
- if (!gotOutput && evt.type === "message_delta" && evt.usage) {
85
+ if ((!gotOutput || options.onMessageStop) && evt.type === "message_delta" && evt.usage) {
82
86
  options.onOutputUsage(evt.usage);
83
87
  gotOutput = true;
84
88
  }
@@ -102,9 +106,11 @@ export function createAnthropicUsageCapture(options) {
102
106
  const parseJSONBody = () => {
103
107
  try {
104
108
  const body = JSON.parse(jsonBuf);
109
+ options.onModel?.(body.model);
105
110
  if (body.usage) {
106
111
  options.onInputUsage(body.usage);
107
112
  options.onOutputUsage(body.usage);
113
+ options.onJSONComplete?.();
108
114
  }
109
115
  }
110
116
  catch { /* not a JSON body after all */ }
@@ -113,20 +119,26 @@ export function createAnthropicUsageCapture(options) {
113
119
  if (dead)
114
120
  return;
115
121
  if (isSSE) {
116
- parseSSEChunk(chunk.toString("utf8"));
122
+ parseSSEChunk(textDecoder.write(chunk));
117
123
  return;
118
124
  }
119
125
  if (jsonBuf.length + chunk.length > MAX_JSON_BODY_BYTES) {
120
126
  die();
121
127
  return;
122
128
  }
123
- jsonBuf += chunk.toString("utf8");
129
+ jsonBuf += textDecoder.write(chunk);
124
130
  };
125
131
  const finish = () => {
126
132
  if (dead)
127
133
  return;
128
- if (isJSON)
134
+ if (isJSON) {
135
+ jsonBuf += textDecoder.end();
129
136
  parseJSONBody();
137
+ }
138
+ else
139
+ parseSSEChunk(textDecoder.end() + "\n");
140
+ if (dead)
141
+ return;
130
142
  dead = true;
131
143
  options.onSettled?.();
132
144
  };
@@ -134,6 +146,7 @@ export function createAnthropicUsageCapture(options) {
134
146
  return {
135
147
  write: (chunk) => consume(chunk),
136
148
  end: () => finish(),
149
+ abort: die,
137
150
  };
138
151
  }
139
152
  decoder.on("data", (chunk) => consume(chunk));
@@ -141,16 +154,17 @@ export function createAnthropicUsageCapture(options) {
141
154
  // Corrupt or truncated compressed data — the capture just stops; the
142
155
  // proxied bytes were never ours to begin with.
143
156
  decoder.on("error", () => die());
157
+ let ending = false;
158
+ const endDecoder = () => { if (dead || ending)
159
+ return; ending = true; decoder.end(); };
144
160
  return {
161
+ // Flush already received compressed input even when its gzip trailer never arrived.
162
+ abort: endDecoder,
145
163
  write: (chunk) => {
146
- if (dead)
164
+ if (dead || ending)
147
165
  return;
148
166
  decoder.write(chunk);
149
167
  },
150
- end: () => {
151
- if (dead)
152
- return;
153
- decoder.end();
154
- },
168
+ end: endDecoder,
155
169
  };
156
170
  }