@timo972/cc-router 0.12.0 → 0.12.1-rc.1

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,10 +1,13 @@
1
+ import { consumeCodexResetCredit } from "../providers/openai/usage-reset.js";
2
+ import { createUsageResetHandler } from "./account-usage-reset.js";
1
3
  import express from "express";
2
4
  import { createProxyMiddleware } from "http-proxy-middleware";
3
5
  import { ServerResponse } from "http";
4
6
  import { timingSafeEqual } from "crypto";
5
7
  import { TokenPool } from "./token-pool.js";
6
- import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
8
+ import { needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
9
  import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
10
+ import { createRefreshAllRunner, describeRefreshAll, refreshAllAccounts } from "./pool-refresh.js";
8
11
  import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
9
12
  import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
13
  import { loadTelemetryState } from "../config/telemetry.js";
@@ -17,7 +20,7 @@ import { loadGrokHealthSnapshots } from "../providers/xai/overview.js";
17
20
  import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
18
21
  import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
19
22
  import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
20
- import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
23
+ import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshOpenAIAccountsOnce, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
21
24
  import { createOpenAIAccount } from "../providers/openai/account-state.js";
22
25
  import { OpenAITokenPool } from "../providers/openai/token-pool.js";
23
26
  import { DEFAULT_CODEX_LIMIT_ID } from "../providers/openai/usage.js";
@@ -61,6 +64,7 @@ export function createOperationalStatus(opts) {
61
64
  health: "/cc-router/health",
62
65
  accounts: "/cc-router/accounts",
63
66
  allowance: "/cc-router/allowance",
67
+ refresh: "/cc-router/refresh",
64
68
  messages: "/v1/messages",
65
69
  responses: "/v1/responses",
66
70
  models: "/v1/models",
@@ -222,7 +226,7 @@ function publicOpenAIAccountView(a, routing) {
222
226
  enabled: a.enabled !== false,
223
227
  sessionLimitPercent: a.sessionLimitPercent,
224
228
  weeklyLimitPercent: a.weeklyLimitPercent,
225
- healthy: a.enabled !== false && a.healthy && expiresInMs > 0,
229
+ healthy: a.enabled !== false && a.healthy && a.authState !== "quarantined" && expiresInMs > 0,
226
230
  busy: routing.metrics.coolingDown,
227
231
  cooldownUntilMs: routing.metrics.cooldownUntilMs ?? 0,
228
232
  globalCooldownUntilMs: routing.cooldowns.globalUntilMs,
@@ -235,6 +239,8 @@ function publicOpenAIAccountView(a, routing) {
235
239
  lastRefreshMs: a.lastRefresh,
236
240
  codexRateLimits: publicCodexRateLimits(a, routing.cooldowns),
237
241
  ...(hasPendingCredentialWrite(a) ? { credentialsPendingWrite: true } : {}),
242
+ ...(a.authState === "quarantined" ? { authState: "quarantined" } : {}),
243
+ ...(a.authFailure ? { authFailure: a.authFailure } : {}),
238
244
  };
239
245
  }
240
246
  function publicCodexRateLimits(a, cooldowns) {
@@ -532,12 +538,72 @@ export async function startServer(opts = {}) {
532
538
  const views = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots());
533
539
  res.json(createAllowanceView(views, Date.now()));
534
540
  });
541
+ // ─── Manual reload (authenticated) ────────────────────────────────────────
542
+ // The dashboard's "reload everything" key. Gives the operator what a
543
+ // restart would — swept cooldowns, due/quarantined tokens re-tried, every
544
+ // account's usage re-fetched — without dropping in-flight requests or
545
+ // sticky sessions. Each provider contributes its own hooks; the route
546
+ // itself knows nothing about OAuth or usage formats.
547
+ const runRefreshAll = createRefreshAllRunner(() => {
548
+ const onError = (provider, error) => {
549
+ const message = error instanceof Error ? error.message : String(error);
550
+ logError(provider, 0, `manual refresh: ${message}`);
551
+ };
552
+ return refreshAllAccounts([
553
+ {
554
+ provider: "anthropic",
555
+ getAll: () => pool.getAll(),
556
+ refreshTokens: async () => {
557
+ let failed = 0;
558
+ await refreshAccountsOnce(pool.getAll(), { onError: error => { failed++; onError("anthropic", error); } });
559
+ return { failed };
560
+ },
561
+ refreshUsage: account => usageRefresher.refreshNow(account),
562
+ },
563
+ {
564
+ provider: "openai",
565
+ getAll: () => openAIAccounts,
566
+ refreshTokens: () => refreshOpenAIAccountsOnce(openAIAccounts, persistOpenAIAccounts, {
567
+ onError: error => onError("openai", error),
568
+ }),
569
+ refreshUsage: account => openAIUsageRefresher.refreshNow(account),
570
+ },
571
+ ], {
572
+ sweepCooldowns: () => {
573
+ pool.sweepExpiredCooldowns();
574
+ openAIPool.sweepExpiredCooldowns();
575
+ },
576
+ onError,
577
+ });
578
+ });
579
+ app.post("/cc-router/refresh", async (_req, res) => {
580
+ let summary;
581
+ try {
582
+ summary = await runRefreshAll();
583
+ }
584
+ catch (err) {
585
+ const message = err instanceof Error ? err.message : String(err);
586
+ logError("refresh", 0, `manual refresh failed: ${message}`);
587
+ res.status(500).json({ error: `Refresh failed: ${message}` });
588
+ return;
589
+ }
590
+ const details = `manual refresh — ${describeRefreshAll(summary)}`;
591
+ stats.addLog({ ts: Date.now(), accountId: "proxy", model: "-", type: "refresh", details });
592
+ res.json({ refresh: summary });
593
+ });
535
594
  // ─── Account management endpoints (authenticated) ─────────────────────────
536
595
  // These are mounted BEFORE the /v1/* proxy middleware so they don't get
537
596
  // forwarded to Anthropic. express.json() is scoped to this sub-router so
538
597
  // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
539
598
  const accountsRouter = express.Router();
540
599
  accountsRouter.use(express.json({ limit: "32kb" }));
600
+ accountsRouter.post("/:id/reset-usage", createUsageResetHandler({
601
+ findAccount: id => openAIAccounts.find(account => account.id === id),
602
+ prepare: account => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
603
+ consume: consumeCodexResetCredit,
604
+ captureReset: account => openAIPool.captureUsageReset(account),
605
+ refresh: account => openAIUsageRefresher.refreshAfterCurrent(account),
606
+ }));
541
607
  // Shape returned to clients — NEVER includes access/refresh tokens.
542
608
  accountsRouter.get("/", (_req, res) => {
543
609
  const resolveRoutingMetrics = createRoutingMetricsResolver();
@@ -989,6 +1055,7 @@ export async function startServer(opts = {}) {
989
1055
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
990
1056
  modelRouting,
991
1057
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
1058
+ timeoutMs: proxyRequestTimeoutMs,
992
1059
  ...upstreamAttempts,
993
1060
  });
994
1061
  mountMessagesCrossProviderRoute(app, {
@@ -997,6 +1064,7 @@ export async function startServer(opts = {}) {
997
1064
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
998
1065
  modelRouting,
999
1066
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
1067
+ timeoutMs: proxyRequestTimeoutMs,
1000
1068
  ...upstreamAttempts,
1001
1069
  });
1002
1070
  // Shared between the retrying /v1/messages route and the generic /v1 chain
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,48}$/;
3
+ const SAFE_CORRELATION = /^[A-Za-z0-9-]{1,48}$/;
4
+ export function createCorrelationId() {
5
+ return `oai-${randomUUID().slice(0, 8)}`;
6
+ }
7
+ export function safeCauseCode(error) {
8
+ let cause = error instanceof Error ? error.cause : undefined;
9
+ for (let depth = 0; depth < 3 && typeof cause === "object" && cause !== null; depth++) {
10
+ const code = cause.code;
11
+ if (typeof code === "string" && SAFE_CODE.test(code))
12
+ return code;
13
+ cause = cause.cause;
14
+ }
15
+ return undefined;
16
+ }
17
+ export function formatTransportDiagnostic(diagnostic) {
18
+ const correlationId = SAFE_CORRELATION.test(diagnostic.correlationId)
19
+ ? diagnostic.correlationId
20
+ : "invalid";
21
+ const fields = [
22
+ `correlation=${correlationId}`,
23
+ `operation=${diagnostic.operation}`,
24
+ ];
25
+ if (diagnostic.status !== undefined && Number.isInteger(diagnostic.status)) {
26
+ fields.push(`status=${diagnostic.status}`);
27
+ }
28
+ if (diagnostic.causeCode !== undefined && SAFE_CODE.test(diagnostic.causeCode)) {
29
+ fields.push(`cause=${diagnostic.causeCode}`);
30
+ }
31
+ return fields.join(" ");
32
+ }
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { randomUUID } from "node:crypto";
2
3
  import React, { useState, useEffect, useCallback, useRef } from "react";
3
4
  import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
4
5
  import { createAccountsApi } from "./accountsApi.js";
@@ -7,6 +8,9 @@ import { getCurrentVersion } from "../utils/self-update.js";
7
8
  import { readClaudeRouting, readCodexRouting, setClaudeRouting, setCodexRouting, } from "../utils/cli-routing.js";
8
9
  import { mergeGrokIntoHealth, loadGrokHealthSnapshotsWithSubscription } from "../providers/xai/overview.js";
9
10
  const POLL_INTERVAL_MS = 2_000;
11
+ /** Progress banner for the manual reload; replaced by the result banner, so
12
+ * only the failure case (client gave up) ever lets it expire. */
13
+ const REFRESH_ALL_BANNER_MS = 120_000;
10
14
  /** Grok's plan/code-access rarely change; refresh far slower than the 2s poll. */
11
15
  const GROK_SUBSCRIPTION_INTERVAL_MS = 60_000;
12
16
  /** Most activity rows the dashboard will show — the list shrinks below this
@@ -528,6 +532,9 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
528
532
  const [connectError, setConnectError] = useState(null);
529
533
  const [lastUpdate, setLastUpdate] = useState(0);
530
534
  const [retryCount, setRetryCount] = useState(0);
535
+ // LiveDashboard unmounts on a health-poll failure. Redemption ownership and
536
+ // retry IDs must survive that transition, not just a change in focus.
537
+ const resetSession = useRef({ inFlight: false, pendingIds: new Map() });
531
538
  const resolvedBase = baseUrl
532
539
  ? baseUrl.replace(/\/+$/, "")
533
540
  : `http://localhost:${port}`;
@@ -543,22 +550,22 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
543
550
  // /v1/user round-trip every tick. Undefined until the first fetch lands, at
544
551
  // which point `mergeGrokIntoHealth` falls back to its access-token tier.
545
552
  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); };
553
+ const refreshGrokSnapshots = useCallback(async () => {
554
+ try {
555
+ grokSnapshotsRef.current = await loadGrokHealthSnapshotsWithSubscription();
556
+ }
557
+ catch {
558
+ // Keep the last known snapshots; the poll degrades to the tier fallback.
559
+ }
561
560
  }, []);
561
+ useEffect(() => {
562
+ refreshGrokSnapshots();
563
+ const timer = setInterval(refreshGrokSnapshots, GROK_SUBSCRIPTION_INTERVAL_MS);
564
+ return () => { clearInterval(timer); };
565
+ }, [refreshGrokSnapshots]);
566
+ // The manual reload key re-runs the same poll immediately so the freshly
567
+ // fetched usage shows up without waiting for the next 2s tick.
568
+ const pollRef = useRef(async () => { });
562
569
  useEffect(() => {
563
570
  let cancelled = false;
564
571
  const healthUrl = `${resolvedBase}/cc-router/health`;
@@ -590,24 +597,29 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
590
597
  setRetryCount(n => n + 1);
591
598
  }
592
599
  };
600
+ pollRef.current = poll;
593
601
  poll();
594
602
  const timer = setInterval(poll, POLL_INTERVAL_MS);
595
603
  return () => { cancelled = true; clearInterval(timer); };
596
604
  }, [resolvedBase, authToken]);
605
+ const refreshLocalViews = useCallback(async () => {
606
+ await refreshGrokSnapshots();
607
+ await pollRef.current();
608
+ }, [refreshGrokSnapshots]);
597
609
  if (connectError) {
598
610
  return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
599
611
  }
600
612
  if (!data) {
601
613
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
602
614
  }
603
- return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent }));
615
+ return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews, resetSession: resetSession.current }));
604
616
  }
605
617
  // ─── Error screen ─────────────────────────────────────────────────────────────
606
618
  function ErrorScreen({ error, port, retries }) {
607
619
  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
620
  }
609
621
  // ─── Live dashboard ───────────────────────────────────────────────────────────
610
- function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, }) {
622
+ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, resetSession, }) {
611
623
  const [cliRouting, setCliRouting] = useState(() => ({
612
624
  claude: readClaudeRouting(),
613
625
  codex: readCodexRouting(),
@@ -633,6 +645,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
633
645
  // ── Focus / mode ──────────────────────────────────────────────────────────
634
646
  const [focus, setFocus] = useState("logs");
635
647
  const [mode, setMode] = useState("view");
648
+ const [resetTarget, setResetTarget] = useState(null);
636
649
  // Compact ("zen") view: hides TOTALS + RECENT ACTIVITY so the account list
637
650
  // gets the whole vertical budget — the fix for a short terminal starving a
638
651
  // long fleet (e.g. showing 1 of 11 accounts). Toggled with [z], view-only,
@@ -770,14 +783,14 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
770
783
  // setBanner can fire on an unmounted component after `n` exits Ink.
771
784
  const [banner, setBanner] = useState(null);
772
785
  const bannerTimerRef = useRef(null);
773
- const showBanner = useCallback((text, color) => {
786
+ const showBanner = useCallback((text, color, durationMs = 4_000) => {
774
787
  if (bannerTimerRef.current)
775
788
  clearTimeout(bannerTimerRef.current);
776
789
  setBanner({ text, color });
777
790
  bannerTimerRef.current = setTimeout(() => {
778
791
  setBanner(null);
779
792
  bannerTimerRef.current = null;
780
- }, 4_000);
793
+ }, durationMs);
781
794
  }, []);
782
795
  useEffect(() => () => {
783
796
  if (bannerTimerRef.current)
@@ -872,6 +885,47 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
872
885
  showBanner(`Models error: ${errMsg(err)}`, "red");
873
886
  }
874
887
  }, [modelsApi, showBanner]);
888
+ // ── Reload everything ────────────────────────────────────────────────────
889
+ // One key for "make it look like I just restarted the router": the server
890
+ // sweeps cooldowns, re-tries due/quarantined tokens and re-fetches every
891
+ // account's usage; the dashboard then re-reads Grok, CLI routing, the
892
+ // model list (if already loaded) and polls health right away.
893
+ const refreshAllInFlightRef = useRef(false);
894
+ const doRefreshAll = useCallback(async () => {
895
+ if (refreshAllInFlightRef.current) {
896
+ showBanner("Reload already running…", "gray");
897
+ return;
898
+ }
899
+ refreshAllInFlightRef.current = true;
900
+ // Stays up until the result banner replaces it: a reload with several
901
+ // slow providers can outlive the default banner lifetime.
902
+ showBanner("Reloading accounts, usage and models…", "cyan", REFRESH_ALL_BANNER_MS);
903
+ try {
904
+ const result = await api.refreshAll();
905
+ refreshCliRouting();
906
+ if (modelsStatus) {
907
+ try {
908
+ const status = await modelsApi.list();
909
+ setModelsStatus(status);
910
+ }
911
+ catch {
912
+ // The account reload succeeded; a stale model list is not worth a red banner.
913
+ }
914
+ }
915
+ await onRefreshAll?.();
916
+ const problems = [
917
+ result.usageFailed > 0 ? `${result.usageFailed} usage fetch failed` : "",
918
+ result.tokenRefreshFailed > 0 ? `${result.tokenRefreshFailed} token refresh failed` : "",
919
+ ].filter(Boolean);
920
+ showBanner(`Reloaded ${result.accounts} accounts — usage fresh for ${result.usageRefreshed}${problems.length ? `, ${problems.join(", ")}` : ""}`, problems.length ? "yellow" : "green");
921
+ }
922
+ catch (err) {
923
+ showBanner(`Reload error: ${errMsg(err)}`, "red");
924
+ }
925
+ finally {
926
+ refreshAllInFlightRef.current = false;
927
+ }
928
+ }, [api, modelsApi, modelsStatus, onRefreshAll, refreshCliRouting, showBanner]);
875
929
  const doToggleCli = useCallback((target) => {
876
930
  const current = target === "claude" ? cliRouting.claude.enabled : cliRouting.codex.enabled;
877
931
  const result = target === "claude"
@@ -910,6 +964,37 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
910
964
  showBanner(`Models error: ${errMsg(err)}`, "red");
911
965
  }
912
966
  }, [modelsApi, selectedModel, showBanner]);
967
+ const doResetUsage = useCallback(async (id) => {
968
+ if (resetSession.inFlight)
969
+ return;
970
+ resetSession.inFlight = true;
971
+ const requestId = resetSession.pendingIds.get(id) ?? randomUUID();
972
+ resetSession.pendingIds.set(id, requestId);
973
+ showBanner(`Redeeming usage reset for ${id}…`, "yellow", REFRESH_ALL_BANNER_MS);
974
+ try {
975
+ const result = await api.resetUsage(id, requestId);
976
+ resetSession.pendingIds.delete(id);
977
+ const messages = {
978
+ reset: `Usage reset redeemed for ${id}`,
979
+ already_redeemed: `Usage reset already redeemed for ${id}`,
980
+ nothing_to_reset: `Nothing to reset for ${id}`,
981
+ no_credit: `No reset credits available for ${id}`,
982
+ };
983
+ showBanner(messages[result.code] + (result.usageRefreshed ? "" : " — usage refresh failed; reload with R"), result.usageRefreshed && (result.code === "reset" || result.code === "already_redeemed") ? "green" : "yellow");
984
+ // Failure to poll the dashboard must not turn a confirmed spend into an
985
+ // unknown outcome or encourage another redemption.
986
+ try {
987
+ await onRefreshAll?.();
988
+ }
989
+ catch { /* the regular poll will retry */ }
990
+ }
991
+ catch {
992
+ showBanner(`Reset outcome unknown for ${id}; Ctrl+R retries the same redemption (keep dashboard open)`, "red");
993
+ }
994
+ finally {
995
+ resetSession.inFlight = false;
996
+ }
997
+ }, [api, onRefreshAll, resetSession, showBanner]);
913
998
  // ── Keyboard handler ──────────────────────────────────────────────────────
914
999
  useInput((input, key) => {
915
1000
  // ── Text editing mode (w / s) ───────────────────────────────────────
@@ -941,6 +1026,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
941
1026
  }
942
1027
  return;
943
1028
  }
1029
+ if (mode === "confirmReset") {
1030
+ if ((input === "y" || input === "Y") && resetTarget)
1031
+ void doResetUsage(resetTarget);
1032
+ else
1033
+ showBanner("Reset cancelled", "gray");
1034
+ setResetTarget(null);
1035
+ setMode("view");
1036
+ return;
1037
+ }
944
1038
  // ── Confirm delete (y/n) ────────────────────────────────────────────
945
1039
  if (mode === "confirmDelete") {
946
1040
  if (input === "y" || input === "Y") {
@@ -970,6 +1064,31 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
970
1064
  exit();
971
1065
  return;
972
1066
  }
1067
+ // Intercept Ctrl+R before model refresh so it only acts on account focus.
1068
+ // Ink exposes the standard DC2 control character as input r + key.ctrl.
1069
+ if (input === "r" && key.ctrl) {
1070
+ if (focus !== "accounts" || !selectedAccount)
1071
+ return;
1072
+ if (resetSession.inFlight) {
1073
+ showBanner("Reset already running", "yellow");
1074
+ return;
1075
+ }
1076
+ if (selectedAccount.provider !== "openai_subscription") {
1077
+ showBanner("Usage resets are only available for ChatGPT accounts", "yellow");
1078
+ return;
1079
+ }
1080
+ if (!resetSession.pendingIds.has(selectedAccount.id) && (selectedAccount.codexRateLimits?.resetCredits?.available ?? 0) <= 0) {
1081
+ showBanner("No reset credits available", "yellow");
1082
+ return;
1083
+ }
1084
+ setResetTarget(selectedAccount.id);
1085
+ setMode("confirmReset");
1086
+ return;
1087
+ }
1088
+ if (input === "R") {
1089
+ void doRefreshAll();
1090
+ return;
1091
+ }
973
1092
  if (key.tab) {
974
1093
  // Compact view hides the activity list, so skip "logs" in the cycle.
975
1094
  if (compact) {
@@ -1111,7 +1230,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1111
1230
  });
1112
1231
  const selectedLog = logs[selectedLogIndex] ?? null;
1113
1232
  const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
1114
- return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_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 }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { 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, { 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, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1233
+ return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_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 }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { 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, { 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 === "confirmReset" && resetTarget && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "yellow", bold: true, children: ["Redeem 1 reset for \"", resetTarget, "\"? [y] yes [n/Esc] cancel"] }) })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
1115
1234
  ? `daemon v${data.version}`
1116
1235
  : "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" })] })) : (
1117
1236
  // A remote router can only be restarted where it runs; printing a
@@ -1119,10 +1238,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1119
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
1120
1239
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1121
1240
  : 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]"
1241
+ ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
1123
1242
  : focus === "models"
1124
- ? " [Tab] [m/r] refresh [c]/[o] default [Esc] logs [z] compact [q]"
1125
- : " [Tab] [m] models [z] compact [q] quit" }) })] }) }));
1243
+ ? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
1244
+ : " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
1126
1245
  }
1127
1246
  function OperationsPanel({ operational, baseUrl, focus, cliRouting }) {
1128
1247
  const authLabel = operational.auth.required ? "protected" : "open";