@timo972/cc-router 0.13.0 → 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 +53 -16
- package/dist/cli/cmd-start.js +14 -0
- package/dist/daemon/service.js +11 -3
- package/dist/proxy/server.js +84 -17
- package/dist/proxy/session-resolve.js +68 -0
- package/dist/proxy/session-router.js +14 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/cli/cmd-start.js
CHANGED
|
@@ -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
|
package/dist/daemon/service.js
CHANGED
|
@@ -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,
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
|
1676
|
+
` unset HOST.\n`));
|
|
1670
1677
|
process.exit(1);
|
|
1671
1678
|
}
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
const
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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.
|