@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.
- package/README.md +18 -0
- package/dist/cli/cmd-accounts.js +23 -4
- package/dist/cli/cmd-setup.js +43 -2
- package/dist/cli/cmd-usage.js +124 -0
- package/dist/cli/index.js +3 -0
- package/dist/config/manager.js +32 -13
- package/dist/config/paths.js +2 -0
- package/dist/protocol/openai-responses-collect.js +31 -3
- package/dist/proxy/anthropic-messages-route.js +29 -3
- package/dist/proxy/anthropic-response-capture.js +15 -4
- package/dist/proxy/anthropic-routing.js +16 -0
- package/dist/proxy/messages-cross-route.js +9 -3
- package/dist/proxy/openai-ingress.js +21 -3
- package/dist/proxy/responses-server.js +16 -11
- package/dist/proxy/server.js +49 -8
- package/dist/proxy/stats.js +38 -9
- package/dist/proxy/usage-capture.js +26 -12
- package/dist/ui/UsageDashboard.js +208 -0
- package/dist/ui/usage-chart.js +100 -0
- package/dist/usage/account-lifecycle.js +188 -0
- package/dist/usage/client.js +154 -0
- package/dist/usage/http.js +67 -0
- package/dist/usage/pricing.js +92 -0
- package/dist/usage/query.js +192 -0
- package/dist/usage/runtime.js +243 -0
- package/dist/usage/store.js +773 -0
- package/dist/usage/subscriptions.js +74 -0
- package/dist/usage/types.js +85 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
entry.firstByteDurationMs
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/proxy/stats.js
CHANGED
|
@@ -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
|
|
63
|
-
entry
|
|
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
|
|
81
|
+
/** Record the positive difference from the preceding cumulative output snapshot. */
|
|
70
82
|
export function applyAnthropicOutputUsage(entry, usage) {
|
|
71
|
-
entry
|
|
72
|
-
|
|
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(
|
|
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(
|
|
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 +=
|
|
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
|
}
|