ai-cc-router 0.1.4 → 0.1.6
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-configure.js +43 -1
- package/dist/cli/cmd-setup.js +39 -5
- package/dist/config/manager.js +22 -1
- package/dist/config/paths.js +3 -0
- package/dist/proxy/server.js +133 -8
- package/dist/proxy/stats.js +4 -1
- package/dist/ui/Dashboard.js +104 -7
- package/dist/utils/claude-config.js +3 -2
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { writeClaudeSettings, removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
3
|
+
import { readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
3
4
|
import { PROXY_PORT, CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
5
|
export function registerConfigure(program) {
|
|
5
6
|
program
|
|
@@ -8,6 +9,9 @@ export function registerConfigure(program) {
|
|
|
8
9
|
.option("--remove", "Remove cc-router settings from ~/.claude/settings.json")
|
|
9
10
|
.option("--port <port>", "Proxy port to configure", String(PROXY_PORT))
|
|
10
11
|
.option("--show", "Show current Claude Code proxy settings")
|
|
12
|
+
.option("--generate-password", "Generate a new proxy secret and sync Claude Code settings")
|
|
13
|
+
.option("--set-password <secret>", "Set a specific proxy secret and sync Claude Code settings")
|
|
14
|
+
.option("--remove-password", "Remove proxy password protection (open access)")
|
|
11
15
|
.action((opts) => {
|
|
12
16
|
if (opts.show) {
|
|
13
17
|
const current = readClaudeProxySettings();
|
|
@@ -20,6 +24,9 @@ export function registerConfigure(program) {
|
|
|
20
24
|
console.log(chalk.yellow(" Claude Code is NOT configured to use cc-router."));
|
|
21
25
|
console.log(chalk.gray(` Run: cc-router configure`));
|
|
22
26
|
}
|
|
27
|
+
const { proxySecret } = readConfig();
|
|
28
|
+
const pwStatus = proxySecret ? chalk.green("yes") : chalk.gray("no");
|
|
29
|
+
console.log(` Password protected: ${pwStatus}`);
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
25
32
|
if (opts.remove) {
|
|
@@ -28,10 +35,45 @@ export function registerConfigure(program) {
|
|
|
28
35
|
console.log(chalk.gray(" Claude Code will use its default authentication on next launch."));
|
|
29
36
|
return;
|
|
30
37
|
}
|
|
38
|
+
if (opts.generatePassword) {
|
|
39
|
+
const secret = generateProxySecret();
|
|
40
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
41
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
42
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
43
|
+
console.log(chalk.green("✓ Proxy password set."));
|
|
44
|
+
console.log(" " + chalk.bold.yellow("Save this — it will not be shown again:"));
|
|
45
|
+
console.log(" " + chalk.bold(secret));
|
|
46
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (opts.setPassword !== undefined) {
|
|
50
|
+
const secret = opts.setPassword.trim();
|
|
51
|
+
if (!secret) {
|
|
52
|
+
console.error(chalk.red("Secret cannot be empty."));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
writeConfig({ ...readConfig(), proxySecret: secret });
|
|
56
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
57
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
58
|
+
console.log(chalk.green("✓ Proxy password updated."));
|
|
59
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (opts.removePassword) {
|
|
63
|
+
const cfg = readConfig();
|
|
64
|
+
delete cfg.proxySecret;
|
|
65
|
+
writeConfig(cfg);
|
|
66
|
+
const { baseUrl } = readClaudeProxySettings();
|
|
67
|
+
writeClaudeSettings(parseInt(opts.port, 10), baseUrl);
|
|
68
|
+
console.log(chalk.green("✓ Proxy password removed. Access is now open."));
|
|
69
|
+
console.log(chalk.gray(" Restart cc-router for the change to take effect."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
31
72
|
const port = parseInt(opts.port, 10);
|
|
32
73
|
writeClaudeSettings(port);
|
|
74
|
+
const { proxySecret } = readConfig();
|
|
33
75
|
console.log(chalk.green(`✓ Updated ${CLAUDE_SETTINGS_PATH}`));
|
|
34
76
|
console.log(chalk.gray(` ANTHROPIC_BASE_URL = http://localhost:${port}`));
|
|
35
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
77
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = ${proxySecret ? chalk.green("(secret configured)") : "proxy-managed"}`));
|
|
36
78
|
});
|
|
37
79
|
}
|
package/dist/cli/cmd-setup.js
CHANGED
|
@@ -7,7 +7,7 @@ import { extractFromKeychain, extractFromCredentialsFile, formatExpiry, redactTo
|
|
|
7
7
|
import { validateToken } from "../utils/token-validator.js";
|
|
8
8
|
import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
9
9
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
10
|
-
import { loadAccounts, accountsFileExists } from "../config/manager.js";
|
|
10
|
+
import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
|
|
11
11
|
import { PROXY_PORT } from "../config/paths.js";
|
|
12
12
|
const execFileAsync = promisify(execFile);
|
|
13
13
|
// ─── Public registration ──────────────────────────────────────────────────────
|
|
@@ -226,11 +226,41 @@ async function runPostSetupFlow(accountCount) {
|
|
|
226
226
|
const port = proxyLocation === "local"
|
|
227
227
|
? PROXY_PORT
|
|
228
228
|
: parseInt(new URL(proxyHost).port || "80", 10);
|
|
229
|
-
|
|
230
|
-
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
231
|
-
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
232
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
229
|
+
// ── Password setup for remote proxy ───────────────────────────────────────
|
|
233
230
|
if (proxyLocation === "remote") {
|
|
231
|
+
const pwChoice = await select({
|
|
232
|
+
message: "Set a proxy password? (strongly recommended for internet-exposed proxies)",
|
|
233
|
+
choices: [
|
|
234
|
+
{ name: "Generate automatically (recommended)", value: "generate" },
|
|
235
|
+
{ name: "Enter my own password", value: "manual" },
|
|
236
|
+
{ name: "Skip — no password protection", value: "skip" },
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
let chosenSecret;
|
|
240
|
+
if (pwChoice === "generate") {
|
|
241
|
+
chosenSecret = generateProxySecret();
|
|
242
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
243
|
+
}
|
|
244
|
+
else if (pwChoice === "manual") {
|
|
245
|
+
const raw = await password({
|
|
246
|
+
message: "Enter proxy password:",
|
|
247
|
+
validate: (v) => v.trim().length >= 8 || "Minimum 8 characters",
|
|
248
|
+
});
|
|
249
|
+
chosenSecret = raw.trim();
|
|
250
|
+
writeConfig({ ...readConfig(), proxySecret: chosenSecret });
|
|
251
|
+
}
|
|
252
|
+
writeClaudeSettings(port, proxyHost);
|
|
253
|
+
if (chosenSecret) {
|
|
254
|
+
console.log(chalk.yellow("\n *** Save this password — you cannot recover it later ***"));
|
|
255
|
+
console.log(" " + chalk.bold(chosenSecret));
|
|
256
|
+
console.log(chalk.gray(" Claude Code has been configured to use it automatically."));
|
|
257
|
+
console.log(chalk.gray(" Other machines: cc-router configure --set-password <value>"));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
261
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
262
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
263
|
+
}
|
|
234
264
|
console.log(chalk.cyan(`\n On the remote machine, start cc-router with:`));
|
|
235
265
|
console.log(chalk.white(` HOST=0.0.0.0 cc-router start`));
|
|
236
266
|
console.log(chalk.cyan(` Or as a service:`));
|
|
@@ -239,6 +269,10 @@ async function runPostSetupFlow(accountCount) {
|
|
|
239
269
|
printDone(accountCount);
|
|
240
270
|
return;
|
|
241
271
|
}
|
|
272
|
+
writeClaudeSettings(port, proxyHost);
|
|
273
|
+
console.log(chalk.green(`\n ✓ ~/.claude/settings.json updated`));
|
|
274
|
+
console.log(chalk.gray(` ANTHROPIC_BASE_URL = ${proxyHost}`));
|
|
275
|
+
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN = proxy-managed`));
|
|
242
276
|
}
|
|
243
277
|
// 2. Only ask about starting the proxy if it's local
|
|
244
278
|
console.log(chalk.bold(`\n${"━".repeat(40)}\n Start the proxy\n${"━".repeat(40)}\n`));
|
package/dist/config/manager.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
3
4
|
export function ensureConfigDir() {
|
|
4
5
|
if (!existsSync(CONFIG_DIR)) {
|
|
5
6
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -36,6 +37,26 @@ export function writeAccountsAtomic(data) {
|
|
|
36
37
|
export function loadAccounts() {
|
|
37
38
|
return deserialize(readAccountsRaw());
|
|
38
39
|
}
|
|
40
|
+
export function readConfig() {
|
|
41
|
+
if (!existsSync(CONFIG_PATH))
|
|
42
|
+
return {};
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function writeConfig(cfg) {
|
|
51
|
+
ensureConfigDir();
|
|
52
|
+
const tmp = CONFIG_PATH + ".tmp";
|
|
53
|
+
writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf-8");
|
|
54
|
+
renameSync(tmp, CONFIG_PATH);
|
|
55
|
+
}
|
|
56
|
+
export function generateProxySecret() {
|
|
57
|
+
return "cc-rtr-" + randomBytes(16).toString("hex");
|
|
58
|
+
}
|
|
59
|
+
// ─── Accounts ─────────────────────────────────────────────────────────────────
|
|
39
60
|
function deserialize(records) {
|
|
40
61
|
return records.map(a => ({
|
|
41
62
|
id: a.id,
|
package/dist/config/paths.js
CHANGED
|
@@ -9,3 +9,6 @@ export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
|
|
|
9
9
|
export const LITELLM_PORT = 4000;
|
|
10
10
|
// When set, the server forwards to LiteLLM instead of Anthropic directly
|
|
11
11
|
export const LITELLM_URL = process.env["LITELLM_URL"];
|
|
12
|
+
// Proxy-level config (password, future settings) — separate from accounts.json
|
|
13
|
+
export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
|
|
14
|
+
path.join(CONFIG_DIR, "config.json");
|
package/dist/proxy/server.js
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
3
|
import { ServerResponse } from "http";
|
|
4
|
+
import { timingSafeEqual } from "crypto";
|
|
4
5
|
import { TokenPool } from "./token-pool.js";
|
|
5
6
|
import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
6
|
-
import { loadAccounts, accountsFileExists, readAccountsFromPath } from "../config/manager.js";
|
|
7
|
+
import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
|
|
7
8
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
8
9
|
import { stats } from "./stats.js";
|
|
9
10
|
import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
|
|
10
11
|
import chalk from "chalk";
|
|
12
|
+
// Mutates entry and updates aggregate counters with cache usage from Anthropic's
|
|
13
|
+
// message_start event. Called asynchronously after the log entry is already stored,
|
|
14
|
+
// so the dashboard picks up the values on the next poll.
|
|
15
|
+
function applyCacheUsage(entry, usage) {
|
|
16
|
+
entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
|
|
17
|
+
entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
|
|
18
|
+
entry.inputTokens = usage["input_tokens"] ?? 0;
|
|
19
|
+
stats.totalCacheReadTokens += entry.cacheReadTokens;
|
|
20
|
+
stats.totalCacheCreationTokens += entry.cacheCreationTokens;
|
|
21
|
+
stats.totalInputTokens += entry.inputTokens;
|
|
22
|
+
}
|
|
11
23
|
export async function startServer(opts = {}) {
|
|
12
24
|
const port = opts.port ?? PROXY_PORT;
|
|
13
25
|
// Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
|
|
@@ -30,6 +42,30 @@ export async function startServer(opts = {}) {
|
|
|
30
42
|
const pool = new TokenPool(accounts);
|
|
31
43
|
startRefreshLoop(accounts);
|
|
32
44
|
const app = express();
|
|
45
|
+
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
46
|
+
// If a proxySecret is configured, all requests must present it as
|
|
47
|
+
// "Authorization: Bearer <secret>". The /cc-router/health endpoint is
|
|
48
|
+
// always exempt so monitoring and PM2 healthchecks keep working.
|
|
49
|
+
const { proxySecret } = readConfig();
|
|
50
|
+
if (proxySecret) {
|
|
51
|
+
const secretBuf = Buffer.from(proxySecret, "utf-8");
|
|
52
|
+
app.use((req, res, next) => {
|
|
53
|
+
if (req.path === "/cc-router/health")
|
|
54
|
+
return next();
|
|
55
|
+
const auth = req.headers["authorization"] ?? "";
|
|
56
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
57
|
+
const tokenBuf = Buffer.from(token, "utf-8");
|
|
58
|
+
if (tokenBuf.length !== secretBuf.length ||
|
|
59
|
+
!timingSafeEqual(tokenBuf, secretBuf)) {
|
|
60
|
+
res.status(401).json({
|
|
61
|
+
type: "error",
|
|
62
|
+
error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" },
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
33
69
|
// ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
|
|
34
70
|
app.get("/cc-router/health", (_req, res) => {
|
|
35
71
|
res.json({
|
|
@@ -40,8 +76,11 @@ export async function startServer(opts = {}) {
|
|
|
40
76
|
totalRequests: stats.totalRequests,
|
|
41
77
|
totalErrors: stats.totalErrors,
|
|
42
78
|
totalRefreshes: stats.totalRefreshes,
|
|
79
|
+
totalCacheReadTokens: stats.totalCacheReadTokens,
|
|
80
|
+
totalCacheCreationTokens: stats.totalCacheCreationTokens,
|
|
81
|
+
totalInputTokens: stats.totalInputTokens,
|
|
43
82
|
accounts: pool.getStats(),
|
|
44
|
-
recentLogs: stats.getRecentLogs(),
|
|
83
|
+
recentLogs: stats.getRecentLogs(50),
|
|
45
84
|
});
|
|
46
85
|
});
|
|
47
86
|
// ─── Proxy middleware ──────────────────────────────────────────────────────
|
|
@@ -90,42 +129,120 @@ export async function startServer(opts = {}) {
|
|
|
90
129
|
if (!account)
|
|
91
130
|
return;
|
|
92
131
|
const status = proxyRes.statusCode ?? 0;
|
|
132
|
+
const durationMs = req._startTime
|
|
133
|
+
? Date.now() - req._startTime
|
|
134
|
+
: undefined;
|
|
135
|
+
// Complete the pending log entry with response info
|
|
136
|
+
const pendingLog = req._pendingLog ?? {
|
|
137
|
+
ts: Date.now(),
|
|
138
|
+
accountId: account.id,
|
|
139
|
+
model: "-",
|
|
140
|
+
type: "route",
|
|
141
|
+
};
|
|
142
|
+
pendingLog.statusCode = status;
|
|
143
|
+
if (durationMs !== undefined)
|
|
144
|
+
pendingLog.durationMs = durationMs;
|
|
93
145
|
if (status === 401) {
|
|
94
146
|
// Token invalid or expired mid-request.
|
|
95
147
|
// Forward the 401 to the client (Claude Code will retry on 401).
|
|
96
148
|
// Schedule a background refresh so the next request succeeds.
|
|
97
149
|
stats.totalErrors++;
|
|
98
150
|
account.errorCount++;
|
|
151
|
+
pendingLog.type = "error";
|
|
152
|
+
pendingLog.details = "token invalid";
|
|
99
153
|
logError(account.id, 401, "Token invalid — scheduling background refresh");
|
|
100
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "401" });
|
|
101
154
|
refreshAccountToken(account).then(ok => {
|
|
102
155
|
if (ok)
|
|
103
156
|
saveAccounts(pool.getAll());
|
|
104
157
|
}).catch(console.error);
|
|
105
158
|
}
|
|
106
|
-
if (status === 429) {
|
|
159
|
+
else if (status === 429) {
|
|
107
160
|
// Rate limited — put account on cooldown for Retry-After seconds.
|
|
108
161
|
stats.totalErrors++;
|
|
109
162
|
account.errorCount++;
|
|
110
163
|
const retryAfter = Number(proxyRes.headers["retry-after"] ?? 60);
|
|
164
|
+
pendingLog.type = "error";
|
|
165
|
+
pendingLog.details = `rate limited — cooldown ${retryAfter}s`;
|
|
111
166
|
logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
|
|
112
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "429" });
|
|
113
167
|
account.busy = true;
|
|
114
168
|
setTimeout(() => { account.busy = false; }, retryAfter * 1_000);
|
|
115
169
|
}
|
|
116
|
-
if (status === 529) {
|
|
170
|
+
else if (status === 529) {
|
|
117
171
|
// Anthropic service overloaded — short cooldown on this account.
|
|
118
172
|
stats.totalErrors++;
|
|
119
173
|
account.errorCount++;
|
|
174
|
+
pendingLog.type = "error";
|
|
175
|
+
pendingLog.details = "service overloaded — cooldown 30s";
|
|
120
176
|
logError(account.id, 529, "Service overloaded — cooldown 30s");
|
|
121
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "529" });
|
|
122
177
|
account.busy = true;
|
|
123
178
|
setTimeout(() => { account.busy = false; }, 30_000);
|
|
124
179
|
}
|
|
180
|
+
const entry = pendingLog;
|
|
181
|
+
stats.addLog(entry);
|
|
182
|
+
// ── Capture cache usage from Anthropic response body ──────────────────
|
|
183
|
+
// The message_start SSE event (or JSON body) carries usage fields:
|
|
184
|
+
// cache_read_input_tokens, cache_creation_input_tokens, input_tokens
|
|
185
|
+
// We add a passive data listener to capture these without breaking the
|
|
186
|
+
// streaming passthrough — all data listeners share the same chunks.
|
|
187
|
+
const contentType = String(proxyRes.headers["content-type"] ?? "");
|
|
188
|
+
const encoding = String(proxyRes.headers["content-encoding"] ?? "");
|
|
189
|
+
const isCompressed = /gzip|br|deflate/.test(encoding);
|
|
190
|
+
if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
|
|
191
|
+
const isSSE = contentType.includes("text/event-stream");
|
|
192
|
+
const MAX_BUF = 8 * 1024;
|
|
193
|
+
let buf = "";
|
|
194
|
+
let captured = false;
|
|
195
|
+
proxyRes.on("data", (chunk) => {
|
|
196
|
+
if (captured || buf.length >= MAX_BUF)
|
|
197
|
+
return;
|
|
198
|
+
buf += chunk.toString("utf8");
|
|
199
|
+
if (isSSE) {
|
|
200
|
+
// message_start is always the first SSE event — parse as soon as we have it
|
|
201
|
+
const lines = buf.split("\n");
|
|
202
|
+
for (const line of lines) {
|
|
203
|
+
if (!line.startsWith("data: "))
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
const evt = JSON.parse(line.slice(6));
|
|
207
|
+
if (evt.type === "message_start" && evt.message?.usage) {
|
|
208
|
+
applyCacheUsage(entry, evt.message.usage);
|
|
209
|
+
captured = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch { /* partial JSON, wait for next chunk */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
if (!isSSE) {
|
|
218
|
+
// Non-streaming: parse full JSON body on 'end'
|
|
219
|
+
proxyRes.on("end", () => {
|
|
220
|
+
if (captured)
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
const body = JSON.parse(buf);
|
|
224
|
+
if (body.usage)
|
|
225
|
+
applyCacheUsage(entry, body.usage);
|
|
226
|
+
}
|
|
227
|
+
catch { /* ignore */ }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
125
231
|
},
|
|
126
232
|
error: (err, _req, res) => {
|
|
127
233
|
stats.totalErrors++;
|
|
128
234
|
logError("proxy", 0, err.message);
|
|
235
|
+
// Complete the pending log entry for connection-level errors
|
|
236
|
+
const pendingLog = _req._pendingLog;
|
|
237
|
+
if (pendingLog) {
|
|
238
|
+
pendingLog.type = "error";
|
|
239
|
+
pendingLog.statusCode = 0;
|
|
240
|
+
pendingLog.details = err.message;
|
|
241
|
+
if (_req._startTime) {
|
|
242
|
+
pendingLog.durationMs = Date.now() - _req._startTime;
|
|
243
|
+
}
|
|
244
|
+
stats.addLog(pendingLog);
|
|
245
|
+
}
|
|
129
246
|
// res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
|
|
130
247
|
if (res instanceof ServerResponse && !res.headersSent) {
|
|
131
248
|
// Match Anthropic's error response format so Claude Code handles it gracefully
|
|
@@ -150,8 +267,16 @@ export async function startServer(opts = {}) {
|
|
|
150
267
|
saveAccounts(pool.getAll());
|
|
151
268
|
}
|
|
152
269
|
req._ccAccount = account;
|
|
270
|
+
req._startTime = Date.now();
|
|
271
|
+
req._pendingLog = {
|
|
272
|
+
ts: Date.now(),
|
|
273
|
+
accountId: account.id,
|
|
274
|
+
model: "-",
|
|
275
|
+
type: "route",
|
|
276
|
+
method: req.method,
|
|
277
|
+
path: req.path,
|
|
278
|
+
};
|
|
153
279
|
stats.totalRequests++;
|
|
154
|
-
stats.addLog({ ts: Date.now(), accountId: account.id, model: "?", type: "route" });
|
|
155
280
|
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
|
|
156
281
|
next();
|
|
157
282
|
}, proxy);
|
package/dist/proxy/stats.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
const MAX_LOG_ENTRIES =
|
|
1
|
+
const MAX_LOG_ENTRIES = 100;
|
|
2
2
|
class ProxyStats {
|
|
3
3
|
totalRequests = 0;
|
|
4
4
|
totalErrors = 0;
|
|
5
5
|
totalRefreshes = 0;
|
|
6
|
+
totalCacheReadTokens = 0;
|
|
7
|
+
totalCacheCreationTokens = 0;
|
|
8
|
+
totalInputTokens = 0;
|
|
6
9
|
startTime = Date.now();
|
|
7
10
|
logs = [];
|
|
8
11
|
addLog(entry) {
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from "react";
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
const POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
const LOG_VISIBLE = 20;
|
|
5
6
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
|
6
7
|
export function Dashboard({ port }) {
|
|
7
8
|
const { exit } = useApp();
|
|
@@ -59,9 +60,28 @@ function ErrorScreen({ error, port, retries }) {
|
|
|
59
60
|
function LiveDashboard({ data, port, lastUpdate }) {
|
|
60
61
|
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
61
62
|
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
62
|
-
|
|
63
|
+
const logs = data.recentLogs;
|
|
64
|
+
// Stable selection: track by timestamp so it survives log rotations
|
|
65
|
+
const [selectedTs, setSelectedTs] = useState(null);
|
|
66
|
+
// Derive index from timestamp; default to 0 (newest)
|
|
67
|
+
const selectedIndex = selectedTs !== null
|
|
68
|
+
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
69
|
+
: 0;
|
|
70
|
+
useInput((_input, key) => {
|
|
71
|
+
if (key.upArrow) {
|
|
72
|
+
const next = Math.max(0, selectedIndex - 1);
|
|
73
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
74
|
+
}
|
|
75
|
+
if (key.downArrow) {
|
|
76
|
+
const next = Math.min(logs.length - 1, selectedIndex + 1);
|
|
77
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const selectedLog = logs[selectedIndex] ?? null;
|
|
81
|
+
const visibleLogs = logs.slice(0, LOG_VISIBLE);
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
|
|
63
83
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
64
|
-
:
|
|
84
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, log.ts))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
65
85
|
}
|
|
66
86
|
// ─── Account row ──────────────────────────────────────────────────────────────
|
|
67
87
|
function AccountRow({ account: a }) {
|
|
@@ -76,11 +96,88 @@ function AccountRow({ account: a }) {
|
|
|
76
96
|
return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
|
|
77
97
|
}
|
|
78
98
|
// ─── Log row ──────────────────────────────────────────────────────────────────
|
|
79
|
-
function LogRow({ log }) {
|
|
99
|
+
function LogRow({ log, selected }) {
|
|
80
100
|
const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
101
|
+
const isError = log.type === "error";
|
|
102
|
+
const isRefresh = log.type === "refresh";
|
|
103
|
+
const typeColor = isError ? "red" : isRefresh ? "yellow" : "gray";
|
|
104
|
+
const typeIcon = isError ? "✗" : isRefresh ? "↻" : "→";
|
|
105
|
+
const statusColor = log.statusCode === undefined ? undefined
|
|
106
|
+
: log.statusCode >= 500 ? "red"
|
|
107
|
+
: log.statusCode >= 400 ? "yellow"
|
|
108
|
+
: log.statusCode >= 200 ? "green"
|
|
109
|
+
: "gray";
|
|
110
|
+
const bg = selected ? "white" : undefined;
|
|
111
|
+
const fg = (c) => selected ? "black" : c;
|
|
112
|
+
// Per-request cache hit rate
|
|
113
|
+
const totalTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
|
|
114
|
+
const cacheHitPct = totalTok > 0 ? Math.round(((log.cacheReadTokens ?? 0) / totalTok) * 100) : null;
|
|
115
|
+
const cacheColor = cacheHitPct === null ? undefined
|
|
116
|
+
: cacheHitPct >= 70 ? "green"
|
|
117
|
+
: cacheHitPct >= 30 ? "yellow"
|
|
118
|
+
: "red";
|
|
119
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
120
|
+
? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
|
|
121
|
+
: _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
|
|
122
|
+
}
|
|
123
|
+
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
|
124
|
+
function DetailPanel({ log }) {
|
|
125
|
+
const time = new Date(log.ts).toLocaleString("en-GB", {
|
|
126
|
+
hour12: false,
|
|
127
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
128
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
129
|
+
});
|
|
130
|
+
const isError = log.type === "error";
|
|
131
|
+
const statusLabel = log.statusCode === undefined ? "—"
|
|
132
|
+
: log.statusCode === 0 ? "connection error"
|
|
133
|
+
: `${log.statusCode} ${httpStatusText(log.statusCode)}`;
|
|
134
|
+
const statusColor = log.statusCode === undefined ? "gray"
|
|
135
|
+
: log.statusCode === 0 ? "red"
|
|
136
|
+
: log.statusCode >= 500 ? "red"
|
|
137
|
+
: log.statusCode >= 400 ? "yellow"
|
|
138
|
+
: "green";
|
|
139
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0 }) }))] })] }));
|
|
140
|
+
}
|
|
141
|
+
function Field({ label, value }) {
|
|
142
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
|
|
143
|
+
}
|
|
144
|
+
function FieldColored({ label, value, color }) {
|
|
145
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
|
|
146
|
+
}
|
|
147
|
+
// ─── Cache health badge (aggregated) ─────────────────────────────────────────
|
|
148
|
+
function CacheHealthBadge({ read, created, input }) {
|
|
149
|
+
const total = read + created + input;
|
|
150
|
+
if (total === 0)
|
|
151
|
+
return null;
|
|
152
|
+
const hitPct = Math.round((read / total) * 100);
|
|
153
|
+
const color = hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
|
|
154
|
+
const label = hitPct >= 70 ? "healthy" : hitPct >= 30 ? "fair" : "poor";
|
|
155
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "cache " }), _jsxs(Text, { color: color, children: [hitPct, "% hit "] }), _jsxs(Text, { color: "gray", children: ["(", label, ")"] })] }));
|
|
156
|
+
}
|
|
157
|
+
// ─── Cache breakdown (per-request detail) ────────────────────────────────────
|
|
158
|
+
function CacheBreakdown({ read, created, input }) {
|
|
159
|
+
const total = read + created + input;
|
|
160
|
+
const hitPct = total > 0 ? (read / total) * 100 : 0;
|
|
161
|
+
const color = total === 0 ? "gray" : hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
|
|
162
|
+
return (_jsxs(_Fragment, { children: [_jsx(FieldColored, { label: "Cache hit", value: total > 0 ? `${fmtTok(read)} tok (${hitPct.toFixed(1)}%)` : "—", color: color }), _jsx(Field, { label: "Cache created", value: fmtTok(created) + " tok" }), _jsx(Field, { label: "Uncached", value: fmtTok(input) + " tok" }), _jsx(Field, { label: "Total input", value: fmtTok(total) + " tok" })] }));
|
|
163
|
+
}
|
|
164
|
+
function fmtTok(n) {
|
|
165
|
+
if (n >= 1_000_000)
|
|
166
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
167
|
+
if (n >= 1_000)
|
|
168
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
169
|
+
return String(n);
|
|
170
|
+
}
|
|
171
|
+
// ─── HTTP status text ─────────────────────────────────────────────────────────
|
|
172
|
+
function httpStatusText(code) {
|
|
173
|
+
const map = {
|
|
174
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
175
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
176
|
+
404: "Not Found", 429: "Too Many Requests",
|
|
177
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
178
|
+
503: "Service Unavailable", 529: "Overloaded",
|
|
179
|
+
};
|
|
180
|
+
return map[code] ?? "";
|
|
84
181
|
}
|
|
85
182
|
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
86
183
|
function formatUptime(seconds) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
|
+
import { readConfig } from "../config/manager.js";
|
|
4
5
|
/**
|
|
5
6
|
* Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
|
|
6
7
|
*
|
|
@@ -36,8 +37,8 @@ export function writeClaudeSettings(port, baseUrl) {
|
|
|
36
37
|
...existingEnv,
|
|
37
38
|
ANTHROPIC_BASE_URL: resolvedUrl,
|
|
38
39
|
// ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
|
|
39
|
-
//
|
|
40
|
-
ANTHROPIC_AUTH_TOKEN: "proxy-managed",
|
|
40
|
+
// Uses the configured proxy secret if set, otherwise falls back to the open placeholder.
|
|
41
|
+
ANTHROPIC_AUTH_TOKEN: readConfig().proxySecret ?? "proxy-managed",
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
|