@timo972/cc-router 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,41 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.12.3] — 2026-09-17
14
+
15
+ ### Added
16
+
17
+ - Account identity and plan details in the dashboard's selected-account line and
18
+ in `cc-router accounts list` while the proxy is running: email, personal or
19
+ workspace type, workspace name, and plan for Claude and ChatGPT accounts,
20
+ Claude subscription status and start date, and plans for stored Grok accounts.
21
+ Claude's start date is when the subscription was created, not the start of the
22
+ current billing period; billing interval is not supported, and renewal dates
23
+ are left unknown rather than guessed from token expiry or usage resets.
24
+ - `GET /cc-router/accounts` returns an optional `accountInfo` object per account,
25
+ with a `fetchStatus` of `fresh`, `stale`, or `unavailable`. Metadata is cached
26
+ in memory for five minutes, refreshed in the background with one-minute retries
27
+ after a failure, and refreshed on demand by `POST /cc-router/refresh` or the
28
+ dashboard's **R**. Listings never block on a provider request; a failed lookup
29
+ keeps the last successful data and marks it stale.
30
+
31
+ ### Changed
32
+
33
+ - `cc-router accounts list` reads live stats from the authenticated
34
+ `/cc-router/accounts` endpoint instead of `/cc-router/health`, sending the
35
+ configured proxy secret.
36
+
37
+ ### Security
38
+
39
+ - Identity metadata is memory-only: it is never written to `accounts.json`,
40
+ never included in health responses — including authenticated health — and
41
+ never sent to telemetry. Provider responses are rebuilt field by field from an
42
+ allowlist with bounded, control-character-free strings and strict timestamps,
43
+ metadata requests refuse redirects so credentials cannot follow one, and
44
+ decoded token claims are used for display only, never to authorize a request.
45
+
46
+ ---
47
+
13
48
  ## [0.12.2] — 2026-09-16
14
49
 
15
50
  ### Added
@@ -757,6 +792,7 @@ cache-aware session routing and a round of security hardening.
757
792
  - `http-proxy-middleware` 3.0.5 → 3.0.7 for GHSA-gcq2-9pq2-cxqm (high). The
758
793
  affected APIs are not used here.
759
794
 
795
+ [0.12.3]: https://github.com/Timo972/cc-router/releases/tag/v0.12.3
760
796
  [0.12.2]: https://github.com/Timo972/cc-router/releases/tag/v0.12.2
761
797
  [0.12.1]: https://github.com/Timo972/cc-router/releases/tag/v0.12.1
762
798
  [0.12.0]: https://github.com/Timo972/cc-router/releases/tag/v0.12.0
@@ -9,6 +9,7 @@ import { importGrokCliAuth } from "../providers/xai/import-auth.js";
9
9
  import { loginXaiWithDeviceCode } from "../providers/xai/device-oauth.js";
10
10
  import { isValidAccountId } from "../proxy/account-rename.js";
