@timo972/cc-router 0.12.2 → 0.12.3-rc.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.
- package/CHANGELOG.md +9 -0
- package/dist/cli/cmd-accounts.js +10 -2
- package/dist/providers/account-info-fetch.js +90 -0
- package/dist/providers/account-info.js +72 -0
- package/dist/proxy/account-info-cache.js +113 -0
- package/dist/proxy/server.js +33 -3
- package/dist/ui/Dashboard.js +30 -1
- package/dist/ui/accountsApi.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,15 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Account identity and plan details in live account listings and the dashboard's
|
|
14
|
+
selected-account details: email and workspace information for Claude and
|
|
15
|
+
ChatGPT, Claude subscription status/start date, and plans for stored Grok
|
|
16
|
+
accounts. Metadata refreshes in the background and with **R**; unavailable
|
|
17
|
+
renewal dates remain unknown. Billing interval is not supported. Private identity details
|
|
18
|
+
are excluded from health responses and telemetry.
|
|
19
|
+
|
|
11
20
|
---
|
|
12
21
|
|
|
13
22
|
## [0.12.2] — 2026-09-16
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -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]"
|
package/dist/ui/accountsApi.js
CHANGED
|
@@ -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