ai-cc-router 0.4.3 → 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
  }
@@ -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.3",
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": {