11
11
  import { createSetupAttempt, failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
12
+ import { sanitizeAccountInfo, formatAccountInfo } from "../providers/account-info.js";
12
13
  export function registerAccounts(program) {
13
14
  const accounts = program
14
15
  .command("accounts")
@@ -63,6 +64,9 @@ export function registerAccounts(program) {
63
64
  ` requests: ${chalk.cyan(String(s.requestCount).padStart(5))}` +
64
65
  ` errors: ${chalk.red(String(s.errorCount).padStart(3))}` +
65
66
  ` expires: ${exp}`);
67
+ const info = formatAccountInfo(s.accountInfo);
68
+ if (info)
69
+ console.log(chalk.gray(` ${info}`));
66
70
  }
67
71
  // The proxy reads accounts.json once at startup, so anything that
68
72
  // rewrites the file afterwards leaves the two out of step. Silence
@@ -618,7 +622,9 @@ export async function addAccountRuntimeAware(record, dependencies = {
618
622
  }
619
623
  async function fetchLiveStats() {
620
624
  try {
621
- const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
625
+ const { proxySecret } = readConfig();
626
+ const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/accounts`, {
627
+ headers: proxySecret ? { authorization: `Bearer ${proxySecret}` } : {},
622
628
  signal: AbortSignal.timeout(1_000),
623
629
  });
624
630
  if (!res.ok)
@@ -627,7 +633,9 @@ async function fetchLiveStats() {
627
633
  if (!Array.isArray(data.accounts))
628
634
  return null;
629
635
  const { mergeGrokIntoHealth } = await import("../providers/xai/overview.js");
630
- return mergeGrokIntoHealth(data).accounts;
636
+ return mergeGrokIntoHealth(data).accounts.map(account => ({
637
+ ...account, accountInfo: sanitizeAccountInfo(account.accountInfo),
638
+ }));
631
639
  }
632
640
  catch {
633
641
  return null;
@@ -0,0 +1,90 @@
1
+ import { infoRecord, infoText, sanitizeAccountInfo } from "./account-info.js";
2
+ import { GROK_USER_ENDPOINT } from "./xai/subscription-fetch.js";
3
+ /** Display hints only: decoded claims never authorize a request or select a token. */
4
+ function tokenClaims(token) {
5
+ try {
6
+ if (token.length > 64_000)
7
+ return {};
8
+ return infoRecord(JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")));
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ }
14
+ export async function fetchAccountInfo(account, options = {}) {
15
+ const fetchImpl = options.fetch ?? globalThis.fetch;
16
+ const now = options.now ?? Date.now;
17
+ const claims = account.provider === "openai_subscription" ? tokenClaims(account.accessToken) : {};
18
+ const auth = infoRecord(claims["https://api.openai.com/auth"]);
19
+ const workspaceId = infoText(auth.chatgpt_account_id);
20
+ const headers = { authorization: `Bearer ${account.accessToken}`, accept: "application/json" };
21
+ if (workspaceId)
22
+ headers["chatgpt-account-id"] = workspaceId;
23
+ const get = async (url) => {
24
+ try {
25
+ const timeout = AbortSignal.timeout(10_000);
26
+ const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
27
+ // Never forward a credential to a redirect destination.
28
+ const response = await fetchImpl(url, { headers, signal, redirect: "error" });
29
+ if (!response.ok)
30
+ return undefined;
31
+ const body = await response.json();
32
+ return Object.keys(infoRecord(body)).length ? infoRecord(body) : undefined;
33
+ }
34
+ catch {
35
+ // Metadata is best effort. Neither raw responses nor errors reach logs/telemetry.
36
+ return undefined;
37
+ }
38
+ };
39
+ if (account.provider === "anthropic_subscription") {
40
+ const body = await get("https://api.anthropic.com/api/oauth/profile");
41
+ const user = infoRecord(body?.account);
42
+ const org = infoRecord(body?.organization);
43
+ if (!infoText(user.uuid) || !infoText(user.email, 254) || !infoText(org.uuid))
44
+ return undefined;
45
+ const plans = { claude_pro: "Pro", claude_max: "Max", claude_team: "Team", claude_enterprise: "Enterprise" };
46
+ const type = infoText(org.organization_type) ?? "";
47
+ let plan = plans[type];
48
+ if (type === "claude_max" && org.rate_limit_tier === "default_claude_max_5x")
49
+ plan = "Max 5x";
50
+ if (type === "claude_max" && org.rate_limit_tier === "default_claude_max_20x")
51
+ plan = "Max 20x";
52
+ return sanitizeAccountInfo({
53
+ email: user.email, accountId: user.uuid, workspaceId: org.uuid, workspaceName: org.name,
54
+ accountType: type === "claude_team" || type === "claude_enterprise" ? "workspace"
55
+ : type === "claude_pro" || type === "claude_max" ? "personal" : "unknown",
56
+ plan,
57
+ subscription: {
58
+ status: org.subscription_status, startedAt: org.subscription_created_at,
59
+ trialEndsAt: org.claude_code_trial_ends_at,
60
+ },
61
+ fetchedAt: now(), fetchStatus: "fresh",
62
+ });
63
+ }
64
+ if (account.provider === "xai_subscription") {
65
+ const body = await get(GROK_USER_ENDPOINT);
66
+ const plan = infoText(body?.subscriptionTier);
67
+ // Identity/team and billing fields have not been verified for this endpoint.
68
+ return plan ? sanitizeAccountInfo({ accountType: "unknown", plan, fetchedAt: now(), fetchStatus: "fresh" }) : undefined;
69
+ }
70
+ const [usage, listing] = await Promise.all([
71
+ get("https://chatgpt.com/backend-api/wham/usage"),
72
+ get("https://chatgpt.com/backend-api/wham/accounts/check"),
73
+ ]);
74
+ const matchingUsage = workspaceId && usage?.account_id === workspaceId ? usage : undefined;
75
+ const entries = Array.isArray(listing?.accounts) ? listing.accounts : [];
76
+ const selected = workspaceId ? entries.map(infoRecord).find(row => row.id === workspaceId) : undefined;
77
+ const profile = infoRecord(claims["https://api.openai.com/profile"]);
78
+ const email = infoText(matchingUsage?.email, 254) ?? infoText(profile.email, 254);
79
+ const plan = infoText(selected?.plan_type) ?? infoText(matchingUsage?.plan_type) ?? infoText(auth.chatgpt_plan_type);
80
+ if (!email && !plan && !selected)
81
+ return undefined;
82
+ const fresh = Boolean(matchingUsage && selected);
83
+ return sanitizeAccountInfo({
84
+ email, plan, workspaceId, workspaceName: selected?.name,
85
+ accountId: matchingUsage?.user_id ?? auth.chatgpt_user_id,
86
+ accountType: selected?.structure,
87
+ ...(fresh ? { fetchedAt: now() } : {}),
88
+ fetchStatus: fresh ? "fresh" : "stale",
89
+ });
90
+ }
@@ -0,0 +1,72 @@
1
+ export function infoRecord(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value)
3
+ ? value : {};
4
+ }
5
+ /** Reject, rather than truncate, control-bearing/unbounded provider strings. */
6
+ export function infoText(value, max = 160) {
7
+ if (typeof value !== "string")
8
+ return undefined;
9
+ const text = value.trim();
10
+ if (!text || text.length > max || /[\p{Cc}\p{Cf}]/u.test(value))
11
+ return undefined;
12
+ return text;
13
+ }
14
+ function timestamp(value) {
15
+ // Only explicit timestamps, not ambiguous local dates, seconds, or durations.
16
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value))
17
+ return undefined;
18
+ const ms = Date.parse(value);
19
+ return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : undefined;
20
+ }
21
+ /** Used at both provider and client boundaries; no arbitrary properties survive. */
22
+ export function sanitizeAccountInfo(value) {
23
+ if (value === null || typeof value !== "object" || Array.isArray(value))
24
+ return undefined;
25
+ const raw = infoRecord(value);
26
+ const info = {
27
+ accountType: raw.accountType === "personal" || raw.accountType === "workspace" ? raw.accountType : "unknown",
28
+ fetchStatus: raw.fetchStatus === "fresh" || raw.fetchStatus === "stale" ? raw.fetchStatus : "unavailable",
29
+ };
30
+ for (const key of ["email", "accountId", "workspaceId", "workspaceName", "plan"]) {
31
+ const text = infoText(raw[key], key === "email" ? 254 : 160);
32
+ if (text)
33
+ info[key] = text;
34
+ }
35
+ if (typeof raw.fetchedAt === "number" && Number.isFinite(raw.fetchedAt) && raw.fetchedAt > 0)
36
+ info.fetchedAt = raw.fetchedAt;
37
+ const sub = infoRecord(raw.subscription);
38
+ const subscription = {};
39
+ const status = infoText(sub.status, 64);
40
+ if (status)
41
+ subscription.status = status;
42
+ for (const key of ["startedAt", "currentPeriodStart", "currentPeriodEnd", "renewsAt", "trialEndsAt"]) {
43
+ const date = timestamp(sub[key]);
44
+ if (date)
45
+ subscription[key] = date;
46
+ }
47
+ if (typeof sub.cancelAtPeriodEnd === "boolean")
48
+ subscription.cancelAtPeriodEnd = sub.cancelAtPeriodEnd;
49
+ if (Object.keys(subscription).length)
50
+ info.subscription = subscription;
51
+ return info;
52
+ }
53
+ export function formatAccountInfo(value) {
54
+ const info = sanitizeAccountInfo(value);
55
+ if (!info)
56
+ return "";
57
+ const parts = [info.email, info.accountType === "unknown" ? undefined : info.accountType, info.workspaceName, info.plan];
58
+ const sub = info.subscription;
59
+ if (sub?.status)
60
+ parts.push(sub.status);
61
+ if (sub?.cancelAtPeriodEnd && sub.currentPeriodEnd)
62
+ parts.push(`Ends ${sub.currentPeriodEnd.slice(0, 10)}`);
63
+ else if (sub?.renewsAt)
64
+ parts.push(`Renews ${sub.renewsAt.slice(0, 10)}`);
65
+ else if (sub?.startedAt)
66
+ parts.push(`Since ${sub.startedAt.slice(0, 10)}`);
67
+ if (sub?.trialEndsAt)
68
+ parts.push(`Trial ends ${sub.trialEndsAt.slice(0, 10)}`);
69
+ if (info.fetchStatus !== "fresh")
70
+ parts.push(`metadata ${info.fetchStatus}`);
71
+ return parts.filter(Boolean).join(" · ");
72
+ }
@@ -0,0 +1,113 @@
1
+ import { createHash } from "node:crypto";
2
+ import { fetchAccountInfo } from "../providers/account-info-fetch.js";
3
+ import { sanitizeAccountInfo } from "../providers/account-info.js";
4
+ const TTL_MS = 5 * 60_000;
5
+ const RETRY_MS = 60_000;
6
+ const key = (account) => `${account.provider}:${account.id}`;
7
+ const fingerprint = (account) => createHash("sha256").update(account.accessToken).digest("hex");
8
+ const unavailable = () => ({ accountType: "unknown", fetchStatus: "unavailable" });
9
+ /** Ephemeral private metadata, isolated from persisted credentials and routing state. */
10
+ export class AccountInfoCache {
11
+ accounts;
12
+ entries = new Map();
13
+ inFlight;
14
+ pendingForced;
15
+ controller = new AbortController();
16
+ timer;
17
+ now;
18
+ fetchInfo;
19
+ constructor(accounts, options = {}) {
20
+ this.accounts = accounts;
21
+ this.now = options.now ?? Date.now;
22
+ this.fetchInfo = options.fetchInfo ?? fetchAccountInfo;
23
+ }
24
+ start() {
25
+ if (this.timer || this.controller.signal.aborted)
26
+ return;
27
+ void this.refresh();
28
+ this.timer = setInterval(() => { void this.refresh(); }, RETRY_MS);
29
+ this.timer.unref();
30
+ }
31
+ get(account) {
32
+ const entry = this.entries.get(key(account));
33
+ if (!entry || entry.fingerprint !== fingerprint(account) || !entry.info)
34
+ return unavailable();
35
+ const info = sanitizeAccountInfo(entry.info);
36
+ if (!info.fetchedAt || this.now() - info.fetchedAt >= TTL_MS || account.expiresAt <= this.now())
37
+ info.fetchStatus = "stale";
38
+ return info;
39
+ }
40
+ refresh(force = false) {
41
+ if (this.controller.signal.aborted)
42
+ return Promise.resolve();
43
+ if (this.inFlight) {
44
+ if (!force)
45
+ return this.inFlight;
46
+ // A token refresh or account addition may have happened after the active
47
+ // pass took its snapshot. One queued pass observes the current sources.
48
+ this.pendingForced ??= this.inFlight.then(() => {
49
+ this.pendingForced = undefined;
50
+ return this.refresh(true);
51
+ });
52
+ return this.pendingForced;
53
+ }
54
+ const operation = this.run(force).catch(() => {
55
+ // Storage may disappear during a refresh. Do not report credential errors.
56
+ }).finally(() => { if (this.inFlight === operation)
57
+ this.inFlight = undefined; });
58
+ this.inFlight = operation;
59
+ return operation;
60
+ }
61
+ async run(force) {
62
+ const accounts = this.accounts();
63
+ const liveKeys = new Set(accounts.map(key));
64
+ for (const id of this.entries.keys())
65
+ if (!liveKeys.has(id))
66
+ this.entries.delete(id);
67
+ const queue = accounts.filter(account => {
68
+ const id = key(account);
69
+ const digest = fingerprint(account);
70
+ let entry = this.entries.get(id);
71
+ if (entry?.fingerprint !== digest) {
72
+ entry = { fingerprint: digest };
73
+ this.entries.set(id, entry);
74
+ }
75
+ const ttl = entry.info?.fetchStatus === "fresh" ? TTL_MS : RETRY_MS;
76
+ return account.enabled !== false && account.expiresAt > this.now()
77
+ && (force || entry.attemptedAt === undefined || this.now() - entry.attemptedAt >= ttl);
78
+ });
79
+ const worker = async () => {
80
+ while (!this.controller.signal.aborted) {
81
+ const account = queue.shift();
82
+ if (!account)
83
+ break;
84
+ // Re-check presence/credentials before network I/O after time spent queued.
85
+ const current = this.accounts().find(row => key(row) === key(account));
86
+ if (!current || current.enabled === false || current.expiresAt <= this.now() || fingerprint(current) !== fingerprint(account))
87
+ continue;
88
+ const entry = this.entries.get(key(account));
89
+ entry.attemptedAt = this.now();
90
+ let info;
91
+ try {
92
+ info = await this.fetchInfo(account, { signal: this.controller.signal, now: this.now });
93
+ }
94
+ catch { /* best effort */ }
95
+ const latest = this.accounts().find(row => key(row) === key(account));
96
+ if (this.controller.signal.aborted || !latest || fingerprint(latest) !== entry.fingerprint)
97
+ continue;
98
+ const safe = sanitizeAccountInfo(info);
99
+ if (safe && (safe.fetchStatus === "fresh" || !entry.info))
100
+ entry.info = safe;
101
+ else if (entry.info)
102
+ entry.info = { ...entry.info, fetchStatus: "stale" };
103
+ }
104
+ };
105
+ await Promise.all([worker(), worker()]);
106
+ }
107
+ stop() {
108
+ if (this.timer)
109
+ clearInterval(this.timer);
110
+ this.controller.abort();
111
+ this.entries.clear();
112
+ }
113
+ }
@@ -40,6 +40,8 @@ import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpe
40
40
  import { addOpenAIAccountTransaction } from "./account-add.js";
41
41
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
42
42
  import { createAllowanceView } from "./allowance.js";
43
+ import { AccountInfoCache } from "./account-info-cache.js";
44
+ import { loadXaiAccounts } from "../config/manager.js";
43
45
  /** Upper bound on how long a shutdown may wait for telemetry to drain. */
44
46
  const TELEMETRY_SHUTDOWN_DEADLINE_MS = 1_000;
45
47
  const zeroRoutingMetrics = () => ({
@@ -433,6 +435,22 @@ export async function startServer(opts = {}) {
433
435
  prepare: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
434
436
  });
435
437
  openAIUsageRefresher.start();
438
+ const accountInfoSources = () => [
439
+ ...pool.getAll().map(account => ({
440
+ id: account.id, provider: "anthropic_subscription",
441
+ accessToken: account.tokens.accessToken, expiresAt: account.tokens.expiresAt, enabled: account.enabled,
442
+ })),
443
+ ...openAIAccounts.map(account => ({
444
+ id: account.id, provider: "openai_subscription",
445
+ accessToken: account.accessToken, expiresAt: account.expiresAt, enabled: account.enabled,
446
+ })),
447
+ ...loadXaiAccounts(accountsPath).map(account => ({
448
+ id: account.id, provider: "xai_subscription",
449
+ accessToken: account.accessToken, expiresAt: account.expiresAt, enabled: account.enabled,
450
+ })),
451
+ ];
452
+ const accountInfoCache = new AccountInfoCache(accountInfoSources);
453
+ accountInfoCache.start();
436
454
  const app = express();
437
455
  const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
438
456
  // Router-side 429 failover / 5xx retry is on by default; `"autoFailover":
@@ -549,12 +567,12 @@ export async function startServer(opts = {}) {
549
567
  // account's usage re-fetched — without dropping in-flight requests or
550
568
  // sticky sessions. Each provider contributes its own hooks; the route
551
569
  // itself knows nothing about OAuth or usage formats.
552
- const runRefreshAll = createRefreshAllRunner(() => {
570
+ const runRefreshAll = createRefreshAllRunner(async () => {
553
571
  const onError = (provider, error) => {
554
572
  const message = error instanceof Error ? error.message : String(error);
555
573
  logError(provider, 0, `manual refresh: ${message}`);
556
574
  };
557
- return refreshAllAccounts([
575
+ const summary = await refreshAllAccounts([
558
576
  {
559
577
  provider: "anthropic",
560
578
  getAll: () => pool.getAll(),
@@ -580,6 +598,10 @@ export async function startServer(opts = {}) {
580
598
  },
581
599
  onError,
582
600
  });
601
+ const metadataStartedAt = Date.now();
602
+ await accountInfoCache.refresh(true);
603
+ summary.durationMs += Math.max(0, Date.now() - metadataStartedAt);
604
+ return summary;
583
605
  });
584
606
  app.post("/cc-router/refresh", async (_req, res) => {
585
607
  let summary;
@@ -612,8 +634,15 @@ export async function startServer(opts = {}) {
612
634
  // Shape returned to clients — NEVER includes access/refresh tokens.
613
635
  accountsRouter.get("/", (_req, res) => {
614
636
  const resolveRoutingMetrics = createRoutingMetricsResolver();
637
+ // Never wait on an upstream metadata request while listing accounts.
638
+ void accountInfoCache.refresh();
639
+ const sources = accountInfoSources();
640
+ res.setHeader("Cache-Control", "no-store");
615
641
  res.json({
616
- accounts: createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots()),
642
+ accounts: createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots()).map(view => {
643
+ const source = sources.find(account => account.id === view.id && account.provider === view.provider);
644
+ return { ...view, ...(source ? { accountInfo: accountInfoCache.get(source) } : {}) };
645
+ }),
617
646
  });
618
647
  });
619
648
  accountsRouter.patch("/providers/:provider", (req, res) => {
@@ -1347,6 +1376,7 @@ export async function startServer(opts = {}) {
1347
1376
  console.log(chalk.yellow("\nShutting down — saving tokens..."));
1348
1377
  usageRefresher.stop();
1349
1378
  openAIUsageRefresher.stop();
1379
+ accountInfoCache.stop();
1350
1380
  saveAccounts(pool.getAll());
1351
1381
  if (managesPidFile()) {
1352
1382
  removePid();
@@ -7,6 +7,7 @@ import { createModelsApi } from "./modelsApi.js";
7
7
  import { getCurrentVersion } from "../utils/self-update.js";
8
8
  import { readClaudeRouting, readCodexRouting, setClaudeRouting, setCodexRouting, } from "../utils/cli-routing.js";
9
9
  import { mergeGrokIntoHealth, loadGrokHealthSnapshotsWithSubscription } from "../providers/xai/overview.js";
10
+ import { formatAccountInfo } from "../providers/account-info.js";
10
11
  const POLL_INTERVAL_MS = 2_000;
11
12
  /** Progress banner for the manual reload; replaced by the result banner, so
12
13
  * only the failure case (client gave up) ever lets it expire. */
@@ -620,6 +621,34 @@ function ErrorScreen({ error, port, retries }) {
620
621
  }
621
622
  // ─── Live dashboard ───────────────────────────────────────────────────────────
622
623
  function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, resetSession, }) {
624
+ // Private identity comes only from the authenticated account endpoint, never health.
625
+ const [accountInfo, setAccountInfo] = useState({});
626
+ useEffect(() => {
627
+ let cancelled = false;
628
+ let pending = false;
629
+ const poll = async () => {
630
+ if (pending)
631
+ return;
632
+ pending = true;
633
+ try {
634
+ const accounts = await api.list();
635
+ if (!cancelled)
636
+ setAccountInfo(Object.fromEntries(accounts.map(account => [
637
+ `${account.provider ?? "anthropic_subscription"}:${account.id}`, account.accountInfo,
638
+ ])));
639
+ }
640
+ catch {
641
+ if (!cancelled)
642
+ setAccountInfo({});
643
+ }
644
+ finally {
645
+ pending = false;
646
+ }
647
+ };
648
+ void poll();
649
+ const timer = setInterval(() => { void poll(); }, POLL_INTERVAL_MS);
650
+ return () => { cancelled = true; clearInterval(timer); };
651
+ }, [api]);
623
652
  const [cliRouting, setCliRouting] = useState(() => ({
624
653
  claude: readClaudeRouting(),
625
654
  codex: readCodexRouting(),
@@ -1235,7 +1264,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1235
1264
  : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
1236
1265
  // A remote router can only be restarted where it runs; printing a
1237
1266
  // local restart command here would never clear the banner.
1238
- _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) })] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_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 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
1267
+ _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) }), focus === "accounts" && selectedAccount && (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", formatAccountInfo(accountInfo[`${selectedAccount.provider ?? "anthropic_subscription"}:${selectedAccount.id}`])] }))] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_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 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
1239
1268
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1240
1269
  : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })), !compact && focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: focus === "accounts"
1241
1270
  ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
@@ -1,3 +1,4 @@
1
+ import { sanitizeAccountInfo } from "../providers/account-info.js";
1
2
  /**
2
3
  * Tiny authenticated HTTP client for /cc-router/accounts.
3
4
  *
@@ -100,10 +101,12 @@ function publicAccountSafeView(value) {
100
101
  ? value.provider
101
102
  : undefined;
102
103
  const rateLimits = publicRateLimits(value.rateLimits);
104
+ const accountInfo = sanitizeAccountInfo(value.accountInfo);
103
105
  const modelCooldowns = publicCooldowns(value.modelCooldowns);
104
106
  return [{
105
107
  id: publicText(value.id, 128, "unknown-account"),
106
108
  ...(provider ? { provider } : {}),
109
+ ...(accountInfo ? { accountInfo } : {}),
107
110
  ...(rateLimits ? { rateLimits } : {}),
108
111
  globalCooldownUntilMs: publicTimestamp(value.globalCooldownUntilMs),
109
112
  modelCooldowns,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {