@timo972/cc-router 0.12.0 → 0.12.1-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.
@@ -0,0 +1,153 @@
1
+ export function createHeaderDeadline(timeoutMs, parent) {
2
+ if (!timeoutMs || timeoutMs <= 0) {
3
+ return { signal: parent, dispose() { } };
4
+ }
5
+ const controller = new AbortController();
6
+ let timer = setTimeout(() => {
7
+ controller.abort(new DOMException("upstream headers timed out", "TimeoutError"));
8
+ }, timeoutMs);
9
+ const onParentAbort = () => controller.abort(parent?.reason);
10
+ if (parent?.aborted) {
11
+ onParentAbort();
12
+ }
13
+ else {
14
+ parent?.addEventListener("abort", onParentAbort, { once: true });
15
+ }
16
+ return {
17
+ signal: controller.signal,
18
+ dispose() {
19
+ if (timer)
20
+ clearTimeout(timer);
21
+ timer = undefined;
22
+ parent?.removeEventListener("abort", onParentAbort);
23
+ },
24
+ };
25
+ }
26
+ /**
27
+ * Wrap a body with a timeout for each upstream read.
28
+ *
29
+ * The zero high-water mark is deliberate. A positive high-water mark lets the
30
+ * Web Streams implementation prefetch the next chunk as soon as the previous
31
+ * one is delivered. When the HTTP response is waiting for downstream drain,
32
+ * that prefetched read would run the upstream idle timer and could time out an
33
+ * otherwise healthy stream solely because its client was slow.
34
+ */
35
+ export function withStreamIdleTimeout(body, idleMs, parent) {
36
+ if (!body)
37
+ return null;
38
+ const timeoutEnabled = idleMs !== undefined && idleMs > 0;
39
+ if (!timeoutEnabled && !parent)
40
+ return body;
41
+ const reader = body.getReader();
42
+ let timer;
43
+ let closed = false;
44
+ let controllerRef;
45
+ const clearIdleTimer = () => {
46
+ if (timer)
47
+ clearTimeout(timer);
48
+ timer = undefined;
49
+ };
50
+ const releaseReader = () => {
51
+ try {
52
+ reader.releaseLock();
53
+ }
54
+ catch {
55
+ // A cancellation can briefly leave read() pending. reader.cancel()
56
+ // settles it before trying this again in its finally callback.
57
+ }
58
+ };
59
+ const finish = (release = true) => {
60
+ if (closed)
61
+ return;
62
+ closed = true;
63
+ clearIdleTimer();
64
+ parent?.removeEventListener("abort", onAbort);
65
+ if (release)
66
+ releaseReader();
67
+ };
68
+ const fail = (error) => {
69
+ if (closed)
70
+ return;
71
+ finish(false);
72
+ controllerRef?.error(error);
73
+ void reader.cancel(error).catch(() => { }).finally(releaseReader);
74
+ };
75
+ const onAbort = () => {
76
+ fail(parent?.reason ?? new DOMException("aborted", "AbortError"));
77
+ };
78
+ const startIdleTimer = () => {
79
+ if (!timeoutEnabled)
80
+ return;
81
+ clearIdleTimer();
82
+ timer = setTimeout(() => {
83
+ fail(new DOMException("upstream stream idle timeout", "TimeoutError"));
84
+ }, idleMs);
85
+ };
86
+ return new ReadableStream({
87
+ start(controller) {
88
+ controllerRef = controller;
89
+ if (parent?.aborted)
90
+ onAbort();
91
+ else
92
+ parent?.addEventListener("abort", onAbort, { once: true });
93
+ },
94
+ async pull(controller) {
95
+ if (closed)
96
+ return;
97
+ startIdleTimer();
98
+ try {
99
+ const next = await reader.read();
100
+ clearIdleTimer();
101
+ if (closed)
102
+ return;
103
+ if (next.done) {
104
+ finish();
105
+ controller.close();
106
+ return;
107
+ }
108
+ controller.enqueue(next.value);
109
+ }
110
+ catch (error) {
111
+ if (closed)
112
+ return;
113
+ finish();
114
+ controller.error(error);
115
+ }
116
+ },
117
+ async cancel(reason) {
118
+ if (closed)
119
+ return;
120
+ finish(false);
121
+ try {
122
+ await reader.cancel(reason);
123
+ }
124
+ finally {
125
+ releaseReader();
126
+ }
127
+ },
128
+ }, { highWaterMark: 0 });
129
+ }
130
+ /** Resolve only when an HTTP response can accept another write. Every losing
131
+ * listener is removed, including the error/close path. */
132
+ export function waitForWritable(res) {
133
+ if (res.destroyed)
134
+ return Promise.resolve(false);
135
+ return new Promise(resolve => {
136
+ let done = false;
137
+ const finish = (value) => {
138
+ if (done)
139
+ return;
140
+ done = true;
141
+ res.removeListener("drain", drain);
142
+ res.removeListener("close", closed);
143
+ res.removeListener("error", errored);
144
+ resolve(value);
145
+ };
146
+ const drain = () => finish(true);
147
+ const closed = () => finish(false);
148
+ const errored = () => finish(false);
149
+ res.once("drain", drain);
150
+ res.once("close", closed);
151
+ res.once("error", errored);
152
+ });
153
+ }
@@ -7,6 +7,9 @@ import { getCurrentVersion } from "../utils/self-update.js";
7
7
  import { readClaudeRouting, readCodexRouting, setClaudeRouting, setCodexRouting, } from "../utils/cli-routing.js";
8
8
  import { mergeGrokIntoHealth, loadGrokHealthSnapshotsWithSubscription } from "../providers/xai/overview.js";
9
9
  const POLL_INTERVAL_MS = 2_000;
10
+ /** Progress banner for the manual reload; replaced by the result banner, so
11
+ * only the failure case (client gave up) ever lets it expire. */
12
+ const REFRESH_ALL_BANNER_MS = 120_000;
10
13
  /** Grok's plan/code-access rarely change; refresh far slower than the 2s poll. */
11
14
  const GROK_SUBSCRIPTION_INTERVAL_MS = 60_000;
12
15
  /** Most activity rows the dashboard will show — the list shrinks below this
@@ -543,22 +546,22 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
543
546
  // /v1/user round-trip every tick. Undefined until the first fetch lands, at
544
547
  // which point `mergeGrokIntoHealth` falls back to its access-token tier.
545
548
  const grokSnapshotsRef = useRef(undefined);
546
- useEffect(() => {
547
- let cancelled = false;
548
- const refresh = async () => {
549
- try {
550
- const snapshots = await loadGrokHealthSnapshotsWithSubscription();
551
- if (!cancelled)
552
- grokSnapshotsRef.current = snapshots;
553
- }
554
- catch {
555
- // Keep the last known snapshots; the poll degrades to the tier fallback.
556
- }
557
- };
558
- refresh();
559
- const timer = setInterval(refresh, GROK_SUBSCRIPTION_INTERVAL_MS);
560
- return () => { cancelled = true; clearInterval(timer); };
549
+ const refreshGrokSnapshots = useCallback(async () => {
550
+ try {
551
+ grokSnapshotsRef.current = await loadGrokHealthSnapshotsWithSubscription();
552
+ }
553
+ catch {
554
+ // Keep the last known snapshots; the poll degrades to the tier fallback.
555
+ }
561
556
  }, []);
557
+ useEffect(() => {
558
+ refreshGrokSnapshots();
559
+ const timer = setInterval(refreshGrokSnapshots, GROK_SUBSCRIPTION_INTERVAL_MS);
560
+ return () => { clearInterval(timer); };
561
+ }, [refreshGrokSnapshots]);
562
+ // The manual reload key re-runs the same poll immediately so the freshly
563
+ // fetched usage shows up without waiting for the next 2s tick.
564
+ const pollRef = useRef(async () => { });
562
565
  useEffect(() => {
563
566
  let cancelled = false;
564
567
  const healthUrl = `${resolvedBase}/cc-router/health`;
@@ -590,24 +593,29 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
590
593
  setRetryCount(n => n + 1);
591
594
  }
592
595
  };
596
+ pollRef.current = poll;
593
597
  poll();
594
598
  const timer = setInterval(poll, POLL_INTERVAL_MS);
595
599
  return () => { cancelled = true; clearInterval(timer); };
596
600
  }, [resolvedBase, authToken]);
601
+ const refreshLocalViews = useCallback(async () => {
602
+ await refreshGrokSnapshots();
603
+ await pollRef.current();
604
+ }, [refreshGrokSnapshots]);
597
605
  if (connectError) {
598
606
  return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
599
607
  }
600
608
  if (!data) {
601
609
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
602
610
  }
603
- return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent }));
611
+ return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews }));
604
612
  }
605
613
  // ─── Error screen ─────────────────────────────────────────────────────────────
606
614
  function ErrorScreen({ error, port, retries }) {
607
615
  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" })] })] }));
608
616
  }
609
617
  // ─── Live dashboard ───────────────────────────────────────────────────────────
610
- function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, }) {
618
+ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, }) {
611
619
  const [cliRouting, setCliRouting] = useState(() => ({
612
620
  claude: readClaudeRouting(),
613
621
  codex: readCodexRouting(),
@@ -770,14 +778,14 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
770
778
  // setBanner can fire on an unmounted component after `n` exits Ink.
771
779
  const [banner, setBanner] = useState(null);
772
780
  const bannerTimerRef = useRef(null);
773
- const showBanner = useCallback((text, color) => {
781
+ const showBanner = useCallback((text, color, durationMs = 4_000) => {
774
782
  if (bannerTimerRef.current)
775
783
  clearTimeout(bannerTimerRef.current);
776
784
  setBanner({ text, color });
777
785
  bannerTimerRef.current = setTimeout(() => {
778
786
  setBanner(null);
779
787
  bannerTimerRef.current = null;
780
- }, 4_000);
788
+ }, durationMs);
781
789
  }, []);
782
790
  useEffect(() => () => {
783
791
  if (bannerTimerRef.current)
@@ -872,6 +880,47 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
872
880
  showBanner(`Models error: ${errMsg(err)}`, "red");
873
881
  }
874
882
  }, [modelsApi, showBanner]);
883
+ // ── Reload everything ────────────────────────────────────────────────────
884
+ // One key for "make it look like I just restarted the router": the server
885
+ // sweeps cooldowns, re-tries due/quarantined tokens and re-fetches every
886
+ // account's usage; the dashboard then re-reads Grok, CLI routing, the
887
+ // model list (if already loaded) and polls health right away.
888
+ const refreshAllInFlightRef = useRef(false);
889
+ const doRefreshAll = useCallback(async () => {
890
+ if (refreshAllInFlightRef.current) {
891
+ showBanner("Reload already running…", "gray");
892
+ return;
893
+ }
894
+ refreshAllInFlightRef.current = true;
895
+ // Stays up until the result banner replaces it: a reload with several
896
+ // slow providers can outlive the default banner lifetime.
897
+ showBanner("Reloading accounts, usage and models…", "cyan", REFRESH_ALL_BANNER_MS);
898
+ try {
899
+ const result = await api.refreshAll();
900
+ refreshCliRouting();
901
+ if (modelsStatus) {
902
+ try {
903
+ const status = await modelsApi.list();
904
+ setModelsStatus(status);
905
+ }
906
+ catch {
907
+ // The account reload succeeded; a stale model list is not worth a red banner.
908
+ }
909
+ }
910
+ await onRefreshAll?.();
911
+ const problems = [
912
+ result.usageFailed > 0 ? `${result.usageFailed} usage fetch failed` : "",
913
+ result.tokenRefreshFailed > 0 ? `${result.tokenRefreshFailed} token refresh failed` : "",
914
+ ].filter(Boolean);
915
+ showBanner(`Reloaded ${result.accounts} accounts — usage fresh for ${result.usageRefreshed}${problems.length ? `, ${problems.join(", ")}` : ""}`, problems.length ? "yellow" : "green");
916
+ }
917
+ catch (err) {
918
+ showBanner(`Reload error: ${errMsg(err)}`, "red");
919
+ }
920
+ finally {
921
+ refreshAllInFlightRef.current = false;
922
+ }
923
+ }, [api, modelsApi, modelsStatus, onRefreshAll, refreshCliRouting, showBanner]);
875
924
  const doToggleCli = useCallback((target) => {
876
925
  const current = target === "claude" ? cliRouting.claude.enabled : cliRouting.codex.enabled;
877
926
  const result = target === "claude"
@@ -970,6 +1019,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
970
1019
  exit();
971
1020
  return;
972
1021
  }
1022
+ if (input === "R") {
1023
+ void doRefreshAll();
1024
+ return;
1025
+ }
973
1026
  if (key.tab) {
974
1027
  // Compact view hides the activity list, so skip "logs" in the cycle.
975
1028
  if (compact) {
@@ -1119,10 +1172,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1119
1172
  _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
1120
1173
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1121
1174
  : 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"
1122
- ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [z] compact [q]"
1175
+ ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [R] reload [z] compact [q]"
1123
1176
  : focus === "models"
1124
- ? " [Tab] [m/r] refresh [c]/[o] default [Esc] logs [z] compact [q]"
1125
- : " [Tab] [m] models [z] compact [q] quit" }) })] }) }));
1177
+ ? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
1178
+ : " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
1126
1179
  }
1127
1180
  function OperationsPanel({ operational, baseUrl, focus, cliRouting }) {
1128
1181
  const authLabel = operational.auth.required ? "protected" : "open";
@@ -6,9 +6,15 @@
6
6
  * flow is not here because it runs inquirer.
7
7
  */
8
8
  const REQUEST_TIMEOUT_MS = 3_000;
9
+ // Worst case is every account timing out: OpenAI token refreshes are
10
+ // sequential with a 15s deadline each, usage fetches run two at a time with
11
+ // 10s each. The server single-flights the pass, so a client that does give
12
+ // up and presses again joins the running one rather than stacking another.
13
+ const REFRESH_ALL_TIMEOUT_MS = 120_000;
9
14
  const MAX_PUBLIC_ROWS = 12;
10
15
  export function createAccountsApi(baseUrl, authToken) {
11
- const base = baseUrl.replace(/\/+$/, "") + "/cc-router/accounts";
16
+ const root = baseUrl.replace(/\/+$/, "");
17
+ const base = root + "/cc-router/accounts";
12
18
  const authHeaders = authToken ? { authorization: `Bearer ${authToken}` } : {};
13
19
  async function send(method, path, body) {
14
20
  const res = await fetch(base + path, {
@@ -35,13 +41,37 @@ export function createAccountsApi(baseUrl, authToken) {
35
41
  const payload = await res.json();
36
42
  return Array.isArray(payload.accounts) ? payload.accounts.flatMap(publicAccountSafeView) : [];
37
43
  }
44
+ async function refreshAll() {
45
+ // Usage fetches for every account run behind this call, so it gets its
46
+ // own, longer budget than the account mutations above.
47
+ const res = await fetch(root + "/cc-router/refresh", {
48
+ method: "POST",
49
+ headers: authHeaders,
50
+ signal: AbortSignal.timeout(REFRESH_ALL_TIMEOUT_MS),
51
+ });
52
+ if (!res.ok)
53
+ throw new Error(`HTTP ${res.status}`);
54
+ const payload = await res.json();
55
+ return publicRefreshResult(payload.refresh);
56
+ }
38
57
  return {
39
58
  list,
59
+ refreshAll,
40
60
  patch(id, patch) { return send("PATCH", `/${encodeURIComponent(id)}`, patch); },
41
61
  setProviderEnabled(provider, enabled) { return send("PATCH", `/providers/${encodeURIComponent(provider)}`, { enabled }); },
42
62
  remove(id) { return send("DELETE", `/${encodeURIComponent(id)}`); },
43
63
  };
44
64
  }
65
+ function publicRefreshResult(value) {
66
+ const record = isRecord(value) ? value : {};
67
+ return {
68
+ accounts: publicInteger(record.accounts),
69
+ usageRefreshed: publicInteger(record.usageRefreshed),
70
+ usageFailed: publicInteger(record.usageFailed),
71
+ tokenRefreshFailed: publicInteger(record.tokenRefreshFailed),
72
+ durationMs: publicInteger(record.durationMs),
73
+ };
74
+ }
45
75
  function isRecord(value) {
46
76
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
47
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.0",
3
+ "version": "0.12.1-rc.0",
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": {