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.
@@ -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
  }
@@ -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
- writeClaudeSettings(port, proxyHost);
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`));
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
2
- import { CONFIG_DIR, ACCOUNTS_PATH } from "./paths.js";
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,
@@ -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");
@@ -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);
@@ -1,8 +1,11 @@
1
- const MAX_LOG_ENTRIES = 50;
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) {
@@ -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
- 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 [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(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.recentLogs.length === 0
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
- : data.recentLogs.slice(0, 10).map((log, i) => (_jsx(LogRow, { log: log }, i))) })] })] }));
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 typeColor = log.type === "error" ? "red" : log.type === "refresh" ? "yellow" : "gray";
82
- const typeIcon = log.type === "error" ? "✗" : log.type === "refresh" ? "↻" : "→";
83
- return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [" ", time, " "] }), _jsxs(Text, { color: typeColor, children: [typeIcon, " "] }), _jsx(Text, { color: "cyan", children: log.accountId.slice(0, 22).padEnd(22) }), _jsxs(Text, { color: typeColor, children: [" ", log.type] }), log.details && _jsxs(Text, { color: "gray", children: [" ", log.details] })] }));
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
- // The proxy replaces this placeholder with the real OAuth token per request.
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": {