@timo972/cc-router 0.13.0-rc.1 → 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/CHANGELOG.md +2 -0
- package/README.md +18 -0
- package/dist/cli/account-flows.js +18 -17
- package/dist/cli/cmd-accounts.js +23 -4
- package/dist/cli/cmd-setup.js +43 -2
- package/dist/cli/cmd-status.js +15 -4
- package/dist/cli/cmd-usage.js +124 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/prompt-cancel.js +21 -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/telemetry/setup-diagnostics.js +4 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -38,6 +38,8 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
38
38
|
|
|
39
39
|
### Fixed
|
|
40
40
|
|
|
41
|
+
- Escape or Ctrl-C at any prompt of the dashboard's `n` / `l` flows returns
|
|
42
|
+
to the dashboard with a quiet "Cancelled." instead of a red failure line.
|
|
41
43
|
- Pressing `n` (and now `l`) in the dashboard no longer kills the process
|
|
42
44
|
before the sign-in prompt can take input. Ink unrefs stdin when it
|
|
43
45
|
unmounts and nothing ref'd it again, so with no other work pending the
|
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 |
|
|
@@ -59,7 +59,7 @@ export async function collectClaudeAccount(options) {
|
|
|
59
59
|
const method = options.method ?? await select({
|
|
60
60
|
message: "How do you want to add the account?",
|
|
61
61
|
choices: claudeMethodChoices(options.offer ?? "all"),
|
|
62
|
-
});
|
|
62
|
+
}, { signal: options.signal });
|
|
63
63
|
const attempt = createSetupAttempt({ provider: "anthropic", method: CLAUDE_METHOD_TELEMETRY[method] });
|
|
64
64
|
attempt.stageCompleted("credential_source_selection");
|
|
65
65
|
// Shared by reference: the file-extraction fallback swaps in a manual-token attempt.
|
|
@@ -106,12 +106,12 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
106
106
|
message: "Paste the token (sk-ant-oat01-...):",
|
|
107
107
|
mask: "•",
|
|
108
108
|
validate: v => v.startsWith("sk-ant-oat01-") || "Must start with sk-ant-oat01-",
|
|
109
|
-
});
|
|
109
|
+
}, { signal: options.signal });
|
|
110
110
|
}
|
|
111
|
-
const useDefaultExpiry = await confirm({ message: "Token valid for 1 year (default)?", default: true });
|
|
111
|
+
const useDefaultExpiry = await confirm({ message: "Token valid for 1 year (default)?", default: true }, { signal: options.signal });
|
|
112
112
|
const expiresAt = useDefaultExpiry
|
|
113
113
|
? Date.now() + LONG_LIVED_TOKEN_TTL_MS
|
|
114
|
-
: await promptExpiry();
|
|
114
|
+
: await promptExpiry(options.signal);
|
|
115
115
|
tokens = { accessToken, refreshToken: undefined, expiresAt, scopes: ["user:inference"] };
|
|
116
116
|
console.log(chalk.gray(" This token has no refresh token and the inference scope only: usage and identity metadata are unavailable for it."));
|
|
117
117
|
}
|
|
@@ -129,7 +129,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
129
129
|
console.log(chalk.yellow(" Could not find credentials in Keychain."));
|
|
130
130
|
console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
|
|
131
131
|
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
132
|
-
const retry = await confirm({ message: "Try another extraction method?", default: true });
|
|
132
|
+
const retry = await confirm({ message: "Try another extraction method?", default: true }, { signal: options.signal });
|
|
133
133
|
attempt.cancelled();
|
|
134
134
|
if (!retry)
|
|
135
135
|
return { account: null, attempt };
|
|
@@ -147,7 +147,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
147
147
|
else {
|
|
148
148
|
console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
|
|
149
149
|
console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
|
|
150
|
-
const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
|
|
150
|
+
const retry = await confirm({ message: "Paste tokens manually instead?", default: true }, { signal: options.signal });
|
|
151
151
|
if (!retry) {
|
|
152
152
|
printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
|
|
153
153
|
attempt.cancelled();
|
|
@@ -158,11 +158,11 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
158
158
|
attempt = createSetupAttempt({ provider: "anthropic", method: "manual_token" });
|
|
159
159
|
current.attempt = attempt;
|
|
160
160
|
attempt.stageCompleted("credential_source_selection");
|
|
161
|
-
tokens = await promptManualTokens();
|
|
161
|
+
tokens = await promptManualTokens(options.signal);
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
if (method === "manual") {
|
|
165
|
-
tokens = await promptManualTokens();
|
|
165
|
+
tokens = await promptManualTokens(options.signal);
|
|
166
166
|
}
|
|
167
167
|
if (!tokens) {
|
|
168
168
|
attempt.cancelled();
|
|
@@ -175,7 +175,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
175
175
|
message: "Account ID (press Enter to accept default):",
|
|
176
176
|
default: `max-account-${options.index}`,
|
|
177
177
|
validate: v => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
|
|
178
|
-
});
|
|
178
|
+
}, { signal: options.signal });
|
|
179
179
|
process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
|
|
180
180
|
const validation = await validateToken(tokens.accessToken);
|
|
181
181
|
if (validation.valid) {
|
|
@@ -187,7 +187,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
187
187
|
console.log(chalk.yellow(` Reason: ${validation.reason}`));
|
|
188
188
|
printDiagnosticId(attempt.stageFailed(validation.diagnostic, "token_validation"));
|
|
189
189
|
console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
|
|
190
|
-
const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
|
|
190
|
+
const keepAnyway = await confirm({ message: "Save this account anyway?", default: false }, { signal: options.signal });
|
|
191
191
|
if (!keepAnyway) {
|
|
192
192
|
attempt.cancelled();
|
|
193
193
|
return { account: null, attempt };
|
|
@@ -215,7 +215,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
|
|
|
215
215
|
* The refresh token stays required here: a pasted long-lived `setup-token`
|
|
216
216
|
* credential has none, and it has its own method rather than this one.
|
|
217
217
|
*/
|
|
218
|
-
async function promptManualTokens() {
|
|
218
|
+
async function promptManualTokens(signal) {
|
|
219
219
|
console.log(chalk.gray("\n You can find your tokens by running:\n" +
|
|
220
220
|
" macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
|
|
221
221
|
" Linux/Windows: cat ~/.claude/.credentials.json\n"));
|
|
@@ -225,21 +225,21 @@ async function promptManualTokens() {
|
|
|
225
225
|
validate: (v) => v.startsWith("sk-ant-oat01-") || v.startsWith("sk-ant-")
|
|
226
226
|
? true
|
|
227
227
|
: "Must start with sk-ant-oat01-",
|
|
228
|
-
});
|
|
228
|
+
}, { signal });
|
|
229
229
|
const refreshToken = await password({
|
|
230
230
|
message: "Paste refreshToken (sk-ant-ort01-...):",
|
|
231
231
|
mask: "•",
|
|
232
232
|
validate: (v) => v.startsWith("sk-ant-ort01-") || v.startsWith("sk-ant-")
|
|
233
233
|
? true
|
|
234
234
|
: "Must start with sk-ant-ort01-",
|
|
235
|
-
});
|
|
235
|
+
}, { signal });
|
|
236
236
|
const useDefaultExpiry = await confirm({
|
|
237
237
|
message: "Use default expiry (8 hours from now)?",
|
|
238
238
|
default: true,
|
|
239
|
-
});
|
|
239
|
+
}, { signal });
|
|
240
240
|
const expiresAt = useDefaultExpiry
|
|
241
241
|
? Date.now() + 8 * 60 * 60 * 1000
|
|
242
|
-
: await promptExpiry();
|
|
242
|
+
: await promptExpiry(signal);
|
|
243
243
|
return {
|
|
244
244
|
accessToken,
|
|
245
245
|
refreshToken,
|
|
@@ -383,15 +383,16 @@ export async function collectReauthRecord(target, options = {}) {
|
|
|
383
383
|
...(target.email ? { email: target.email } : {}),
|
|
384
384
|
offer: "login",
|
|
385
385
|
...(options.longLived ? { method: "setup_token" } : {}),
|
|
386
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
386
387
|
});
|
|
387
388
|
return account ? { record: credentialsOnly(accountToRecord(account)), attempt } : null;
|
|
388
389
|
}
|
|
389
390
|
/** An explicit expiry: ISO date or Unix milliseconds, validated as typed. */
|
|
390
|
-
async function promptExpiry() {
|
|
391
|
+
async function promptExpiry(signal) {
|
|
391
392
|
const raw = await input({
|
|
392
393
|
message: "Paste expiresAt (ISO date or ms timestamp):",
|
|
393
394
|
validate: v => parseExpiryInput(v) !== null || "Enter an ISO date (2027-01-01) or a Unix timestamp in milliseconds",
|
|
394
|
-
});
|
|
395
|
+
}, { signal });
|
|
395
396
|
return parseExpiryInput(raw);
|
|
396
397
|
}
|
|
397
398
|
/**
|
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`));
|
package/dist/cli/cmd-status.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { PROXY_PORT } from "../config/paths.js";
|
|
3
3
|
import { readConfig } from "../config/manager.js";
|
|
4
|
-
import { classifyHttpSetupFailure, failAttemptFromError, } from "../telemetry/setup-diagnostics.js";
|
|
4
|
+
import { classifyHttpSetupFailure, failAttemptFromError, isPromptCancellation, } from "../telemetry/setup-diagnostics.js";
|
|
5
|
+
import { withEscapeCancel } from "./prompt-cancel.js";
|
|
5
6
|
export function resolveStatusTarget(port) {
|
|
6
7
|
const cfg = readConfig();
|
|
7
8
|
if (cfg.client) {
|
|
@@ -169,11 +170,12 @@ async function runReauthFlow(target, intent) {
|
|
|
169
170
|
let attempt;
|
|
170
171
|
try {
|
|
171
172
|
const { collectReauthRecord } = await import("./account-flows.js");
|
|
172
|
-
|
|
173
|
+
console.log(chalk.gray(" Esc or Ctrl-C returns to the dashboard.\n"));
|
|
174
|
+
const result = await withEscapeCancel(signal => collectReauthRecord({
|
|
173
175
|
id: intent.id,
|
|
174
176
|
provider: intent.provider,
|
|
175
177
|
...(intent.email ? { email: intent.email } : {}),
|
|
176
|
-
});
|
|
178
|
+
}, { signal }));
|
|
177
179
|
if (!result)
|
|
178
180
|
return null;
|
|
179
181
|
attempt = result.attempt;
|
|
@@ -201,6 +203,10 @@ async function runReauthFlow(target, intent) {
|
|
|
201
203
|
return result.record.id;
|
|
202
204
|
}
|
|
203
205
|
catch (err) {
|
|
206
|
+
if (isPromptCancellation(err)) {
|
|
207
|
+
console.log(chalk.gray("\n Cancelled."));
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
204
210
|
console.error(chalk.red(`\n✗ Re-authentication failed: ${err.message}`));
|
|
205
211
|
if (attempt) {
|
|
206
212
|
const outcome = failAttemptFromError(attempt, err, "persistence");
|
|
@@ -219,8 +225,9 @@ async function runAddAccountFlow(target) {
|
|
|
219
225
|
let attempt;
|
|
220
226
|
try {
|
|
221
227
|
const { collectClaudeAccount, accountToRecord } = await import("./account-flows.js");
|
|
228
|
+
console.log(chalk.gray(" Esc or Ctrl-C returns to the dashboard.\n"));
|
|
222
229
|
// The index shown in the flow is just for display, pick something neutral.
|
|
223
|
-
const setup = await collectClaudeAccount({ index: 1 });
|
|
230
|
+
const setup = await withEscapeCancel(signal => collectClaudeAccount({ index: 1, signal }));
|
|
224
231
|
attempt = setup.attempt;
|
|
225
232
|
const account = setup.account;
|
|
226
233
|
if (!account)
|
|
@@ -250,6 +257,10 @@ async function runAddAccountFlow(target) {
|
|
|
250
257
|
return account.id;
|
|
251
258
|
}
|
|
252
259
|
catch (err) {
|
|
260
|
+
if (isPromptCancellation(err)) {
|
|
261
|
+
console.log(chalk.gray("\n Cancelled."));
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
253
264
|
console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
|
|
254
265
|
if (attempt) {
|
|
255
266
|
const outcome = failAttemptFromError(attempt, err, "persistence");
|
|
@@ -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);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
/** The Escape key itself — not an escape sequence and not an Alt-chord. */
|
|
3
|
+
export function isEscapeKey(key) {
|
|
4
|
+
return key?.name === "escape" && key.sequence === "\x1b";
|
|
5
|
+
}
|
|
6
|
+
export async function withEscapeCancel(flow, stdin = process.stdin) {
|
|
7
|
+
// Idempotent: the prompt's own readline interface installs the same decoder.
|
|
8
|
+
readline.emitKeypressEvents(stdin);
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const onKeypress = (_input, key) => {
|
|
11
|
+
if (isEscapeKey(key))
|
|
12
|
+
controller.abort();
|
|
13
|
+
};
|
|
14
|
+
stdin.on("keypress", onKeypress);
|
|
15
|
+
try {
|
|
16
|
+
return await flow(controller.signal);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
stdin.off("keypress", onKeypress);
|
|
20
|
+
}
|
|
21
|
+
}
|
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");
|