@timo972/cc-router 0.10.0-rc.1 → 0.10.0-rc.3
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 +83 -1
- package/dist/cli/cmd-accounts.js +103 -1
- package/dist/cli/cmd-start.js +14 -1
- package/dist/config/manager.js +19 -0
- package/dist/daemon/launcher.js +70 -23
- package/dist/daemon/pid.js +14 -0
- package/dist/daemon/service.js +64 -5
- package/dist/providers/openai/token-pool.js +22 -0
- package/dist/proxy/account-patch.js +10 -0
- package/dist/proxy/account-rename.js +48 -0
- package/dist/proxy/anthropic-routing.js +15 -0
- package/dist/proxy/messages-cross-route.js +5 -1
- package/dist/proxy/openai-ingress.js +2 -0
- package/dist/proxy/responses-server.js +3 -0
- package/dist/proxy/server.js +58 -4
- package/dist/proxy/session-router.js +23 -0
- package/dist/proxy/token-pool.js +22 -0
- package/dist/ui/Dashboard.js +103 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,89 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `cc-router accounts rename <id> <new-id>`. An account's id is the key its
|
|
14
|
+
routing state hangs off — in-flight counters and sticky session bindings are
|
|
15
|
+
both id-keyed — so on a running proxy the rename runs as a transaction
|
|
16
|
+
(`PATCH /cc-router/accounts/:id` with `{"id": ...}`): pool, session router,
|
|
17
|
+
and accounts.json move together, and are rolled back together if persistence
|
|
18
|
+
fails. Sticky sessions keep their prompt-cache affinity through the rename.
|
|
19
|
+
With no proxy running the record is renamed on disk. A proxy from before
|
|
20
|
+
this feature answers the PATCH with 200 having silently ignored the field —
|
|
21
|
+
that is detected and reported as an error rather than falling back to a disk
|
|
22
|
+
write its refresh loop would overwrite. The id namespace is shared across
|
|
23
|
+
both providers, so a rename onto any existing account name is refused (409).
|
|
24
|
+
|
|
25
|
+
- The health endpoint reports the daemon's version, and the dashboard shows a
|
|
26
|
+
version-mismatch banner when the daemon runs a different build than the CLI
|
|
27
|
+
rendering it. A service manager can keep an old build alive long after an
|
|
28
|
+
upgrade — launchd pins the versioned pnpm store path in its plist, so the
|
|
29
|
+
daemon silently stays on the old version across upgrades and even reboots —
|
|
30
|
+
and every log row and account view on the dashboard comes from that build.
|
|
31
|
+
Until now nothing surfaced this: a fix could ship, the package could update,
|
|
32
|
+
and the dashboard would still render the old daemon's output as if the new
|
|
33
|
+
version were broken. A daemon that reports no version at all predates the
|
|
34
|
+
field, which is itself proof it is outdated, and banners the same way.
|
|
35
|
+
- The activity list scrolls to follow its selection. The dashboard renders the
|
|
36
|
+
newest 20 of up to 50 entries, but the arrow keys would walk the selection
|
|
37
|
+
through all 50 — past row 20 the highlight left the screen while the detail
|
|
38
|
+
panel kept updating for rows that were not visible. The window now shifts by
|
|
39
|
+
one row when the selection steps past its bottom or top edge, stays put while
|
|
40
|
+
the selection moves inside it, and re-clamps when new entries push the
|
|
41
|
+
selected row (which is timestamp-anchored) out of the stored window.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- OpenAI activity rows carry the same columns as Claude ones. The OpenAI ingress
|
|
46
|
+
recorded a path but no method and no client, and the dashboard needs both
|
|
47
|
+
`method` and `path` to render the request — so those rows fell back to the
|
|
48
|
+
bare entry type and read `route` under a blank client column, beside
|
|
49
|
+
`POST /messages` and `cli` on the Claude rows. Codex CLI traffic now reports
|
|
50
|
+
a `codex` source of its own rather than borrowing `cli`, which the detail
|
|
51
|
+
panel spells out as "Claude Code"; a `/v1/messages` request that cross-routes
|
|
52
|
+
to an OpenAI backend is still classified by the client that sent it.
|
|
53
|
+
- An OpenAI account's usage bars are labelled from each window's own duration
|
|
54
|
+
instead of by position. Codex reports its weekly window in the `primary` slot
|
|
55
|
+
and leaves `secondary` empty, but the bars assumed primary meant 5h and
|
|
56
|
+
secondary meant weekly — so an account at 100% of its weekly quota displayed
|
|
57
|
+
as `5h 100%` next to a `weekly 0%` bar that was really the empty slot. The
|
|
58
|
+
countdown gave it away: a 5h window cannot reset five days out.
|
|
59
|
+
- A named Codex bucket no longer renders twice. Codex sends an absent window as
|
|
60
|
+
an all-zero placeholder rather than omitting it, so the empty `secondary` was
|
|
61
|
+
treated as real and emitted a second row — carrying the same label as the
|
|
62
|
+
first, because a zero-length window falls through to a guessed one.
|
|
63
|
+
- An account id exactly as long as its column no longer runs into the status
|
|
64
|
+
next to it (`plus-developer-droidLIMITED`).
|
|
65
|
+
- The status dashboard can enable, disable, and remove OpenAI accounts. Three
|
|
66
|
+
guards still sent the operator to the CLI for operations the management
|
|
67
|
+
endpoints had already gained: `e` answered "OpenAI accounts are managed from
|
|
68
|
+
the CLI", and delete refused both at the keypress and again inside the
|
|
69
|
+
confirmation, so the second gate would have caught anyone who got past the
|
|
70
|
+
first. The cap keys (`w`/`s`) never had such a check, which is what made the
|
|
71
|
+
inconsistency visible.
|
|
72
|
+
- `cc-router start` no longer has to be run twice. In service mode it wrote the
|
|
73
|
+
LaunchAgent plist and immediately bootstrapped it, but `launchctl bootout`
|
|
74
|
+
returns as soon as launchd accepts the request — not once the job is gone.
|
|
75
|
+
Bootstrapping the same label during that window fails with
|
|
76
|
+
`Bootstrap failed: 5: Input/output error`, and the legacy `launchctl load`
|
|
77
|
+
fallback fails identically, so the command printed a warning and exited
|
|
78
|
+
successfully with nothing running. It now waits for launchd to release the
|
|
79
|
+
label before loading, and retries the bootstrap until a deadline.
|
|
80
|
+
- A failed start is no longer reported as a success. Service mode installed the
|
|
81
|
+
service and returned without checking that anything was listening — the
|
|
82
|
+
background path already health-checked, the service path did not. It now
|
|
83
|
+
polls the health endpoint and exits non-zero with the log location if the
|
|
84
|
+
proxy never answers.
|
|
85
|
+
- `cc-router stop` waits for the proxy to actually exit before reporting
|
|
86
|
+
success. With no PID file the stop fell through to killing by port, which
|
|
87
|
+
returned as soon as SIGTERM was sent; a `start` issued straight afterwards
|
|
88
|
+
then raced the still-running process. The port path now waits for the
|
|
89
|
+
process to die and escalates to SIGKILL, matching the PID path.
|
|
90
|
+
- A service-managed proxy writes a PID file. `writePid`/`removePid` were gated
|
|
91
|
+
on `CC_ROUTER_DAEMON`, which the LaunchAgent and systemd unit never set —
|
|
92
|
+
they set `CC_ROUTER_SERVICE` — so every service-managed instance left no PID
|
|
93
|
+
behind and took the weaker port-based stop path.
|
|
12
94
|
|
|
13
95
|
---
|
|
14
96
|
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, readConfig, serialize } from "../config/manager.js";
|
|
2
|
+
import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, renameAccountRecordById, readConfig, serialize } from "../config/manager.js";
|
|
3
3
|
import { saveAccounts } from "../proxy/token-refresher.js";
|
|
4
4
|
import { formatExpiry, redactToken } from "../utils/token-extractor.js";
|
|
5
5
|
import { PROXY_PORT } from "../config/paths.js";
|
|
6
6
|
import { createOpenAIAccountRecord } from "../providers/openai/account-record.js";
|
|
7
7
|
import { loginOpenAIWithDeviceCode } from "../providers/openai/device-oauth.js";
|
|
8
|
+
import { isValidAccountId } from "../proxy/account-rename.js";
|
|
8
9
|
export function registerAccounts(program) {
|
|
9
10
|
const accounts = program
|
|
10
11
|
.command("accounts")
|
|
@@ -233,6 +234,47 @@ export function registerAccounts(program) {
|
|
|
233
234
|
console.log(chalk.yellow(" No accounts left. Run: cc-router setup"));
|
|
234
235
|
}
|
|
235
236
|
});
|
|
237
|
+
accounts
|
|
238
|
+
.command("rename <id> <new-id>")
|
|
239
|
+
.description("Rename an account — its routing state and sticky sessions follow the new name")
|
|
240
|
+
.action(async (id, newId) => {
|
|
241
|
+
if (!accountsFileExists()) {
|
|
242
|
+
console.log(chalk.yellow("No accounts configured."));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!isValidAccountId(newId)) {
|
|
246
|
+
console.log(chalk.red(`✗ "${newId}" is not a valid account name.`));
|
|
247
|
+
console.log(chalk.gray(" 1-64 characters: alphanumeric start, then letters, digits, dots, underscores, or dashes."));
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
const { ids: existingIds } = mergeAccountInventory(loadAccounts().map(a => a.id), loadOpenAIAccounts().map(a => a.id), await fetchLiveStats());
|
|
251
|
+
if (!existingIds.includes(id)) {
|
|
252
|
+
console.log(chalk.red(`✗ Account "${id}" not found.`));
|
|
253
|
+
console.log(chalk.gray(` Available: ${existingIds.join(", ")}`));
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
if (id === newId) {
|
|
257
|
+
console.log(chalk.gray(`Account is already named "${newId}".`));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (existingIds.includes(newId)) {
|
|
261
|
+
console.log(chalk.red(`✗ An account named "${newId}" already exists.`));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
let result;
|
|
265
|
+
try {
|
|
266
|
+
result = await renameAccountRuntimeAware(id, newId);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
270
|
+
console.log(chalk.red(`✗ Could not rename "${id}": ${message}`));
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
console.log(chalk.green(`✓ Renamed "${id}" → "${newId}".`));
|
|
274
|
+
console.log(result.mode === "live"
|
|
275
|
+
? chalk.gray(" Applied to the running proxy — in-flight requests and sticky sessions follow the new name.")
|
|
276
|
+
: chalk.gray(" Saved to accounts.json — loads on next start: cc-router start"));
|
|
277
|
+
});
|
|
236
278
|
}
|
|
237
279
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
238
280
|
/** Tell the user whether the new account is already live or needs a restart. */
|
|
@@ -326,6 +368,66 @@ export async function tryRemoveAccountFromRunningProxy(id, options = {}) {
|
|
|
326
368
|
}
|
|
327
369
|
return true;
|
|
328
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Rename an account on a running proxy. Returns false only when no proxy can
|
|
373
|
+
* be reached (the caller then renames on disk); HTTP errors are authoritative
|
|
374
|
+
* and thrown. A 200 whose returned account still carries the old id means the
|
|
375
|
+
* proxy predates rename support — its patch validation drops unknown fields
|
|
376
|
+
* and reports success having done nothing — and MUST be an error, not a
|
|
377
|
+
* fallthrough to disk: that proxy's refresh loop persists its own snapshot
|
|
378
|
+
* over accounts.json and would silently undo a disk-side rename.
|
|
379
|
+
*/
|
|
380
|
+
export async function tryRenameAccountOnRunningProxy(id, newId, options = {}) {
|
|
381
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
382
|
+
const baseUrl = (options.baseUrl ?? `http://localhost:${PROXY_PORT}`).replace(/\/+$/, "");
|
|
383
|
+
const authToken = options.authToken ?? readConfig().proxySecret;
|
|
384
|
+
let response;
|
|
385
|
+
try {
|
|
386
|
+
response = await fetchImpl(`${baseUrl}/cc-router/accounts/${encodeURIComponent(id)}`, {
|
|
387
|
+
method: "PATCH",
|
|
388
|
+
headers: {
|
|
389
|
+
"content-type": "application/json",
|
|
390
|
+
...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
|
|
391
|
+
},
|
|
392
|
+
body: JSON.stringify({ id: newId }),
|
|
393
|
+
signal: AbortSignal.timeout(3_000),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
if (!response.ok) {
|
|
400
|
+
let detail = "";
|
|
401
|
+
try {
|
|
402
|
+
const payload = await response.json();
|
|
403
|
+
if (typeof payload.error === "string")
|
|
404
|
+
detail = `: ${payload.error}`;
|
|
405
|
+
}
|
|
406
|
+
catch { /* best effort */ }
|
|
407
|
+
throw new Error(`HTTP ${response.status}${detail}`);
|
|
408
|
+
}
|
|
409
|
+
let renamedId;
|
|
410
|
+
try {
|
|
411
|
+
const payload = await response.json();
|
|
412
|
+
renamedId = payload.account?.id;
|
|
413
|
+
}
|
|
414
|
+
catch { /* fall through to the mismatch error below */ }
|
|
415
|
+
if (renamedId !== newId) {
|
|
416
|
+
throw new Error("the running proxy does not support rename (older version) — update and restart it first: cc-router stop --keep-config && cc-router start");
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
export async function renameAccountRuntimeAware(id, newId, dependencies = {
|
|
421
|
+
tryRenameLive: tryRenameAccountOnRunningProxy,
|
|
422
|
+
renameStored: renameAccountRecordById,
|
|
423
|
+
}) {
|
|
424
|
+
if (await dependencies.tryRenameLive(id, newId))
|
|
425
|
+
return { mode: "live" };
|
|
426
|
+
const renamed = dependencies.renameStored(id, newId);
|
|
427
|
+
if (!renamed)
|
|
428
|
+
throw new Error(`Account "${id}" disappeared before it could be renamed`);
|
|
429
|
+
return { mode: "stored", renamed };
|
|
430
|
+
}
|
|
329
431
|
export async function removeAccountRuntimeAware(id, dependencies = {
|
|
330
432
|
tryRemoveLive: tryRemoveAccountFromRunningProxy,
|
|
331
433
|
removeStored: removeAccountRecordById,
|
package/dist/cli/cmd-start.js
CHANGED
|
@@ -4,7 +4,7 @@ import { PROXY_PORT, LITELLM_PORT, ACCOUNTS_PATH } from "../config/paths.js";
|
|
|
4
4
|
import { accountsFileExists, readConfig, writeConfig, generateProxySecret, } from "../config/manager.js";
|
|
5
5
|
import { writeClaudeSettings } from "../utils/claude-config.js";
|
|
6
6
|
import { checkForUpdate, performUpdate, PKG_NAME } from "../utils/self-update.js";
|
|
7
|
-
import { launchDaemon } from "../daemon/launcher.js";
|
|
7
|
+
import { launchDaemon, waitForHealth } from "../daemon/launcher.js";
|
|
8
8
|
import { installService } from "../daemon/service.js";
|
|
9
9
|
import { getLocalIPs } from "../utils/network.js";
|
|
10
10
|
export function registerStart(program) {
|
|
@@ -79,6 +79,19 @@ export function registerStart(program) {
|
|
|
79
79
|
}
|
|
80
80
|
if (prefs.mode === "service") {
|
|
81
81
|
await installService(prefs.serverMode);
|
|
82
|
+
// Installing the service is not the same as the proxy being up: if
|
|
83
|
+
// launchd rejects the load, `installService` only warns. Verify, so a
|
|
84
|
+
// failed start is not reported as a success.
|
|
85
|
+
if (await waitForHealth(port, 10_000)) {
|
|
86
|
+
console.log(chalk.green(`✓ CC-Router running on port ${port}`));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(chalk.yellow(`\n⚠ Service configured, but nothing is answering on port ${port}.`));
|
|
90
|
+
console.log(chalk.gray(` Check the logs: cc-router logs`));
|
|
91
|
+
console.log(chalk.gray(` Then try again: cc-router start`));
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
82
95
|
}
|
|
83
96
|
else {
|
|
84
97
|
// background mode
|
package/dist/config/manager.js
CHANGED
|
@@ -90,6 +90,25 @@ export function removeAccountRecordById(id) {
|
|
|
90
90
|
writeAccountsAtomicToPath(ACCOUNTS_PATH, existing.filter(a => a.id !== id));
|
|
91
91
|
return removed;
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Rename a stored account record in place, keeping every other field. The
|
|
95
|
+
* uniqueness check spans ALL providers — both live in one accounts.json and
|
|
96
|
+
* one URL namespace, so two records sharing an id would be unaddressable.
|
|
97
|
+
* Returns the renamed record, or null if no record has `oldId`.
|
|
98
|
+
*/
|
|
99
|
+
export function renameAccountRecordById(oldId, newId) {
|
|
100
|
+
ensureConfigDir();
|
|
101
|
+
const existing = readAccountsRaw();
|
|
102
|
+
const target = existing.find(a => a.id === oldId) ?? null;
|
|
103
|
+
if (!target)
|
|
104
|
+
return null;
|
|
105
|
+
if (newId !== oldId && existing.some(a => a.id === newId)) {
|
|
106
|
+
throw new Error(`An account named "${newId}" already exists`);
|
|
107
|
+
}
|
|
108
|
+
target.id = newId;
|
|
109
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, existing);
|
|
110
|
+
return target;
|
|
111
|
+
}
|
|
93
112
|
function normalizeAccountProvider(record) {
|
|
94
113
|
return record.provider === "openai_subscription"
|
|
95
114
|
? "openai_subscription"
|
package/dist/daemon/launcher.js
CHANGED
|
@@ -101,7 +101,8 @@ export async function stopDaemon(port = PROXY_PORT) {
|
|
|
101
101
|
return killByPort(port);
|
|
102
102
|
}
|
|
103
103
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
104
|
-
|
|
104
|
+
/** Poll the proxy's health endpoint until it answers or the budget runs out. */
|
|
105
|
+
export async function waitForHealth(port, timeoutMs) {
|
|
105
106
|
const start = Date.now();
|
|
106
107
|
while (Date.now() - start < timeoutMs) {
|
|
107
108
|
try {
|
|
@@ -125,34 +126,80 @@ async function waitForDeath(pid, timeoutMs) {
|
|
|
125
126
|
}
|
|
126
127
|
return false;
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Terminate whatever holds `port` and wait until it is actually gone.
|
|
131
|
+
*
|
|
132
|
+
* The previous implementation returned `true` as soon as SIGTERM was sent, so
|
|
133
|
+
* `cc-router stop` reported "✓ Proxy process stopped" while the process was
|
|
134
|
+
* still shutting down. A `start` issued immediately afterwards then raced that
|
|
135
|
+
* teardown. The PID-based path already waited (`waitForDeath`); this is the
|
|
136
|
+
* fallback taken when no PID file exists, and it now waits too.
|
|
137
|
+
*/
|
|
138
|
+
export async function killPortAndWait(port, deps, timeoutMs = 5_000) {
|
|
139
|
+
const pids = await deps.listPids(port);
|
|
140
|
+
if (pids.length === 0)
|
|
141
|
+
return false;
|
|
142
|
+
for (const pid of pids)
|
|
143
|
+
deps.kill(pid, "SIGTERM");
|
|
144
|
+
const deadline = deps.now() + timeoutMs;
|
|
145
|
+
const anyAlive = () => pids.some(pid => deps.isAlive(pid));
|
|
146
|
+
while (anyAlive()) {
|
|
147
|
+
if (deps.now() >= deadline) {
|
|
148
|
+
for (const pid of pids) {
|
|
149
|
+
if (deps.isAlive(pid))
|
|
150
|
+
deps.kill(pid, "SIGKILL");
|
|
151
|
+
}
|
|
152
|
+
await deps.sleep(POST_KILL_GRACE_MS);
|
|
153
|
+
return !anyAlive();
|
|
154
|
+
}
|
|
155
|
+
await deps.sleep(DEATH_POLL_MS);
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
const DEATH_POLL_MS = 200;
|
|
160
|
+
const POST_KILL_GRACE_MS = 500;
|
|
128
161
|
async function killByPort(port) {
|
|
129
162
|
const { execFile } = await import("child_process");
|
|
130
163
|
const { promisify } = await import("util");
|
|
131
164
|
const execFileAsync = promisify(execFile);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return
|
|
143
|
-
await execFileAsync("taskkill", ["/PID", pid, "/F"]);
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
|
|
148
|
-
const pids = stdout.trim().split("\n").filter(Boolean);
|
|
149
|
-
if (pids.length === 0)
|
|
150
|
-
return false;
|
|
151
|
-
for (const p of pids) {
|
|
152
|
-
await execFileAsync("kill", ["-TERM", p]);
|
|
165
|
+
const listPids = async (p) => {
|
|
166
|
+
try {
|
|
167
|
+
if (isWindows()) {
|
|
168
|
+
const { stdout } = await execFileAsync("netstat", ["-ano"]);
|
|
169
|
+
const match = stdout
|
|
170
|
+
.split("\n")
|
|
171
|
+
.find(line => line.includes(`:${p}`) && line.includes("LISTENING"));
|
|
172
|
+
if (!match)
|
|
173
|
+
return [];
|
|
174
|
+
const pid = Number(match.trim().split(/\s+/).at(-1));
|
|
175
|
+
return Number.isNaN(pid) ? [] : [pid];
|
|
153
176
|
}
|
|
154
|
-
|
|
177
|
+
const { stdout } = await execFileAsync("lsof", ["-ti", `:${p}`]);
|
|
178
|
+
return stdout.trim().split("\n").filter(Boolean).map(Number).filter(n => !Number.isNaN(n));
|
|
155
179
|
}
|
|
180
|
+
catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
return await killPortAndWait(port, {
|
|
186
|
+
listPids,
|
|
187
|
+
// Windows has no signals: taskkill /F is the only lever, so both the
|
|
188
|
+
// graceful and forced step map onto it.
|
|
189
|
+
kill: (pid, signal) => {
|
|
190
|
+
if (isWindows()) {
|
|
191
|
+
void execFileAsync("taskkill", ["/PID", String(pid), "/F"]).catch(() => { });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
process.kill(pid, signal);
|
|
196
|
+
}
|
|
197
|
+
catch { /* already gone */ }
|
|
198
|
+
},
|
|
199
|
+
isAlive: isProcessAlive,
|
|
200
|
+
sleep,
|
|
201
|
+
now: Date.now,
|
|
202
|
+
});
|
|
156
203
|
}
|
|
157
204
|
catch {
|
|
158
205
|
return false;
|
package/dist/daemon/pid.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
2
2
|
import { PID_PATH, PROXY_PORT } from "../config/paths.js";
|
|
3
3
|
import { ensureConfigDir } from "../config/manager.js";
|
|
4
|
+
/**
|
|
5
|
+
* Whether this process owns the PID file — i.e. its lifetime is what `cc-router
|
|
6
|
+
* stop` should act on.
|
|
7
|
+
*
|
|
8
|
+
* Both the background daemon (`CC_ROUTER_DAEMON`) and the OS service manager
|
|
9
|
+
* (`CC_ROUTER_SERVICE`, set by the LaunchAgent/systemd unit) qualify. A service
|
|
10
|
+
* instance used to leave no PID file, so `stop` fell through to killing by
|
|
11
|
+
* port, which does not wait for the process to exit. A plain `--foreground` run
|
|
12
|
+
* in a terminal owns nothing: it is the user's to Ctrl+C, and writing its PID
|
|
13
|
+
* would let `stop` target the wrong process.
|
|
14
|
+
*/
|
|
15
|
+
export function managesPidFile(env = process.env) {
|
|
16
|
+
return env["CC_ROUTER_DAEMON"] === "1" || env["CC_ROUTER_SERVICE"] === "1";
|
|
17
|
+
}
|
|
4
18
|
/** Write the current process PID to the PID file. */
|
|
5
19
|
export function writePid(pid) {
|
|
6
20
|
try {
|
package/dist/daemon/service.js
CHANGED
|
@@ -93,6 +93,60 @@ ${envVars}
|
|
|
93
93
|
</plist>
|
|
94
94
|
`;
|
|
95
95
|
}
|
|
96
|
+
const TEARDOWN_POLL_MS = 200;
|
|
97
|
+
const TEARDOWN_TIMEOUT_MS = 10_000;
|
|
98
|
+
/**
|
|
99
|
+
* Load a LaunchAgent that may have just been booted out.
|
|
100
|
+
*
|
|
101
|
+
* `launchctl bootout` returns as soon as launchd accepts the request, not once
|
|
102
|
+
* the job is gone. Bootstrapping the same label during that window fails with
|
|
103
|
+
* "Bootstrap failed: 5: Input/output error" — and the legacy `load` fallback
|
|
104
|
+
* fails the same way, so retrying through it does not help. That is what made
|
|
105
|
+
* `cc-router start` need a second invocation after a restart: the first one
|
|
106
|
+
* raced launchd's teardown, warned, and left nothing running.
|
|
107
|
+
*
|
|
108
|
+
* So: poll until launchd no longer knows the label, then bootstrap, retrying
|
|
109
|
+
* until a shared deadline because launchd can still reject briefly after the
|
|
110
|
+
* job disappears from `print`.
|
|
111
|
+
*/
|
|
112
|
+
export async function bootstrapAfterTeardown(opts) {
|
|
113
|
+
const { uid, label, plistPath, run, sleep, now } = opts;
|
|
114
|
+
const deadline = now() + (opts.timeoutMs ?? TEARDOWN_TIMEOUT_MS);
|
|
115
|
+
// Phase 1 — wait for launchd to forget the old job.
|
|
116
|
+
for (;;) {
|
|
117
|
+
let stillLoaded;
|
|
118
|
+
try {
|
|
119
|
+
await run(["print", `gui/${uid}/${label}`]);
|
|
120
|
+
stillLoaded = true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
stillLoaded = false; // `print` fails once the label is gone
|
|
124
|
+
}
|
|
125
|
+
if (!stillLoaded)
|
|
126
|
+
break;
|
|
127
|
+
if (now() >= deadline)
|
|
128
|
+
return false;
|
|
129
|
+
await sleep(TEARDOWN_POLL_MS);
|
|
130
|
+
}
|
|
131
|
+
// Phase 2 — bootstrap, retrying while launchd finishes releasing the label.
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
await run(["bootstrap", `gui/${uid}`, plistPath]);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
if (now() >= deadline)
|
|
139
|
+
return false;
|
|
140
|
+
await sleep(TEARDOWN_POLL_MS);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const launchctlRun = async (args) => {
|
|
145
|
+
await execFileAsync("launchctl", args);
|
|
146
|
+
};
|
|
147
|
+
function sleepMs(ms) {
|
|
148
|
+
return new Promise(r => setTimeout(r, ms));
|
|
149
|
+
}
|
|
96
150
|
async function installMacOS(serverMode) {
|
|
97
151
|
// Ensure LaunchAgents dir exists
|
|
98
152
|
const launchAgentsDir = dirname(LAUNCHD_PLIST);
|
|
@@ -103,12 +157,17 @@ async function installMacOS(serverMode) {
|
|
|
103
157
|
await launchctlUnload();
|
|
104
158
|
}
|
|
105
159
|
writeFileSync(LAUNCHD_PLIST, buildPlist(serverMode), "utf-8");
|
|
106
|
-
// Load — try modern `bootstrap` first, fallback to legacy `load`
|
|
107
160
|
const uid = String(process.getuid?.() ?? 501);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
161
|
+
const loaded = await bootstrapAfterTeardown({
|
|
162
|
+
uid,
|
|
163
|
+
label: LAUNCHD_LABEL,
|
|
164
|
+
plistPath: LAUNCHD_PLIST,
|
|
165
|
+
run: launchctlRun,
|
|
166
|
+
sleep: sleepMs,
|
|
167
|
+
now: Date.now,
|
|
168
|
+
});
|
|
169
|
+
if (!loaded) {
|
|
170
|
+
// Last resort for hosts where `bootstrap` is unavailable rather than busy.
|
|
112
171
|
try {
|
|
113
172
|
await execFileAsync("launchctl", ["load", LAUNCHD_PLIST]);
|
|
114
173
|
}
|
|
@@ -175,6 +175,28 @@ export class OpenAITokenPool {
|
|
|
175
175
|
findById(id) {
|
|
176
176
|
return this.accounts.find(account => account.id === id) ?? null;
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Change an account's id in place. The in-flight counter is keyed by id and
|
|
180
|
+
* must move with it — an open lease's release() re-reads `account.id`, so
|
|
181
|
+
* after a rename it decrements the NEW key, which would otherwise never
|
|
182
|
+
* have been incremented. Cooldowns are keyed by the account object and
|
|
183
|
+
* follow the rename untouched. Returns the renamed account, or null if the
|
|
184
|
+
* id was not found. Callers are responsible for id-uniqueness and for
|
|
185
|
+
* session-binding migration. Mirrors `TokenPool.renameAccount`.
|
|
186
|
+
*/
|
|
187
|
+
renameAccount(oldId, newId) {
|
|
188
|
+
const account = this.findById(oldId);
|
|
189
|
+
if (!account)
|
|
190
|
+
return null;
|
|
191
|
+
if (newId !== oldId) {
|
|
192
|
+
const load = this.inFlight.get(oldId);
|
|
193
|
+
this.inFlight.delete(oldId);
|
|
194
|
+
if (load !== undefined)
|
|
195
|
+
this.inFlight.set(newId, load);
|
|
196
|
+
account.id = newId;
|
|
197
|
+
}
|
|
198
|
+
return account;
|
|
199
|
+
}
|
|
178
200
|
/**
|
|
179
201
|
* Drop every piece of per-account routing state after the account has been
|
|
180
202
|
* removed from the shared `accounts` array. The array splice itself is owned
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidAccountId } from "./account-rename.js";
|
|
1
2
|
import { clampPercent } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validate a `PATCH /cc-router/accounts/:id` request body. Shared between the
|
|
@@ -7,6 +8,15 @@ import { clampPercent } from "./types.js";
|
|
|
7
8
|
*/
|
|
8
9
|
export function validateAccountPatchBody(body) {
|
|
9
10
|
const patch = {};
|
|
11
|
+
if (body.id !== undefined) {
|
|
12
|
+
if (!isValidAccountId(body.id)) {
|
|
13
|
+
return { ok: false, error: "id must be 1-64 characters: alphanumeric start, then letters, digits, dots, underscores, or dashes" };
|
|
14
|
+
}
|
|
15
|
+
if (body.enabled !== undefined || body.sessionLimitPercent !== undefined || body.weeklyLimitPercent !== undefined) {
|
|
16
|
+
return { ok: false, error: "id (rename) cannot be combined with other fields" };
|
|
17
|
+
}
|
|
18
|
+
patch.id = body.id;
|
|
19
|
+
}
|
|
10
20
|
if (body.enabled !== undefined) {
|
|
11
21
|
if (typeof body.enabled !== "boolean") {
|
|
12
22
|
return { ok: false, error: "enabled must be boolean" };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renaming an account changes the key that routing state hangs off: the
|
|
3
|
+
* pool's in-flight counters and the session router's sticky bindings are
|
|
4
|
+
* both id-keyed, so a rename is a transaction over pool + router + disk —
|
|
5
|
+
* not a field write. Mirrors account-deletion.ts in shape: the provider
|
|
6
|
+
* branches in server.ts supply their pool/router/persist as ports, and the
|
|
7
|
+
* CLI shares the id rules from here.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An account id ends up in URL paths (`/cc-router/accounts/:id`), fixed-width
|
|
11
|
+
* dashboard columns, and accounts.json — allow one conservative shape
|
|
12
|
+
* everywhere: alphanumeric start, then dots/underscores/dashes, 64 max.
|
|
13
|
+
*/
|
|
14
|
+
const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
15
|
+
export function isValidAccountId(value) {
|
|
16
|
+
return typeof value === "string" && ACCOUNT_ID_RE.test(value);
|
|
17
|
+
}
|
|
18
|
+
export class AccountRenameConflictError extends Error {
|
|
19
|
+
constructor(id) {
|
|
20
|
+
super(`An account named "${id}" already exists`);
|
|
21
|
+
this.name = "AccountRenameConflictError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Rename an account atomically with respect to disk: runtime state is only
|
|
26
|
+
* left renamed if persistence succeeded, otherwise it is renamed back so
|
|
27
|
+
* memory keeps matching accounts.json. `takenIds` must cover every live id
|
|
28
|
+
* across ALL providers — the two pools share one id namespace (one URL
|
|
29
|
+
* space, one accounts.json).
|
|
30
|
+
*/
|
|
31
|
+
export function renameAccountTransaction(oldId, newId, takenIds, ports) {
|
|
32
|
+
if (newId === oldId)
|
|
33
|
+
return "renamed";
|
|
34
|
+
if (takenIds.has(newId))
|
|
35
|
+
throw new AccountRenameConflictError(newId);
|
|
36
|
+
if (!ports.rename(oldId, newId))
|
|
37
|
+
return "not_found";
|
|
38
|
+
ports.renameSessions(oldId, newId);
|
|
39
|
+
try {
|
|
40
|
+
ports.persist();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
ports.rename(newId, oldId);
|
|
44
|
+
ports.renameSessions(newId, oldId);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return "renamed";
|
|
48
|
+
}
|
|
@@ -2,6 +2,21 @@ import { acquireRequestRoute } from "./lease-lifecycle.js";
|
|
|
2
2
|
import { normalizeSessionId } from "./session-router.js";
|
|
3
3
|
import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
|
|
4
4
|
const SESSION_HEADER = "x-claude-code-session-id";
|
|
5
|
+
/**
|
|
6
|
+
* Classify which Anthropic-shaped client sent a request, for the activity log.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors the precedence the direct Claude proxy path applies: a Claude Code
|
|
9
|
+
* session header wins, an `x-api-key` means Claude Desktop, anything else is a
|
|
10
|
+
* raw API caller. Shared so a `/v1/messages` request that cross-routes to an
|
|
11
|
+
* OpenAI backend is still labelled by the client that sent it.
|
|
12
|
+
*/
|
|
13
|
+
export function detectAnthropicClientSource(headers) {
|
|
14
|
+
if (headers[SESSION_HEADER] !== undefined)
|
|
15
|
+
return "cli";
|
|
16
|
+
if (headers["x-api-key"] !== undefined)
|
|
17
|
+
return "desktop";
|
|
18
|
+
return "api";
|
|
19
|
+
}
|
|
5
20
|
/** Extract exactly one native HTTP session header field without joined duplicates. */
|
|
6
21
|
export function extractClaudeSessionId(request) {
|
|
7
22
|
const distinct = request.headersDistinct;
|
|
@@ -9,7 +9,7 @@ import { terminalResponsePayload, usageFromTerminalEvent, usageFromResponseBody,
|
|
|
9
9
|
import { extractAnthropicRouteContext } from "./request-model.js";
|
|
10
10
|
import { stats, applyCodexUsage } from "./stats.js";
|
|
11
11
|
import { extractCodexSessionKey } from "./openai-routing.js";
|
|
12
|
-
import { sendAnthropicNoEligibleResponse } from "./anthropic-routing.js";
|
|
12
|
+
import { sendAnthropicNoEligibleResponse, detectAnthropicClientSource } from "./anthropic-routing.js";
|
|
13
13
|
import { mirrorUpstreamHeaders, runOpenAIIngress, } from "./openai-ingress.js";
|
|
14
14
|
const MESSAGES_ENVELOPE = {
|
|
15
15
|
wrap: (type, message) => ({ type: "error", error: { type, message } }),
|
|
@@ -348,6 +348,10 @@ export function mountMessagesCrossProviderRoute(app, opts) {
|
|
|
348
348
|
sessionKey: extractCodexSessionKey(req, req.body),
|
|
349
349
|
requestedModel: route.upstreamModel,
|
|
350
350
|
path: "/v1/messages",
|
|
351
|
+
method: req.method,
|
|
352
|
+
// A Claude-shaped client that happens to route to an OpenAI backend is
|
|
353
|
+
// still that client — classify it the way the Claude path does.
|
|
354
|
+
source: detectAnthropicClientSource(req.headers),
|
|
351
355
|
openAIRouter: opts.openAIRouter,
|
|
352
356
|
openAIPool: opts.openAIPool,
|
|
353
357
|
prepareOpenAIAccount,
|
|
@@ -298,6 +298,8 @@ export async function runOpenAIIngress(opts) {
|
|
|
298
298
|
model: requestedModel,
|
|
299
299
|
type: "route",
|
|
300
300
|
path,
|
|
301
|
+
...(opts.method !== undefined ? { method: opts.method } : {}),
|
|
302
|
+
...(opts.source !== undefined ? { source: opts.source } : {}),
|
|
301
303
|
details,
|
|
302
304
|
};
|
|
303
305
|
let finalStatus = upstream.status;
|
|
@@ -124,6 +124,9 @@ export function mountResponsesRoutes(app, opts) {
|
|
|
124
124
|
sessionKey: extractCodexSessionKey(req, req.body),
|
|
125
125
|
requestedModel: route.upstreamModel,
|
|
126
126
|
path: "/v1/responses",
|
|
127
|
+
method: req.method,
|
|
128
|
+
// Only the Codex CLI speaks the Responses API to this proxy.
|
|
129
|
+
source: "codex",
|
|
127
130
|
openAIRouter: opts.openAIRouter,
|
|
128
131
|
openAIPool: opts.openAIPool,
|
|
129
132
|
prepareOpenAIAccount,
|
package/dist/proxy/server.js
CHANGED
|
@@ -5,14 +5,15 @@ import { timingSafeEqual } from "crypto";
|
|
|
5
5
|
import { TokenPool } from "./token-pool.js";
|
|
6
6
|
import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
7
7
|
import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
|
|
8
|
-
import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner } from "../utils/self-update.js";
|
|
8
|
+
import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
|
|
9
9
|
import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
|
|
10
10
|
import { loadTelemetryState } from "../config/telemetry.js";
|
|
11
11
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
12
12
|
import { createLocalRoutingErrorLog, stats } from "./stats.js";
|
|
13
13
|
import { PROXY_PORT, LITELLM_URL, ACCOUNTS_PATH } from "../config/paths.js";
|
|
14
|
-
import { writePid, removePid } from "../daemon/pid.js";
|
|
14
|
+
import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
|
|
15
15
|
import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
|
|
16
|
+
import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
|
|
16
17
|
import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
|
|
17
18
|
import { createOpenAIAccount } from "../providers/openai/account-state.js";
|
|
18
19
|
import { OpenAITokenPool } from "../providers/openai/token-pool.js";
|
|
@@ -487,6 +488,11 @@ export async function startServer(opts = {}) {
|
|
|
487
488
|
}
|
|
488
489
|
res.json({
|
|
489
490
|
status,
|
|
491
|
+
// The version of the code this daemon actually runs — not what is
|
|
492
|
+
// installed on disk. A service manager can keep an old build alive
|
|
493
|
+
// long after an upgrade (launchd pins the versioned pnpm store path
|
|
494
|
+
// in its plist), and without this field no client can tell.
|
|
495
|
+
version: getCurrentVersion(),
|
|
490
496
|
mode,
|
|
491
497
|
target,
|
|
492
498
|
operational: createOperationalStatus({
|
|
@@ -627,6 +633,54 @@ export async function startServer(opts = {}) {
|
|
|
627
633
|
return;
|
|
628
634
|
}
|
|
629
635
|
const patch = validation.patch;
|
|
636
|
+
// A rename is a transaction over pool + session router + disk, not a
|
|
637
|
+
// field write (see account-rename.ts) — validation already guarantees it
|
|
638
|
+
// arrives alone. The two pools share one id namespace, so uniqueness is
|
|
639
|
+
// checked across both regardless of which provider owns the account.
|
|
640
|
+
if (patch.id !== undefined) {
|
|
641
|
+
const newId = patch.id;
|
|
642
|
+
const takenIds = new Set([
|
|
643
|
+
...pool.getAll().map(a => a.id),
|
|
644
|
+
...openAIAccounts.map(a => a.id),
|
|
645
|
+
]);
|
|
646
|
+
const inAnthropic = pool.findById(id) !== null;
|
|
647
|
+
if (!inAnthropic && !openAIAccounts.some(a => a.id === id)) {
|
|
648
|
+
res.status(404).json({ error: `Account "${id}" not found` });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
renameAccountTransaction(id, newId, takenIds, inAnthropic
|
|
653
|
+
? {
|
|
654
|
+
rename: (oldId, nextId) => pool.renameAccount(oldId, nextId) !== null,
|
|
655
|
+
renameSessions: (oldId, nextId) => { sessionRouter.renameAccount(oldId, nextId); },
|
|
656
|
+
persist: () => saveAccounts(pool.getAll()),
|
|
657
|
+
}
|
|
658
|
+
: {
|
|
659
|
+
rename: (oldId, nextId) => openAIPool.renameAccount(oldId, nextId) !== null,
|
|
660
|
+
renameSessions: (oldId, nextId) => { openAIRouter.renameAccount(oldId, nextId); },
|
|
661
|
+
persist: () => persistOpenAIAccounts(openAIAccounts),
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
if (err instanceof AccountRenameConflictError) {
|
|
666
|
+
res.status(409).json({ error: err.message });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
670
|
+
logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
|
|
671
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (inAnthropic) {
|
|
675
|
+
const account = pool.findById(newId);
|
|
676
|
+
res.json({ account: publicAnthropicAccountView(account, createRoutingMetricsResolver()(account.id)) });
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
const account = openAIAccounts.find(a => a.id === newId);
|
|
680
|
+
res.json({ account: publicOpenAIAccountView(account, resolveOpenAIRouting(account.id)) });
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
630
684
|
// Snapshot the previous values so we can roll back on persistence failure
|
|
631
685
|
const existing = pool.findById(id);
|
|
632
686
|
if (existing) {
|
|
@@ -1126,7 +1180,7 @@ export async function startServer(opts = {}) {
|
|
|
1126
1180
|
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
1127
1181
|
usageRefresher.stop();
|
|
1128
1182
|
saveAccounts(pool.getAll());
|
|
1129
|
-
if (
|
|
1183
|
+
if (managesPidFile()) {
|
|
1130
1184
|
removePid();
|
|
1131
1185
|
}
|
|
1132
1186
|
process.exit(0);
|
|
@@ -1193,7 +1247,7 @@ export async function startServer(opts = {}) {
|
|
|
1193
1247
|
}
|
|
1194
1248
|
app.listen(port, host, () => {
|
|
1195
1249
|
// Write PID for daemon/service process management
|
|
1196
|
-
if (
|
|
1250
|
+
if (managesPidFile()) {
|
|
1197
1251
|
writePid(process.pid);
|
|
1198
1252
|
}
|
|
1199
1253
|
const totalAccountCount = accounts.length + openAIAccounts.length;
|
|
@@ -92,6 +92,29 @@ export class SessionRouter {
|
|
|
92
92
|
}
|
|
93
93
|
return removed;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Re-point every binding (and the aggregate session count) at an account's
|
|
97
|
+
* new id after a rename. Without this the sticky re-acquire looks the old
|
|
98
|
+
* id up in the pool, finds nothing, and silently fails the session over —
|
|
99
|
+
* a rename must not break prompt-cache affinity. Returns bindings moved.
|
|
100
|
+
*/
|
|
101
|
+
renameAccount(oldId, newId) {
|
|
102
|
+
if (newId === oldId)
|
|
103
|
+
return 0;
|
|
104
|
+
let moved = 0;
|
|
105
|
+
for (const binding of this.bindings.values()) {
|
|
106
|
+
if (binding.accountId !== oldId)
|
|
107
|
+
continue;
|
|
108
|
+
binding.accountId = newId;
|
|
109
|
+
moved++;
|
|
110
|
+
}
|
|
111
|
+
const count = this.activeSessionCounts.get(oldId);
|
|
112
|
+
if (count !== undefined) {
|
|
113
|
+
this.activeSessionCounts.delete(oldId);
|
|
114
|
+
this.activeSessionCounts.set(newId, count);
|
|
115
|
+
}
|
|
116
|
+
return moved;
|
|
117
|
+
}
|
|
95
118
|
getActiveSessionCount(accountId) {
|
|
96
119
|
this.sweepExpiredBindings(this.now());
|
|
97
120
|
return this.getRawActiveSessionCount(accountId);
|
package/dist/proxy/token-pool.js
CHANGED
|
@@ -599,6 +599,28 @@ export class TokenPool {
|
|
|
599
599
|
}
|
|
600
600
|
return a;
|
|
601
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Change an account's id in place. The in-flight counter is keyed by id and
|
|
604
|
+
* must move with it: an open lease's release() re-reads `account.id`, so
|
|
605
|
+
* after a rename it decrements the NEW key — which would never have been
|
|
606
|
+
* incremented, leaving the old key stuck at its count forever. Cooldowns
|
|
607
|
+
* are keyed by the Account object and follow the rename untouched.
|
|
608
|
+
* Returns the renamed account, or null if the id was not found. Callers
|
|
609
|
+
* are responsible for id-uniqueness and for session-binding migration.
|
|
610
|
+
*/
|
|
611
|
+
renameAccount(oldId, newId) {
|
|
612
|
+
const account = this.findById(oldId);
|
|
613
|
+
if (!account)
|
|
614
|
+
return null;
|
|
615
|
+
if (newId !== oldId) {
|
|
616
|
+
const load = this.inFlight.get(oldId);
|
|
617
|
+
this.inFlight.delete(oldId);
|
|
618
|
+
if (load !== undefined)
|
|
619
|
+
this.inFlight.set(newId, load);
|
|
620
|
+
account.id = newId;
|
|
621
|
+
}
|
|
622
|
+
return account;
|
|
623
|
+
}
|
|
602
624
|
/**
|
|
603
625
|
* Append a new account built from a persisted AccountRecord.
|
|
604
626
|
* Rejects duplicates by id — callers should pre-check with findById().
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -3,9 +3,14 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
|
3
3
|
import { Box, Text, useInput, useApp } from "ink";
|
|
4
4
|
import { createAccountsApi } from "./accountsApi.js";
|
|
5
5
|
import { createModelsApi } from "./modelsApi.js";
|
|
6
|
+
import { getCurrentVersion } from "../utils/self-update.js";
|
|
6
7
|
const POLL_INTERVAL_MS = 2_000;
|
|
7
8
|
const LOG_VISIBLE = 20;
|
|
8
9
|
const MODEL_VISIBLE_ROWS = 16;
|
|
10
|
+
const DASHBOARD_VERSION = getCurrentVersion();
|
|
11
|
+
// Distinguishes "this machine's daemon" (restartable from this shell) from a
|
|
12
|
+
// remote router the dashboard is merely pointed at.
|
|
13
|
+
const LOCAL_TARGET_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
|
|
9
14
|
const EMPTY_RL = {
|
|
10
15
|
status: "unknown", fiveHourUtil: 0, fiveHourReset: 0,
|
|
11
16
|
sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
|
|
@@ -102,6 +107,45 @@ function codexWindowLabel(windowMinutes, fallback) {
|
|
|
102
107
|
}
|
|
103
108
|
return fallback;
|
|
104
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether a reported window carries real data.
|
|
112
|
+
*
|
|
113
|
+
* Codex sends absent windows as all-zero placeholders rather than omitting the
|
|
114
|
+
* field, so a truthiness check treats "no such window" as a window with no
|
|
115
|
+
* duration — which then renders under a guessed label.
|
|
116
|
+
*/
|
|
117
|
+
function hasWindow(window) {
|
|
118
|
+
return window !== undefined && window.windowMinutes > 0;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The default (`codex`) bucket's windows, each labelled from its own duration.
|
|
122
|
+
*
|
|
123
|
+
* These used to be read positionally — `primary` as the 5h window, `secondary`
|
|
124
|
+
* as the weekly one. Codex reports the weekly window in `primary` and leaves
|
|
125
|
+
* `secondary` empty, so an account at 100% of its weekly quota displayed as
|
|
126
|
+
* "5h 100%" beside a "weekly 0%" bar that was really the empty slot. The reset
|
|
127
|
+
* countdown gave it away: a 5h window cannot reset five days out.
|
|
128
|
+
*/
|
|
129
|
+
export function getCodexDefaultWindows(codex) {
|
|
130
|
+
const bucket = codex?.buckets.find(b => b.limitId === "codex");
|
|
131
|
+
if (!bucket)
|
|
132
|
+
return [];
|
|
133
|
+
const windows = [];
|
|
134
|
+
for (const [window, fallback] of [
|
|
135
|
+
[bucket.primary, "5h"],
|
|
136
|
+
[bucket.secondary, "weekly"],
|
|
137
|
+
]) {
|
|
138
|
+
if (!hasWindow(window))
|
|
139
|
+
continue;
|
|
140
|
+
windows.push({
|
|
141
|
+
label: codexWindowLabel(window.windowMinutes, fallback),
|
|
142
|
+
utilization: window.utilization,
|
|
143
|
+
resetAt: window.resetAt,
|
|
144
|
+
kind: window.windowMinutes >= 10_080 ? "weekly" : "session",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return windows;
|
|
148
|
+
}
|
|
105
149
|
/** Named Codex metered buckets as compact capacity rows (default bucket renders as bars). */
|
|
106
150
|
export function getCodexCapacityRows(codex, globalCooldownUntilMs, now = Date.now()) {
|
|
107
151
|
const rows = [];
|
|
@@ -110,10 +154,14 @@ export function getCodexCapacityRows(codex, globalCooldownUntilMs, now = Date.no
|
|
|
110
154
|
continue;
|
|
111
155
|
const cooling = bucket.cooldownUntilMs > now;
|
|
112
156
|
const windows = [];
|
|
113
|
-
|
|
157
|
+
// A zero-width window is Codex's placeholder for "this bucket has no such
|
|
158
|
+
// window", not a real one — it arrives as an all-zero object rather than
|
|
159
|
+
// being omitted. Rendering it duplicated the bucket, and both rows carried
|
|
160
|
+
// the same label because codexWindowLabel(0) falls through to its fallback.
|
|
161
|
+
if (hasWindow(bucket.primary)) {
|
|
114
162
|
windows.push({ label: codexWindowLabel(bucket.primary.windowMinutes, "5h"), ...bucket.primary });
|
|
115
163
|
}
|
|
116
|
-
if (bucket.secondary) {
|
|
164
|
+
if (hasWindow(bucket.secondary)) {
|
|
117
165
|
windows.push({ label: codexWindowLabel(bucket.secondary.windowMinutes, "weekly"), ...bucket.secondary });
|
|
118
166
|
}
|
|
119
167
|
for (const window of windows) {
|
|
@@ -165,6 +213,24 @@ export function isCodexLimited(codex) {
|
|
|
165
213
|
return false;
|
|
166
214
|
return (defaultBucket.primary?.utilization ?? 0) >= 1 || (defaultBucket.secondary?.utilization ?? 0) >= 1;
|
|
167
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* First visible row of a scrolling list window that follows its selection.
|
|
218
|
+
*
|
|
219
|
+
* The window stays where it is while the selection moves inside it, and
|
|
220
|
+
* shifts just far enough to contain the selection when it crosses an edge —
|
|
221
|
+
* one row per one-row step, but any distance when the selection jumps (it is
|
|
222
|
+
* timestamp-anchored, so a burst of new entries can move it many rows at
|
|
223
|
+
* once). A stale `scrollTop` from a longer list clamps back into range.
|
|
224
|
+
*/
|
|
225
|
+
export function followScrollWindow(scrollTop, selectedIndex, total, visible) {
|
|
226
|
+
const maxTop = Math.max(0, total - visible);
|
|
227
|
+
let top = Math.min(Math.max(0, scrollTop), maxTop);
|
|
228
|
+
if (selectedIndex < top)
|
|
229
|
+
top = selectedIndex;
|
|
230
|
+
else if (selectedIndex > top + visible - 1)
|
|
231
|
+
top = selectedIndex - visible + 1;
|
|
232
|
+
return Math.min(Math.max(0, top), maxTop);
|
|
233
|
+
}
|
|
168
234
|
export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
169
235
|
const { exit } = useApp();
|
|
170
236
|
const [data, setData] = useState(null);
|
|
@@ -242,13 +308,18 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
242
308
|
const selectedLogIndex = selectedTs !== null
|
|
243
309
|
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
244
310
|
: 0;
|
|
311
|
+
// First visible activity row. The stored position only moves on navigation;
|
|
312
|
+
// the derived value re-clamps every render because the selection is
|
|
313
|
+
// timestamp-anchored — new entries arriving between keypresses can push the
|
|
314
|
+
// selected row out of the stored window, and it must stay visible anyway.
|
|
315
|
+
const [logScrollTop, setLogScrollTop] = useState(0);
|
|
316
|
+
const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length, LOG_VISIBLE);
|
|
245
317
|
// Selected account by id
|
|
246
318
|
const [selectedAccountId, setSelectedAccountId] = useState(null);
|
|
247
319
|
const selectedAccountIndex = selectedAccountId !== null
|
|
248
320
|
? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
|
|
249
321
|
: 0;
|
|
250
322
|
const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
|
|
251
|
-
const selectedAccountIsAnthropic = selectedAccount?.provider !== "openai_subscription";
|
|
252
323
|
const [modelsStatus, setModelsStatus] = useState(null);
|
|
253
324
|
const [selectedModelId, setSelectedModelId] = useState(null);
|
|
254
325
|
const modelRows = modelsStatus?.models ?? [];
|
|
@@ -287,13 +358,14 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
287
358
|
return s || "unknown error";
|
|
288
359
|
};
|
|
289
360
|
// ── Async helpers (fire-and-forget with error → banner) ──────────────────
|
|
361
|
+
// Provider-agnostic: `PATCH /cc-router/accounts/:id` applies `enabled` to
|
|
362
|
+
// OpenAI accounts through the same transaction contract as Claude ones, and
|
|
363
|
+
// drops their sticky bindings on disable. The cap keys below never had a
|
|
364
|
+
// provider check; this one was left behind after the endpoint gained OpenAI
|
|
365
|
+
// support, so the dashboard was refusing an operation the server had.
|
|
290
366
|
const doToggleEnabled = useCallback(async () => {
|
|
291
367
|
if (!selectedAccount)
|
|
292
368
|
return;
|
|
293
|
-
if (selectedAccount.provider === "openai_subscription") {
|
|
294
|
-
showBanner("OpenAI accounts are managed from the CLI", "yellow");
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
369
|
const newValue = !(selectedAccount.enabled !== false);
|
|
298
370
|
try {
|
|
299
371
|
await api.patch(selectedAccount.id, { enabled: newValue });
|
|
@@ -336,10 +408,6 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
336
408
|
const doDelete = useCallback(async () => {
|
|
337
409
|
if (!selectedAccount)
|
|
338
410
|
return;
|
|
339
|
-
if (selectedAccount.provider === "openai_subscription") {
|
|
340
|
-
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
411
|
try {
|
|
344
412
|
await api.remove(selectedAccount.id);
|
|
345
413
|
showBanner(`Removed ${selectedAccount.id}`, "yellow");
|
|
@@ -455,10 +523,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
455
523
|
if (key.upArrow) {
|
|
456
524
|
const next = Math.max(0, selectedLogIndex - 1);
|
|
457
525
|
setSelectedTs(logs[next]?.ts ?? null);
|
|
526
|
+
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
|
|
458
527
|
}
|
|
459
528
|
if (key.downArrow) {
|
|
460
529
|
const next = Math.min(logs.length - 1, selectedLogIndex + 1);
|
|
461
530
|
setSelectedTs(logs[next]?.ts ?? null);
|
|
531
|
+
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
|
|
462
532
|
}
|
|
463
533
|
}
|
|
464
534
|
if (focus === "accounts") {
|
|
@@ -493,11 +563,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
493
563
|
setEditBuffer("");
|
|
494
564
|
return;
|
|
495
565
|
}
|
|
566
|
+
// Also provider-agnostic: `DELETE /cc-router/accounts/:id` removes an
|
|
567
|
+
// OpenAI account through `deleteOpenAIAccountTransaction`, which is the
|
|
568
|
+
// same path `cc-router accounts remove` reaches. Sending the operator to
|
|
569
|
+
// the CLI for something the dashboard can do was left over from before
|
|
570
|
+
// that existed.
|
|
496
571
|
if (input === "d") {
|
|
497
|
-
if (!selectedAccountIsAnthropic) {
|
|
498
|
-
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
572
|
setMode("confirmDelete");
|
|
502
573
|
return;
|
|
503
574
|
}
|
|
@@ -540,10 +611,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
540
611
|
}
|
|
541
612
|
});
|
|
542
613
|
const selectedLog = logs[selectedLogIndex] ?? null;
|
|
543
|
-
const visibleLogs = logs.slice(
|
|
544
|
-
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"] })] }),
|
|
614
|
+
const visibleLogs = logs.slice(logWindowTop, logWindowTop + LOG_VISIBLE);
|
|
615
|
+
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"] })] }), 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
|
|
616
|
+
? `daemon v${data.version}`
|
|
617
|
+
: "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" })] })) : (
|
|
618
|
+
// A remote router can only be restarted where it runs; printing a
|
|
619
|
+
// local restart command here would never clear the banner.
|
|
620
|
+
_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 }), _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
|
|
545
621
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
546
|
-
: 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 })] }))] }));
|
|
622
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
|
|
547
623
|
}
|
|
548
624
|
function OperationsPanel({ operational, baseUrl, focus }) {
|
|
549
625
|
const authLabel = operational.auth.required ? "protected" : "open";
|
|
@@ -599,7 +675,7 @@ function AccountRow({ account: a, selected }) {
|
|
|
599
675
|
const globalCapacity = getGlobalCapacityView(rl);
|
|
600
676
|
const isOpenAI = a.provider === "openai_subscription";
|
|
601
677
|
const codex = a.codexRateLimits;
|
|
602
|
-
const
|
|
678
|
+
const codexDefaultWindows = getCodexDefaultWindows(codex);
|
|
603
679
|
const capacityRows = isOpenAI
|
|
604
680
|
? getCodexCapacityRows(a.codexRateLimits, a.globalCooldownUntilMs)
|
|
605
681
|
: getAccountCapacityRows(a);
|
|
@@ -632,10 +708,10 @@ function AccountRow({ account: a, selected }) {
|
|
|
632
708
|
? codex.credits.balance
|
|
633
709
|
: codex.credits.hasCredits ? "yes" : "no"
|
|
634
710
|
: undefined;
|
|
635
|
-
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(
|
|
711
|
+
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(21) }), _jsx(Text, { color: statusColor, children: statusLabel }), providerTag && _jsx(Text, { color: isOpenAI ? "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) }), _jsxs(Text, { color: "gray", children: [" ", a.activeSessions ?? 0, " active / ", a.inFlightRequests ?? 0, " streams"] }), capsHint && _jsx(Text, { color: "yellow", children: capsHint }), a.credentialsPendingWrite && (
|
|
636
712
|
// The account still works — its rotated token is live in memory — but a
|
|
637
713
|
// restart before the pending write lands would need a re-login.
|
|
638
|
-
_jsx(Text, { color: "yellow", children: " creds unsaved" }))] }), (rl.lastUpdated > 0 || usage) && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: globalCapacity.fiveHour.utilization, resetTs: globalCapacity.fiveHour.resetAt, isActive: rl.claim === "five_hour", cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d all-model", util: globalCapacity.sevenDay.utilization, resetTs: globalCapacity.sevenDay.resetAt, isActive: rl.claim === "seven_day", cap: w7 }), usage && _jsx(Text, { color: globalCapacity.usageFetchStatus === "fresh" ? "gray" : "yellow", children: ` usage ${globalCapacity.usageFetchStatus} ${usage.fetchedAt > 0 ? formatAgo(usage.fetchedAt) : ""}` })] })), isOpenAI &&
|
|
714
|
+
_jsx(Text, { color: "yellow", children: " creds unsaved" }))] }), (rl.lastUpdated > 0 || usage) && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: globalCapacity.fiveHour.utilization, resetTs: globalCapacity.fiveHour.resetAt, isActive: rl.claim === "five_hour", cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d all-model", util: globalCapacity.sevenDay.utilization, resetTs: globalCapacity.sevenDay.resetAt, isActive: rl.claim === "seven_day", cap: w7 }), usage && _jsx(Text, { color: globalCapacity.usageFetchStatus === "fresh" ? "gray" : "yellow", children: ` usage ${globalCapacity.usageFetchStatus} ${usage.fetchedAt > 0 ? formatAgo(usage.fetchedAt) : ""}` })] })), isOpenAI && codexDefaultWindows.length > 0 && (_jsxs(Box, { paddingLeft: 4, children: [codexDefaultWindows.map((window, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsx(Text, { children: " " }), _jsx(UtilBar, { label: window.label, util: window.utilization, resetTs: window.resetAt, isActive: false, cap: window.kind === "weekly" ? w7 : s5 })] }, window.label))), creditsLabel !== undefined && _jsx(Text, { color: "gray", children: ` credits ${creditsLabel}` })] })), capacityRows.map((row, index) => (_jsxs(Box, { paddingLeft: 4, children: [_jsxs(Text, { color: row.color, children: [" ", row.label] }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", row.state] }), row.utilization !== undefined && _jsx(Text, { color: row.color, children: ` ${Math.round(row.utilization * 100)}%` }), row.resetAt !== undefined && row.resetAt > 0 && (_jsxs(Text, { color: "gray", children: [" ", `↻${formatResetIn(row.resetAt)}`] }))] }, `${row.label}-${index}`)))] }));
|
|
639
715
|
}
|
|
640
716
|
// ─── Utilization bar ─────────────────────────────────────────────────────────
|
|
641
717
|
function UtilBar({ label, util, resetTs, isActive, cap }) {
|
|
@@ -680,10 +756,12 @@ function LogRow({ log, selected }) {
|
|
|
680
756
|
const sourceLabel = log.source === "cli" ? "cli"
|
|
681
757
|
: log.source === "desktop" ? "dsk"
|
|
682
758
|
: log.source === "api" ? "api"
|
|
683
|
-
: "
|
|
759
|
+
: log.source === "codex" ? "cdx"
|
|
760
|
+
: " ";
|
|
684
761
|
const sourceColor = log.source === "cli" ? "blue"
|
|
685
762
|
: log.source === "desktop" ? "magenta"
|
|
686
|
-
: "
|
|
763
|
+
: log.source === "codex" ? "cyan"
|
|
764
|
+
: "gray";
|
|
687
765
|
// Per-request token stats
|
|
688
766
|
const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
|
|
689
767
|
const outputTok = log.outputTokens ?? 0;
|
|
@@ -759,6 +837,8 @@ function sourceFullLabel(source) {
|
|
|
759
837
|
return "Claude Code";
|
|
760
838
|
if (source === "desktop")
|
|
761
839
|
return "Claude Desktop";
|
|
840
|
+
if (source === "codex")
|
|
841
|
+
return "Codex CLI";
|
|
762
842
|
if (source === "api")
|
|
763
843
|
return "API";
|
|
764
844
|
return "—";
|
package/package.json
CHANGED