@timo972/cc-router 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
3
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
+
import { createAccountsApi } from "./accountsApi.js";
|
|
5
|
+
import { createModelsApi } from "./modelsApi.js";
|
|
6
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
7
|
+
const LOG_VISIBLE = 20;
|
|
8
|
+
const MODEL_VISIBLE_ROWS = 16;
|
|
9
|
+
const EMPTY_RL = {
|
|
10
|
+
status: "unknown", fiveHourUtil: 0, fiveHourReset: 0,
|
|
11
|
+
sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
|
|
12
|
+
requestsLimit: 0, lastUpdated: 0,
|
|
13
|
+
};
|
|
14
|
+
export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [data, setData] = useState(null);
|
|
17
|
+
const [connectError, setConnectError] = useState(null);
|
|
18
|
+
const [lastUpdate, setLastUpdate] = useState(0);
|
|
19
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
20
|
+
const resolvedBase = baseUrl
|
|
21
|
+
? baseUrl.replace(/\/+$/, "")
|
|
22
|
+
: `http://localhost:${port}`;
|
|
23
|
+
const api = React.useMemo(() => createAccountsApi(resolvedBase, authToken), [resolvedBase, authToken]);
|
|
24
|
+
const modelsApi = React.useMemo(() => createModelsApi(resolvedBase, authToken), [resolvedBase, authToken]);
|
|
25
|
+
// Only q to quit when no live data yet (no mode to cancel)
|
|
26
|
+
useInput((input, key) => {
|
|
27
|
+
if (!data && (input === "q" || key.escape))
|
|
28
|
+
exit();
|
|
29
|
+
});
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
let cancelled = false;
|
|
32
|
+
const healthUrl = `${resolvedBase}/cc-router/health`;
|
|
33
|
+
const headers = authToken
|
|
34
|
+
? { authorization: `Bearer ${authToken}` }
|
|
35
|
+
: {};
|
|
36
|
+
const poll = async () => {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(healthUrl, {
|
|
39
|
+
headers,
|
|
40
|
+
signal: AbortSignal.timeout(1_500),
|
|
41
|
+
});
|
|
42
|
+
if (cancelled)
|
|
43
|
+
return;
|
|
44
|
+
if (res.ok) {
|
|
45
|
+
setData(await res.json());
|
|
46
|
+
setConnectError(null);
|
|
47
|
+
setLastUpdate(Date.now());
|
|
48
|
+
setRetryCount(0);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
setConnectError(`Proxy returned HTTP ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
if (cancelled)
|
|
56
|
+
return;
|
|
57
|
+
setConnectError(`Cannot connect to ${resolvedBase}`);
|
|
58
|
+
setRetryCount(n => n + 1);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
poll();
|
|
62
|
+
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
|
63
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
64
|
+
}, [resolvedBase, authToken]);
|
|
65
|
+
if (connectError) {
|
|
66
|
+
return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
|
|
67
|
+
}
|
|
68
|
+
if (!data) {
|
|
69
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
|
|
70
|
+
}
|
|
71
|
+
return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent }));
|
|
72
|
+
}
|
|
73
|
+
// ─── Error screen ─────────────────────────────────────────────────────────────
|
|
74
|
+
function ErrorScreen({ error, port, retries }) {
|
|
75
|
+
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" })] })] }));
|
|
76
|
+
}
|
|
77
|
+
// ─── Live dashboard ───────────────────────────────────────────────────────────
|
|
78
|
+
function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, }) {
|
|
79
|
+
const { exit } = useApp();
|
|
80
|
+
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
81
|
+
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
82
|
+
const logs = data.recentLogs;
|
|
83
|
+
// ── Focus / mode ──────────────────────────────────────────────────────────
|
|
84
|
+
const [focus, setFocus] = useState("logs");
|
|
85
|
+
const [mode, setMode] = useState("view");
|
|
86
|
+
// Selected log by timestamp (existing)
|
|
87
|
+
const [selectedTs, setSelectedTs] = useState(null);
|
|
88
|
+
const selectedLogIndex = selectedTs !== null
|
|
89
|
+
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
90
|
+
: 0;
|
|
91
|
+
// Selected account by id
|
|
92
|
+
const [selectedAccountId, setSelectedAccountId] = useState(null);
|
|
93
|
+
const selectedAccountIndex = selectedAccountId !== null
|
|
94
|
+
? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
|
|
95
|
+
: 0;
|
|
96
|
+
const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
|
|
97
|
+
const selectedAccountIsAnthropic = selectedAccount?.provider !== "openai_subscription";
|
|
98
|
+
const [modelsStatus, setModelsStatus] = useState(null);
|
|
99
|
+
const [selectedModelId, setSelectedModelId] = useState(null);
|
|
100
|
+
const modelRows = modelsStatus?.models ?? [];
|
|
101
|
+
const selectedModelIndex = selectedModelId !== null
|
|
102
|
+
? Math.max(0, modelRows.findIndex(m => m.id === selectedModelId))
|
|
103
|
+
: 0;
|
|
104
|
+
const selectedModel = modelRows[selectedModelIndex] ?? null;
|
|
105
|
+
// Inline text input state (for w / s keys)
|
|
106
|
+
const [editBuffer, setEditBuffer] = useState("");
|
|
107
|
+
// Transient banner (error or success, cleared after 4s).
|
|
108
|
+
// The timer handle is stored in a ref so new banners cancel the previous
|
|
109
|
+
// timeout and component unmount also clears it — otherwise a deferred
|
|
110
|
+
// setBanner can fire on an unmounted component after `n` exits Ink.
|
|
111
|
+
const [banner, setBanner] = useState(null);
|
|
112
|
+
const bannerTimerRef = useRef(null);
|
|
113
|
+
const showBanner = useCallback((text, color) => {
|
|
114
|
+
if (bannerTimerRef.current)
|
|
115
|
+
clearTimeout(bannerTimerRef.current);
|
|
116
|
+
setBanner({ text, color });
|
|
117
|
+
bannerTimerRef.current = setTimeout(() => {
|
|
118
|
+
setBanner(null);
|
|
119
|
+
bannerTimerRef.current = null;
|
|
120
|
+
}, 4_000);
|
|
121
|
+
}, []);
|
|
122
|
+
useEffect(() => () => {
|
|
123
|
+
if (bannerTimerRef.current)
|
|
124
|
+
clearTimeout(bannerTimerRef.current);
|
|
125
|
+
}, []);
|
|
126
|
+
// Normalize any thrown value to a displayable string — rejections from
|
|
127
|
+
// fetch/AbortSignal can be DOMException without .message, strings, or
|
|
128
|
+
// even undefined.
|
|
129
|
+
const errMsg = (err) => {
|
|
130
|
+
if (err instanceof Error && err.message)
|
|
131
|
+
return err.message;
|
|
132
|
+
const s = String(err ?? "");
|
|
133
|
+
return s || "unknown error";
|
|
134
|
+
};
|
|
135
|
+
// ── Async helpers (fire-and-forget with error → banner) ──────────────────
|
|
136
|
+
const doToggleEnabled = useCallback(async () => {
|
|
137
|
+
if (!selectedAccount)
|
|
138
|
+
return;
|
|
139
|
+
if (selectedAccount.provider === "openai_subscription") {
|
|
140
|
+
showBanner("OpenAI accounts are managed from the CLI", "yellow");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const newValue = !(selectedAccount.enabled !== false);
|
|
144
|
+
try {
|
|
145
|
+
await api.patch(selectedAccount.id, { enabled: newValue });
|
|
146
|
+
showBanner(`${selectedAccount.id} → ${newValue ? "enabled" : "disabled"}`, newValue ? "green" : "yellow");
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
150
|
+
}
|
|
151
|
+
}, [selectedAccount, api, showBanner]);
|
|
152
|
+
const doToggleProvider = useCallback(async (provider) => {
|
|
153
|
+
const providerStatus = provider === "anthropic_subscription"
|
|
154
|
+
? data.operational?.providers.anthropic
|
|
155
|
+
: data.operational?.providers.openai;
|
|
156
|
+
const label = provider === "anthropic_subscription" ? "Claude" : "OpenAI";
|
|
157
|
+
if (!providerStatus?.configured || providerStatus.accounts === 0) {
|
|
158
|
+
showBanner(`${label} accounts are not configured`, "yellow");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const enabled = providerStatus.enabled < providerStatus.accounts;
|
|
162
|
+
try {
|
|
163
|
+
await api.setProviderEnabled(provider, enabled);
|
|
164
|
+
showBanner(`${label} accounts → ${enabled ? "enabled" : "disabled"}`, enabled ? "green" : "yellow");
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
168
|
+
}
|
|
169
|
+
}, [api, data.operational, showBanner]);
|
|
170
|
+
const doSetLimit = useCallback(async (field, value) => {
|
|
171
|
+
if (!selectedAccount)
|
|
172
|
+
return;
|
|
173
|
+
if (selectedAccount.provider === "openai_subscription") {
|
|
174
|
+
showBanner("OpenAI accounts do not use Anthropic caps", "yellow");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await api.patch(selectedAccount.id, { [field]: value });
|
|
179
|
+
const label = field === "sessionLimitPercent" ? "5h cap" : "7d cap";
|
|
180
|
+
showBanner(`${selectedAccount.id} → ${label} = ${value}%`, "green");
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
184
|
+
}
|
|
185
|
+
}, [selectedAccount, api, showBanner]);
|
|
186
|
+
const doDelete = useCallback(async () => {
|
|
187
|
+
if (!selectedAccount)
|
|
188
|
+
return;
|
|
189
|
+
if (selectedAccount.provider === "openai_subscription") {
|
|
190
|
+
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await api.remove(selectedAccount.id);
|
|
195
|
+
showBanner(`Removed ${selectedAccount.id}`, "yellow");
|
|
196
|
+
setSelectedAccountId(null);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
showBanner(`Error: ${errMsg(err)}`, "red");
|
|
200
|
+
}
|
|
201
|
+
}, [selectedAccount, api, showBanner]);
|
|
202
|
+
const doLoadModels = useCallback(async () => {
|
|
203
|
+
try {
|
|
204
|
+
const status = await modelsApi.list();
|
|
205
|
+
setModelsStatus(status);
|
|
206
|
+
setSelectedModelId(status.models[0]?.id ?? null);
|
|
207
|
+
setFocus("models");
|
|
208
|
+
showBanner(`Loaded ${status.models.length} models`, "green");
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
showBanner(`Models error: ${errMsg(err)}`, "red");
|
|
212
|
+
}
|
|
213
|
+
}, [modelsApi, showBanner]);
|
|
214
|
+
const doSetSelectedModel = useCallback(async (provider) => {
|
|
215
|
+
if (!selectedModel)
|
|
216
|
+
return;
|
|
217
|
+
if (provider === "claude" && !selectedModel.id.startsWith("anthropic/")) {
|
|
218
|
+
showBanner("Select an anthropic/* model for Claude", "yellow");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (provider === "openai" && !selectedModel.id.startsWith("openai/")) {
|
|
222
|
+
showBanner("Select an openai/* model for OpenAI", "yellow");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const status = await modelsApi.setDefaults(provider === "claude"
|
|
227
|
+
? { claudeModel: selectedModel.id }
|
|
228
|
+
: { openAIModel: selectedModel.id });
|
|
229
|
+
setModelsStatus(previous => ({
|
|
230
|
+
routing: status.routing,
|
|
231
|
+
models: status.models.length > 0 ? status.models : previous?.models ?? [],
|
|
232
|
+
}));
|
|
233
|
+
showBanner(`${provider === "claude" ? "Claude" : "OpenAI"} default → ${selectedModel.id}`, "green");
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
showBanner(`Models error: ${errMsg(err)}`, "red");
|
|
237
|
+
}
|
|
238
|
+
}, [modelsApi, selectedModel, showBanner]);
|
|
239
|
+
// ── Keyboard handler ──────────────────────────────────────────────────────
|
|
240
|
+
useInput((input, key) => {
|
|
241
|
+
// ── Text editing mode (w / s) ───────────────────────────────────────
|
|
242
|
+
if (mode === "editSession" || mode === "editWeekly") {
|
|
243
|
+
if (key.escape) {
|
|
244
|
+
setMode("view");
|
|
245
|
+
setEditBuffer("");
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (key.return) {
|
|
249
|
+
const parsed = parseInt(editBuffer, 10);
|
|
250
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 100) {
|
|
251
|
+
const field = mode === "editSession" ? "sessionLimitPercent" : "weeklyLimitPercent";
|
|
252
|
+
void doSetLimit(field, parsed);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
showBanner("Invalid: enter a number 0–100", "red");
|
|
256
|
+
}
|
|
257
|
+
setMode("view");
|
|
258
|
+
setEditBuffer("");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (key.backspace || key.delete) {
|
|
262
|
+
setEditBuffer(b => b.slice(0, -1));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (/^[0-9]$/.test(input) && editBuffer.length < 3) {
|
|
266
|
+
setEditBuffer(b => b + input);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
// ── Confirm delete (y/n) ────────────────────────────────────────────
|
|
271
|
+
if (mode === "confirmDelete") {
|
|
272
|
+
if (input === "y" || input === "Y") {
|
|
273
|
+
void doDelete();
|
|
274
|
+
setMode("view");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
setMode("view");
|
|
278
|
+
showBanner("Delete cancelled", "gray");
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// ── Normal view mode ────────────────────────────────────────────────
|
|
283
|
+
// Always call exit() so Ink fully unmounts and releases stdin.
|
|
284
|
+
// The outer dashboardLoop reads `pendingIntent` after waitUntilExit().
|
|
285
|
+
if (input === "q") {
|
|
286
|
+
onIntent?.("quit");
|
|
287
|
+
exit();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (key.escape) {
|
|
291
|
+
if (focus === "accounts" || focus === "models") {
|
|
292
|
+
setFocus("logs");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
onIntent?.("quit");
|
|
296
|
+
exit();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (key.tab) {
|
|
300
|
+
setFocus(f => f === "logs" ? "accounts" : f === "accounts" ? "models" : "logs");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Navigation: ↑↓ move within the focused panel
|
|
304
|
+
if (focus === "logs") {
|
|
305
|
+
if (key.upArrow) {
|
|
306
|
+
const next = Math.max(0, selectedLogIndex - 1);
|
|
307
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
308
|
+
}
|
|
309
|
+
if (key.downArrow) {
|
|
310
|
+
const next = Math.min(logs.length - 1, selectedLogIndex + 1);
|
|
311
|
+
setSelectedTs(logs[next]?.ts ?? null);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (focus === "accounts") {
|
|
315
|
+
if (key.upArrow) {
|
|
316
|
+
const next = Math.max(0, selectedAccountIndex - 1);
|
|
317
|
+
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
318
|
+
}
|
|
319
|
+
if (key.downArrow) {
|
|
320
|
+
const next = Math.min(data.accounts.length - 1, selectedAccountIndex + 1);
|
|
321
|
+
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
322
|
+
}
|
|
323
|
+
// Account actions (only when focus = accounts)
|
|
324
|
+
if (input === "e") {
|
|
325
|
+
void doToggleEnabled();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (input === "a") {
|
|
329
|
+
void doToggleProvider("anthropic_subscription");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (input === "o") {
|
|
333
|
+
void doToggleProvider("openai_subscription");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (input === "w") {
|
|
337
|
+
if (!selectedAccountIsAnthropic) {
|
|
338
|
+
showBanner("OpenAI accounts do not use Anthropic caps", "yellow");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
setMode("editWeekly");
|
|
342
|
+
setEditBuffer("");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (input === "s") {
|
|
346
|
+
if (!selectedAccountIsAnthropic) {
|
|
347
|
+
showBanner("OpenAI accounts do not use Anthropic caps", "yellow");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
setMode("editSession");
|
|
351
|
+
setEditBuffer("");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (input === "d") {
|
|
355
|
+
if (!selectedAccountIsAnthropic) {
|
|
356
|
+
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
setMode("confirmDelete");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (focus === "models") {
|
|
364
|
+
if (key.upArrow) {
|
|
365
|
+
const next = Math.max(0, selectedModelIndex - 1);
|
|
366
|
+
setSelectedModelId(modelRows[next]?.id ?? null);
|
|
367
|
+
}
|
|
368
|
+
if (key.downArrow) {
|
|
369
|
+
const next = Math.min(modelRows.length - 1, selectedModelIndex + 1);
|
|
370
|
+
setSelectedModelId(modelRows[next]?.id ?? null);
|
|
371
|
+
}
|
|
372
|
+
if (input === "r") {
|
|
373
|
+
void doLoadModels();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (input === "c") {
|
|
377
|
+
void doSetSelectedModel("claude");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (input === "o") {
|
|
381
|
+
void doSetSelectedModel("openai");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (input === "m") {
|
|
386
|
+
void doLoadModels();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// n = add account — works regardless of focus.
|
|
390
|
+
// Requires an onIntent handler because the outer loop runs the OAuth
|
|
391
|
+
// flow after Ink unmounts; if none is wired, this key is a no-op.
|
|
392
|
+
if (input === "n") {
|
|
393
|
+
if (onIntent) {
|
|
394
|
+
onIntent("addAccount");
|
|
395
|
+
exit();
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
const selectedLog = logs[selectedLogIndex] ?? null;
|
|
401
|
+
const visibleLogs = logs.slice(0, LOG_VISIBLE);
|
|
402
|
+
return (_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 }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models" }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [a] Claude all [o] OpenAI all [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, 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, { marginTop: 1, 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, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _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 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
|
|
403
|
+
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
404
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
405
|
+
}
|
|
406
|
+
function OperationsPanel({ operational, baseUrl, focus }) {
|
|
407
|
+
const authLabel = operational.auth.required ? "protected" : "open";
|
|
408
|
+
const authColor = operational.auth.required ? "green" : "yellow";
|
|
409
|
+
const claudeReady = operational.capabilities.anthropicMessages;
|
|
410
|
+
const openAIReady = operational.capabilities.openAIResponses;
|
|
411
|
+
const crossReady = operational.capabilities.crossProviderMessages;
|
|
412
|
+
const modelsReady = operational.capabilities.dynamicModels;
|
|
413
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " OPERATIONS " }), _jsx(Text, { color: "gray", children: "base " }), _jsx(Text, { color: "cyan", children: baseUrl }), _jsx(Text, { color: "gray", children: " \u00B7 auth " }), _jsx(Text, { color: authColor, children: authLabel }), _jsx(Text, { color: "gray", children: " \u00B7 models " }), _jsx(Text, { color: modelsReady ? "green" : "red", children: modelsReady ? "dynamic" : "off" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(ProviderBadge, { label: "Claude", status: operational.providers.anthropic, ready: claudeReady }), _jsx(Text, { color: "gray", children: " " }), _jsx(ProviderBadge, { label: "OpenAI", status: operational.providers.openai, ready: openAIReady }), _jsx(Text, { color: "gray", children: " \u00B7 cross-route " }), _jsx(Text, { color: crossReady ? "green" : "gray", children: crossReady ? "ready" : "needs OpenAI" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "endpoints " }), _jsx(Text, { color: "white", children: operational.endpoints.messages }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.responses }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.models }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.accounts })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "routing " }), _jsxs(Text, { color: "white", children: ["claude=", operational.routing.anthropicDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.anthropicAliases.join(",") || "-", "]"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", operational.routing.openAIDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.openAIAliases.join(",") || "-", "]"] })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "models " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[m] list/select" }), _jsx(Text, { color: "gray", children: " change " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[c] Claude [o] OpenAI" })] })] }));
|
|
414
|
+
}
|
|
415
|
+
function ModelsPanel({ status, selectedIndex, focused, }) {
|
|
416
|
+
const models = status?.models ?? [];
|
|
417
|
+
const visible = getVisibleModelWindow(models, selectedIndex, MODEL_VISIBLE_ROWS);
|
|
418
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " MODELS " }), _jsx(Text, { color: "gray", children: "[m/r] refresh [\u2191/\u2193] select [c] Claude default [o] OpenAI default [Esc] logs" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "current " }), _jsxs(Text, { color: "white", children: ["claude=", status?.routing.anthropicDefaultModel ?? "default"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", status?.routing.openAIDefaultModel ?? "default"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: status === null
|
|
419
|
+
? _jsx(Text, { color: "gray", children: " Press [m] to load models from provider APIs" })
|
|
420
|
+
: models.length === 0
|
|
421
|
+
? _jsx(Text, { color: "gray", children: " No models discovered" })
|
|
422
|
+
: (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" showing ", visible.start + 1, "-", visible.end, " of ", models.length] }), visible.rows.map((model, i) => (_jsx(ModelRow, { model: model, selected: focused && visible.start + i === selectedIndex, currentClaude: status.routing.anthropicDefaultModel, currentOpenAI: status.routing.openAIDefaultModel }, model.id)))] })) })] }));
|
|
423
|
+
}
|
|
424
|
+
export function getVisibleModelWindow(models, selectedIndex, maxRows = MODEL_VISIBLE_ROWS) {
|
|
425
|
+
if (models.length <= maxRows) {
|
|
426
|
+
return { rows: models, start: 0, end: models.length };
|
|
427
|
+
}
|
|
428
|
+
const selected = Math.max(0, Math.min(selectedIndex, models.length - 1));
|
|
429
|
+
const start = Math.max(0, Math.min(selected - maxRows + 1, models.length - maxRows));
|
|
430
|
+
const end = Math.min(models.length, start + maxRows);
|
|
431
|
+
return {
|
|
432
|
+
rows: models.slice(start, end),
|
|
433
|
+
start,
|
|
434
|
+
end,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function ModelRow({ model, selected, currentClaude, currentOpenAI, }) {
|
|
438
|
+
const id = model.id;
|
|
439
|
+
const upstream = id.replace(/^anthropic\//, "").replace(/^openai\//, "");
|
|
440
|
+
const isClaudeDefault = currentClaude === upstream || id === "claude/default";
|
|
441
|
+
const isOpenAIDefault = currentOpenAI === upstream || id === "openai/default";
|
|
442
|
+
const providerColor = id.startsWith("openai/") ? "cyan" : id.startsWith("anthropic/") ? "magenta" : "gray";
|
|
443
|
+
const marker = isClaudeDefault ? " Claude" : isOpenAIDefault ? " OpenAI" : "";
|
|
444
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "▶" : " " }), _jsxs(Text, { color: providerColor, children: [" ", id] }), marker && _jsx(Text, { color: "green", children: marker })] }));
|
|
445
|
+
}
|
|
446
|
+
function ProviderBadge({ label, status, ready, }) {
|
|
447
|
+
const color = !status.configured ? "gray" : ready ? "green" : "yellow";
|
|
448
|
+
const text = status.configured
|
|
449
|
+
? `${label} ${status.healthy}/${status.accounts} healthy`
|
|
450
|
+
: `${label} not configured`;
|
|
451
|
+
return _jsx(Text, { color: color, children: text });
|
|
452
|
+
}
|
|
453
|
+
// ─── Account row (two-line: status + utilization bars) ───────────────────────
|
|
454
|
+
function AccountRow({ account: a, selected }) {
|
|
455
|
+
const rl = a.rateLimits ?? EMPTY_RL;
|
|
456
|
+
const isLimited = rl.status === "rate_limited";
|
|
457
|
+
const isDisabled = a.enabled === false;
|
|
458
|
+
const dot = isDisabled ? "⊘" : isLimited ? "⊘" : a.busy ? "◌" : a.healthy ? "●" : "●";
|
|
459
|
+
const dotColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
460
|
+
const statusLabel = isDisabled ? "OFF " : isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
|
|
461
|
+
const statusColor = isDisabled ? "gray" : isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
462
|
+
const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
|
|
463
|
+
const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
|
|
464
|
+
: a.expiresInMs < 30 * 60 * 1000 ? "yellow"
|
|
465
|
+
: "white";
|
|
466
|
+
const providerTag = a.provider === "openai_subscription"
|
|
467
|
+
? " [OpenAI]"
|
|
468
|
+
: rl.plan ? ` [${rl.plan}]` : "";
|
|
469
|
+
// User-defined caps hint
|
|
470
|
+
const s5 = a.sessionLimitPercent ?? 100;
|
|
471
|
+
const w7 = a.weeklyLimitPercent ?? 100;
|
|
472
|
+
const hasCaps = s5 < 100 || w7 < 100;
|
|
473
|
+
const capsHint = hasCaps
|
|
474
|
+
? ` cap${s5 < 100 ? ` 5h≤${s5}%` : ""}${w7 < 100 ? ` 7d≤${w7}%` : ""}`
|
|
475
|
+
: "";
|
|
476
|
+
const pointer = selected ? "▶" : " ";
|
|
477
|
+
const nameColor = isDisabled ? "gray" : undefined;
|
|
478
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: pointer }), _jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { color: nameColor, dimColor: isDisabled, children: a.id.slice(0, 20).padEnd(20) }), _jsx(Text, { color: statusColor, children: statusLabel }), providerTag && _jsx(Text, { color: a.provider === "openai_subscription" ? "cyan" : "magenta", children: providerTag.padEnd(10) }), !providerTag && _jsx(Text, { children: "".padEnd(10) }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " tok " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(8) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) }), a.provider !== "openai_subscription" && (_jsxs(Text, { color: "gray", children: [" ", a.activeSessions ?? 0, " active / ", a.inFlightRequests ?? 0, " streams"] })), capsHint && _jsx(Text, { color: "yellow", children: capsHint })] }), rl.lastUpdated > 0 && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: rl.fiveHourUtil, resetTs: rl.fiveHourReset, isActive: rl.claim === "five_hour", cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d", util: rl.sevenDayUtil, resetTs: rl.sevenDayReset, isActive: rl.claim === "seven_day", cap: w7 })] }))] }));
|
|
479
|
+
}
|
|
480
|
+
// ─── Utilization bar ─────────────────────────────────────────────────────────
|
|
481
|
+
function UtilBar({ label, util, resetTs, isActive, cap }) {
|
|
482
|
+
const pct = Math.round(util * 100);
|
|
483
|
+
const BAR_W = 12;
|
|
484
|
+
const filled = Math.round(util * BAR_W);
|
|
485
|
+
const capPos = Math.round((cap / 100) * BAR_W);
|
|
486
|
+
const bar = "█".repeat(Math.min(filled, BAR_W)) + "░".repeat(Math.max(BAR_W - filled, 0));
|
|
487
|
+
const color = pct >= cap ? "red" : pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
|
|
488
|
+
const resetLabel = resetTs > 0 ? formatResetIn(resetTs) : "";
|
|
489
|
+
const capLabel = cap < 100 ? ` cap ${cap}%` : "";
|
|
490
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? "white" : "gray", bold: isActive, children: [label, " "] }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { color: color, children: [String(pct).padStart(4), "%"] }), capLabel && _jsx(Text, { color: "yellow", children: capLabel }), resetLabel && _jsxs(Text, { color: "gray", children: [" \u21BB", resetLabel] })] }));
|
|
491
|
+
}
|
|
492
|
+
function formatResetIn(unixSeconds) {
|
|
493
|
+
const diff = unixSeconds - Date.now() / 1000;
|
|
494
|
+
if (diff <= 0)
|
|
495
|
+
return "now";
|
|
496
|
+
const d = Math.floor(diff / 86400);
|
|
497
|
+
const h = Math.floor((diff % 86400) / 3600);
|
|
498
|
+
const m = Math.floor((diff % 3600) / 60);
|
|
499
|
+
if (d > 0)
|
|
500
|
+
return `${d}d${h}h`;
|
|
501
|
+
if (h > 0)
|
|
502
|
+
return `${h}h${m}m`;
|
|
503
|
+
return `${m}m`;
|
|
504
|
+
}
|
|
505
|
+
// ─── Log row ──────────────────────────────────────────────────────────────────
|
|
506
|
+
function LogRow({ log, selected }) {
|
|
507
|
+
const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
|
|
508
|
+
const isError = log.type === "error";
|
|
509
|
+
const isRefresh = log.type === "refresh";
|
|
510
|
+
const typeColor = isError ? "red" : isRefresh ? "yellow" : "gray";
|
|
511
|
+
const typeIcon = isError ? "✗" : isRefresh ? "↻" : "→";
|
|
512
|
+
const statusColor = log.statusCode === undefined ? undefined
|
|
513
|
+
: log.statusCode >= 500 ? "red"
|
|
514
|
+
: log.statusCode >= 400 ? "yellow"
|
|
515
|
+
: log.statusCode >= 200 ? "green"
|
|
516
|
+
: "gray";
|
|
517
|
+
const bg = selected ? "white" : undefined;
|
|
518
|
+
const fg = (c) => selected ? "black" : c;
|
|
519
|
+
const sourceLabel = log.source === "cli" ? "cli"
|
|
520
|
+
: log.source === "desktop" ? "dsk"
|
|
521
|
+
: log.source === "api" ? "api"
|
|
522
|
+
: " ";
|
|
523
|
+
const sourceColor = log.source === "cli" ? "blue"
|
|
524
|
+
: log.source === "desktop" ? "magenta"
|
|
525
|
+
: "gray";
|
|
526
|
+
// Per-request token stats
|
|
527
|
+
const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
|
|
528
|
+
const outputTok = log.outputTokens ?? 0;
|
|
529
|
+
const cacheHitPct = inputTok > 0 ? Math.round(((log.cacheReadTokens ?? 0) / inputTok) * 100) : null;
|
|
530
|
+
const cacheColor = cacheHitPct === null ? undefined
|
|
531
|
+
: cacheHitPct >= 70 ? "green"
|
|
532
|
+
: cacheHitPct >= 30 ? "yellow"
|
|
533
|
+
: "red";
|
|
534
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(sourceColor), children: [sourceLabel, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
|
|
535
|
+
? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
|
|
536
|
+
: _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), (inputTok > 0 || outputTok > 0) && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", fmtTok(inputTok), "\u2191 ", fmtTok(outputTok), "\u2193"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
|
|
537
|
+
}
|
|
538
|
+
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
|
539
|
+
function DetailPanel({ log }) {
|
|
540
|
+
const time = new Date(log.ts).toLocaleString("en-GB", {
|
|
541
|
+
hour12: false,
|
|
542
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
543
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
544
|
+
});
|
|
545
|
+
const isError = log.type === "error";
|
|
546
|
+
const statusLabel = log.statusCode === undefined ? "—"
|
|
547
|
+
: log.statusCode === 0 ? "connection error"
|
|
548
|
+
: `${log.statusCode} ${httpStatusText(log.statusCode)}`;
|
|
549
|
+
const statusColor = log.statusCode === undefined ? "gray"
|
|
550
|
+
: log.statusCode === 0 ? "red"
|
|
551
|
+
: log.statusCode >= 500 ? "red"
|
|
552
|
+
: log.statusCode >= 400 ? "yellow"
|
|
553
|
+
: "green";
|
|
554
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type }), _jsx(Field, { label: "Source", value: sourceFullLabel(log.source) })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
|
|
555
|
+
}
|
|
556
|
+
function Field({ label, value }) {
|
|
557
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
|
|
558
|
+
}
|
|
559
|
+
function FieldColored({ label, value, color }) {
|
|
560
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
|
|
561
|
+
}
|
|
562
|
+
// ─── Cache health badge (aggregated) ─────────────────────────────────────────
|
|
563
|
+
function CacheHealthBadge({ read, created, input }) {
|
|
564
|
+
const total = read + created + input;
|
|
565
|
+
if (total === 0)
|
|
566
|
+
return null;
|
|
567
|
+
const hitPct = Math.round((read / total) * 100);
|
|
568
|
+
const color = hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
|
|
569
|
+
const label = hitPct >= 70 ? "healthy" : hitPct >= 30 ? "fair" : "poor";
|
|
570
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "cache " }), _jsxs(Text, { color: color, children: [hitPct, "% hit "] }), _jsxs(Text, { color: "gray", children: ["(", label, ")"] })] }));
|
|
571
|
+
}
|
|
572
|
+
// ─── Cache breakdown (per-request detail) ────────────────────────────────────
|
|
573
|
+
function CacheBreakdown({ read, created, input, output }) {
|
|
574
|
+
const totalInput = read + created + input;
|
|
575
|
+
const hitPct = totalInput > 0 ? (read / totalInput) * 100 : 0;
|
|
576
|
+
const color = totalInput === 0 ? "gray" : hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
|
|
577
|
+
return (_jsxs(_Fragment, { children: [_jsx(FieldColored, { label: "Cache hit", value: totalInput > 0 ? `${fmtTok(read)} tok (${hitPct.toFixed(1)}%)` : "—", color: color }), _jsx(Field, { label: "Cache created", value: fmtTok(created) + " tok" }), _jsx(Field, { label: "Uncached", value: fmtTok(input) + " tok" }), _jsx(Field, { label: "Total input", value: fmtTok(totalInput) + " tok" }), _jsx(Field, { label: "Output", value: fmtTok(output) + " tok" }), _jsx(Field, { label: "Total", value: fmtTok(totalInput + output) + " tok" })] }));
|
|
578
|
+
}
|
|
579
|
+
// ─── Token summary (aggregated totals) ──────────────────────────────────────
|
|
580
|
+
function TokenSummary({ cacheRead, cacheCreated, uncached, output }) {
|
|
581
|
+
const totalInput = cacheRead + cacheCreated + uncached;
|
|
582
|
+
const totalAll = totalInput + output;
|
|
583
|
+
if (totalAll === 0)
|
|
584
|
+
return null;
|
|
585
|
+
return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "input " }), _jsx(Text, { color: "white", children: fmtTok(totalInput) }), _jsx(Text, { color: "gray", children: " (cached " }), _jsx(Text, { color: "green", children: fmtTok(cacheRead) }), _jsx(Text, { color: "gray", children: " + new " }), _jsx(Text, { color: "yellow", children: fmtTok(cacheCreated) }), _jsx(Text, { color: "gray", children: " + uncached " }), _jsx(Text, { color: "white", children: fmtTok(uncached) }), _jsx(Text, { color: "gray", children: ") \u00B7 output " }), _jsx(Text, { color: "white", children: fmtTok(output) }), _jsx(Text, { color: "gray", children: " \u00B7 total " }), _jsx(Text, { color: "cyan", bold: true, children: fmtTok(totalAll) })] }));
|
|
586
|
+
}
|
|
587
|
+
function fmtTok(n) {
|
|
588
|
+
if (n >= 1_000_000)
|
|
589
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
590
|
+
if (n >= 1_000)
|
|
591
|
+
return `${(n / 1_000).toFixed(1)}k`;
|
|
592
|
+
return String(n);
|
|
593
|
+
}
|
|
594
|
+
// ─── Source label ─────────────────────────────────────────────────────────────
|
|
595
|
+
function sourceFullLabel(source) {
|
|
596
|
+
if (source === "cli")
|
|
597
|
+
return "Claude Code";
|
|
598
|
+
if (source === "desktop")
|
|
599
|
+
return "Claude Desktop";
|
|
600
|
+
if (source === "api")
|
|
601
|
+
return "API";
|
|
602
|
+
return "—";
|
|
603
|
+
}
|
|
604
|
+
// ─── HTTP status text ─────────────────────────────────────────────────────────
|
|
605
|
+
function httpStatusText(code) {
|
|
606
|
+
const map = {
|
|
607
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
608
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
609
|
+
404: "Not Found", 429: "Too Many Requests",
|
|
610
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
611
|
+
503: "Service Unavailable", 529: "Overloaded",
|
|
612
|
+
};
|
|
613
|
+
return map[code] ?? "";
|
|
614
|
+
}
|
|
615
|
+
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
616
|
+
function formatUptime(seconds) {
|
|
617
|
+
const h = Math.floor(seconds / 3_600);
|
|
618
|
+
const m = Math.floor((seconds % 3_600) / 60);
|
|
619
|
+
const s = seconds % 60;
|
|
620
|
+
if (h > 0)
|
|
621
|
+
return `${h}h ${m}m`;
|
|
622
|
+
if (m > 0)
|
|
623
|
+
return `${m}m ${s}s`;
|
|
624
|
+
return `${s}s`;
|
|
625
|
+
}
|
|
626
|
+
function formatMs(ms) {
|
|
627
|
+
const totalMin = Math.round(ms / 60_000);
|
|
628
|
+
if (totalMin >= 60)
|
|
629
|
+
return `${Math.floor(totalMin / 60)}h ${totalMin % 60}m`;
|
|
630
|
+
return `${totalMin}m`;
|
|
631
|
+
}
|
|
632
|
+
function formatAgo(ts) {
|
|
633
|
+
if (!ts)
|
|
634
|
+
return "never";
|
|
635
|
+
const s = Math.round((Date.now() - ts) / 1_000);
|
|
636
|
+
if (s < 60)
|
|
637
|
+
return `${s}s ago`;
|
|
638
|
+
const m = Math.floor(s / 60);
|
|
639
|
+
return `${m}m ago`;
|
|
640
|
+
}
|