ai-cc-router 0.4.2 → 0.5.0

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,5 @@
1
1
  import chalk from "chalk";
2
- import { loadAccounts, accountsFileExists, writeAccountsAtomic } from "../config/manager.js";
2
+ import { loadAccounts, accountsFileExists, writeAccountsAtomic, serialize } from "../config/manager.js";
3
3
  import { saveAccounts } from "../proxy/token-refresher.js";
4
4
  import { formatExpiry, redactToken } from "../utils/token-extractor.js";
5
5
  import { PROXY_PORT } from "../config/paths.js";
@@ -107,13 +107,7 @@ export function registerAccounts(program) {
107
107
  console.log(chalk.gray("Cancelled."));
108
108
  return;
109
109
  }
110
- writeAccountsAtomic(filtered.map(a => ({
111
- id: a.id,
112
- accessToken: a.tokens.accessToken,
113
- refreshToken: a.tokens.refreshToken,
114
- expiresAt: a.tokens.expiresAt,
115
- scopes: a.tokens.scopes,
116
- })));
110
+ writeAccountsAtomic(serialize(filtered));
117
111
  console.log(chalk.green(`✓ Removed "${id}". ${filtered.length} account(s) remaining.`));
118
112
  if (filtered.length === 0) {
119
113
  console.log(chalk.yellow(" No accounts left. Run: cc-router setup"));
@@ -7,7 +7,7 @@ import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-co
7
7
  import { saveAccounts } from "../proxy/token-refresher.js";
8
8
  import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
9
9
  import { PROXY_PORT } from "../config/paths.js";
10
- import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
10
+ import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS } from "../proxy/types.js";
11
11
  import { existsSync } from "fs";
12
12
  import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
13
13
  import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
@@ -104,6 +104,7 @@ export async function setupSingleAccount(index) {
104
104
  lastRefresh: 0,
105
105
  consecutiveErrors: 0,
106
106
  rateLimits: { ...DEFAULT_RATE_LIMITS },
107
+ ...ACCOUNT_USER_DEFAULTS,
107
108
  };
108
109
  }
109
110
  // ─── Full wizard ──────────────────────────────────────────────────────────────
@@ -1,22 +1,30 @@
1
1
  import chalk from "chalk";
2
2
  import { PROXY_PORT } from "../config/paths.js";
3
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() {
4
+ export function resolveStatusTarget(port) {
11
5
  const cfg = readConfig();
12
6
  if (cfg.client) {
13
7
  const base = cfg.client.remoteUrl.replace(/\/+$/, "");
14
8
  const headers = {};
15
9
  if (cfg.client.remoteSecret)
16
10
  headers["authorization"] = `Bearer ${cfg.client.remoteSecret}`;
17
- return { healthUrl: `${base}/cc-router/health`, headers, baseUrl: base, authToken: cfg.client.remoteSecret };
11
+ return {
12
+ baseUrl: base,
13
+ healthUrl: `${base}/cc-router/health`,
14
+ headers,
15
+ authToken: cfg.client.remoteSecret,
16
+ };
18
17
  }
19
- return { healthUrl: `http://localhost:${PROXY_PORT}/cc-router/health`, headers: {} };
18
+ const base = `http://localhost:${port}`;
19
+ const headers = {};
20
+ if (cfg.proxySecret)
21
+ headers["authorization"] = `Bearer ${cfg.proxySecret}`;
22
+ return {
23
+ baseUrl: base,
24
+ healthUrl: `${base}/cc-router/health`,
25
+ headers,
26
+ authToken: cfg.proxySecret,
27
+ };
20
28
  }
21
29
  export function registerStatus(program) {
22
30
  program
@@ -30,11 +38,11 @@ export function registerStatus(program) {
30
38
  await jsonOutput(port);
31
39
  return;
32
40
  }
33
- await launchDashboard(port);
41
+ await dashboardLoop(port);
34
42
  });
35
43
  }
36
44
  async function jsonOutput(port) {
37
- const { healthUrl, headers } = resolveTarget();
45
+ const { healthUrl, headers } = resolveStatusTarget(port);
38
46
  try {
39
47
  const res = await fetch(healthUrl, {
40
48
  headers,
@@ -58,16 +66,91 @@ async function jsonOutput(port) {
58
66
  process.exit(1);
59
67
  }
60
68
  }
61
- async function launchDashboard(port) {
62
- const { baseUrl, authToken } = resolveTarget();
69
+ /**
70
+ * Launches the Ink dashboard and handles "re-launch" intents.
71
+ *
72
+ * The dashboard cannot run inquirer prompts while Ink owns stdin, so when
73
+ * the user presses `n` to add an account, Ink unmounts and this loop runs
74
+ * the OAuth flow synchronously. Once tokens are obtained and POSTed to the
75
+ * server, the dashboard is re-rendered and polling resumes.
76
+ */
77
+ async function dashboardLoop(port) {
63
78
  // Dynamic imports keep these heavy deps out of the cold-start path
64
79
  const [{ render }, { createElement }, { Dashboard }] = await Promise.all([
65
80
  import("ink"),
66
81
  import("react"),
67
82
  import("../ui/Dashboard.js"),
68
83
  ]);
69
- render(createElement(Dashboard, { port, baseUrl, authToken }), {
70
- // Let Ink handle Ctrl+C — it calls exit() which cleanly unmounts
71
- exitOnCtrlC: true,
72
- });
84
+ while (true) {
85
+ const target = resolveStatusTarget(port);
86
+ const intent = await new Promise((resolve) => {
87
+ const onIntent = (i) => resolve(i);
88
+ const instance = render(createElement(Dashboard, {
89
+ port,
90
+ baseUrl: target.baseUrl,
91
+ authToken: target.authToken,
92
+ onIntent,
93
+ }),
94
+ // Let Ink handle Ctrl+C — it calls exit() which cleanly unmounts
95
+ { exitOnCtrlC: true });
96
+ // If Ink exits on its own (Ctrl+C or `q`) without firing onIntent, treat as quit.
97
+ instance.waitUntilExit().then(() => resolve("quit"));
98
+ });
99
+ if (intent === "quit")
100
+ return;
101
+ // Intent: addAccount — unmount and run the OAuth flow, then POST the
102
+ // resulting tokens to the server we're connected to (local or remote).
103
+ console.log();
104
+ console.log(chalk.cyan("→ Adding a new account..."));
105
+ console.log();
106
+ const added = await runAddAccountFlow(target);
107
+ if (added) {
108
+ console.log(chalk.green(`\n✓ Account "${added}" added. Returning to dashboard...\n`));
109
+ }
110
+ else {
111
+ console.log(chalk.yellow("\n No account added. Returning to dashboard...\n"));
112
+ }
113
+ // Fall through → loop re-renders the dashboard
114
+ }
115
+ }
116
+ /**
117
+ * Runs the existing setupSingleAccount() OAuth flow, then POSTs the resulting
118
+ * tokens to /cc-router/accounts on the active target. Returns the new id on
119
+ * success, or null if the user aborted / an error occurred.
120
+ */
121
+ async function runAddAccountFlow(target) {
122
+ try {
123
+ const { setupSingleAccount } = await import("./cmd-setup.js");
124
+ // The index shown in the flow is just for display, pick something neutral.
125
+ const account = await setupSingleAccount(1);
126
+ if (!account)
127
+ return null;
128
+ const res = await fetch(`${target.baseUrl}/cc-router/accounts`, {
129
+ method: "POST",
130
+ headers: {
131
+ "content-type": "application/json",
132
+ ...target.headers,
133
+ },
134
+ body: JSON.stringify({
135
+ id: account.id,
136
+ accessToken: account.tokens.accessToken,
137
+ refreshToken: account.tokens.refreshToken,
138
+ expiresAt: account.tokens.expiresAt,
139
+ scopes: account.tokens.scopes,
140
+ }),
141
+ signal: AbortSignal.timeout(5_000),
142
+ });
143
+ if (!res.ok) {
144
+ const text = await res.text().catch(() => "");
145
+ console.error(chalk.red(`\n✗ Server rejected account: HTTP ${res.status}`));
146
+ if (text)
147
+ console.error(chalk.gray(` ${text}`));
148
+ return null;
149
+ }
150
+ return account.id;
151
+ }
152
+ catch (err) {
153
+ console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
154
+ return null;
155
+ }
73
156
  }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync } from "fs";
2
2
  import { randomBytes } from "crypto";
3
3
  import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
4
- import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
4
+ import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "../proxy/types.js";
5
5
  export function ensureConfigDir() {
6
6
  if (!existsSync(CONFIG_DIR)) {
7
7
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -83,5 +83,25 @@ function deserialize(records) {
83
83
  lastRefresh: 0,
84
84
  consecutiveErrors: 0,
85
85
  rateLimits: { ...DEFAULT_RATE_LIMITS },
86
+ enabled: a.enabled !== false, // default true
87
+ sessionLimitPercent: a.sessionLimitPercent !== undefined
88
+ ? clampPercent(a.sessionLimitPercent)
89
+ : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
90
+ weeklyLimitPercent: a.weeklyLimitPercent !== undefined
91
+ ? clampPercent(a.weeklyLimitPercent)
92
+ : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
93
+ }));
94
+ }
95
+ /** Serialize runtime Account[] back to the flat on-disk AccountRecord[] shape. */
96
+ export function serialize(accounts) {
97
+ return accounts.map(a => ({
98
+ id: a.id,
99
+ accessToken: a.tokens.accessToken,
100
+ refreshToken: a.tokens.refreshToken,
101
+ expiresAt: a.tokens.expiresAt,
102
+ scopes: a.tokens.scopes,
103
+ enabled: a.enabled,
104
+ sessionLimitPercent: a.sessionLimitPercent,
105
+ weeklyLimitPercent: a.weeklyLimitPercent,
86
106
  }));
87
107
  }
@@ -205,7 +205,9 @@ export function writeAddonScript(target, secret) {
205
205
  src = readFileSync(bundled, "utf-8");
206
206
  }
207
207
  else {
208
- // Inline fallback — minimal addon (only redirects /v1/messages and /v1/models)
208
+ // Inline fallback — minimal addon (handles /v1/messages and /v1/models
209
+ // for both api.anthropic.com traffic and requests already pointed at the
210
+ // CC-Router target, injecting the secret in both cases).
209
211
  src = `
210
212
  import os
211
213
  from mitmproxy import http
@@ -218,19 +220,27 @@ _target_parsed = urlparse(_target)
218
220
  if not _target_parsed.scheme or not _target_parsed.netloc:
219
221
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
220
222
 
223
+ _target_host = (_target_parsed.hostname or "").lower()
224
+ _target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
225
+
221
226
  _secret = os.environ.get("CC_ROUTER_SECRET", "")
222
227
 
223
228
  _REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
224
229
 
225
230
  def request(flow: http.HTTPFlow) -> None:
226
- if flow.request.pretty_host != "api.anthropic.com":
231
+ host = (flow.request.pretty_host or "").lower()
232
+ port = flow.request.port
233
+ is_anthropic = host == "api.anthropic.com"
234
+ is_target = host == _target_host and port == _target_port
235
+ if not is_anthropic and not is_target:
227
236
  return
228
237
  if not flow.request.path.startswith(_REDIRECT_PREFIXES):
229
238
  return
230
- flow.request.scheme = _target_parsed.scheme
231
- flow.request.host = _target_parsed.hostname or "localhost"
232
- flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
233
- flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
239
+ if is_anthropic:
240
+ flow.request.scheme = _target_parsed.scheme
241
+ flow.request.host = _target_host or "localhost"
242
+ flow.request.port = _target_port
243
+ flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
234
244
  if _secret:
235
245
  flow.request.headers["x-api-key"] = _secret
236
246
  `.trimStart();
@@ -2,9 +2,9 @@ import express from "express";
2
2
  import { createProxyMiddleware } from "http-proxy-middleware";
3
3
  import { ServerResponse } from "http";
4
4
  import { timingSafeEqual } from "crypto";
5
- import { TokenPool } from "./token-pool.js";
5
+ import { TokenPool, EmptyPoolError } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
- import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
7
+ import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig, serialize } from "../config/manager.js";
8
8
  import { checkForUpdate, performUpdate, restartSelf } from "../utils/self-update.js";
9
9
  import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
10
  import { loadTelemetryState } from "../config/telemetry.js";
@@ -76,6 +76,13 @@ export async function startServer(opts = {}) {
76
76
  process.exit(1);
77
77
  }
78
78
  const pool = new TokenPool(accounts);
79
+ // Log when the pool falls back to a capped account — makes the cap bypass
80
+ // visible in the dashboard's "RECENT ACTIVITY" instead of being silent.
81
+ pool.onCapBypass = (a) => {
82
+ const msg = `all accounts capped — routing to ${a.id} (5h: ${Math.round(a.rateLimits.fiveHourUtil * 100)}%, 7d: ${Math.round(a.rateLimits.sevenDayUtil * 100)}%)`;
83
+ logError(a.id, 0, msg);
84
+ stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "error", details: msg });
85
+ };
79
86
  startRefreshLoop(accounts);
80
87
  const app = express();
81
88
  // ─── Proxy auth middleware ─────────────────────────────────────────────────
@@ -124,6 +131,173 @@ export async function startServer(opts = {}) {
124
131
  recentLogs: stats.getRecentLogs(50),
125
132
  });
126
133
  });
