@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 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
- Nothing yet.
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
 
@@ -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
@@ -101,7 +101,8 @@ export async function stopDaemon(port = PROXY_PORT) {
101
101
  return killByPort(port);
102
102
  }
103
103
  // ─── Helpers ──────────────────────────────────────────────────────────────────
104
- async function waitForHealth(port, timeoutMs) {
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
- try {
133
- if (isWindows()) {
134
- const { stdout } = await execFileAsync("netstat", ["-ano"]);
135
- const match = stdout
136
- .split("\n")
137
- .find(line => line.includes(`:${port}`) && line.includes("LISTENING"));
138
- if (!match)
139
- return false;
140
- const pid = match.trim().split(/\s+/).at(-1);
141
- if (!pid || isNaN(Number(pid)))
142
- return false;
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
- return true;
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;
@@ -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 {
@@ -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
- try {
109
- await execFileAsync("launchctl", ["bootstrap", `gui/${uid}`, LAUNCHD_PLIST]);
110
- }
111
- catch {
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,
@@ -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 (process.env["CC_ROUTER_DAEMON"] === "1") {
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 (process.env["CC_ROUTER_DAEMON"] === "1") {
1196
+ if (managesPidFile()) {
1197
1197
  writePid(process.pid);
1198
1198
  }
1199
1199
  const totalAccountCount = accounts.length + openAIAccounts.length;
@@ -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
- if (bucket.primary) {
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 codexDefaultBucket = codex?.buckets.find(bucket => bucket.limitId === "codex");
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(20) }), _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 && (
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 && codexDefaultBucket && (codexDefaultBucket.primary || codexDefaultBucket.secondary) && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: codexDefaultBucket.primary?.utilization ?? 0, resetTs: codexDefaultBucket.primary?.resetAt ?? 0, isActive: false, cap: s5 }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "weekly", util: codexDefaultBucket.secondary?.utilization ?? 0, resetTs: codexDefaultBucket.secondary?.resetAt ?? 0, isActive: false, cap: w7 }), 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}`)))] }));
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
- : "gray";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.0-rc.1",
3
+ "version": "0.10.0-rc.2",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {