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.
package/dist/cli/cmd-client.js
CHANGED
|
@@ -16,7 +16,7 @@ function isClaudeDesktopInstalled() {
|
|
|
16
16
|
}
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
|
-
async function
|
|
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",
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
console.log(
|
|
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(
|
|
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(`
|
|
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(`
|
|
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
|
package/dist/cli/cmd-status.js
CHANGED
|
@@ -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(
|
|
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
|
|
31
|
-
|
|
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)
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -8,8 +8,7 @@ const EMPTY_RL = {
|
|
|
8
8
|
sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
|
|
9
9
|
requestsLimit: 0, lastUpdated: 0,
|
|
10
10
|
};
|
|
11
|
-
|
|
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(
|
|
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
package/src/interceptor/addon.py
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
# mitmproxy addon — redirects
|
|
1
|
+
# mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
30
|
-
|
|
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)
|