134
+ // ─── Account management endpoints (authenticated) ─────────────────────────
135
+ // These are mounted BEFORE the /v1/* proxy middleware so they don't get
136
+ // forwarded to Anthropic. express.json() is scoped to this sub-router so
137
+ // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
138
+ const accountsRouter = express.Router();
139
+ accountsRouter.use(express.json({ limit: "32kb" }));
140
+ // Shape returned to clients — NEVER includes access/refresh tokens.
141
+ const publicAccountView = (a) => ({
142
+ id: a.id,
143
+ enabled: a.enabled,
144
+ sessionLimitPercent: a.sessionLimitPercent,
145
+ weeklyLimitPercent: a.weeklyLimitPercent,
146
+ healthy: a.healthy,
147
+ busy: a.busy,
148
+ requestCount: a.requestCount,
149
+ errorCount: a.errorCount,
150
+ expiresInMs: a.tokens.expiresAt - Date.now(),
151
+ lastUsedMs: a.lastUsed,
152
+ lastRefreshMs: a.lastRefresh,
153
+ rateLimits: a.rateLimits,
154
+ });
155
+ accountsRouter.get("/", (_req, res) => {
156
+ res.json({ accounts: pool.getAll().map(publicAccountView) });
157
+ });
158
+ /**
159
+ * Persist the pool to disk, returning a structured result instead of
160
+ * throwing. Callers hold a rollback closure for in-memory state in case
161
+ * the disk write fails — so a ENOSPC / EACCES doesn't leave the server
162
+ * silently out of sync with accounts.json.
163
+ */
164
+ const tryPersist = (rollback) => {
165
+ try {
166
+ saveAccounts(pool.getAll());
167
+ return { ok: true };
168
+ }
169
+ catch (err) {
170
+ const message = err instanceof Error ? err.message : String(err);
171
+ try {
172
+ rollback();
173
+ }
174
+ catch { /* best effort */ }
175
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
176
+ return { ok: false, message };
177
+ }
178
+ };
179
+ accountsRouter.patch("/:id", (req, res) => {
180
+ const { id } = req.params;
181
+ const body = (req.body ?? {});
182
+ const patch = {};
183
+ if (body.enabled !== undefined) {
184
+ if (typeof body.enabled !== "boolean") {
185
+ res.status(400).json({ error: "enabled must be boolean" });
186
+ return;
187
+ }
188
+ patch.enabled = body.enabled;
189
+ }
190
+ for (const key of ["sessionLimitPercent", "weeklyLimitPercent"]) {
191
+ const v = body[key];
192
+ if (v === undefined)
193
+ continue;
194
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 100) {
195
+ res.status(400).json({ error: `${key} must be a number between 0 and 100` });
196
+ return;
197
+ }
198
+ patch[key] = v;
199
+ }
200
+ // Snapshot the previous values so we can roll back on persistence failure
201
+ const existing = pool.findById(id);
202
+ if (!existing) {
203
+ res.status(404).json({ error: `Account "${id}" not found` });
204
+ return;
205
+ }
206
+ const prev = {
207
+ enabled: existing.enabled,
208
+ sessionLimitPercent: existing.sessionLimitPercent,
209
+ weeklyLimitPercent: existing.weeklyLimitPercent,
210
+ };
211
+ const updated = pool.updateAccount(id, patch);
212
+ if (!updated) {
213
+ res.status(404).json({ error: `Account "${id}" not found` });
214
+ return;
215
+ }
216
+ const result = tryPersist(() => {
217
+ pool.updateAccount(id, prev);
218
+ });
219
+ if (!result.ok) {
220
+ res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
221
+ return;
222
+ }
223
+ res.json({ account: publicAccountView(updated) });
224
+ });
225
+ accountsRouter.post("/", (req, res) => {
226
+ const body = (req.body ?? {});
227
+ const required = ["id", "accessToken", "refreshToken", "expiresAt"];
228
+ for (const k of required) {
229
+ if (body[k] === undefined || body[k] === null || body[k] === "") {
230
+ res.status(400).json({ error: `Missing required field: ${k}` });
231
+ return;
232
+ }
233
+ }
234
+ if (typeof body.id !== "string" || typeof body.accessToken !== "string" ||
235
+ typeof body.refreshToken !== "string" || typeof body.expiresAt !== "number") {
236
+ res.status(400).json({ error: "Invalid field types on account record" });
237
+ return;
238
+ }
239
+ if (pool.findById(body.id)) {
240
+ res.status(409).json({ error: `Account "${body.id}" already exists` });
241
+ return;
242
+ }
243
+ const record = {
244
+ id: body.id,
245
+ accessToken: body.accessToken,
246
+ refreshToken: body.refreshToken,
247
+ expiresAt: body.expiresAt,
248
+ scopes: Array.isArray(body.scopes) ? body.scopes : ["user:inference", "user:profile"],
249
+ enabled: body.enabled,
250
+ sessionLimitPercent: body.sessionLimitPercent,
251
+ weeklyLimitPercent: body.weeklyLimitPercent,
252
+ };
253
+ let added;
254
+ try {
255
+ added = pool.addAccount(record);
256
+ }
257
+ catch (err) {
258
+ res.status(400).json({ error: err.message });
259
+ return;
260
+ }
261
+ const result = tryPersist(() => {
262
+ pool.removeAccount(record.id);
263
+ });
264
+ if (!result.ok) {
265
+ res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
266
+ return;
267
+ }
268
+ res.status(201).json({ account: publicAccountView(added) });
269
+ });
270
+ accountsRouter.delete("/:id", (req, res) => {
271
+ const { id } = req.params;
272
+ // Refuse to remove the last account — downstream /v1/* would have no
273
+ // token to route with and the pool would throw EmptyPoolError on the
274
+ // next request. Users who want an empty pool should `cc-router stop`.
275
+ if (pool.getAll().length <= 1) {
276
+ res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
277
+ return;
278
+ }
279
+ const existing = pool.findById(id);
280
+ if (!existing) {
281
+ res.status(404).json({ error: `Account "${id}" not found` });
282
+ return;
283
+ }
284
+ // Snapshot for rollback. serialize() gives us a persistable AccountRecord.
285
+ const snapshot = serialize([existing])[0];
286
+ const removed = pool.removeAccount(id);
287
+ if (!removed) {
288
+ res.status(404).json({ error: `Account "${id}" not found` });
289
+ return;
290
+ }
291
+ const result = tryPersist(() => {
292
+ pool.addAccount(snapshot);
293
+ });
294
+ if (!result.ok) {
295
+ res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
296
+ return;
297
+ }
298
+ res.json({ ok: true, id });
299
+ });
300
+ app.use("/cc-router/accounts", accountsRouter);
127
301
  // ─── Proxy middleware ──────────────────────────────────────────────────────
128
302
  // IMPORTANT: selfHandleResponse must be false (default) for SSE streaming to
129
303
  // work transparently. Setting it to true breaks streaming.
