@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 CHANGED
@@ -109,6 +109,24 @@ it later. Adding more accounts is `cc-router accounts login`, re-signing one in
109
109
  Per-platform token extraction, Codex CLI, Docker and everything else lives in
110
110
  [the docs](docs/).
111
111
 
112
+ ## Usage history and savings
113
+
114
+ Run `cc-router usage` for persistent token history, provider/model stacked bars,
115
+ and a daily activity grid. Switch between day, week, month and year; history
116
+ survives service restarts. Configure your monthly subscription costs to compare
117
+ them with the estimated standard API token value:
118
+
119
+ ```bash
120
+ cc-router usage subscription set personal --monthly-usd 100 --from 2026-09-01
121
+ cc-router usage
122
+ cc-router usage --period month --provider claude --json
123
+ ```
124
+
125
+ Use your actual monthly USD cost and account name in place of the example.
126
+ Missing prices or costs are explicitly marked incomplete. See the
127
+ [usage reference](docs/cli-reference.md#persistent-token-usage-and-savings) for
128
+ configuration, keyboard controls, estimation limits and offline/remote access.
129
+
112
130
  ## Documentation
113
131
 
114
132
  | Guide | What's in it |
@@ -191,9 +191,9 @@ export function registerAccounts(program) {
191
191
  return;
192
192
  }
193
193
  const record = await flows.loginGrokAccount({ accountId: opts.id });
194
- upsertAccountRecord(record);
194
+ const { mode } = await addAccountRuntimeAware(record);
195
195
  console.log(chalk.green(`\n✓ Grok account "${record.id}" saved via device login.\n`));
196
- printAddOutcome("stored");
196
+ printAddOutcome(mode);
197
197
  }));
198
198
  // ── accounts add ─────────────────────────────────────────────────────────
199
199
  accounts
@@ -222,9 +222,9 @@ export function registerAccounts(program) {
222
222
  return;
223
223
  }
224
224
  const record = await flows.importGrokAccount({ accountId: opts.id });
225
- upsertAccountRecord(record);
225
+ const { mode } = await addAccountRuntimeAware(record);
226
226
  console.log(chalk.green(`\n✓ Grok account "${record.id}" imported from ~/.grok.\n`));
227
- printAddOutcome("stored");
227
+ printAddOutcome(mode);
228
228
  }));
229
229
  // ── accounts reauth ──────────────────────────────────────────────────────
230
230
  accounts
