@timo972/cc-router 0.10.0-rc.0 → 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
@@ -6,7 +6,63 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ---
8
8
 
9
- ## [0.10.0] — 2026-08-17
9
+ ## [Unreleased]
10
+
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.
62
+
63
+ ---
64
+
65
+ ## [0.10.0] — 2026-08-18
10
66
 
11
67
  ### Added
12
68
 
@@ -19,6 +75,13 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
19
75
  account-global otherwise; local 429/503 responses when no account is eligible.
20
76
  - Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
21
77
  plan, request/error/in-flight/session counts, and cooldown state.
78
+ - Unprefixed `gpt-*` models route to OpenAI. The Codex CLI writes the bare slug
79
+ from its own registry — `model = "gpt-5.6-sol"` in `config.toml`, or whatever
80
+ its `/model` picker selects — and an unprefixed name went to the Claude path,
81
+ where `/v1/responses` answers `501 Not Implemented`. No configuration could
82
+ redirect it, because `openAIAliases` is only consulted for names that are
83
+ already prefixed; those aliases now apply to the bare form as well. Every
84
+ other unprefixed model still routes to Claude.
22
85
 
23
86
  ### Changed
24
87
 
@@ -29,6 +92,32 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
29
92
 
30
93
  ### Fixed
31
94
 
95
+ - A refresh token the OAuth server rejects as terminally expired
96
+ (`400 invalid_grant`) is no longer retried forever. Every rejection was
97
+ treated as transient, so the five-minute refresh loop kept re-POSTing a token
98
+ that could never succeed — one account issued roughly 2000 futile requests
99
+ over three weeks on the shared Claude Code `client_id`, and nothing marked it
100
+ as needing re-authentication. Such an account is now flagged, dropped from the
101
+ refresh loop, and reported once to the operator; the flag is persisted, so a
102
+ restart neither resumes the futile traffic nor returns the dead account to the
103
+ routing pool, where it would have answered every request with a `401`. Any
104
+ other rejection — a different 400, 401, 429, 5xx, a network error — is still
105
+ retried. Thanks to @ethanhawkes-gif.
106
+ - `accounts list` no longer prints a count that contradicts the rows beneath it.
107
+ The proxy reads `accounts.json` once at startup and holds that snapshot, so
108
+ the two sources diverge the moment anything rewrites the file underneath it;
109
+ the count came from disk while the rows came from the live pool, and a real
110
+ drift surfaced only as "Accounts (4 configured)" above six rows. An account
111
+ could be routing live while its refresh token existed nowhere on disk — one
112
+ restart from needing re-authentication — with nothing to indicate it. The
113
+ count now describes the rows it sits above, and both directions of drift are
114
+ named: accounts missing from disk (a credential-loss risk, with the recovery)
115
+ and accounts on disk the proxy has not loaded (merely stale).
116
+ - `accounts remove` accepts an account that exists only in the running proxy.
117
+ The guard validated the id against disk while the removal it guards prefers
118
+ the live pool — the same pool `list` displays — so an account you could see
119
+ and the code could remove was rejected as "not found". The inventory is now
120
+ the union of both sources.
32
121
  - An unexpected failure partway through an OpenAI request — an upstream
33
122
  connection error, a rejected token refresh, a mid-stream abort — no longer
34
123
  takes down the proxy. Both `/v1/responses` and the `/v1/messages` OpenAI
package/README.md CHANGED
@@ -18,7 +18,7 @@ Distribute Claude Code requests across Claude subscriptions, and expose an OpenA
18
18
  ### Features
19
19
 
20
20
  - **Cache-aware session routing** — keep each Claude Code session on one account while distributing new sessions across 2-20 Claude Max accounts
21
- - **Multi-provider routing** — route `openai/*` models to OpenAI ChatGPT/Codex subscription accounts and Claude models to Claude subscriptions
21
+ - **Multi-provider routing** — route `openai/*` and unprefixed `gpt-*` models to OpenAI ChatGPT/Codex subscription accounts and Claude models to Claude subscriptions
22
22
  - **Transparent Claude proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
23
23
  - **Codex CLI support** — configure Codex to use CC-Router as a Responses-compatible provider
24
24
  - **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
@@ -320,11 +320,20 @@ CC_ROUTER_TOKEN=cc-rtr-your-secret codex -m openai/gpt-5.5
320
320
 
321
321
  Model prefixes:
322
322
 
323
- | Prefix | Upstream |
323
+ | Model | Upstream |
324
324
  |--------|----------|
325
325
  | `openai/*` | OpenAI ChatGPT/Codex subscription route |
326
+ | `gpt-*` (no prefix) | OpenAI ChatGPT/Codex subscription route |
326
327
  | `claude/*` | Claude subscription route |
327
328
  | `anthropic/*` | Claude subscription route |
329
+ | anything else with no prefix | Claude subscription route |
330
+
331
+ The unprefixed `gpt-*` rule exists for clients that do not speak this
332
+ convention. The Codex CLI writes the bare slug from its own registry — either
333
+ `model = "gpt-5.6-sol"` in `~/.codex/config.toml` or whatever its `/model`
334
+ picker selects — so those names arrive without a prefix and would otherwise be
335
+ routed to Claude, where `/v1/responses` answers `501`. Configured
336
+ `openAIAliases` apply to the bare form too.
328
337
 
329
338
  Examples after the configuration above:
330
339
 
@@ -31,7 +31,12 @@ export function registerAccounts(program) {
31
31
  console.log(JSON.stringify(liveStats ?? buildStoredAccountsJson(stored, openAIStored), null, 2));
32
32
  return;
33
33
  }
34
- console.log(chalk.bold(`\n Accounts (${stored.length + openAIStored.length} configured)\n`));
34
+ // The count has to describe the rows printed below it. Taking it from
35
+ // disk while listing the proxy's live pool made drift invisible — the
36
+ // header claimed four accounts above six rows.
37
+ console.log(chalk.bold(liveStats
38
+ ? `\n Accounts (${liveStats.length} in the running proxy)\n`
39
+ : `\n Accounts (${stored.length + openAIStored.length} configured)\n`));
35
40
  if (liveStats) {
36
41
  console.log(chalk.green(" ● Proxy is running — showing live stats\n"));
37
42
  for (const s of liveStats) {
@@ -52,6 +57,22 @@ export function registerAccounts(program) {
52
57
  ` errors: ${chalk.red(String(s.errorCount).padStart(3))}` +
53
58
  ` expires: ${exp}`);
54
59
  }
60
+ // The proxy reads accounts.json once at startup, so anything that
61
+ // rewrites the file afterwards leaves the two out of step. Silence
62
+ // here is how an account can be routing live while its refresh token
63
+ // exists nowhere on disk — one restart from having to authenticate it
64
+ // again.
65
+ const { unpersisted, unloaded } = accountDrift(liveStats.map(s => s.id), [...stored.map(a => a.id), ...openAIStored.map(a => a.id)]);
66
+ if (unpersisted.length > 0) {
67
+ console.log(chalk.red(`\n ⚠ Not in accounts.json: ${unpersisted.join(", ")}`));
68
+ console.log(chalk.gray(" These live only in the running proxy. Restarting it loses their\n"
69
+ + " credentials — re-add them, or update any one account to make the\n"
70
+ + " proxy write its pool back to disk."));
71
+ }
72
+ if (unloaded.length > 0) {
73
+ console.log(chalk.yellow(`\n ⚠ In accounts.json but not loaded: ${unloaded.join(", ")}`));
74
+ console.log(chalk.gray(" Restart the proxy to pick them up: cc-router start"));
75
+ }
55
76
  }
56
77
  else {
57
78
  console.log(chalk.gray(" (Proxy not running — showing stored configuration)\n"));
@@ -181,10 +202,7 @@ export function registerAccounts(program) {
181
202
  }
182
203
  const anthropicAccounts = loadAccounts();
183
204
  const openAIAccounts = loadOpenAIAccounts();
184
- const existingIds = [
185
- ...anthropicAccounts.map(a => a.id),
186
- ...openAIAccounts.map(a => a.id),
187
- ];
205
+ const { ids: existingIds, openAIIds } = mergeAccountInventory(anthropicAccounts.map(a => a.id), openAIAccounts.map(a => a.id), await fetchLiveStats());
188
206
  if (!existingIds.includes(id)) {
189
207
  console.log(chalk.red(`✗ Account "${id}" not found.`));
190
208
  console.log(chalk.gray(` Available: ${existingIds.join(", ")}`));
@@ -199,7 +217,7 @@ export function registerAccounts(program) {
199
217
  console.log(chalk.gray("Cancelled."));
200
218
  return;
201
219
  }
202
- const isOpenAI = openAIAccounts.some(account => account.id === id);
220
+ const isOpenAI = openAIIds.has(id);
203
221
  try {
204
222
  await removeAccountRuntimeAware(id);
205
223
  }
@@ -223,6 +241,46 @@ function printAddOutcome(mode) {
223
241
  ? chalk.gray(" Loaded into the running proxy — available now, no restart needed.\n")
224
242
  : chalk.gray(" Restart the proxy to load the new account: cc-router start\n"));
225
243
  }
244
+ /**
245
+ * Every account this CLI could act on, from both places one can live.
246
+ *
247
+ * The proxy loads accounts.json once at startup and holds that snapshot, so
248
+ * the two sources drift the moment the file changes underneath a running
249
+ * proxy — and they answer different questions. Removal prefers the live pool
250
+ * (see `removeAccountRuntimeAware`), so validating an id against disk alone
251
+ * rejected accounts that existed and were perfectly removable.
252
+ */
253
+ export function mergeAccountInventory(storedAnthropicIds, storedOpenAIIds, live) {
254
+ const openAIIds = new Set(storedOpenAIIds);
255
+ for (const account of live ?? []) {
256
+ if (account.provider === "openai_subscription")
257
+ openAIIds.add(account.id);
258
+ }
259
+ return {
260
+ ids: [...new Set([
261
+ ...storedAnthropicIds,
262
+ ...storedOpenAIIds,
263
+ ...(live ?? []).map(account => account.id),
264
+ ])],
265
+ openAIIds,
266
+ };
267
+ }
268
+ /**
269
+ * Where the running proxy and accounts.json disagree.
270
+ *
271
+ * `unpersisted` is the dangerous direction: those accounts exist only in the
272
+ * proxy's memory, so a restart loses their refresh tokens and they have to be
273
+ * authenticated again. `unloaded` is merely stale — the records are safe on
274
+ * disk, the proxy just has not read them.
275
+ */
276
+ export function accountDrift(liveIds, storedIds) {
277
+ const live = new Set(liveIds);
278
+ const stored = new Set(storedIds);
279
+ return {
280
+ unpersisted: liveIds.filter(id => !stored.has(id)),
281
+ unloaded: storedIds.filter(id => !live.has(id)),
282
+ };
283
+ }
226
284
  export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts) {
227
285
  return [
228
286
  ...anthropicAccounts.map(a => ({
@@ -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
@@ -240,13 +240,18 @@ function deserialize(records) {
240
240
  expiresAt: a.expiresAt,
241
241
  scopes: a.scopes ?? ["user:inference", "user:profile"],
242
242
  },
243
- healthy: true,
243
+ // An authExpired account must come back unhealthy. `needsRefresh()` skips
244
+ // it, so the startup refresh that would otherwise fail and clear `healthy`
245
+ // never runs — and TokenPool.hardBlock() gates only on `enabled && healthy`,
246
+ // so defaulting to true here would route live traffic to a dead token.
247
+ healthy: a.authExpired !== true,
244
248
  busy: false,
245
249
  requestCount: 0,
246
250
  errorCount: 0,
247
251
  lastUsed: 0,
248
252
  lastRefresh: 0,
249
253
  consecutiveErrors: 0,
254
+ authExpired: a.authExpired === true,
250
255
  rateLimits: { ...DEFAULT_RATE_LIMITS },
251
256
  enabled: a.enabled !== false, // default true
252
257
  sessionLimitPercent: a.sessionLimitPercent !== undefined
@@ -269,5 +274,6 @@ export function serialize(accounts) {
269
274
  enabled: a.enabled,
270
275
  sessionLimitPercent: a.sessionLimitPercent,
271
276
  weeklyLimitPercent: a.weeklyLimitPercent,
277
+ ...(a.authExpired ? { authExpired: true } : {}),
272
278
  }));
273
279
  }
@@ -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
  }
@@ -6,10 +6,29 @@ function cleanModel(model) {
6
6
  const trimmed = model?.trim();
7
7
  return trimmed ? trimmed : undefined;
8
8
  }
9
+ /**
10
+ * Unprefixed models that belong to OpenAI anyway.
11
+ *
12
+ * Clients do not all speak this router's `provider/model` convention. The
13
+ * Codex CLI writes the bare slug from its own registry — `model =
14
+ * "gpt-5.6-sol"` in config.toml, or whatever its `/model` picker selects — and
15
+ * an unprefixed name used to fall through to Anthropic, where the Responses
16
+ * ingress answers `501`. No configuration could redirect it either, since
17
+ * `openAIAliases` is only consulted once a name is already prefixed.
18
+ *
19
+ * `gpt-` is unambiguous: no Claude model is named that way, so claiming it
20
+ * costs the Anthropic path nothing. Everything else unprefixed still goes to
21
+ * Anthropic, which is what existing setups rely on.
22
+ */
23
+ function isBareOpenAIModel(publicModel) {
24
+ return publicModel.toLowerCase().startsWith("gpt-");
25
+ }
9
26
  export function parseModelRef(model, config = {}) {
10
27
  const publicModel = cleanModel(model) ?? cleanModel(config.anthropicDefaultModel) ?? "claude/sonnet";
11
- if (publicModel.startsWith("openai/")) {
12
- const openAIModel = publicModel.slice("openai/".length);
28
+ if (publicModel.startsWith("openai/") || isBareOpenAIModel(publicModel)) {
29
+ const openAIModel = publicModel.startsWith("openai/")
30
+ ? publicModel.slice("openai/".length)
31
+ : publicModel;
13
32
  const defaultOpenAIModel = cleanModel(config.openAIDefaultModel);
14
33
  return {
15
34
  provider: "openai_subscription",
@@ -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;
@@ -26,7 +26,35 @@ const pendingDurability = new WeakSet();
26
26
  function isReservedForDeletion(account) {
27
27
  return (deletionReservations.get(account) ?? 0) > 0;
28
28
  }
29
+ /**
30
+ * A refresh rejection is terminal when the OAuth server reports `invalid_grant`
31
+ * (HTTP 400, "refresh token expired"). Such a token can never be refreshed
32
+ * again, so it must not be retried. Every other rejection — a different 400
33
+ * error code, 401, 429, 5xx, a network error — is treated as transient and
34
+ * remains eligible for retry.
35
+ *
36
+ * The structured `error` field is checked first so a different 400 (e.g.
37
+ * `invalid_request`) is not misread as terminal; a non-JSON body falls back to
38
+ * a substring check for older/plain-text responses.
39
+ */
40
+ function isTerminalAuthFailure(status, body) {
41
+ if (status !== 400)
42
+ return false;
43
+ try {
44
+ const parsed = JSON.parse(body);
45
+ if (typeof parsed?.error === "string")
46
+ return parsed.error === "invalid_grant";
47
+ }
48
+ catch {
49
+ // Not JSON — fall through to the plain-text check below.
50
+ }
51
+ return /invalid_grant/i.test(body);
52
+ }
29
53
  export function needsRefresh(account) {
54
+ // A token the server rejected as terminally expired can never succeed; keep
55
+ // it out of the loop so it is not POSTed to the OAuth endpoint forever.
56
+ if (account.authExpired)
57
+ return false;
30
58
  return ownedRefreshLocks.has(account) ||
31
59
  pendingDurability.has(account) ||
32
60
  (account.tokens.expiresAt - Date.now()) < REFRESH_BUFFER_MS;
@@ -145,6 +173,13 @@ async function _doRefresh(account) {
145
173
  console.error(` Status: ${res.status} — ${body}`);
146
174
  account.consecutiveErrors++;
147
175
  account.healthy = false;
176
+ if (isTerminalAuthFailure(res.status, body) && !account.authExpired) {
177
+ // Permanent rejection: retrying can only fail and hammers the OAuth
178
+ // endpoint (thousands of dead POSTs on one client_id). Take the account
179
+ // out of the refresh loop and tell the operator once.
180
+ account.authExpired = true;
181
+ console.error(` Account ${account.id} needs re-authentication: its refresh token was rejected as expired (invalid_grant). Re-add the account to resume routing.`);
182
+ }
148
183
  return false;
149
184
  }
150
185
  const data = await res.json();
@@ -155,6 +190,7 @@ async function _doRefresh(account) {
155
190
  account.tokens.scopes = data.scope.split(" ");
156
191
  account.healthy = true;
157
192
  account.consecutiveErrors = 0;
193
+ account.authExpired = false;
158
194
  account.lastRefresh = Date.now();
159
195
  stats.totalRefreshes++;
160
196
  stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "refresh" });
@@ -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.0",
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": {