@@ -310,8 +484,23 @@ export async function startServer(opts = {}) {
310
484
  // ─── /v1/* — select account, refresh if needed, then proxy ───────────────
311
485
  // CRITICAL: Do NOT use express.json() here — it consumes the body stream
312
486
  // and breaks SSE streaming passthrough.
313
- app.use("/v1", async (req, _res, next) => {
314
- const account = pool.getNext();
487
+ app.use("/v1", async (req, res, next) => {
488
+ let account;
489
+ try {
490
+ account = pool.getNext();
491
+ }
492
+ catch (err) {
493
+ if (err instanceof EmptyPoolError) {
494
+ stats.totalErrors++;
495
+ logError("proxy", 503, err.message);
496
+ res.status(503).json({
497
+ type: "error",
498
+ error: { type: "no_accounts", message: err.message },
499
+ });
500
+ return;
501
+ }
502
+ throw err;
503
+ }
315
504
  // Synchronous refresh if token expires within the buffer window
316
505
  if (needsRefresh(account)) {
317
506
  const ok = await refreshAccountToken(account);
@@ -1,3 +1,10 @@
1
+ import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "./types.js";
2
+ export class EmptyPoolError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "EmptyPoolError";
6
+ }
7
+ }
1
8
  /** Returns the earliest non-zero reset timestamp (seconds) for an account. */
2
9
  function earliestReset(a) {
3
10
  const r = a.rateLimits;
@@ -5,6 +12,15 @@ function earliestReset(a) {
5
12
  return Math.min(r.fiveHourReset, r.sevenDayReset);
6
13
  return r.fiveHourReset || r.sevenDayReset || Infinity;
7
14
  }
15
+ /** True when the account's user-defined caps have been reached. */
16
+ function overUserCap(a) {
17
+ return (a.rateLimits.fiveHourUtil * 100 >= a.sessionLimitPercent ||
18
+ a.rateLimits.sevenDayUtil * 100 >= a.weeklyLimitPercent);
19
+ }
20
+ /** Filter out accounts the user has taken out of the rotation. */
21
+ function isUsable(a) {
22
+ return a.enabled && !overUserCap(a);
23
+ }
8
24
  export class TokenPool {
9
25
  accounts;
10
26
  currentIndex = 0;
@@ -12,24 +28,52 @@ export class TokenPool {
12
28
  this.accounts = accounts;
13
29
  }
14
30
  /**
15
- * Round-robin selection among healthy, non-busy, non-rate-limited accounts.
16
- * Falls back to least-loaded if all are busy/limited.
17
- * When all are rate-limited, picks the one with the earliest reset.
18
- * Falls back to accounts[0] if all are unhealthy.
31
+ * Round-robin selection among accounts that are:
32
+ * healthy
33
+ * not busy
34
+ * not rate-limited by Anthropic
35
+ * • enabled (user toggle)
36
+ * • under the user-configured 5h/7d caps
37
+ *
38
+ * Fallback chain when nothing is available:
39
+ * 1. Any healthy+usable (enabled & under caps) account — pick earliest reset.
40
+ * 2. Any healthy account — pick earliest reset. This intentionally ignores
41
+ * user caps when every option is capped; limits are advisory, not a hard
42
+ * ban that would leave Claude Code with no working account. The fallback
43
+ * is logged via the optional onCapBypass callback so the dashboard can
44
+ * surface it instead of silently exceeding the cap.
45
+ * 3. accounts[0] as a last resort (only if every account is unhealthy).
46
+ *
47
+ * Throws `EmptyPoolError` when there are no accounts at all — callers in
48
+ * the request path should map this to a 503. The DELETE endpoint guards
49
+ * against this state by refusing to remove the last account.
19
50
  */
20
51
  getNext() {
21
- const available = this.accounts.filter(a => a.healthy && !a.busy && a.rateLimits.status !== "rate_limited");
52
+ if (this.accounts.length === 0) {
53
+ throw new EmptyPoolError("token pool is empty — add an account first");
54
+ }
55
+ const available = this.accounts.filter(a => a.healthy &&
56
+ !a.busy &&
57
+ a.rateLimits.status !== "rate_limited" &&
58
+ isUsable(a));
22
59
  if (available.length === 0) {
60
+ const healthyUsable = this.accounts.filter(a => a.healthy && isUsable(a));
61
+ if (healthyUsable.length > 0) {
62
+ return healthyUsable.reduce((best, a) => earliestReset(a) < earliestReset(best) ? a : best);
63
+ }
23
64
  const healthy = this.accounts.filter(a => a.healthy);
24
65
  if (healthy.length === 0) {
25
66
  return this.accounts[0];
26
67
  }
27
- // All healthy but busy/limited pick earliest reset time
28
- return healthy.reduce((best, a) => {
29
- const resetA = earliestReset(a);
30
- const resetBest = earliestReset(best);
31
- return resetA < resetBest ? a : best;
32
- });
68
+ // All healthy accounts are either busy, rate-limited, or over user caps.
69
+ // Fall back to the one that'll reset soonest — see docstring. Notify
70
+ // the listener so the bypass becomes visible in the dashboard.
71
+ const fallback = healthy.reduce((best, a) => earliestReset(a) < earliestReset(best) ? a : best);
72
+ const someCapped = this.accounts.some(a => a.healthy && overUserCap(a));
73
+ if (someCapped && this.onCapBypass) {
74
+ this.onCapBypass(fallback);
75
+ }
76
+ return fallback;
33
77
  }
34
78
  const account = available[this.currentIndex % available.length];
35
79
  this.currentIndex = (this.currentIndex + 1) % available.length;
@@ -37,6 +81,9 @@ export class TokenPool {
37
81
  account.lastUsed = Date.now();
38
82
  return account;
39
83
  }
84
+ /** Optional listener fired when a request is routed to a capped account
85
+ * because every account in the pool was over its user-configured cap. */
86
+ onCapBypass;
40
87
  getAll() {
41
88
  return this.accounts;
42
89
  }
@@ -54,6 +101,90 @@ export class TokenPool {
54
101
  lastUsedMs: a.lastUsed,
55
102
  lastRefreshMs: a.lastRefresh,
56
103
  rateLimits: a.rateLimits,
104
+ enabled: a.enabled,
105
+ sessionLimitPercent: a.sessionLimitPercent,
106
+ weeklyLimitPercent: a.weeklyLimitPercent,
57
107
  }));
58
108
  }
109
+ // ─── Mutation API (used by the authenticated HTTP endpoints) ───────────────
110
+ findById(id) {
111
+ return this.accounts.find(a => a.id === id) ?? null;
112
+ }
113
+ /**
114
+ * Apply a partial update to an account's user-controlled fields.
115
+ * Only `enabled`, `sessionLimitPercent`, and `weeklyLimitPercent` are
116
+ * touched — token fields are never accepted via this API.
117
+ * Returns the updated account, or null if the id was not found.
118
+ */
119
+ updateAccount(id, patch) {
120
+ const a = this.findById(id);
121
+ if (!a)
122
+ return null;
123
+ if (patch.enabled !== undefined)
124
+ a.enabled = !!patch.enabled;
125
+ if (patch.sessionLimitPercent !== undefined) {
126
+ a.sessionLimitPercent = clampPercent(patch.sessionLimitPercent);
127
+ }
128
+ if (patch.weeklyLimitPercent !== undefined) {
129
+ a.weeklyLimitPercent = clampPercent(patch.weeklyLimitPercent);
130
+ }
131
+ return a;
132
+ }
133
+ /**
134
+ * Append a new account built from a persisted AccountRecord.
135
+ * Rejects duplicates by id — callers should pre-check with findById().
136
+ */
137
+ addAccount(record) {
138
+ if (this.findById(record.id)) {
139
+ throw new Error(`Account "${record.id}" already exists`);
140
+ }
141
+ const account = {
142
+ id: record.id,
143
+ tokens: {
144
+ accessToken: record.accessToken,
145
+ refreshToken: record.refreshToken,
146
+ expiresAt: record.expiresAt,
147
+ scopes: record.scopes ?? ["user:inference", "user:profile"],
148
+ },
149
+ healthy: true,
150
+ busy: false,
151
+ requestCount: 0,
152
+ errorCount: 0,
153
+ lastUsed: 0,
154
+ lastRefresh: 0,
155
+ consecutiveErrors: 0,
156
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
157
+ enabled: record.enabled !== false,
158
+ sessionLimitPercent: record.sessionLimitPercent !== undefined
159
+ ? clampPercent(record.sessionLimitPercent)
160
+ : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
161
+ weeklyLimitPercent: record.weeklyLimitPercent !== undefined
162
+ ? clampPercent(record.weeklyLimitPercent)
163
+ : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
164
+ };
165
+ this.accounts.push(account);
166
+ return account;
167
+ }
168
+ /**
169
+ * Remove an account by id. Returns true if something was removed.
170
+ *
171
+ * CRITICAL: mutates `this.accounts` IN PLACE via splice() rather than
172
+ * reassigning it. The server passes the same array reference to
173
+ * `startRefreshLoop()` at startup; reassigning would desynchronize the
174
+ * refresh loop from the pool, and the loop's `saveAccounts(accounts)` call
175
+ * would later resurrect the deleted account on disk.
176
+ */
177
+ removeAccount(id) {
178
+ const idx = this.accounts.findIndex(a => a.id === id);
179
+ if (idx === -1)
180
+ return false;
181
+ this.accounts.splice(idx, 1);
182
+ if (this.accounts.length > 0) {
183
+ this.currentIndex = this.currentIndex % this.accounts.length;
184
+ }
185
+ else {
186
+ this.currentIndex = 0;
187
+ }
188
+ return true;
189
+ }
59
190
  }
@@ -1,4 +1,4 @@
1
- import { writeAccountsAtomic } from "../config/manager.js";
1
+ import { writeAccountsAtomic, serialize } from "../config/manager.js";
2
2
  import { logRefresh } from "./logger.js";
3
3
  import { stats } from "./stats.js";
4
4
  /**
@@ -86,14 +86,7 @@ async function _doRefresh(account) {
86
86
  * Must be called after every successful refresh since refresh_token ROTATES.
87
87
  */
88
88
  export function saveAccounts(accounts) {
89
- const records = accounts.map(a => ({
90
- id: a.id,
91
- accessToken: a.tokens.accessToken,
92
- refreshToken: a.tokens.refreshToken,
93
- expiresAt: a.tokens.expiresAt,
94
- scopes: a.tokens.scopes,
95
- }));
96
- writeAccountsAtomic(records);
89
+ writeAccountsAtomic(serialize(accounts));
97
90
  }
98
91
  /**
99
92
  * Background refresh loop: checks every 5 minutes and refreshes any
@@ -9,3 +9,21 @@ export const DEFAULT_RATE_LIMITS = {
9
9
  requestsLimit: 0,
10
10
  lastUpdated: 0,
11
11
  };
12
+ /**
13
+ * Single source of truth for the default values of user-controllable
14
+ * account fields. Used by deserialize(), TokenPool.addAccount(),
15
+ * setupSingleAccount(), and the PATCH validation path.
16
+ */
17
+ export const ACCOUNT_USER_DEFAULTS = {
18
+ enabled: true,
19
+ sessionLimitPercent: 100,
20
+ weeklyLimitPercent: 100,
21
+ };
22
+ /**
23
+ * Coerce any unknown value into a valid percent in [0, 100].
24
+ * Non-numbers, NaN, and out-of-range values collapse to the fallback (100).
25
+ */
26
+ export function clampPercent(n) {
27
+ const v = typeof n === "number" && Number.isFinite(n) ? n : 100;
28
+ return Math.max(0, Math.min(100, Math.round(v)));
29
+ }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useState, useEffect } from "react";
2
+ import React, { useState, useEffect, useCallback, useRef } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
+ import { createAccountsApi } from "./accountsApi.js";
4
5
  const POLL_INTERVAL_MS = 2_000;
5
6
  const LOG_VISIBLE = 20;
6
7
  const EMPTY_RL = {
@@ -8,21 +9,24 @@ const EMPTY_RL = {
8
9
  sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
9
10
  requestsLimit: 0, lastUpdated: 0,
10
11
  };
11
- export function Dashboard({ port, baseUrl, authToken }) {
12
+ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
12
13
  const { exit } = useApp();
13
14
  const [data, setData] = useState(null);
14
15
  const [connectError, setConnectError] = useState(null);
15
16
  const [lastUpdate, setLastUpdate] = useState(0);
16
17
  const [retryCount, setRetryCount] = useState(0);
18
+ const resolvedBase = baseUrl
19
+ ? baseUrl.replace(/\/+$/, "")
20
+ : `http://localhost:${port}`;
21
+ const api = React.useMemo(() => createAccountsApi(resolvedBase, authToken), [resolvedBase, authToken]);
22
+ // Only q to quit when no live data yet (no mode to cancel)
17
23
  useInput((input, key) => {
18
- if (input === "q" || key.escape)
24
+ if (!data && (input === "q" || key.escape))
19
25
  exit();
20
26
  });
21
27
  useEffect(() => {
22
28
  let cancelled = false;
23
- const healthUrl = baseUrl
24
- ? `${baseUrl.replace(/\/+$/, "")}/cc-router/health`
25
- : `http://localhost:${port}/cc-router/health`;
29
+ const healthUrl = `${resolvedBase}/cc-router/health`;
26
30
  const headers = authToken
27
31
  ? { authorization: `Bearer ${authToken}` }
28
32
  : {};
@@ -47,77 +51,264 @@ export function Dashboard({ port, baseUrl, authToken }) {
47
51
  catch {
48
52
  if (cancelled)
49
53
  return;
50
- setConnectError(`Cannot connect to http://localhost:${port}`);
54
+ setConnectError(`Cannot connect to ${resolvedBase}`);
51
55
  setRetryCount(n => n + 1);
52
56
  }
53
57
  };
54
58
  poll();
55
59
  const timer = setInterval(poll, POLL_INTERVAL_MS);
56
60
  return () => { cancelled = true; clearInterval(timer); };
57
- }, [port]);
61
+ }, [resolvedBase, authToken]);
58
62
  if (connectError) {
59
63
  return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
60
64
  }
61
65
  if (!data) {
62
- return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to http://localhost:", port, "..."] }) }));
66
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
63
67
  }
64
- return _jsx(LiveDashboard, { data: data, port: port, lastUpdate: lastUpdate });
68
+ return (_jsx(LiveDashboard, { data: data, port: port, lastUpdate: lastUpdate, api: api, onIntent: onIntent }));
65
69
  }
66
70
  // ─── Error screen ─────────────────────────────────────────────────────────────
67
71
  function ErrorScreen({ error, port, retries }) {
68
72
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, marginX: 2, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Is the proxy running? Start it with:" }), _jsx(Text, { color: "cyan", children: " cc-router start" })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", children: ["Retrying every ", POLL_INTERVAL_MS / 1000, "s"] }), retries > 0 && _jsxs(Text, { color: "gray", children: [" (attempt ", retries, ")"] }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] })] }));
69
73
  }
70
74
  // ─── Live dashboard ───────────────────────────────────────────────────────────
71
- function LiveDashboard({ data, port, lastUpdate }) {
75
+ function LiveDashboard({ data, port, lastUpdate, api, onIntent, }) {
76
+ const { exit } = useApp();
72
77
  const healthyCount = data.accounts.filter(a => a.healthy).length;
73
78
  const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
74
79
  const logs = data.recentLogs;
75
- // Stable selection: track by timestamp so it survives log rotations
80
+ // ── Focus / mode ──────────────────────────────────────────────────────────
81
+ const [focus, setFocus] = useState("logs");
82
+ const [mode, setMode] = useState("view");
83
+ // Selected log by timestamp (existing)
76
84
  const [selectedTs, setSelectedTs] = useState(null);
77
- // Derive index from timestamp; default to 0 (newest)
78
- const selectedIndex = selectedTs !== null
85
+ const selectedLogIndex = selectedTs !== null
79
86
  ? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
80
87
  : 0;
81
- useInput((_input, key) => {
82
- if (key.upArrow) {
83
- const next = Math.max(0, selectedIndex - 1);
84
- setSelectedTs(logs[next]?.ts ?? null);
88
+ // Selected account by id
89
+ const [selectedAccountId, setSelectedAccountId] = useState(null);
90
+ const selectedAccountIndex = selectedAccountId !== null
91
+ ? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
92
+ : 0;
93
+ const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
94
+ // Inline text input state (for w / s keys)
95
+ const [editBuffer, setEditBuffer] = useState("");
96
+ // Transient banner (error or success, cleared after 4s).
97
+ // The timer handle is stored in a ref so new banners cancel the previous
98
+ // timeout and component unmount also clears it — otherwise a deferred
99
+ // setBanner can fire on an unmounted component after `n` exits Ink.
100
+ const [banner, setBanner] = useState(null);
101
+ const bannerTimerRef = useRef(null);
102
+ const showBanner = useCallback((text, color) => {
103
+ if (bannerTimerRef.current)
104
+ clearTimeout(bannerTimerRef.current);
105
+ setBanner({ text, color });
106
+ bannerTimerRef.current = setTimeout(() => {
107
+ setBanner(null);
108
+ bannerTimerRef.current = null;
109
+ }, 4_000);
110
+ }, []);
111
+ useEffect(() => () => {
112
+ if (bannerTimerRef.current)
113
+ clearTimeout(bannerTimerRef.current);
114
+ }, []);
115
+ // Normalize any thrown value to a displayable string — rejections from
116
+ // fetch/AbortSignal can be DOMException without .message, strings, or
117
+ // even undefined.
118
+ const errMsg = (err) => {
119
+ if (err instanceof Error && err.message)
120
+ return err.message;
121
+ const s = String(err ?? "");
122
+ return s || "unknown error";
123
+ };
124
+ // ── Async helpers (fire-and-forget with error → banner) ──────────────────
125
+ const doToggleEnabled = useCallback(async () => {
126
+ if (!selectedAccount)
127
+ return;
128
+ const newValue = !(selectedAccount.enabled !== false);
129
+ try {
130
+ await api.patch(selectedAccount.id, { enabled: newValue });
131
+ showBanner(`${selectedAccount.id} → ${newValue ? "enabled" : "disabled"}`, newValue ? "green" : "yellow");
132
+ }
133
+ catch (err) {
134
+ showBanner(`Error: ${errMsg(err)}`, "red");
135
+ }
136
+ }, [selectedAccount, api, showBanner]);
137
+ const doSetLimit = useCallback(async (field, value) => {
138
+ if (!selectedAccount)
139
+ return;
140
+ try {
141
+ await api.patch(selectedAccount.id, { [field]: value });
142
+ const label = field === "sessionLimitPercent" ? "5h cap" : "7d cap";
143
+ showBanner(`${selectedAccount.id} → ${label} = ${value}%`, "green");
144
+ }
145
+ catch (err) {
146
+ showBanner(`Error: ${errMsg(err)}`, "red");
147
+ }
148
+ }, [selectedAccount, api, showBanner]);
149
+ const doDelete = useCallback(async () => {
150
+ if (!selectedAccount)
151
+ return;
152
+ try {
153
+ await api.remove(selectedAccount.id);
154
+ showBanner(`Removed ${selectedAccount.id}`, "yellow");
155
+ setSelectedAccountId(null);
156
+ }
157
+ catch (err) {
158
+ showBanner(`Error: ${errMsg(err)}`, "red");
159
+ }
160
+ }, [selectedAccount, api, showBanner]);
161
+ // ── Keyboard handler ──────────────────────────────────────────────────────
162
+ useInput((input, key) => {
163
+ // ── Text editing mode (w / s) ───────────────────────────────────────
164
+ if (mode === "editSession" || mode === "editWeekly") {
165
+ if (key.escape) {
166
+ setMode("view");
167
+ setEditBuffer("");
168
+ return;
169
+ }
170
+ if (key.return) {
171
+ const parsed = parseInt(editBuffer, 10);
172
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 100) {
173
+ const field = mode === "editSession" ? "sessionLimitPercent" : "weeklyLimitPercent";
174
+ void doSetLimit(field, parsed);
175
+ }
176
+ else {
177
+ showBanner("Invalid: enter a number 0–100", "red");
178
+ }
179
+ setMode("view");
180
+ setEditBuffer("");
181
+ return;
182
+ }
183
+ if (key.backspace || key.delete) {
184
+ setEditBuffer(b => b.slice(0, -1));
185
+ return;
186
+ }
187
+ if (/^[0-9]$/.test(input) && editBuffer.length < 3) {
188
+ setEditBuffer(b => b + input);
189
+ }
190
+ return;
85
191
  }
86
- if (key.downArrow) {
87
- const next = Math.min(logs.length - 1, selectedIndex + 1);
88
- setSelectedTs(logs[next]?.ts ?? null);
192
+ // ── Confirm delete (y/n) ────────────────────────────────────────────
193
+ if (mode === "confirmDelete") {
194
+ if (input === "y" || input === "Y") {
195
+ void doDelete();
196
+ setMode("view");
197
+ }
198
+ else {
199
+ setMode("view");
200
+ showBanner("Delete cancelled", "gray");
201
+ }
202
+ return;
203
+ }
204
+ // ── Normal view mode ────────────────────────────────────────────────
205
+ if (input === "q") {
206
+ onIntent ? onIntent("quit") : exit();
207
+ return;
208
+ }
209
+ if (key.escape) {
210
+ if (focus === "accounts") {
211
+ setFocus("logs");
212
+ return;
213
+ }
214
+ onIntent ? onIntent("quit") : exit();
215
+ return;
216
+ }
217
+ if (key.tab) {
218
+ setFocus(f => f === "logs" ? "accounts" : "logs");
219
+ return;
220
+ }
221
+ // Navigation: ↑↓ move within the focused panel
222
+ if (focus === "logs") {
223
+ if (key.upArrow) {
224
+ const next = Math.max(0, selectedLogIndex - 1);
225
+ setSelectedTs(logs[next]?.ts ?? null);
226
+ }
227
+ if (key.downArrow) {
228
+ const next = Math.min(logs.length - 1, selectedLogIndex + 1);
229
+ setSelectedTs(logs[next]?.ts ?? null);
230
+ }
231
+ }
232
+ if (focus === "accounts") {
233
+ if (key.upArrow) {
234
+ const next = Math.max(0, selectedAccountIndex - 1);
235
+ setSelectedAccountId(data.accounts[next]?.id ?? null);
236
+ }
237
+ if (key.downArrow) {
238
+ const next = Math.min(data.accounts.length - 1, selectedAccountIndex + 1);
239
+ setSelectedAccountId(data.accounts[next]?.id ?? null);
240
+ }
241
+ // Account actions (only when focus = accounts)
242
+ if (input === "e") {
243
+ void doToggleEnabled();
244
+ return;
245
+ }
246
+ if (input === "w") {
247
+ setMode("editWeekly");
248
+ setEditBuffer("");
249
+ return;
250
+ }
251
+ if (input === "s") {
252
+ setMode("editSession");
253
+ setEditBuffer("");
254
+ return;
255
+ }
256
+ if (input === "d") {
257
+ setMode("confirmDelete");
258
+ return;
259
+ }
260
+ }
261
+ // n = add account — works regardless of focus
262
+ if (input === "n") {
263
+ if (onIntent) {
264
+ onIntent("addAccount");
265
+ exit();
266
+ }
267
+ return;
89
268
  }
90
269
  });
91
- const selectedLog = logs[selectedIndex] ?? null;
270
+ const selectedLog = logs[selectedLogIndex] ?? null;
92
271
  const visibleLogs = logs.slice(0, LOG_VISIBLE);
93
- 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, { flexDirection: "column", children: [_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(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _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
272
+ 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(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { marginTop: 1, paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_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(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _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
94
273
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
95
- : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, `${log.ts}-${i}`))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
274
+ : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
96
275
  }
97
276
  // ─── Account row (two-line: status + utilization bars) ───────────────────────
98
- function AccountRow({ account: a }) {
277
+ function AccountRow({ account: a, selected }) {
99
278
  const rl = a.rateLimits ?? EMPTY_RL;
100
279
  const isLimited = rl.status === "rate_limited";
101
- const dot = isLimited ? "⊘" : a.busy ? "◌" : a.healthy ? "●" : "●";
102
- const dotColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
103
- const statusLabel = isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
104
- const statusColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
280
+ const isDisabled = a.enabled === false;
281
+ const dot = isDisabled ? "⊘" : isLimited ? "" : a.busy ? "" : a.healthy ? "" : "";
282
+ const dotColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
283
+ const statusLabel = isDisabled ? "OFF " : isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
284
+ const statusColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
105
285
  const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
106
286
  const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
107
287
  : a.expiresInMs < 30 * 60 * 1000 ? "yellow"
108
288
  : "white";
109
289
  const planTag = rl.plan ? ` [${rl.plan}]` : "";
110
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 20).padEnd(20) }), _jsx(Text, { color: statusColor, children: statusLabel }), planTag && _jsx(Text, { color: "magenta", children: planTag.padEnd(10) }), !planTag && _jsx(Text, { children: "".padEnd(10) }), _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: " tok " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(8) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }), rl.lastUpdated > 0 && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: rl.fiveHourUtil, resetTs: rl.fiveHourReset, isActive: rl.claim === "five_hour" }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d", util: rl.sevenDayUtil, resetTs: rl.sevenDayReset, isActive: rl.claim === "seven_day" })] }))] }));
290
+ // User-defined caps hint
291
+ const s5 = a.sessionLimitPercent ?? 100;
292
+ const w7 = a.weeklyLimitPercent ?? 100;
293
+ const hasCaps = s5 < 100 || w7 < 100;
294
+ const capsHint = hasCaps
295
+ ? ` cap${s5 < 100 ? ` 5h≤${s5}%` : ""}${w7 < 100 ? ` 7d≤${w7}%` : ""}`
296
+ : "";
297
+ const pointer = selected ? "▶" : " ";
298
+ const nameColor = isDisabled ? "gray" : undefined;
299
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: pointer }), _jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { color: nameColor, dimColor: isDisabled, children: a.id.slice(0, 20).padEnd(20) }), _jsx(Text, { color: statusColor, children: statusLabel }), planTag && _jsx(Text, { color: "magenta", children: planTag.padEnd(10) }), !planTag && _jsx(Text, { children: "".padEnd(10) }), _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: " tok " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(8) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) }), capsHint && _jsx(Text, { color: "yellow", children: capsHint })] }), rl.lastUpdated > 0 && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: rl.fiveHourUtil, resetTs: rl.fiveHourReset, isActive: rl.claim === "five_hour", cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d", util: rl.sevenDayUtil, resetTs: rl.sevenDayReset, isActive: rl.claim === "seven_day", cap: w7 })] }))] }));
111
300
  }
112
301
  // ─── Utilization bar ─────────────────────────────────────────────────────────
113
- function UtilBar({ label, util, resetTs, isActive }) {
302
+ function UtilBar({ label, util, resetTs, isActive, cap }) {
114
303
  const pct = Math.round(util * 100);
115
304
  const BAR_W = 12;
116
305
  const filled = Math.round(util * BAR_W);
306
+ const capPos = Math.round((cap / 100) * BAR_W);
117
307
  const bar = "█".repeat(Math.min(filled, BAR_W)) + "░".repeat(Math.max(BAR_W - filled, 0));
118
- const color = pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
308
+ const color = pct >= cap ? "red" : pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
119
309
  const resetLabel = resetTs > 0 ? formatResetIn(resetTs) : "";
120
- return (_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? "white" : "gray", bold: isActive, children: [label, " "] }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { color: color, children: [String(pct).padStart(4), "%"] }), resetLabel && _jsxs(Text, { color: "gray", children: [" \u21BB", resetLabel] })] }));
310
+ const capLabel = cap < 100 ? ` cap ${cap}%` : "";
311
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? "white" : "gray", bold: isActive, children: [label, " "] }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { color: color, children: [String(pct).padStart(4), "%"] }), capLabel && _jsx(Text, { color: "yellow", children: capLabel }), resetLabel && _jsxs(Text, { color: "gray", children: [" \u21BB", resetLabel] })] }));
121
312
  }
