@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
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 |
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -191,9 +191,9 @@ export function registerAccounts(program) {
|
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
193
|
const record = await flows.loginGrokAccount({ accountId: opts.id });
|
|
194
|
-
|
|
194
|
+
const { mode } = await addAccountRuntimeAware(record);
|
|
195
195
|
console.log(chalk.green(`\n✓ Grok account "${record.id}" saved via device login.\n`));
|
|
196
|
-
printAddOutcome(
|
|
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
|
-
|
|
225
|
+
const { mode } = await addAccountRuntimeAware(record);
|
|
226
226
|
console.log(chalk.green(`\n✓ Grok account "${record.id}" imported from ~/.grok.\n`));
|
|
227
|
-
printAddOutcome(
|
|
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
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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);
|
package/dist/config/manager.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
const existing =
|
|
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(
|
|
119
|
+
writeAccountsAtomicToPath(path, next);
|
|
101
120
|
}
|
|
102
|
-
export function removeAccountRecordById(id) {
|
|
103
|
-
|
|
104
|
-
const existing =
|
|
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(
|
|
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) {
|
package/dist/config/paths.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
618
|
+
void handler(req, res).catch(error => { if (req._ccUsageEntry)
|
|
619
|
+
finishUsageAttempt(req._ccUsageEntry, false); next(error); });
|
|
594
620
|
});
|
|
595
621
|
}
|