@@ -623,6 +623,25 @@ export async function removeAccountRuntimeAware(id, dependencies = {
623
623
  throw new Error(`Account "${id}" disappeared before it could be removed`);
624
624
  return { mode: "stored", removed };
625
625
  }
626
+ /** Read-only probe for commands that cannot be represented as a live mutation. */
627
+ export async function isAccountApiReachable(options = {}) {
628
+ const fetchImpl = options.fetch ?? globalThis.fetch;
629
+ const baseUrl = (options.baseUrl ?? `http://localhost:${PROXY_PORT}`).replace(/\/+$/, "");
630
+ const authToken = options.authToken ?? readConfig().proxySecret;
631
+ let response;
632
+ try {
633
+ response = await fetchImpl(`${baseUrl}/cc-router/accounts`, {
634
+ headers: authToken ? { authorization: `Bearer ${authToken}` } : {},
635
+ signal: AbortSignal.timeout(3_000),
636
+ });
637
+ }
638
+ catch {
639
+ return false;
640
+ }
641
+ if (!response.ok)
642
+ throw new Error(`HTTP ${response.status} while checking the running proxy`);
643
+ return true;
644
+ }
626
645
  /**
627
646
  * Add an account to a running proxy so it becomes routable without a restart.
628
647
  * Returns false only when no running proxy can be reached (the caller then
@@ -9,6 +9,8 @@ import { existsSync } from "fs";
9
9
  import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
10
10
  import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
11
11
  import { collectClaudeAccount } from "./account-flows.js";
12
+ import { accountToRecord } from "./account-flows.js";
13
+ import { addAccountRuntimeAware, isAccountApiReachable, tryAddAccountToRunningProxy } from "./cmd-accounts.js";
12
14
  import { withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
13
15
  // ─── Public registration ──────────────────────────────────────────────────────
14
16
  export function registerSetup(program) {
@@ -56,6 +58,8 @@ export async function runSetupWizard({ addMode }) {
56
58
  return;
57
59
  }
58
60
  }
61
+ let replaceExisting = false;
62
+ let includeExisting = addMode;
59
63
  if (hasExisting && !addMode) {
60
64
  const existing = loadAccounts();
61
65
  console.log(chalk.yellow(` Found ${existing.length} existing account(s).\n`));
@@ -80,6 +84,10 @@ export async function runSetupWizard({ addMode }) {
80
84
  console.log(chalk.gray("\nCancelled.\n"));
81
85
  return;
82
86
  }
87
+ replaceExisting = true;
88
+ }
89
+ else {
90
+ includeExisting = true;
83
91
  }
84
92
  }
85
93
  let numAccounts = 1;
@@ -114,14 +122,19 @@ export async function runSetupWizard({ addMode }) {
114
122
  return;
115
123
  }
116
124
  // Merge: existing accounts minus any overwritten by ID, plus new ones
117
- const existingAccounts = (hasExisting && !addMode) ? [] : (hasExisting ? loadAccounts() : []);
125
+ const existingAccounts = hasExisting && includeExisting ? loadAccounts() : [];
118
126
  const merged = [
119
127
  ...existingAccounts.filter(a => !newAccounts.some(n => n.id === a.id)),
120
128
  ...newAccounts,
121
129
  ];
122
130
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
131
+ let persistenceMode;
123
132
  try {
124
- saveAccounts(merged);
133
+ persistenceMode = await persistSetupAccountsRuntimeAware({
134
+ newAccounts,
135
+ merged,
136
+ replaceExisting,
137
+ });
125
138
  }
126
139
  catch (error) {
127
140
  const outcomes = savedAttempts.map(attempt => attempt.failed(error, "persistence"));
@@ -130,6 +143,9 @@ export async function runSetupWizard({ addMode }) {
130
143
  throw error;
131
144
  }
132
145
  console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
146
+ if (persistenceMode === "live") {
147
+ console.log(chalk.gray(" Loaded into the running proxy — available now, no restart needed."));
148
+ }
133
149
  for (const attempt of savedAttempts) {
134
150
  attempt.stageCompleted("persistence");
135
151
  attempt.succeeded();
@@ -137,6 +153,31 @@ export async function runSetupWizard({ addMode }) {
137
153
  // ─── Post-setup interactive flow ─────────────────────────────────────────
138
154
  await runPostSetupFlow(merged.length);
139
155
  }
156
+ export async function persistSetupAccountsRuntimeAware(input, dependencies = {
157
+ isLive: isAccountApiReachable,
158
+ // The wizard merges by id, so a re-collected account must upsert live the
159
+ // way `saveAccounts(merged)` upserts on disk; without `replace` the daemon
160
+ // answers 409 and discards the login the operator just completed.
161
+ tryAddLive: live => tryAddAccountToRunningProxy(live, { replace: true }),
162
+ saveStored: saveAccounts,
163
+ }) {
164
+ if (input.replaceExisting) {
165
+ if (await dependencies.isLive()) {
166
+ throw new Error("Cannot replace all accounts while the proxy is running. Stop it first: cc-router stop --keep-config");
167
+ }
168
+ dependencies.saveStored(input.merged);
169
+ return "stored";
170
+ }
171
+ for (const account of input.newAccounts) {
172
+ const { mode } = await addAccountRuntimeAware(accountToRecord(account), {
173
+ tryAddLive: dependencies.tryAddLive,
174
+ addStored: () => dependencies.saveStored(input.merged),
175
+ });
176
+ if (mode === "stored")
177
+ return "stored";
178
+ }
179
+ return "live";
180
+ }
140
181
  // ─── Post-setup interactive flow ─────────────────────────────────────────────
141
182
  async function runPostSetupFlow(accountCount) {
142
183
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Configure this machine\n${"━".repeat(40)}\n`));
@@ -0,0 +1,124 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { USAGE_DIR, PROXY_PORT } from "../config/paths.js";
3
+ import { readAccountsRaw, readConfig } from "../config/manager.js";
4
+ import { resolveStatusTarget } from "./cmd-status.js";
5
+ import { createUsageClient } from "../usage/client.js";
6
+ import { recoverAccountTransition, reconcileUsageAccounts } from "../usage/account-lifecycle.js";
7
+ import { totalTokens, utcTimestamp, usageProvider } from "../usage/types.js";
8
+ const aliases = { claude: "anthropic_subscription", anthropic: "anthropic_subscription", openai: "openai_subscription", grok: "xai_subscription", xai: "xai_subscription" };
9
+ export function parseUsageOptions(options) {
10
+ const period = options.period ?? "month";
11
+ if (!["day", "week", "month", "year"].includes(period))
12
+ throw new InvalidArgumentError("Period must be day, week, month or year");
13
+ if (options.date) {
14
+ if (!/^\d{4}-\d\d-\d\d$/.test(options.date))
15
+ throw new InvalidArgumentError("Date must be YYYY-MM-DD (UTC)");
16
+ utcTimestamp(options.date);
17
+ }
18
+ const providers = options.provider?.map(p => usageProvider(aliases[p] ?? p));
19
+ return { period: period, ...(options.date ? { date: options.date } : {}), ...(providers ? { providers: [...new Set(providers)] } : {}) };
20
+ }
21
+ function client(portText) {
22
+ const port = Number(portText ?? PROXY_PORT);
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
24
+ throw new InvalidArgumentError("Port must be 1..65535");
25
+ const target = resolveStatusTarget(port);
26
+ return createUsageClient({ baseUrl: target.baseUrl, headers: target.headers, remote: Boolean(readConfig().client), directory: USAGE_DIR,
27
+ initializeOffline: store => {
28
+ const aliases = [];
29
+ for (const raw of readAccountsRaw()) {
30
+ if (!raw || typeof raw !== "object")
31
+ continue;
32
+ const record = raw;
33
+ if (typeof record.id !== "string")
34
+ continue;
35
+ const provider = record.provider ?? "anthropic_subscription";
36
+ if (typeof provider === "string" && ["anthropic_subscription", "openai_subscription", "xai_subscription"].includes(provider))
37
+ aliases.push({ id: record.id, provider: provider });
38
+ }
39
+ recoverAccountTransition(store, USAGE_DIR, aliases);
40
+ reconcileUsageAccounts(store, aliases);
41
+ },
42
+ });
43
+ }
44
+ export function formatUsageText(report) {
45
+ const { totals: t, costs: c } = report;
46
+ const money = (n) => n === null ? "unavailable" : `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
47
+ return [
48
+ `Usage ${report.period}: ${report.start.slice(0, 10)} — ${report.end.slice(0, 10)} (UTC, end exclusive)`,
49
+ `Tokens: ${totalTokens(t).toLocaleString("en-US")} | input ${t.input} | output ${t.output} | cache read ${t.cacheRead} | cache write ${t.cacheWrite}`,
50
+ `API equivalent: ${money(c.pricedApiUsd)}${c.coverage.pricingComplete ? "" : " (partial; unpriced tokens excluded)"}`,
51
+ `Subscription: ${money(c.subscriptionUsd)}${c.coverage.subscriptionComplete ? "" : " (partial; configure missing costs)"}`,
52
+ `Net savings: ${money(c.savingsUsd)}${c.savingsPercent === null ? "" : ` (${c.savingsPercent.toFixed(1)}%)`}`,
53
+ ...report.warnings.map(w => `Warning: ${w}`),
54
+ ].join("\n");
55
+ }
56
+ export function registerUsage(program) {
57
+ const usage = program.command("usage").description("Persistent token usage and API-equivalent subscription savings")
58
+ .option("--period <period>", "day, week, month or year", "month")
59
+ .option("--date <date>", "Date within the selected period (YYYY-MM-DD, UTC)")
60
+ .option("--provider <provider>", "Filter Claude, OpenAI or Grok (repeatable)", (value, previous = []) => [...previous, value])
61
+ .option("--port <port>", "Proxy port", String(PROXY_PORT))
62
+ .option("--json", "Output JSON without initializing the terminal UI")
63
+ .action(async (options) => {
64
+ try {
65
+ const query = parseUsageOptions(options);
66
+ const api = client(options.port);
67
+ if (options.json || !process.stdout.isTTY || !process.stdin.isTTY) {
68
+ const report = await api.query(query);
69
+ console.log(options.json ? JSON.stringify(report, null, 2) : formatUsageText(report));
70
+ return;
71
+ }
72
+ const [{ render }, { createElement }, { UsageDashboard }] = await Promise.all([import("ink"), import("react"), import("../ui/UsageDashboard.js")]);
73
+ const instance = render(createElement(UsageDashboard, { load: api.query, initialQuery: query }), { exitOnCtrlC: true });
74
+ try {
75
+ await instance.waitUntilExit();
76
+ }
77
+ finally {
78
+ if (process.stdin.isTTY)
79
+ process.stdin.setRawMode(false);
80
+ }
81
+ }
82
+ catch (error) {
83
+ program.error(error instanceof Error ? error.message : "Usage query failed");
84
+ }
85
+ });
86
+ const subscriptions = usage.command("subscription").description("Configure effective-dated monthly USD subscription costs");
87
+ const guarded = (command, action) => command.action(async (...args) => {
88
+ try {
89
+ await action(...args);
90
+ }
91
+ catch (error) {
92
+ program.error(error instanceof Error ? error.message : "Subscription update failed");
93
+ }
94
+ });
95
+ const set = subscriptions.command("set <account>").description("Start a monthly USD rate; closes the previous open interval")
96
+ .requiredOption("--monthly-usd <amount>", "Monthly subscription cost in USD")
97
+ .requiredOption("--from <date>", "Effective date, YYYY-MM-DD (UTC)");
98
+ guarded(set, async (account, opts) => {
99
+ if (!/^\d+(?:\.\d+)?$/.test(opts.monthlyUsd) || !Number.isFinite(Number(opts.monthlyUsd)))
100
+ throw new InvalidArgumentError("Monthly USD must be a finite nonnegative amount");
101
+ parseUsageOptions({ date: opts.from });
102
+ await client(usage.opts().port).setSubscription(account, Number(opts.monthlyUsd), opts.from);
103
+ console.log(`Subscription cost saved for ${account}.`);
104
+ });
105
+ const end = subscriptions.command("end <account>").description("End a subscription cost interval")
106
+ .requiredOption("--on <date>", "Exclusive end date, YYYY-MM-DD (UTC)");
107
+ guarded(end, async (account, opts) => { parseUsageOptions({ date: opts.on }); await client(usage.opts().port).endSubscription(account, opts.on); console.log(`Subscription interval ended for ${account}.`); });
108
+ const list = subscriptions.command("list").description("List historical subscription costs").option("--json", "Output JSON");
109
+ guarded(list, async (opts) => {
110
+ const result = await client(usage.opts().port).subscriptions();
111
+ if (opts.json) {
112
+ console.log(JSON.stringify(result, null, 2));
113
+ return;
114
+ }
115
+ if (!result.subscriptions.length) {
116
+ console.log("No subscription costs configured. Use: cc-router usage subscription set <account> --monthly-usd <amount> --from YYYY-MM-DD");
117
+ return;
118
+ }
119
+ for (const sub of result.subscriptions) {
120
+ const account = result.accounts.find(a => a.key === sub.accountKey);
121
+ console.log(`${account?.alias ?? sub.accountKey}${account?.retired ? " (retired)" : ""} | $${sub.monthlyUsd.toFixed(2)}/month | ${sub.from.slice(0, 10)} → ${sub.to?.slice(0, 10) ?? "ongoing"} | ${sub.accountKey}`);
122
+ }
123
+ });
124
+ }
package/dist/cli/index.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from "commander";
3
3
  import { registerSetup } from "./cmd-setup.js";
4
4
  import { registerStart } from "./cmd-start.js";
5
5
  import { registerStop, registerRevert } from "./cmd-stop.js";
6
+ import { registerUsage } from "./cmd-usage.js";
6
7
  import { registerStatus } from "./cmd-status.js";
7
8
  import { registerAccounts } from "./cmd-accounts.js";
8
9
  import { registerConfigure } from "./cmd-configure.js";
@@ -30,6 +31,7 @@ Examples:
30
31
  $ cc-router start --reconfigure# Re-ask run preferences
31
32
  $ cc-router stop # Stop proxy (offers to remove auto-start / config)
32
33
  $ cc-router status # Live dashboard with account stats
34
+ $ cc-router usage # Persistent token usage and subscription savings
33
35
  $ cc-router models list # List dynamically discovered provider models
34
36
  $ cc-router logs # View proxy logs (background mode)
35
37
  $ cc-router accounts list # Show Claude, ChatGPT, and Grok accounts
@@ -48,6 +50,7 @@ registerStart(program);
48
50
  registerStop(program);
49
51
  registerRevert(program);
50
52
  registerStatus(program);
53
+ registerUsage(program);
51
54
  registerModels(program);
52
55
  registerAccounts(program);
53
56
  registerConfigure(program);
@@ -1,4 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from "fs";
1
+ import { coordinateAccountWrite, withUsageRename } from "../usage/account-lifecycle.js";
2
+ import { dirname } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync, openSync, fsyncSync, closeSync } from "fs";
2
4
  import { randomBytes } from "crypto";
3
5
  import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
4
6
  import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent, withInheritedSettings } from "../proxy/types.js";
@@ -31,12 +33,30 @@ function writeFileSecureSync(path, data) {
31
33
  chmodSync(tmp, SECRET_FILE_MODE);
32
34
  }
33
35
  catch { /* best effort */ }
36
+ const fd = openSync(tmp, "r");
37
+ try {
38
+ fsyncFileBestEffort(fd);
39
+ }
40
+ finally {
41
+ closeSync(fd);
42
+ }
34
43
  renameSync(tmp, path);
35
44
  try {
36
45
  chmodSync(path, SECRET_FILE_MODE);
37
46
  }
38
47
  catch { /* best effort */ }
39
48
  }
49
+ export function fsyncFileBestEffort(fd, sync = fsyncSync) {
50
+ try {
51
+ sync(fd);
52
+ }
53
+ catch (error) {
54
+ const code = error.code;
55
+ if (process.platform === "win32" && (code === "EPERM" || code === "EINVAL"))
56
+ return;
57
+ throw error;
58
+ }
59
+ }
40
60
  export function accountsFileExists(path) {
41
61
  return existsSync(path ?? ACCOUNTS_PATH);
42
62
  }
@@ -63,8 +83,7 @@ export function writeAccountsAtomic(data) {
63
83
  writeAccountsAtomicToPath(ACCOUNTS_PATH, data);
64
84
  }
65
85
  function writeAccountsAtomicToPath(path, data) {
66
- // accounts.json holds plaintext OAuth access + refresh tokens — owner-only.
67
- writeFileSecureSync(path, JSON.stringify(data, null, 2));
86
+ coordinateAccountWrite(path, readRawFromPath(path), data, () => writeFileSecureSync(path, JSON.stringify(data, null, 2)));
68
87
  }
69
88
  /**
70
89
  * Replace the Anthropic records in an accounts file, leaving every other
@@ -77,16 +96,16 @@ function writeAccountsAtomicToPath(path, data) {
77
96
  * `saveOpenAIAccountsToPath`, which has always taken the path.
78
97
  */
79
98
  export function writeAnthropicAccountsPreservingOtherProviders(data, path = ACCOUNTS_PATH) {
80
- ensureConfigDir();
99
+ mkdirSync(dirname(path), { recursive: true, mode: SECRET_DIR_MODE });
81
100
  // Read from the same file being written, or the merge would carry another
82
101
  // file's non-Anthropic records into this one.
83
102
  const existing = readRawFromPath(path);
84
103
  const nonAnthropic = existing.filter(a => a.provider !== undefined && a.provider !== "anthropic_subscription");
85
104
  writeAccountsAtomicToPath(path, [...data, ...nonAnthropic]);
86
105
  }
87
- export function upsertAccountRecord(record) {
88
- ensureConfigDir();
89
- const existing = readAccountsRaw();
106
+ export function upsertAccountRecord(record, path = ACCOUNTS_PATH) {
107
+ mkdirSync(dirname(path), { recursive: true, mode: SECRET_DIR_MODE });
108
+ const existing = readRawFromPath(path);
90
109
  // Compare normalised providers: a Claude record written before provider
91
110
  // tags existed has none, and a strict comparison appended a tagged
92
111
  // duplicate next to it instead of replacing it.
@@ -97,15 +116,15 @@ export function upsertAccountRecord(record) {
97
116
  // A re-authentication replaces credentials, not the operator's settings.
98
117
  previous ? withInheritedSettings(record, previous) : record,
99
118
  ];
100
- writeAccountsAtomicToPath(ACCOUNTS_PATH, next);
119
+ writeAccountsAtomicToPath(path, next);
101
120
  }
102
- export function removeAccountRecordById(id) {
103
- ensureConfigDir();
104
- const existing = readAccountsRaw();
121
+ export function removeAccountRecordById(id, path = ACCOUNTS_PATH) {
122
+ mkdirSync(dirname(path), { recursive: true, mode: SECRET_DIR_MODE });
123
+ const existing = readRawFromPath(path);
105
124
  const removed = existing.find(a => a.id === id) ?? null;
106
125
  if (!removed)
107
126
  return null;
108
- writeAccountsAtomicToPath(ACCOUNTS_PATH, existing.filter(a => a.id !== id));
127
+ writeAccountsAtomicToPath(path, existing.filter(a => a.id !== id));
109
128
  return removed;
110
129
  }
111
130
  /**
@@ -124,7 +143,7 @@ export function renameAccountRecordById(oldId, newId) {
124
143
  throw new Error(`An account named "${newId}" already exists`);
125
144
  }
126
145
  target.id = newId;
127
- writeAccountsAtomicToPath(ACCOUNTS_PATH, existing);
146
+ withUsageRename(ACCOUNTS_PATH, oldId, newId, () => writeAccountsAtomicToPath(ACCOUNTS_PATH, existing));
128
147
  return target;
129
148
  }
130
149
  function normalizeAccountProvider(record) {
@@ -20,3 +20,5 @@ export const TELEMETRY_PATH = process.env["TELEMETRY_PATH"] ??
20
20
  export const PID_PATH = path.join(CONFIG_DIR, "cc-router.pid");
21
21
  // Log file — daemon stdout/stderr redirect here
22
22
  export const LOG_PATH = path.join(CONFIG_DIR, "cc-router.log");
23
+ /** Durable usage history follows an explicit accounts file unless overridden. */
24
+ export const USAGE_DIR = process.env["USAGE_DIR"] ?? path.join(path.dirname(ACCOUNTS_PATH), "usage");
@@ -74,7 +74,7 @@ export async function collectCodexResponseStream(upstream,
74
74
  * verdict even when the read is later cut short — the catch below turns any
75
75
  * such interruption into a generic "malformed stream" and would otherwise
76
76
  * bury it. */
77
- onUpstreamFailure) {
77
+ onUpstreamFailure, onResponse) {
78
78
  if (!upstream.ok) {
79
79
  onUpstreamFailure?.();
80
80
  const contentType = upstream.headers.get("content-type") ?? undefined;
@@ -83,7 +83,9 @@ onUpstreamFailure) {
83
83
  const contentType = upstream.headers.get("content-type") ?? "";
84
84
  if (contentType.includes("application/json")) {
85
85
  try {
86
- return { kind: "json", status: upstream.status, body: await upstream.json() };
86
+ const body = await upstream.json();
87
+ onResponse?.(body);
88
+ return { kind: "json", status: upstream.status, body };
87
89
  }
88
90
  catch {
89
91
  return upstreamError("Malformed upstream JSON body");
@@ -97,6 +99,8 @@ onUpstreamFailure) {
97
99
  let terminalResponse;
98
100
  let failure;
99
101
  const applyEvent = (event) => {
102
+ if (event && typeof event === "object")
103
+ onResponse?.(event.response);
100
104
  const payload = terminalResponsePayload(event);
101
105
  if (payload !== undefined) {
102
106
  terminalResponse = payload;
@@ -182,13 +186,18 @@ export function usageFromTerminalEvent(event) {
182
186
  * ended in failure can still be reported (for stats/activity only) as the
183
187
  * failure it was, without altering a single byte written to the client.
184
188
  */
185
- export function createCodexUsageObserver() {
189
+ export function createCodexUsageObserver(onResponse, contentType = "text/event-stream") {
186
190
  const decoder = new TextDecoder();
187
191
  let remainder = "";
192
+ const isJSON = contentType.includes("application/json");
193
+ let json = "";
194
+ let jsonOversized = false;
188
195
  let totals;
189
196
  let failure;
190
197
  let completed = false;
191
198
  const applyEvent = (event) => {
199
+ if (event && typeof event === "object")
200
+ onResponse?.(event.response);
192
201
  totals = usageFromTerminalEvent(event) ?? totals;
193
202
  if (typeof event !== "object" || event === null)
194
203
  return;
@@ -212,6 +221,15 @@ export function createCodexUsageObserver() {
212
221
  // frame goes uncaptured, never that the response breaks. Tolerant
213
222
  // parsing keeps the rest of the chunk's valid events.
214
223
  try {
224
+ if (isJSON) {
225
+ if (json.length + chunk.byteLength > 20 * 1024 * 1024) {
226
+ jsonOversized = true;
227
+ json = "";
228
+ }
229
+ if (!jsonOversized)
230
+ json += decoder.decode(chunk, { stream: true });
231
+ return;
232
+ }
215
233
  const parsed = parseSseLines(remainder + decoder.decode(chunk, { stream: true }), { tolerant: true });
216
234
  remainder = parsed.remainder;
217
235
  parsed.events.forEach(applyEvent);
@@ -222,6 +240,16 @@ export function createCodexUsageObserver() {
222
240
  },
223
241
  finish() {
224
242
  try {
243
+ if (isJSON) {
244
+ if (!jsonOversized) {
245
+ const body = JSON.parse(json + decoder.decode());
246
+ onResponse?.(body);
247
+ totals = usageFromResponseBody(body);
248
+ }
249
+ json = "";
250
+ jsonOversized = true;
251
+ return totals;
252
+ }
225
253
  const tail = decoder.decode();
226
254
  if (tail || remainder) {
227
255
  parseSseLines(remainder + tail + "\n", { tolerant: true }).events.forEach(applyEvent);
@@ -1,3 +1,4 @@
1
+ import { finishUsageAttempt, discardUsageAttempt } from "./stats.js";
1
2
  import { request as httpRequest } from "node:http";
2
3
  import { request as httpsRequest } from "node:https";
3
4
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, extractClaudeSessionId, } from "./anthropic-routing.js";
@@ -210,7 +211,20 @@ export function mountAnthropicMessagesRoute(app, opts) {
210
211
  // the first upstream response headers arrive, then cleared for good so a
211
212
  // long-lived stream is never cut (see anthropic-proxy.ts).
212
213
  req.socket.setTimeout(opts.timeoutMs);
214
+ const attemptEntries = new WeakMap();
215
+ if (req._ccUsageEntry)
216
+ attemptEntries.set(route, req._ccUsageEntry);
217
+ const entryFor = (lease) => {
218
+ let entry = attemptEntries.get(lease);
219
+ if (!entry) {
220
+ entry = { ts: now(), accountId: lease.account.id, model, type: "route" };
221
+ opts.usageRuntime?.bind(entry, "anthropic_subscription");
222
+ attemptEntries.set(lease, entry);
223
+ }
224
+ return entry;
225
+ };
213
226
  for (let attempt = 1;; attempt++) {
227
+ const entry = entryFor(route);
214
228
  const account = route.account;
215
229
  const attemptStartedAt = now();
216
230
  const attemptSpan = startTelemetrySpan("provider.inference", {
@@ -222,6 +236,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
222
236
  });
223
237
  /** Close this attempt's span exactly once, on the outcome it ended with. */
224
238
  const endAttempt = (outcome, extra = {}) => {
239
+ finishUsageAttempt(entry, false);
225
240
  attemptSpan.annotate({
226
241
  ...extra,
227
242
  outcome,
@@ -246,6 +261,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
246
261
  upstream = await forwarded.response;
247
262
  }
248
263
  catch (error) {
264
+ finishUsageAttempt(entry, false);
249
265
  release();
250
266
  // A hung-up client rejects this await through the abort above. That
251
267
  // is a cancellation, not an upstream failure — there is no client
@@ -302,7 +318,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
302
318
  // binding invalidation — run before any retry decision, so the
303
319
  // re-acquisition below already sees the failed account excluded.
304
320
  const failureRouting = applyUpstreamFailureRoutingDetailed(status, upstream.headers, route, opts.sessionRouter, opts.pool, now);
305
- const entry = {
321
+ Object.assign(entry, {
306
322
  ts: attemptStartedAt,
307
323
  accountId: account.id,
308
324
  model,
@@ -313,7 +329,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
313
329
  source,
314
330
  details: routeReasonDetails(route),
315
331
  durationMs: now() - attemptStartedAt,
316
- };
332
+ });
317
333
  if (status === 401) {
318
334
  // Token invalid or expired mid-request. Forward the 401 to the client
319
335
  // (Claude Code will retry on 401) and schedule a background refresh
@@ -365,6 +381,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
365
381
  let next;
366
382
  try {
367
383
  next = acquireRequestRoute(sessionHeader, res, opts.sessionRouter, context);
384
+ entryFor(next.route);
368
385
  }
369
386
  catch (error) {
370
387
  // Nothing eligible to fail over to — pass the failure through.
@@ -380,6 +397,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
380
397
  // Re-sending a 429 to the account that produced it would only
381
398
  // reproduce the rate limit. The cooldown normally guarantees a
382
399
  // different account here; if it ever does not, pass through.
400
+ discardUsageAttempt(entryFor(next.route));
383
401
  next.release();
384
402
  next = undefined;
385
403
  }
@@ -410,11 +428,14 @@ export function mountAnthropicMessagesRoute(app, opts) {
410
428
  logError(failoverAccount.id, 0, `failover token refresh still pending after ${retryRefreshTimeoutMs}ms — relaying held upstream failure`);
411
429
  }
412
430
  if (outcome !== "refreshed") {
431
+ discardUsageAttempt(entryFor(next.route));
413
432
  next.release();
414
433
  next = undefined;
415
434
  }
416
435
  }
417
436
  if (clientGone.signal.aborted || res.writableEnded) {
437
+ if (next)
438
+ discardUsageAttempt(entryFor(next.route));
418
439
  next?.release();
419
440
  release();
420
441
  upstream.destroy();
@@ -429,6 +450,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
429
450
  streamOutcome: "upstream_error",
430
451
  });
431
452
  entry.details = `${entry.details}:will-retry`;
453
+ finishUsageAttempt(entry, false);
432
454
  recordActivity(entry);
433
455
  upstream.destroy();
434
456
  release();
@@ -443,6 +465,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
443
465
  if (sameAccount) {
444
466
  await retryDelay(sameAccountDelayMs, clientGone.signal);
445
467
  if (clientGone.signal.aborted || res.writableEnded) {
468
+ discardUsageAttempt(entryFor(route));
446
469
  release();
447
470
  return;
448
471
  }
@@ -487,6 +510,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
487
510
  entry.type = "error";
488
511
  entry.statusCode = 502;
489
512
  entry.details = `${entry.details}:held-response-lost`;
513
+ finishUsageAttempt(entry, false);
490
514
  recordActivity(entry);
491
515
  release();
492
516
  endAttempt("upstream_error", { httpStatusCode: 502, streamOutcome: "upstream_error" });
@@ -582,6 +606,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
582
606
  };
583
607
  app.post("/v1/messages", requireBufferedBody, createAnthropicRoutingMiddleware({
584
608
  sessionRouter: opts.sessionRouter,
609
+ usageRuntime: opts.usageRuntime,
585
610
  ...(opts.onEmptyPool ? { onEmptyPool: opts.onEmptyPool } : {}),
586
611
  ...(opts.onNoEligibleAccount ? { onNoEligibleAccount: opts.onNoEligibleAccount } : {}),
587
612
  ...(opts.now ? { now: opts.now } : {}),
@@ -590,6 +615,7 @@ export function mountAnthropicMessagesRoute(app, opts) {
590
615
  refresh: opts.refresh,
591
616
  onRefreshFailure: opts.onRefreshFailure,
592
617
  }), (req, res, next) => {
593
- void handler(req, res).catch(next);
618
+ void handler(req, res).catch(error => { if (req._ccUsageEntry)
619
+ finishUsageAttempt(req._ccUsageEntry, false); next(error); });
594
620
  });
595
621
  }