@timo972/cc-router 0.10.0-rc.1 → 0.10.0-rc.2
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 +51 -1
- package/dist/cli/cmd-start.js +14 -1
- package/dist/daemon/launcher.js +70 -23
- package/dist/daemon/pid.js +14 -0
- package/dist/daemon/service.js +64 -5
- 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 +3 -3
- package/dist/ui/Dashboard.js +64 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,7 +8,57 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- OpenAI activity rows carry the same columns as Claude ones. The OpenAI ingress
|
|
14
|
+
recorded a path but no method and no client, and the dashboard needs both
|
|
15
|
+
`method` and `path` to render the request — so those rows fell back to the
|
|
16
|
+
bare entry type and read `route` under a blank client column, beside
|
|
17
|
+
`POST /messages` and `cli` on the Claude rows. Codex CLI traffic now reports
|
|
18
|
+
a `codex` source of its own rather than borrowing `cli`, which the detail
|
|
19
|
+
panel spells out as "Claude Code"; a `/v1/messages` request that cross-routes
|
|
20
|
+
to an OpenAI backend is still classified by the client that sent it.
|
|
21
|
+
- An OpenAI account's usage bars are labelled from each window's own duration
|
|
22
|
+
instead of by position. Codex reports its weekly window in the `primary` slot
|
|
23
|
+
and leaves `secondary` empty, but the bars assumed primary meant 5h and
|
|
24
|
+
secondary meant weekly — so an account at 100% of its weekly quota displayed
|
|
25
|
+
as `5h 100%` next to a `weekly 0%` bar that was really the empty slot. The
|
|
26
|
+
countdown gave it away: a 5h window cannot reset five days out.
|
|
27
|
+
- A named Codex bucket no longer renders twice. Codex sends an absent window as
|
|
28
|
+
an all-zero placeholder rather than omitting it, so the empty `secondary` was
|
|
29
|
+
treated as real and emitted a second row — carrying the same label as the
|
|
30
|
+
first, because a zero-length window falls through to a guessed one.
|
|
31
|
+
- An account id exactly as long as its column no longer runs into the status
|
|
32
|
+
next to it (`plus-developer-droidLIMITED`).
|
|
33
|
+
- The status dashboard can enable, disable, and remove OpenAI accounts. Three
|
|
34
|
+
guards still sent the operator to the CLI for operations the management
|
|
35
|
+
endpoints had already gained: `e` answered "OpenAI accounts are managed from
|
|
36
|
+
the CLI", and delete refused both at the keypress and again inside the
|
|
37
|
+
confirmation, so the second gate would have caught anyone who got past the
|
|
38
|
+
first. The cap keys (`w`/`s`) never had such a check, which is what made the
|
|
39
|
+
inconsistency visible.
|
|
40
|
+
- `cc-router start` no longer has to be run twice. In service mode it wrote the
|
|
41
|
+
LaunchAgent plist and immediately bootstrapped it, but `launchctl bootout`
|
|
42
|
+
returns as soon as launchd accepts the request — not once the job is gone.
|
|
43
|
+
Bootstrapping the same label during that window fails with
|
|
44
|
+
`Bootstrap failed: 5: Input/output error`, and the legacy `launchctl load`
|
|
45
|
+
fallback fails identically, so the command printed a warning and exited
|
|
46
|
+
successfully with nothing running. It now waits for launchd to release the
|
|
47
|
+
label before loading, and retries the bootstrap until a deadline.
|
|
48
|
+
- A failed start is no longer reported as a success. Service mode installed the
|
|
49
|
+
service and returned without checking that anything was listening — the
|
|
50
|
+
background path already health-checked, the service path did not. It now
|
|
51
|
+
polls the health endpoint and exits non-zero with the log location if the
|
|
52
|
+
proxy never answers.
|
|
53
|
+
- `cc-router stop` waits for the proxy to actually exit before reporting
|
|
54
|
+
success. With no PID file the stop fell through to killing by port, which
|
|
55
|
+
returned as soon as SIGTERM was sent; a `start` issued straight afterwards
|
|
56
|
+
then raced the still-running process. The port path now waits for the
|
|
57
|
+
process to die and escalates to SIGKILL, matching the PID path.
|
|
58
|
+
- A service-managed proxy writes a PID file. `writePid`/`removePid` were gated
|
|
59
|
+
on `CC_ROUTER_DAEMON`, which the LaunchAgent and systemd unit never set —
|
|
60
|
+
they set `CC_ROUTER_SERVICE` — so every service-managed instance left no PID
|
|
61
|
+
behind and took the weaker port-based stop path.
|
|
12
62
|
|
|
13
63
|
---
|
|
14
64
|
|
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/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
|
}
|
|
@@ -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
|
@@ -11,7 +11,7 @@ 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
16
|
import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
|
|
17
17
|
import { createOpenAIAccount } from "../providers/openai/account-state.js";
|
|
@@ -1126,7 +1126,7 @@ export async function startServer(opts = {}) {
|
|
|
1126
1126
|
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
1127
1127
|
usageRefresher.stop();
|
|
1128
1128
|
saveAccounts(pool.getAll());
|
|
1129
|
-
if (
|
|
1129
|
+
if (managesPidFile()) {
|
|
1130
1130
|
removePid();
|
|
1131
1131
|
}
|
|
1132
1132
|
process.exit(0);
|
|
@@ -1193,7 +1193,7 @@ export async function startServer(opts = {}) {
|
|
|
1193
1193
|
}
|
|
1194
1194
|
app.listen(port, host, () => {
|
|
1195
1195
|
// Write PID for daemon/service process management
|
|
1196
|
-
if (
|
|
1196
|
+
if (managesPidFile()) {
|
|
1197
1197
|
writePid(process.pid);
|
|
1198
1198
|
}
|
|
1199
1199
|
const totalAccountCount = accounts.length + openAIAccounts.length;
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -102,6 +102,45 @@ function codexWindowLabel(windowMinutes, fallback) {
|
|
|
102
102
|
}
|
|
103
103
|
return fallback;
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Whether a reported window carries real data.
|
|
107
|
+
*
|
|
108
|
+
* Codex sends absent windows as all-zero placeholders rather than omitting the
|
|
109
|
+
* field, so a truthiness check treats "no such window" as a window with no
|
|
110
|
+
* duration — which then renders under a guessed label.
|
|
111
|
+
*/
|
|
112
|
+
function hasWindow(window) {
|
|
113
|
+
return window !== undefined && window.windowMinutes > 0;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The default (`codex`) bucket's windows, each labelled from its own duration.
|
|
117
|
+
*
|
|
118
|
+
* These used to be read positionally — `primary` as the 5h window, `secondary`
|
|
119
|
+
* as the weekly one. Codex reports the weekly window in `primary` and leaves
|
|
120
|
+
* `secondary` empty, so an account at 100% of its weekly quota displayed as
|
|
121
|
+
* "5h 100%" beside a "weekly 0%" bar that was really the empty slot. The reset
|
|
122
|
+
* countdown gave it away: a 5h window cannot reset five days out.
|
|
123
|
+
*/
|
|
124
|
+
export function getCodexDefaultWindows(codex) {
|
|
125
|
+
const bucket = codex?.buckets.find(b => b.limitId === "codex");
|
|
126
|
+
if (!bucket)
|
|
127
|
+
return [];
|
|
128
|
+
const windows = [];
|
|
129
|
+
for (const [window, fallback] of [
|
|
130
|
+
[bucket.primary, "5h"],
|
|
131
|
+
[bucket.secondary, "weekly"],
|
|
132
|
+
]) {
|
|
133
|
+
if (!hasWindow(window))
|
|
134
|
+
continue;
|
|
135
|
+
windows.push({
|
|
136
|
+
label: codexWindowLabel(window.windowMinutes, fallback),
|
|
137
|
+
utilization: window.utilization,
|
|
138
|
+
resetAt: window.resetAt,
|
|
139
|
+
kind: window.windowMinutes >= 10_080 ? "weekly" : "session",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return windows;
|
|
143
|
+
}
|
|
105
144
|
/** Named Codex metered buckets as compact capacity rows (default bucket renders as bars). */
|
|
106
145
|
export function getCodexCapacityRows(codex, globalCooldownUntilMs, now = Date.now()) {
|
|
107
146
|
const rows = [];
|
|
@@ -110,10 +149,14 @@ export function getCodexCapacityRows(codex, globalCooldownUntilMs, now = Date.no
|
|
|
110
149
|
continue;
|
|
111
150
|
const cooling = bucket.cooldownUntilMs > now;
|
|
112
151
|
const windows = [];
|
|
113
|
-
|
|
152
|
+
// A zero-width window is Codex's placeholder for "this bucket has no such
|
|
153
|
+
// window", not a real one — it arrives as an all-zero object rather than
|
|
154
|
+
// being omitted. Rendering it duplicated the bucket, and both rows carried
|
|
155
|
+
// the same label because codexWindowLabel(0) falls through to its fallback.
|
|
156
|
+
if (hasWindow(bucket.primary)) {
|
|
114
157
|
windows.push({ label: codexWindowLabel(bucket.primary.windowMinutes, "5h"), ...bucket.primary });
|
|
115
158
|
}
|
|
116
|
-
if (bucket.secondary) {
|
|
159
|
+
if (hasWindow(bucket.secondary)) {
|
|
117
160
|
windows.push({ label: codexWindowLabel(bucket.secondary.windowMinutes, "weekly"), ...bucket.secondary });
|
|
118
161
|
}
|
|
119
162
|
for (const window of windows) {
|
|
@@ -248,7 +291,6 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
248
291
|
? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
|
|
249
292
|
: 0;
|
|
250
293
|
const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
|
|
251
|
-
const selectedAccountIsAnthropic = selectedAccount?.provider !== "openai_subscription";
|
|
252
294
|
const [modelsStatus, setModelsStatus] = useState(null);
|
|
253
295
|
const [selectedModelId, setSelectedModelId] = useState(null);
|
|
254
296
|
const modelRows = modelsStatus?.models ?? [];
|
|
@@ -287,13 +329,14 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
287
329
|
return s || "unknown error";
|
|
288
330
|
};
|
|
289
331
|
// ── Async helpers (fire-and-forget with error → banner) ──────────────────
|
|
332
|
+
// Provider-agnostic: `PATCH /cc-router/accounts/:id` applies `enabled` to
|
|
333
|
+
// OpenAI accounts through the same transaction contract as Claude ones, and
|
|
334
|
+
// drops their sticky bindings on disable. The cap keys below never had a
|
|
335
|
+
// provider check; this one was left behind after the endpoint gained OpenAI
|
|
336
|
+
// support, so the dashboard was refusing an operation the server had.
|
|
290
337
|
const doToggleEnabled = useCallback(async () => {
|
|
291
338
|
if (!selectedAccount)
|
|
292
339
|
return;
|
|
293
|
-
if (selectedAccount.provider === "openai_subscription") {
|
|
294
|
-
showBanner("OpenAI accounts are managed from the CLI", "yellow");
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
340
|
const newValue = !(selectedAccount.enabled !== false);
|
|
298
341
|
try {
|
|
299
342
|
await api.patch(selectedAccount.id, { enabled: newValue });
|
|
@@ -336,10 +379,6 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
336
379
|
const doDelete = useCallback(async () => {
|
|
337
380
|
if (!selectedAccount)
|
|
338
381
|
return;
|
|
339
|
-
if (selectedAccount.provider === "openai_subscription") {
|
|
340
|
-
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
382
|
try {
|
|
344
383
|
await api.remove(selectedAccount.id);
|
|
345
384
|
showBanner(`Removed ${selectedAccount.id}`, "yellow");
|
|
@@ -493,11 +532,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
493
532
|
setEditBuffer("");
|
|
494
533
|
return;
|
|
495
534
|
}
|
|
535
|
+
// Also provider-agnostic: `DELETE /cc-router/accounts/:id` removes an
|
|
536
|
+
// OpenAI account through `deleteOpenAIAccountTransaction`, which is the
|
|
537
|
+
// same path `cc-router accounts remove` reaches. Sending the operator to
|
|
538
|
+
// the CLI for something the dashboard can do was left over from before
|
|
539
|
+
// that existed.
|
|
496
540
|
if (input === "d") {
|
|
497
|
-
if (!selectedAccountIsAnthropic) {
|
|
498
|
-
showBanner("Use cc-router accounts remove for OpenAI accounts", "yellow");
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
541
|
setMode("confirmDelete");
|
|
502
542
|
return;
|
|
503
543
|
}
|
|
@@ -599,7 +639,7 @@ function AccountRow({ account: a, selected }) {
|
|
|
599
639
|
const globalCapacity = getGlobalCapacityView(rl);
|
|
600
640
|
const isOpenAI = a.provider === "openai_subscription";
|
|
601
641
|
const codex = a.codexRateLimits;
|
|
602
|
-
const
|
|
642
|
+
const codexDefaultWindows = getCodexDefaultWindows(codex);
|
|
603
643
|
const capacityRows = isOpenAI
|
|
604
644
|
? getCodexCapacityRows(a.codexRateLimits, a.globalCooldownUntilMs)
|
|
605
645
|
: getAccountCapacityRows(a);
|
|
@@ -632,10 +672,10 @@ function AccountRow({ account: a, selected }) {
|
|
|
632
672
|
? codex.credits.balance
|
|
633
673
|
: codex.credits.hasCredits ? "yes" : "no"
|
|
634
674
|
: 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(
|
|
675
|
+
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
676
|
// The account still works — its rotated token is live in memory — but a
|
|
637
677
|
// 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 &&
|
|
678
|
+
_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
679
|
}
|
|
640
680
|
// ─── Utilization bar ─────────────────────────────────────────────────────────
|
|
641
681
|
function UtilBar({ label, util, resetTs, isActive, cap }) {
|
|
@@ -680,10 +720,12 @@ function LogRow({ log, selected }) {
|
|
|
680
720
|
const sourceLabel = log.source === "cli" ? "cli"
|
|
681
721
|
: log.source === "desktop" ? "dsk"
|
|
682
722
|
: log.source === "api" ? "api"
|
|
683
|
-
: "
|
|
723
|
+
: log.source === "codex" ? "cdx"
|
|
724
|
+
: " ";
|
|
684
725
|
const sourceColor = log.source === "cli" ? "blue"
|
|
685
726
|
: log.source === "desktop" ? "magenta"
|
|
686
|
-
: "
|
|
727
|
+
: log.source === "codex" ? "cyan"
|
|
728
|
+
: "gray";
|
|
687
729
|
// Per-request token stats
|
|
688
730
|
const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
|
|
689
731
|
const outputTok = log.outputTokens ?? 0;
|
|
@@ -759,6 +801,8 @@ function sourceFullLabel(source) {
|
|
|
759
801
|
return "Claude Code";
|
|
760
802
|
if (source === "desktop")
|
|
761
803
|
return "Claude Desktop";
|
|
804
|
+
if (source === "codex")
|
|
805
|
+
return "Codex CLI";
|
|
762
806
|
if (source === "api")
|
|
763
807
|
return "API";
|
|
764
808
|
return "—";
|
package/package.json
CHANGED