@timo972/cc-router 0.12.1-rc.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
10
 
11
11
  ### Added
12
12
 
13
+ - Dashboard `Ctrl+R` confirms redemption of one banked usage-limit reset for
14
+ the focused ChatGPT account, with duplicate-submission protection and stable
15
+ retry IDs for uncertain outcomes. No custom Meta key mapping is needed.
16
+ Account usage is refreshed after redemption.
17
+
13
18
  - Dashboard `[R]` reloads account usage and due tokens without restarting the
14
19
  router or dropping active requests and sticky sessions. It also refreshes
15
20
  Grok snapshots, CLI routing state, and an already-loaded model list, with
package/README.md CHANGED
@@ -588,11 +588,30 @@ The dashboard is also a control surface. In local mode it controls the local pro
588
588
  | `e` | Enable/disable selected Claude account |
589
589
  | `w` / `s` | Change selected Claude account weekly/session cap |
590
590
  | `d` | Delete selected Claude account |
591
+ | `Ctrl+R` | Confirm redeeming one banked usage-limit reset for the focused ChatGPT account |
592
+ | `R` | Reload account usage and due credentials without restarting the router |
591
593
  | `m` / `r` | Load or refresh discovered provider models |
592
594
  | `c` | Toggle Claude Code routing (or set Claude model default when MODELS is focused) |
593
595
  | `x` | Toggle Codex CLI routing (proxy stays up) |
594
596
  | `o` | Set selected `openai/*` model as OpenAI default |
595
597
 
598
+ To redeem a ChatGPT reset, press `Tab` to focus accounts, select the account
599
+ with the arrow keys, then press `Ctrl+R` and confirm with `y` (`n` or `Esc`
600
+ cancels). The `rst` column shows banked reset credits. Accounts with zero or
601
+ unknown credits cannot start a new redemption. This spends a real reset credit;
602
+ it is not the same as refreshing usage with uppercase `R`.
603
+
604
+ Press **Control + R** (not Command + R or Shift + R). This uses the standard
605
+ terminal Ctrl+R sequence and does not require a custom Meta/Option key mapping.
606
+
607
+ Redemption targets only that account and refreshes its usage afterward. If the
608
+ network outcome is unknown, keep the dashboard open and retry the shortcut: it
609
+ reuses the same redemption ID instead of spending another credit. Those retry
610
+ IDs are held for the current dashboard session only; after restarting it,
611
+ inspect usage before requesting another reset. A successful redemption with a
612
+ failed usage refresh is reported separately. Confirmed resets plus fresh usage clear only superseded quota cooldowns.
613
+ Overload holds, unreported/exhausted limits, and newer quota signals are preserved.
614
+
596
615
  List and change models without waiting for a package update:
597
616
 
598
617
  ```bash
@@ -69,8 +69,13 @@ export class OpenAITokenPool {
69
69
  // Each cause extends its own horizon, so neither can misrepresent the
70
70
  // other's duration in either direction.
71
71
  state.globalUntil = Math.max(state.globalUntil, expiry);
72
- if (cause === "rate_limit")
72
+ if (cause === "rate_limit") {
73
73
  state.rateLimitedUntil = Math.max(state.rateLimitedUntil, expiry);
74
+ state.quotaRevision++;
75
+ }
76
+ else {
77
+ state.unavailableUntil = Math.max(state.unavailableUntil, expiry);
78
+ }
74
79
  }
75
80
  setBucketCooldownForAccount(account, limitId, durationMs) {
76
81
  const expiry = this.proposedExpiry(account, durationMs);
@@ -80,6 +85,7 @@ export class OpenAITokenPool {
80
85
  if (!state.bucketUntil.has(limitId))
81
86
  this.makeRoomForBucketCooldown(state);
82
87
  state.bucketUntil.set(limitId, Math.max(state.bucketUntil.get(limitId) ?? 0, expiry));
88
+ state.quotaRevision++;
83
89
  }
84
90
  /**
85
91
  * Keep `bucketUntil` bounded before a new limit id is added. Every distinct
@@ -114,6 +120,31 @@ export class OpenAITokenPool {
114
120
  if (soonestId !== undefined)
115
121
  state.bucketUntil.delete(soonestId);
116
122
  }
123
+ /** Capture before a redemption. Apply only after confirmed redemption and a
124
+ * fresh post-redemption usage fetch. New quota evidence conservatively fences
125
+ * the whole reconciliation; unrelated overload holds retain their own expiry. */
126
+ captureUsageReset(account) {
127
+ const state = this.cooldowns.get(account);
128
+ const revision = state?.quotaRevision;
129
+ return update => {
130
+ if (!state || this.findById(account.id) !== account
131
+ || this.cooldowns.get(account) !== state || state.quotaRevision !== revision)
132
+ return;
133
+ for (const bucket of update.buckets) {
134
+ const windows = [bucket.primary, bucket.secondary].filter(window => window !== undefined);
135
+ // An omitted bucket/window is not evidence of recovery. Exhaustion in
136
+ // retained windows is still independently enforced by hardBlock().
137
+ if (windows.length === 0 || windows.some(window => window.utilization >= 1))
138
+ continue;
139
+ if (bucket.limitId === DEFAULT_CODEX_LIMIT_ID) {
140
+ state.rateLimitedUntil = 0;
141
+ state.globalUntil = state.unavailableUntil;
142
+ }
143
+ state.bucketUntil.delete(bucket.limitId);
144
+ }
145
+ this.sweepExpiredCooldowns();
146
+ };
147
+ }
117
148
  getCooldownView(accountId) {
118
149
  const account = this.findById(accountId);
119
150
  if (!account)
@@ -351,7 +382,7 @@ export class OpenAITokenPool {
351
382
  cooldownsFor(account) {
352
383
  let state = this.cooldowns.get(account);
353
384
  if (!state) {
354
- state = { globalUntil: 0, rateLimitedUntil: 0, bucketUntil: new Map() };
385
+ state = { globalUntil: 0, rateLimitedUntil: 0, unavailableUntil: 0, quotaRevision: 0, bucketUntil: new Map() };
355
386
  this.cooldowns.set(account, state);
356
387
  }
357
388
  return state;
@@ -363,6 +394,8 @@ export class OpenAITokenPool {
363
394
  return false;
364
395
  const now = this.now();
365
396
  let recovered = false;
397
+ if (state.unavailableUntil > 0 && state.unavailableUntil <= now)
398
+ state.unavailableUntil = 0;
366
399
  if (state.rateLimitedUntil > 0 && state.rateLimitedUntil <= now)
367
400
  state.rateLimitedUntil = 0;
368
401
  if (state.globalUntil > 0 && state.globalUntil <= now) {
@@ -0,0 +1,30 @@
1
+ export async function consumeCodexResetCredit(account, redeemRequestId) {
2
+ // Contract: openai/codex 1715e55076737158ba61d43158ede504de6d4ce1,
3
+ // codex-rs/backend-client/src/client/rate_limit_resets.rs and types.rs.
4
+ // Never retry a spend with a fresh ID: a lost response may have consumed it.
5
+ try {
6
+ const response = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", {
7
+ method: "POST",
8
+ headers: {
9
+ authorization: `Bearer ${account.accessToken}`,
10
+ "content-type": "application/json",
11
+ },
12
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
13
+ signal: AbortSignal.timeout(10_000),
14
+ redirect: "error",
15
+ });
16
+ if (response.ok) {
17
+ const body = await response.json();
18
+ if (body && typeof body === "object" && "code" in body) {
19
+ const code = body.code;
20
+ if (code === "reset" || code === "nothing_to_reset" || code === "no_credit" || code === "already_redeemed") {
21
+ return { code };
22
+ }
23
+ }
24
+ }
25
+ }
26
+ catch {
27
+ // Do not relay upstream bodies, credentials, or network error details.
28
+ }
29
+ throw new Error("Reset outcome unknown; retry with the same redemption ID");
30
+ }
@@ -0,0 +1,64 @@
1
+ export function createUsageResetHandler(options) {
2
+ const inFlight = new WeakSet();
3
+ // One retained snapshot per account allows an uncertain retry to reconcile
4
+ // using the ORIGINAL quota evidence, not quota learned after the first spend.
5
+ const snapshots = new WeakMap();
6
+ return async (req, res) => {
7
+ const id = req.params.id;
8
+ const requestId = req.body?.redeemRequestId;
9
+ if (typeof requestId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(requestId)) {
10
+ res.status(400).json({ error: "redeemRequestId must be a UUID" });
11
+ return;
12
+ }
13
+ const account = options.findAccount(id);
14
+ if (!account) {
15
+ res.status(404).json({ error: "ChatGPT account not found" });
16
+ return;
17
+ }
18
+ if (inFlight.has(account)) {
19
+ res.status(409).json({ error: "Reset already running for this account" });
20
+ return;
21
+ }
22
+ inFlight.add(account);
23
+ try {
24
+ if (!await options.prepare(account)) {
25
+ res.status(503).json({ error: "Account credentials unavailable; reset not submitted" });
26
+ return;
27
+ }
28
+ if (options.findAccount(id) !== account) {
29
+ res.status(404).json({ error: "ChatGPT account changed; reset not submitted" });
30
+ return;
31
+ }
32
+ const previous = snapshots.get(account);
33
+ const replay = previous?.id === requestId;
34
+ const snapshot = replay ? previous : { id: requestId, reconcile: options.captureReset?.(account) };
35
+ snapshots.set(account, snapshot);
36
+ const result = await options.consume(account, requestId);
37
+ if (result.code === "already_redeemed" && !replay) {
38
+ // This UUID predates our ownership. Repeated historical replays must
39
+ // never promote its newly captured quota snapshot into trusted evidence.
40
+ snapshot.reconcile = undefined;
41
+ }
42
+ // Refresh even for no_credit/nothing_to_reset: the displayed snapshot may
43
+ // be stale. Never fabricate windows or decrement credits locally.
44
+ let usageRefreshed = false;
45
+ if (options.findAccount(id) === account) {
46
+ try {
47
+ const usage = await options.refresh(account);
48
+ usageRefreshed = usage.ok;
49
+ if (usage.ok && (result.code === "reset" || (result.code === "already_redeemed" && replay))) {
50
+ snapshot.reconcile?.(usage.update);
51
+ }
52
+ }
53
+ catch { /* retain confirmed redemption */ }
54
+ }
55
+ res.json({ reset: { ...result, usageRefreshed } });
56
+ }
57
+ catch {
58
+ res.status(502).json({ error: "Reset outcome unknown; retry with the same redemption ID" });
59
+ }
60
+ finally {
61
+ inFlight.delete(account);
62
+ }
63
+ };
64
+ }
@@ -1,3 +1,5 @@
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";
@@ -595,6 +597,13 @@ export async function startServer(opts = {}) {
595
597
  // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
596
598
  const accountsRouter = express.Router();
597
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
+ }));
598
607
  // Shape returned to clients — NEVER includes access/refresh tokens.
599
608
  accountsRouter.get("/", (_req, res) => {
600
609
  const resolveRoutingMetrics = createRoutingMetricsResolver();
@@ -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";
@@ -531,6 +532,9 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
531
532
  const [connectError, setConnectError] = useState(null);
532
533
  const [lastUpdate, setLastUpdate] = useState(0);
533
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() });
534
538
  const resolvedBase = baseUrl
535
539
  ? baseUrl.replace(/\/+$/, "")
536
540
  : `http://localhost:${port}`;