122
313
  function formatResetIn(unixSeconds) {
123
314
  const diff = unixSeconds - Date.now() / 1000;
@@ -212,7 +403,6 @@ function TokenSummary({ cacheRead, cacheCreated, uncached, output }) {
212
403
  const totalAll = totalInput + output;
213
404
  if (totalAll === 0)
214
405
  return null;
215
- const hitPct = totalInput > 0 ? (cacheRead / totalInput) * 100 : 0;
216
406
  return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "input " }), _jsx(Text, { color: "white", children: fmtTok(totalInput) }), _jsx(Text, { color: "gray", children: " (cached " }), _jsx(Text, { color: "green", children: fmtTok(cacheRead) }), _jsx(Text, { color: "gray", children: " + new " }), _jsx(Text, { color: "yellow", children: fmtTok(cacheCreated) }), _jsx(Text, { color: "gray", children: " + uncached " }), _jsx(Text, { color: "white", children: fmtTok(uncached) }), _jsx(Text, { color: "gray", children: ") \u00B7 output " }), _jsx(Text, { color: "white", children: fmtTok(output) }), _jsx(Text, { color: "gray", children: " \u00B7 total " }), _jsx(Text, { color: "cyan", bold: true, children: fmtTok(totalAll) })] }));
217
407
  }
218
408
  function fmtTok(n) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tiny authenticated HTTP client for /cc-router/accounts.
3
+ *
4
+ * Used by the Ink dashboard to mutate account settings (enable/disable,
5
+ * set per-account caps, delete) without exiting the TUI. The `addAccount`
6
+ * flow is NOT in here — that runs inquirer and must exit Ink first; see
7
+ * src/cli/cmd-status.ts `runAddAccountFlow`.
8
+ */
9
+ const REQUEST_TIMEOUT_MS = 3_000;
10
+ export function createAccountsApi(baseUrl, authToken) {
11
+ const base = baseUrl.replace(/\/+$/, "") + "/cc-router/accounts";
12
+ const authHeaders = authToken
13
+ ? { authorization: `Bearer ${authToken}` }
14
+ : {};
15
+ async function send(method, path, body) {
16
+ const res = await fetch(base + path, {
17
+ method,
18
+ headers: {
19
+ ...authHeaders,
20
+ ...(body !== undefined ? { "content-type": "application/json" } : {}),
21
+ },
22
+ body: body !== undefined ? JSON.stringify(body) : undefined,
23
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
24
+ });
25
+ if (!res.ok) {
26
+ // Try to surface the server's error message if we can read one
27
+ let detail = "";
28
+ try {
29
+ const data = await res.json();
30
+ if (data?.error)
31
+ detail = `: ${data.error}`;
32
+ }
33
+ catch { /* best effort */ }
34
+ throw new Error(`HTTP ${res.status}${detail}`);
35
+ }
36
+ }
37
+ return {
38
+ patch(id, patch) {
39
+ return send("PATCH", `/${encodeURIComponent(id)}`, patch);
40
+ },
41
+ remove(id) {
42
+ return send("DELETE", `/${encodeURIComponent(id)}`);
43
+ },
44
+ };
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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,14 +1,25 @@
1
- # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router.
1
+ # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router and
2
+ # injects the proxy secret as an auth header.
2
3
  #
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)
4
+ # There are TWO cases to handle:
10
5
  #
11
- # Only /v1/messages* and /v1/models are safe to redirect because CC-Router
6
+ # 1. Requests to api.anthropic.com (Claude Desktop native features)
7
+ # → rewrite host/port to CC-Router target + inject x-api-key
8
+ #
9
+ # 2. Requests already pointed at the CC-Router target host (Claude Code
10
+ # inside Desktop Cowork/Agent mode, which reads ~/.claude/settings.json
11
+ # and goes direct to ANTHROPIC_BASE_URL)
12
+ # → inject x-api-key (no rewrite needed)
13
+ #
14
+ # Claude Desktop sends many types of requests:
15
+ # /v1/messages → LLM inference (redirect + auth)
16
+ # /v1/messages/count_tokens → token counting (redirect + auth)
17
+ # /v1/oauth/* → session auth (must NOT touch)
18
+ # /v1/environments/* → bridge/cowork (must NOT touch)
19
+ # /v1/models → model listing (redirect + auth)
20
+ # /api/* → desktop features (must NOT touch)
21
+ #
22
+ # Only /v1/messages* and /v1/models are safe to touch because CC-Router
12
23
  # injects its own OAuth token. Everything else carries the user's own
13
24
  # session token for features CC-Router doesn't handle.
14
25
 
@@ -24,7 +35,10 @@ _target_parsed = urlparse(_target)
24
35
  if not _target_parsed.scheme or not _target_parsed.netloc:
25
36
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
26
37
 
27
- # Optional proxy secret — when set, injected as x-api-key on redirected requests
38
+ _target_host = (_target_parsed.hostname or "").lower()
39
+ _target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
40
+
41
+ # Optional proxy secret — when set, injected as x-api-key on routed requests
28
42
  _secret = os.environ.get("CC_ROUTER_SECRET", "")
29
43
 
30
44
  # Paths that CC-Router can handle (it injects its own OAuth token)
@@ -35,22 +49,30 @@ _REDIRECT_PREFIXES = (
35
49
 
36
50
 
37
51
  def request(flow: http.HTTPFlow) -> None:
38
- if flow.request.pretty_host != "api.anthropic.com":
52
+ host = (flow.request.pretty_host or "").lower()
53
+ port = flow.request.port
54
+ is_anthropic = host == "api.anthropic.com"
55
+ is_target = host == _target_host and port == _target_port
56
+
57
+ # Not a host we care about — pass through untouched
58
+ if not is_anthropic and not is_target:
39
59
  return
40
60
 
41
- # Only redirect inference and model-listing paths
61
+ # Only touch inference and model-listing paths
42
62
  if not flow.request.path.startswith(_REDIRECT_PREFIXES):
43
63
  return
44
64
 
45
- flow.request.scheme = _target_parsed.scheme
46
- flow.request.host = _target_parsed.hostname or "localhost"
47
- flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
48
- flow.request.headers["host"] = flow.request.host + (
49
- f":{flow.request.port}"
50
- if flow.request.port not in (80, 443)
51
- else ""
52
- )
65
+ # Case 1: rewrite api.anthropic.com CC-Router target
66
+ if is_anthropic:
67
+ flow.request.scheme = _target_parsed.scheme
68
+ flow.request.host = _target_host or "localhost"
69
+ flow.request.port = _target_port
70
+ flow.request.headers["host"] = flow.request.host + (
71
+ f":{flow.request.port}"
72
+ if flow.request.port not in (80, 443)
73
+ else ""
74
+ )
53
75
 
54
- # Authenticate against the proxy if a secret is configured
76
+ # Case 1 and 2: authenticate against the proxy if a secret is configured
55
77
  if _secret:
56
78
  flow.request.headers["x-api-key"] = _secret