@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 +5 -0
- package/README.md +19 -0
- package/dist/providers/openai/token-pool.js +35 -2
- package/dist/providers/openai/usage-reset.js +30 -0
- package/dist/proxy/account-usage-reset.js +64 -0
- package/dist/proxy/server.js +9 -0
- package/dist/ui/Dashboard.js +70 -4
- package/dist/ui/accountsApi.js +16 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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();
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -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" }) })] }) }));
|
package/dist/ui/accountsApi.js
CHANGED
|
@@ -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