@@ -608,14 +612,14 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
608
612
  if (!data) {
609
613
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
610
614
  }
611
- return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews }));
615
+ return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews, resetSession: resetSession.current }));
612
616
  }
613
617
  // ─── Error screen ─────────────────────────────────────────────────────────────
614
618
  function ErrorScreen({ error, port, retries }) {
615
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" })] })] }));
616
620
  }
617
621
  // ─── Live dashboard ───────────────────────────────────────────────────────────
618
- function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, }) {
622
+ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, resetSession, }) {
619
623
  const [cliRouting, setCliRouting] = useState(() => ({
620
624
  claude: readClaudeRouting(),
621
625
  codex: readCodexRouting(),
@@ -641,6 +645,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
641
645
  // ── Focus / mode ──────────────────────────────────────────────────────────
642
646
  const [focus, setFocus] = useState("logs");
643
647
  const [mode, setMode] = useState("view");
648
+ const [resetTarget, setResetTarget] = useState(null);
644
649
  // Compact ("zen") view: hides TOTALS + RECENT ACTIVITY so the account list
645
650
  // gets the whole vertical budget — the fix for a short terminal starving a
646
651
  // long fleet (e.g. showing 1 of 11 accounts). Toggled with [z], view-only,
@@ -959,6 +964,37 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
959
964
  showBanner(`Models error: ${errMsg(err)}`, "red");
960
965
  }
961
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]);
962
998
  // ── Keyboard handler ──────────────────────────────────────────────────────
963
999
  useInput((input, key) => {
964
1000
  // ── Text editing mode (w / s) ───────────────────────────────────────
@@ -990,6 +1026,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
990
1026
  }
991
1027
  return;
992
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
+ }
993
1038
  // ── Confirm delete (y/n) ────────────────────────────────────────────
994
1039
  if (mode === "confirmDelete") {
995
1040
  if (input === "y" || input === "Y") {
@@ -1019,6 +1064,27 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1019
1064
  exit();
1020
1065
  return;
1021
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
+ }
1022
1088
  if (input === "R") {
1023
1089
  void doRefreshAll();
1024
1090
  return;
@@ -1164,7 +1230,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1164
1230
  });
1165
1231
  const selectedLog = logs[selectedLogIndex] ?? null;
1166
1232
  const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
1167
- 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
1168
1234
  ? `daemon v${data.version}`
1169
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" })] })) : (
1170
1236
  // A remote router can only be restarted where it runs; printing a
@@ -1172,7 +1238,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1172
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
1173
1239
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1174
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"
1175
- ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [R] reload [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]"
1176
1242
  : focus === "models"
1177
1243
  ? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
1178
1244
  : " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
@@ -57,6 +57,22 @@ export function createAccountsApi(baseUrl, authToken) {
57
57
  return {
58
58
  list,
59
59
  refreshAll,
60
+ async resetUsage(id, redeemRequestId) {
61
+ const response = await fetch(`${base}/${encodeURIComponent(id)}/reset-usage`, {
62
+ method: "POST",
63
+ headers: { ...authHeaders, "content-type": "application/json" },
64
+ body: JSON.stringify({ redeemRequestId }),
65
+ signal: AbortSignal.timeout(REFRESH_ALL_TIMEOUT_MS),
66
+ });
67
+ if (!response.ok)
68
+ throw new Error(`HTTP ${response.status}`);
69
+ const body = await response.json();
70
+ const reset = isRecord(body) ? body.reset : undefined;
71
+ if (!isRecord(reset) || typeof reset.code !== "string" || !["reset", "nothing_to_reset", "no_credit", "already_redeemed"].includes(reset.code)
72
+ || typeof reset.usageRefreshed !== "boolean")
73
+ throw new Error("Invalid reset response");
74
+ return { code: reset.code, usageRefreshed: reset.usageRefreshed };
75
+ },
60
76
  patch(id, patch) { return send("PATCH", `/${encodeURIComponent(id)}`, patch); },
61
77
  setProviderEnabled(provider, enabled) { return send("PATCH", `/providers/${encodeURIComponent(provider)}`, { enabled }); },
62
78
  remove(id) { return send("DELETE", `/${encodeURIComponent(id)}`); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.1-rc.0",
3
+ "version": "0.12.1-rc.1",
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": {