@timo972/cc-router 0.13.0-rc.6 → 0.13.1

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 CHANGED
@@ -8,23 +8,46 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- ### Changed
11
+ ---
12
+
13
+ ## [0.13.1] — 2026-11-06
14
+
15
+ ### Added
16
+
17
+ - `POST /cc-router/sessions/resolve` returns the account that currently owns a
18
+ caller-known session (`{"provider":"anthropic"|"openai","sessionId":…}`),
19
+ with its provider and `lastSeenAt`, or `404` for an unknown or expired
20
+ binding. It looks up exactly one session: it does not list the affinity
21
+ table, echo the session ID or refresh the binding TTL, and it uses the
22
+ router's normal authentication. See
23
+ [session routing](docs/session-routing.md#resolving-a-caller-known-session-binding).
24
+
25
+ ### Fixed
26
+
27
+ - The background daemon and the OS service no longer die on startup when npm
28
+ has a newer version. They run `start --foreground` with stdin ignored, so
29
+ the "Update now?" prompt could not be answered and the process exited before
30
+ it bound the port. Only an interactive terminal start offers the update now.
31
+ - The proxy now binds both the IPv4 (`127.0.0.1`) and IPv6 (`::1`) loopback
32
+ addresses by default, instead of IPv4 only. On Windows `localhost` usually
33
+ resolves to `::1` first, so a client (Claude Code, the CLI, the mitmproxy
34
+ addon) reaching the IPv6 loopback while the server listened only on IPv4
35
+ got no response and every API/chat request hung until it timed out. The
36
+ second bind never blocks startup: hosts without an IPv6 stack skip it, and
37
+ a busy `[::1]:<port>` (for example a stale process) is retried every 2s
38
+ until it binds, instead of leaving `localhost` clients hanging for the
39
+ life of the process. An explicit `HOST`, including `HOST=127.0.0.1`, is
40
+ honoured verbatim, so the refusal message for a non-loopback `HOST` without
41
+ a proxy secret now says to unset `HOST`.
42
+ - The Windows login service now sets its environment without trailing
43
+ spaces. `set VAR=value && …` stored `"1 "` and `"0.0.0.0 "`, so the service
44
+ was never recognised as the service (no PID file, wrong runtime mode in
45
+ telemetry), and in server mode it failed to bind (`ENOTFOUND`). The next
46
+ `cc-router start` rewrites the registry entry.
47
+
48
+ ---
12
49
 
13
- - `cc-router usage` charts and its daily grid switch between tokens and
14
- estimated API spend (`s`) and narrow to input or output (`i`); every bucket,
15
- model series and day in the report now carries frozen-rate USD per token
16
- category. The model view (`l`) lists input, output, cache and spend per
17
- model. Input, output and cache figures share one colour each across the
18
- header, the cost line and the model view.
19
- - The usage view fills the terminal like `cc-router status`, is indented one
20
- column, and quits on `Esc`. Chart columns scale to the available width and
21
- the chart shares the daily grid's right edge; the axis names its unit
22
- instead of running day numbers together. Warnings sit below the grid.
23
- - Subscription cost and net savings are no longer displayed in the usage
24
- dashboard or the plain-text summary; the API-equivalent value stands alone,
25
- broken down by input, output and cache. Both remain in `--json` output and
26
- `usage subscription` still records costs. A client talking to an older
27
- router shows the per-category spend as unavailable rather than zero.
50
+ ## [0.13.0] — 2026-10-18
28
51
 
29
52
  ### ⚠️ Breaking
30
53
 
@@ -38,6 +61,17 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
38
61
 
39
62
  ### Added
40
63
 
64
+ - `cc-router usage` records token usage persistently, across restarts, and
65
+ shows it per provider and model as stacked bars and a daily activity grid
66
+ for a day, week, month or year. Charts and grid switch between tokens and
67
+ estimated API-equivalent spend (`s`) and narrow to input or output (`i`);
68
+ the model view (`l`) lists input, output, cache and spend per model. Spend
69
+ uses frozen per-category API rates, and missing prices are marked
70
+ incomplete. The view fills the terminal and quits on `Esc`. `--json` prints
71
+ the report without the terminal UI, and `usage subscription set|end|list`
72
+ records effective-dated monthly subscription costs, which appear in the
73
+ `--json` report. See the
74
+ [usage reference](docs/cli-reference.md#persistent-token-usage-and-savings).
41
75
  - `cc-router accounts login claude` drives `claude auth login --claudeai`
42
76
  (with `--email` prefill) and imports the new credentials; `--long-lived`
43
77
  drives `claude setup-token` for a one-year, refresh-less token.
@@ -903,6 +937,9 @@ cache-aware session routing and a round of security hardening.
903
937
  - `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
904
938
  affected APIs are not used here.
905
939
 
940
+ [Unreleased]: https://github.com/Timo972/cc-router/compare/v0.13.1...HEAD
941
+ [0.13.1]: https://github.com/Timo972/cc-router/releases/tag/v0.13.1
942
+ [0.13.0]: https://github.com/Timo972/cc-router/releases/tag/v0.13.0
906
943
  [0.12.4]: https://github.com/Timo972/cc-router/releases/tag/v0.12.4
907
944
  [0.12.3]: https://github.com/Timo972/cc-router/releases/tag/v0.12.3
908
945
  [0.12.2]: https://github.com/Timo972/cc-router/releases/tag/v0.12.2
@@ -5,6 +5,7 @@ import { accountsFileExists, readConfig, writeConfig, generateProxySecret, } fro
5
5
  import { writeClaudeSettings } from "../utils/claude-config.js";
6
6
  import { checkForUpdate, performUpdate, PKG_NAME } from "../utils/self-update.js";
7
7
  import { launchDaemon, waitForHealth } from "../daemon/launcher.js";
8
+ import { managesPidFile } from "../daemon/pid.js";
8
9
  import { installService } from "../daemon/service.js";
9
10
  import { getLocalIPs } from "../utils/network.js";
10
11
  /**
@@ -203,7 +204,20 @@ async function ensureClaudeCodeConfigured(prefs, cfg) {
203
204
  }
204
205
  }
205
206
  // ─── Update check ────────────────────────────────────────────────────────────
207
+ /**
208
+ * Whether this start may ask "Update now?". The background daemon and the OS
209
+ * service run `start --foreground` with stdin ignored, so the prompt cannot be
210
+ * answered: inquirer rejects on the closed stdin and the process exits before
211
+ * it binds, which kept the proxy down whenever npm had a newer version. Only an
212
+ * interactive terminal start offers the update; the managed instance still
213
+ * logs the notify-only update notice from the server.
214
+ */
215
+ export function canPromptForUpdate(env = process.env, stdinIsTTY = process.stdin.isTTY) {
216
+ return stdinIsTTY === true && !managesPidFile(env);
217
+ }
206
218
  async function maybeUpdate() {
219
+ if (!canPromptForUpdate())
220
+ return;
207
221
  let check;
208
222
  try {
209
223
  check = await checkForUpdate(true); // force fresh check, skip disk cache
@@ -273,10 +273,18 @@ async function uninstallLinux() {
273
273
  function buildWindowsCommand() {
274
274
  return `"${process.execPath}" "${CLI_ENTRY}" start --foreground`;
275
275
  }
276
+ /**
277
+ * The Run-key command that starts the proxy at login. `set VAR=value && …`
278
+ * would store the space before `&&` in the value ("1 ", "0.0.0.0 "): the
279
+ * service then never recognised CC_ROUTER_SERVICE=1 (no PID file) and server
280
+ * mode failed to bind with ENOTFOUND. So `&&` follows the value directly.
281
+ */
282
+ export function buildWindowsAutostartCommand(serverMode, command = buildWindowsCommand()) {
283
+ const env = serverMode ? "set HOST=0.0.0.0&& set CC_ROUTER_SERVICE=1&& " : "set CC_ROUTER_SERVICE=1&& ";
284
+ return `cmd /c "${env}${command}"`;
285
+ }
276
286
  async function installWindows(serverMode) {
277
- const cmd = serverMode
278
- ? `cmd /c "set HOST=0.0.0.0 && set CC_ROUTER_SERVICE=1 && ${buildWindowsCommand()}"`
279
- : `cmd /c "set CC_ROUTER_SERVICE=1 && ${buildWindowsCommand()}"`;
287
+ const cmd = buildWindowsAutostartCommand(serverMode);
280
288
  try {
281
289
  await execFileAsync("reg", [
282
290
  "add", WINDOWS_REG_KEY,
@@ -38,6 +38,7 @@ import { mountMessagesCrossProviderRoute } from "./messages-cross-route.js";
38
38
  import { mountModelsRoute } from "./models-server.js";
39
39
  import chalk from "chalk";
40
40
  import { SessionRouter } from "./session-router.js";
41
+ import { registerSessionResolveRoute } from "./session-resolve.js";
41
42
  import { createAnthropicProxy } from "./anthropic-proxy.js";
42
43
  import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
43
44
  import { OpenAIUsageRefresher } from "../providers/openai/usage-fetch.js";
@@ -54,6 +55,8 @@ import { AccountInfoCache } from "./account-info-cache.js";
54
55
  import { loadXaiAccounts } from "../config/manager.js";
55
56
  /** Upper bound on how long a shutdown may wait for telemetry to drain. */
56
57
  const TELEMETRY_SHUTDOWN_DEADLINE_MS = 1_000;
58
+ /** How often a busy secondary loopback bind ([::1]:<port>) is retried. */
59
+ const SECONDARY_BIND_RETRY_MS = 2_000;
57
60
  const zeroRoutingMetrics = () => ({
58
61
  inFlightRequests: 0,
59
62
  activeSessions: 0,
@@ -666,6 +669,10 @@ export async function startServer(opts = {}) {
666
669
  stats.addLog({ ts: Date.now(), accountId: "proxy", model: "-", type: "refresh", details });
667
670
  res.json({ refresh: summary });
668
671
  });
672
+ registerSessionResolveRoute(app, {
673
+ anthropicRouter: sessionRouter,
674
+ openAIRouter,
675
+ });
669
676
  // ─── Account management endpoints (authenticated) ─────────────────────────
670
677
  // These are mounted BEFORE the /v1/* proxy middleware so they don't get
671
678
  // forwarded to Anthropic. express.json() is scoped to this sub-router so
@@ -1666,25 +1673,85 @@ export async function startServer(opts = {}) {
1666
1673
  ` anyone who can reach it use your Claude/OpenAI accounts.\n` +
1667
1674
  ` Fix: run 'cc-router start' and set a password when asked, or add a\n` +
1668
1675
  ` "proxySecret" to ~/.cc-router/config.json. To bind localhost only,\n` +
1669
- ` unset HOST (or set HOST=127.0.0.1).\n`));
1676
+ ` unset HOST.\n`));
1670
1677
  process.exit(1);
1671
1678
  }
1672
- const server = app.listen(port, host);
1673
- await new Promise((resolve, reject) => {
1674
- const onError = (error) => {
1675
- server.off("listening", onListening);
1676
- usageRefresher.stop();
1677
- openAIUsageRefresher.stop();
1678
- accountInfoCache.stop();
1679
- usageRuntime.close();
1680
- reject(error);
1681
- };
1682
- const onListening = () => {
1683
- server.off("error", onError);
1684
- resolve();
1685
- };
1686
- server.once("error", onError);
1687
- server.once("listening", onListening);
1679
+ // Default loopback must answer on BOTH IPv4 and IPv6 loopback. On Windows
1680
+ // (and anywhere getaddrinfo lists ::1 before 127.0.0.1) a client resolving
1681
+ // "localhost" reaches ::1 first; if we listen only on 127.0.0.1 that
1682
+ // connection is never accepted and the client hangs until it times out —
1683
+ // which is exactly why every Claude request "times out" once the proxy is
1684
+ // running. Binding both loopback addresses keeps us localhost-only (unlike
1685
+ // :: / 0.0.0.0) while accepting whichever family the client picks. An
1686
+ // explicit HOST, including HOST=127.0.0.1, is honoured verbatim.
1687
+ const bindAddresses = process.env["HOST"] === undefined ? ["127.0.0.1", "::1"] : [host];
1688
+ const listenOn = (addr, p) => new Promise((resolve, reject) => {
1689
+ const s = app.listen(p, addr);
1690
+ const onError = (error) => { s.off("listening", onListening); reject(error); };
1691
+ const onListening = () => { s.off("error", onError); resolve(s); };
1692
+ s.once("error", onError);
1693
+ s.once("listening", onListening);
1694
+ });
1695
+ // The primary bind (IPv4 loopback, or the explicit HOST) must succeed.
1696
+ let server;
1697
+ try {
1698
+ server = await listenOn(bindAddresses[0], port);
1699
+ }
1700
+ catch (error) {
1701
+ usageRefresher.stop();
1702
+ openAIUsageRefresher.stop();
1703
+ accountInfoCache.stop();
1704
+ usageRuntime.close();
1705
+ throw error;
1706
+ }
1707
+ // Secondary loopback binds must not block startup. A host without IPv6
1708
+ // (EAFNOSUPPORT / EADDRNOTAVAIL) skips them for good. Anything else, usually
1709
+ // EADDRINUSE from a stale process still holding [::1]:<port>, is retried in
1710
+ // the background: giving up would leave `localhost` clients on Windows
1711
+ // hanging for the whole life of this process. Reuse the port the primary
1712
+ // actually bound so `port: 0` still pairs both families.
1713
+ const boundPort = server.address()?.port ?? port;
1714
+ const extraServers = [];
1715
+ const retryTimers = new Set();
1716
+ let primaryClosed = false;
1717
+ const bindSecondary = async (addr, attempt) => {
1718
+ if (primaryClosed)
1719
+ return;
1720
+ try {
1721
+ const s = await listenOn(addr, boundPort);
1722
+ if (primaryClosed) {
1723
+ s.close();
1724
+ return;
1725
+ }
1726
+ extraServers.push(s);
1727
+ if (attempt > 0)
1728
+ console.log(chalk.gray(` (loopback ${addr}:${boundPort} is bound now)`));
1729
+ }
1730
+ catch (error) {
1731
+ const code = error.code;
1732
+ if (code === "EAFNOSUPPORT" || code === "EADDRNOTAVAIL") {
1733
+ console.log(chalk.gray(` (note: loopback ${addr} is not available on this host, skipped)`));
1734
+ return;
1735
+ }
1736
+ if (attempt === 0) {
1737
+ console.log(chalk.yellow(` ⚠ loopback ${addr}:${boundPort} is busy (${code ?? error.message}), retrying every ${SECONDARY_BIND_RETRY_MS / 1000}s`));
1738
+ }
1739
+ const timer = setTimeout(() => {
1740
+ retryTimers.delete(timer);
1741
+ void bindSecondary(addr, attempt + 1);
1742
+ }, SECONDARY_BIND_RETRY_MS);
1743
+ timer.unref();
1744
+ retryTimers.add(timer);
1745
+ }
1746
+ };
1747
+ for (const addr of bindAddresses.slice(1))
1748
+ await bindSecondary(addr, 0);
1749
+ server.once("close", () => {
1750
+ primaryClosed = true;
1751
+ for (const timer of retryTimers)
1752
+ clearTimeout(timer);
1753
+ for (const s of extraServers)
1754
+ s.close();
1688
1755
  });
1689
1756
  process.on("SIGTERM", () => { void shutdown(); });
1690
1757
  process.on("SIGINT", () => { void shutdown(); });
@@ -0,0 +1,68 @@
1
+ import express from "express";
2
+ import { normalizeSessionId } from "./session-router.js";
3
+ export function parseSessionResolveRequest(body) {
4
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
5
+ return invalidRequest();
6
+ }
7
+ const keys = Object.keys(body);
8
+ if (keys.length !== 2 || !keys.includes("provider") || !keys.includes("sessionId")) {
9
+ return invalidRequest();
10
+ }
11
+ const candidate = body;
12
+ if (candidate.provider !== "anthropic" && candidate.provider !== "openai") {
13
+ return invalidRequest();
14
+ }
15
+ const sessionId = normalizeSessionId(candidate.sessionId);
16
+ if (!sessionId)
17
+ return invalidRequest();
18
+ return {
19
+ ok: true,
20
+ request: {
21
+ provider: candidate.provider,
22
+ sessionId,
23
+ },
24
+ };
25
+ }
26
+ export function resolveSessionBinding(request, routers) {
27
+ const router = request.provider === "anthropic"
28
+ ? routers.anthropicRouter
29
+ : routers.openAIRouter;
30
+ const binding = router.resolve(request.sessionId);
31
+ if (!binding) {
32
+ return {
33
+ status: 404,
34
+ body: { error: "Session binding not found" },
35
+ };
36
+ }
37
+ return {
38
+ status: 200,
39
+ body: {
40
+ accountId: binding.accountId,
41
+ provider: request.provider,
42
+ lastSeenAt: binding.lastSeenAt,
43
+ },
44
+ };
45
+ }
46
+ export function registerSessionResolveRoute(app, routers) {
47
+ const route = express.Router();
48
+ route.use(express.json({ limit: "1kb", strict: true }));
49
+ route.post("/", (req, res) => {
50
+ const parsed = parseSessionResolveRequest(req.body);
51
+ if (!parsed.ok) {
52
+ res.status(400).json({ error: parsed.error });
53
+ return;
54
+ }
55
+ const result = resolveSessionBinding(parsed.request, routers);
56
+ res.status(result.status).json(result.body);
57
+ });
58
+ route.use((_error, _req, res, _next) => {
59
+ res.status(400).json({ error: "Invalid session resolve request" });
60
+ });
61
+ app.use("/cc-router/sessions/resolve", route);
62
+ }
63
+ function invalidRequest() {
64
+ return {
65
+ ok: false,
66
+ error: "Invalid session resolve request",
67
+ };
68
+ }
@@ -63,6 +63,20 @@ export class SessionRouter {
63
63
  const binding = this.insertBinding(sessionId, lease.account.id, now);
64
64
  return this.wrapScoped(lease, existing ? "failover" : "new-session", sessionId, binding.generation, modelFamily);
65
65
  }
66
+ resolve(sessionHeader) {
67
+ const sessionId = normalizeSessionId(sessionHeader);
68
+ if (!sessionId)
69
+ return undefined;
70
+ const now = this.now();
71
+ this.sweepExpiredBindings(now);
72
+ const binding = this.bindings.get(sessionId);
73
+ if (!binding)
74
+ return undefined;
75
+ return {
76
+ accountId: binding.accountId,
77
+ lastSeenAt: binding.lastSeen,
78
+ };
79
+ }
66
80
  /**
67
81
  * Remove a binding only if it still belongs to the expected account. This
68
82
  * protects a new failover binding from a late response on the old account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.13.0-rc.6",
3
+ "version": "0.13.1",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {