ai-cc-router 0.2.0 → 0.2.2

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.
@@ -16,7 +16,7 @@ function isClaudeDesktopInstalled() {
16
16
  }
17
17
  return false;
18
18
  }
19
- async function testRemoteConnection(url, secret) {
19
+ async function fetchRemoteHealth(url, secret) {
20
20
  try {
21
21
  const headers = {};
22
22
  if (secret)
@@ -28,12 +28,37 @@ async function testRemoteConnection(url, secret) {
28
28
  if (!res.ok)
29
29
  return { ok: false, error: `HTTP ${res.status}` };
30
30
  const data = (await res.json());
31
- return { ok: data.status === "ok" || data.status === "degraded", accounts: data.accounts?.length };
31
+ return { ok: data.status === "ok" || data.status === "degraded", data };
32
32
  }
33
33
  catch (e) {
34
34
  return { ok: false, error: e.message };
35
35
  }
36
36
  }
37
+ function formatUptime(seconds) {
38
+ if (!seconds || seconds < 0)
39
+ return "0s";
40
+ const h = Math.floor(seconds / 3600);
41
+ const m = Math.floor((seconds % 3600) / 60);
42
+ const s = Math.floor(seconds % 60);
43
+ if (h > 0)
44
+ return `${h}h ${m}m`;
45
+ if (m > 0)
46
+ return `${m}m ${s}s`;
47
+ return `${s}s`;
48
+ }
49
+ function formatNumber(n) {
50
+ if (n === undefined || n === null)
51
+ return "0";
52
+ if (n >= 1_000_000)
53
+ return `${(n / 1_000_000).toFixed(1)}M`;
54
+ if (n >= 1_000)
55
+ return `${(n / 1_000).toFixed(1)}k`;
56
+ return String(n);
57
+ }
58
+ function formatTime(ts) {
59
+ const d = new Date(ts);
60
+ return d.toLocaleTimeString(undefined, { hour12: false });
61
+ }
37
62
  function formatUrl(raw) {
38
63
  let url = raw.trim().replace(/\/+$/, "");
39
64
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
@@ -68,14 +93,14 @@ export function registerClient(program) {
68
93
  }
69
94
  // 3. Test connection
70
95
  console.log(chalk.gray(`\nTesting connection to ${url}...`));
71
- const test = await testRemoteConnection(url, secret);
96
+ const test = await fetchRemoteHealth(url, secret);
72
97
  if (!test.ok) {
73
98
  console.error(chalk.red(`\n✗ Cannot reach CC-Router at ${url}`));
74
99
  console.error(chalk.yellow(` Error: ${test.error}`));
75
100
  console.error(chalk.gray(" Make sure the server is running and accessible.\n"));
76
101
  process.exit(1);
77
102
  }
78
- console.log(chalk.green(`✓ Connected — ${test.accounts ?? "?"} accounts on server\n`));
103
+ console.log(chalk.green(`✓ Connected — ${test.data?.accounts?.length ?? "?"} accounts on server\n`));
79
104
  // 4. Save client config
80
105
  const cfg = readConfig();
81
106
  const clientCfg = { remoteUrl: url };
@@ -122,8 +147,9 @@ export function registerClient(program) {
122
147
  // ── cc-router client status ─────────────────────────────────────────────────
123
148
  client
124
149
  .command("status")
125
- .description("Show client connection status")
126
- .action(async () => {
150
+ .description("Show client connection status with live stats from the remote")
151
+ .option("--json", "Output raw remote health JSON")
152
+ .action(async (opts) => {
127
153
  const cfg = readConfig();
128
154
  const claude = readClaudeProxySettings();
129
155
  if (!cfg.client) {
@@ -131,27 +157,69 @@ export function registerClient(program) {
131
157
  console.log(chalk.gray(" Run: cc-router client connect <url>\n"));
132
158
  return;
133
159
  }
160
+ // Fetch live health from remote
161
+ const test = await fetchRemoteHealth(cfg.client.remoteUrl, cfg.client.remoteSecret);
162
+ if (opts.json) {
163
+ console.log(JSON.stringify(test.data ?? { error: test.error }, null, 2));
164
+ return;
165
+ }
134
166
  console.log(chalk.bold("\n📡 CC-Router Client Status\n"));
135
167
  console.log(` Remote: ${chalk.cyan(cfg.client.remoteUrl)}`);
136
168
  console.log(` Auth: ${cfg.client.remoteSecret ? chalk.green("secret configured") : chalk.gray("no auth")}`);
137
169
  console.log(` Claude: ${claude.baseUrl ? chalk.green(claude.baseUrl) : chalk.red("not configured")}`);
138
- // Ping remote
139
- const test = await testRemoteConnection(cfg.client.remoteUrl, cfg.client.remoteSecret);
140
- if (test.ok) {
141
- console.log(` Server: ${chalk.green("online")} (${test.accounts} accounts)`);
170
+ if (!test.ok) {
171
+ console.log(` Server: ${chalk.red("unreachable")} — ${test.error}`);
172
+ console.log(chalk.gray("\n The remote proxy isn't responding. Your requests may be failing."));
173
+ console.log();
174
+ return;
175
+ }
176
+ const d = test.data;
177
+ console.log(` Server: ${chalk.green("online")} · up ${chalk.gray(formatUptime(d.uptime ?? 0))}`);
178
+ // ── Totals ─────────────────────────────────────────────────────────
179
+ console.log(chalk.bold("\n TOTALS"));
180
+ console.log(` Requests: ${chalk.cyan(formatNumber(d.totalRequests))}` +
181
+ ` Errors: ${((d.totalErrors ?? 0) > 0 ? chalk.red : chalk.gray)(formatNumber(d.totalErrors))}`);
182
+ console.log(` Input: ${chalk.gray(formatNumber(d.totalInputTokens))} tok` +
183
+ ` Output: ${chalk.gray(formatNumber(d.totalOutputTokens))} tok` +
184
+ ` Cache read: ${chalk.gray(formatNumber(d.totalCacheReadTokens))} tok`);
185
+ // ── Accounts ───────────────────────────────────────────────────────
186
+ if (d.accounts && d.accounts.length > 0) {
187
+ console.log(chalk.bold("\n ACCOUNTS"));
188
+ for (const a of d.accounts) {
189
+ const dot = a.healthy ? chalk.green("●") : chalk.red("●");
190
+ console.log(` ${dot} ${a.id.padEnd(20)} req ${String(a.requestCount ?? 0).padStart(5)} ` +
191
+ `err ${String(a.errorCount ?? 0).padStart(3)}`);
192
+ }
193
+ }
194
+ // ── Recent activity ────────────────────────────────────────────────
195
+ if (d.recentLogs && d.recentLogs.length > 0) {
196
+ console.log(chalk.bold("\n RECENT ACTIVITY (last 5)"));
197
+ for (const log of d.recentLogs.slice(0, 5)) {
198
+ const status = log.statusCode ?? 0;
199
+ const statusColor = status >= 500 || status === 0 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
200
+ const duration = log.durationMs ? ` ${chalk.gray(log.durationMs + "ms")}` : "";
201
+ console.log(` ${chalk.gray(formatTime(log.ts))} ${log.accountId.padEnd(18)} ` +
202
+ `${(log.method ?? "?").padEnd(5)} ${(log.path ?? "?").padEnd(22)} ` +
203
+ `${statusColor(String(status))}${duration}`);
204
+ }
142
205
  }
143
206
  else {
144
- console.log(` Server: ${chalk.red("unreachable")} ${test.error}`);
207
+ console.log(chalk.gray("\n No recent activity on the remote proxy."));
145
208
  }
146
- // Desktop status
209
+ // ── Desktop status ─────────────────────────────────────────────────
210
+ console.log(chalk.bold("\n DESKTOP INTERCEPTOR"));
147
211
  if (cfg.client.desktopEnabled) {
148
212
  const running = await isInterceptorRunning();
149
- console.log(` Desktop: ${running ? chalk.green("intercepting") : chalk.yellow("configured but not running")}`);
213
+ console.log(` ${running ? chalk.green("● running") : chalk.yellow("configured but stopped")}`);
214
+ if (!running) {
215
+ console.log(chalk.gray(" Start with: cc-router client start-desktop"));
216
+ }
150
217
  }
151
218
  else {
152
- console.log(` Desktop: ${chalk.gray("not configured")}`);
219
+ console.log(` ${chalk.gray("not configured")}`);
153
220
  }
154
221
  console.log();
222
+ console.log(chalk.gray(" Live dashboard: cc-router status\n"));
155
223
  });
156
224
  // ── cc-router client start-desktop ──────────────────────────────────────────
157
225
  client
@@ -1,5 +1,23 @@
1
1
  import chalk from "chalk";
2
2
  import { PROXY_PORT } from "../config/paths.js";
3
+ import { readConfig } from "../config/manager.js";
4
+ /**
5
+ * Resolves where the health endpoint lives.
6
+ *
7
+ * In client mode → remote CC-Router URL (from config)
8
+ * Otherwise → http://localhost:<port>
9
+ */
10
+ function resolveTarget() {
11
+ const cfg = readConfig();
12
+ if (cfg.client) {
13
+ const base = cfg.client.remoteUrl.replace(/\/+$/, "");
14
+ const headers = {};
15
+ if (cfg.client.remoteSecret)
16
+ headers["authorization"] = `Bearer ${cfg.client.remoteSecret}`;
17
+ return { healthUrl: `${base}/cc-router/health`, headers, baseUrl: base, authToken: cfg.client.remoteSecret };
18
+ }
19
+ return { healthUrl: `http://localhost:${PROXY_PORT}/cc-router/health`, headers: {} };
20
+ }
3
21
  export function registerStatus(program) {
4
22
  program
5
23
  .command("status")
@@ -16,8 +34,10 @@ export function registerStatus(program) {
16
34
  });
17
35
  }
18
36
  async function jsonOutput(port) {
37
+ const { healthUrl, headers } = resolveTarget();
19
38
  try {
20
- const res = await fetch(`http://localhost:${port}/cc-router/health`, {
39
+ const res = await fetch(healthUrl, {
40
+ headers,
21
41
  signal: AbortSignal.timeout(2_000),
22
42
  });
23
43
  if (!res.ok) {
@@ -27,19 +47,26 @@ async function jsonOutput(port) {
27
47
  console.log(JSON.stringify(await res.json(), null, 2));
28
48
  }
29
49
  catch {
30
- console.error(chalk.red(`Cannot connect to proxy at http://localhost:${port}`));
31
- console.error(chalk.gray("Is it running? Start with: cc-router start"));
50
+ console.error(chalk.red(`Cannot connect to proxy at ${healthUrl}`));
51
+ const cfg = readConfig();
52
+ if (cfg.client) {
53
+ console.error(chalk.gray("Is the remote CC-Router running?"));
54
+ }
55
+ else {
56
+ console.error(chalk.gray("Is it running? Start with: cc-router start"));
57
+ }
32
58
  process.exit(1);
33
59
  }
34
60
  }
35
61
  async function launchDashboard(port) {
62
+ const { baseUrl, authToken } = resolveTarget();
36
63
  // Dynamic imports keep these heavy deps out of the cold-start path
37
64
  const [{ render }, { createElement }, { Dashboard }] = await Promise.all([
38
65
  import("ink"),
39
66
  import("react"),
40
67
  import("../ui/Dashboard.js"),
41
68
  ]);
42
- render(createElement(Dashboard, { port }), {
69
+ render(createElement(Dashboard, { port, baseUrl, authToken }), {
43
70
  // Let Ink handle Ctrl+C — it calls exit() which cleanly unmounts
44
71
  exitOnCtrlC: true,
45
72
  });
@@ -136,7 +136,7 @@ export function writeAddonScript(target) {
136
136
  writeFileSync(ADDON_PATH, src, "utf-8");
137
137
  }
138
138
  else {
139
- // Inline fallback — minimal addon
139
+ // Inline fallback — minimal addon (only redirects /v1/messages and /v1/models)
140
140
  const script = `
141
141
  import os
142
142
  from mitmproxy import http
@@ -144,10 +144,13 @@ from urllib.parse import urlparse
144
144
 
145
145
  _target = os.environ.get("CC_ROUTER_TARGET", ${JSON.stringify(target)}).rstrip("/")
146
146
  _p = urlparse(_target)
147
+ _REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
147
148
 
148
149
  def request(flow: http.HTTPFlow) -> None:
149
150
  if flow.request.pretty_host != "api.anthropic.com":
150
151
  return
152
+ if not flow.request.path.startswith(_REDIRECT_PREFIXES):
153
+ return
151
154
  flow.request.scheme = _p.scheme
152
155
  flow.request.host = _p.hostname or "localhost"
153
156
  flow.request.port = _p.port or (443 if _p.scheme == "https" else 80)
@@ -8,8 +8,7 @@ const EMPTY_RL = {
8
8
  sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
9
9
  requestsLimit: 0, lastUpdated: 0,
10
10
  };
11
- // ─── Dashboard component ──────────────────────────────────────────────────────
12
- export function Dashboard({ port }) {
11
+ export function Dashboard({ port, baseUrl, authToken }) {
13
12
  const { exit } = useApp();
14
13
  const [data, setData] = useState(null);
15
14
  const [connectError, setConnectError] = useState(null);
@@ -21,9 +20,16 @@ export function Dashboard({ port }) {
21
20
  });
22
21
  useEffect(() => {
23
22
  let cancelled = false;
23
+ const healthUrl = baseUrl
24
+ ? `${baseUrl.replace(/\/+$/, "")}/cc-router/health`
25
+ : `http://localhost:${port}/cc-router/health`;
26
+ const headers = authToken
27
+ ? { authorization: `Bearer ${authToken}` }
28
+ : {};
24
29
  const poll = async () => {
25
30
  try {
26
- const res = await fetch(`http://localhost:${port}/cc-router/health`, {
31
+ const res = await fetch(healthUrl, {
32
+ headers,
27
33
  signal: AbortSignal.timeout(1_500),
28
34
  });
29
35
  if (cancelled)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,11 +1,16 @@
1
- # mitmproxy addon — redirects api.anthropic.com traffic to CC-Router.
1
+ # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router.
2
2
  #
3
- # Installed and launched by `cc-router client start-desktop`.
4
- # Target URL comes from the CC_ROUTER_TARGET env var (set by the launcher).
3
+ # Claude Desktop sends many types of requests to api.anthropic.com:
4
+ # /v1/messages → LLM inference (this is what we redirect)
5
+ # /v1/messages/count_tokens → token counting (redirect too)
6
+ # /v1/oauth/* → session auth (must NOT redirect)
7
+ # /v1/environments/* → bridge/cowork (must NOT redirect)
8
+ # /v1/models → model listing (redirect — CC-Router proxies this)
9
+ # /api/* → desktop features (must NOT redirect)
5
10
  #
6
- # Why not set the target as a script argument: mitmproxy's addon loader
7
- # does not pass argv through reliably, and env vars give us a single
8
- # unambiguous channel that survives the spawn boundary.
11
+ # Only /v1/messages* and /v1/models are safe to redirect because CC-Router
12
+ # injects its own OAuth token. Everything else carries the user's own
13
+ # session token for features CC-Router doesn't handle.
9
14
 
10
15
  import os
11
16
  from urllib.parse import urlparse
@@ -16,22 +21,27 @@ _target_raw = os.environ.get("CC_ROUTER_TARGET", "http://localhost:3456")
16
21
  _target = _target_raw.rstrip("/")
17
22
  _target_parsed = urlparse(_target)
18
23
 
19
- # Fail closed on boot if the target is unusable — better than silently
20
- # forwarding to a broken URL and seeing Claude Desktop timeout.
21
24
  if not _target_parsed.scheme or not _target_parsed.netloc:
22
25
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
23
26
 
27
+ # Paths that CC-Router can handle (it injects its own OAuth token)
28
+ _REDIRECT_PREFIXES = (
29
+ "/v1/messages",
30
+ "/v1/models",
31
+ )
32
+
24
33
 
25
34
  def request(flow: http.HTTPFlow) -> None:
26
35
  if flow.request.pretty_host != "api.anthropic.com":
27
36
  return
28
37
 
29
- # Preserve path + query (e.g. /v1/messages?beta=oauth-2025-04-20)
30
- # and swap only the scheme + host.
38
+ # Only redirect inference and model-listing paths
39
+ if not flow.request.path.startswith(_REDIRECT_PREFIXES):
40
+ return
41
+
31
42
  flow.request.scheme = _target_parsed.scheme
32
43
  flow.request.host = _target_parsed.hostname or "localhost"
33
44
  flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
34
- # Rewrite the Host header so CC-Router sees itself, not api.anthropic.com.
35
45
  flow.request.headers["host"] = flow.request.host + (
36
46
  f":{flow.request.port}"
37
47
  if flow.request.port not in (80, 443)