@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 +90 -1
- package/README.md +11 -2
- package/dist/cli/cmd-accounts.js +64 -6
- package/dist/cli/cmd-start.js +14 -1
- package/dist/config/manager.js +7 -1
- package/dist/daemon/launcher.js +70 -23
- package/dist/daemon/pid.js +14 -0
- package/dist/daemon/service.js +64 -5
- package/dist/protocol/model-ref.js +21 -2
- 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/proxy/token-refresher.js +36 -0
- package/dist/ui/Dashboard.js +64 -20
- package/package.json +1 -1
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
|
-
## [
|
|
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
|
-
|
|
|
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
|
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 => ({
|
package/dist/cli/cmd-start.js
CHANGED
|
@@ -4,7 +4,7 @@ import { PROXY_PORT, LITELLM_PORT, ACCOUNTS_PATH } from "../config/paths.js";
|
|
|
4
4
|
import { accountsFileExists, readConfig, writeConfig, generateProxySecret, } from "../config/manager.js";
|
|
5
5
|
import { writeClaudeSettings } from "../utils/claude-config.js";
|
|
6
6
|
import { checkForUpdate, performUpdate, PKG_NAME } from "../utils/self-update.js";
|
|
7
|
-
import { launchDaemon } from "../daemon/launcher.js";
|
|
7
|
+
import { launchDaemon, waitForHealth } from "../daemon/launcher.js";
|
|
8
8
|
import { installService } from "../daemon/service.js";
|
|
9
9
|
import { getLocalIPs } from "../utils/network.js";
|
|
10
10
|
export function registerStart(program) {
|
|
@@ -79,6 +79,19 @@ export function registerStart(program) {
|
|
|
79
79
|
}
|
|
80
80
|
if (prefs.mode === "service") {
|
|
81
81
|
await installService(prefs.serverMode);
|
|
82
|
+
// Installing the service is not the same as the proxy being up: if
|
|
83
|
+
// launchd rejects the load, `installService` only warns. Verify, so a
|
|
84
|
+
// failed start is not reported as a success.
|
|
85
|
+
if (await waitForHealth(port, 10_000)) {
|
|
86
|
+
console.log(chalk.green(`✓ CC-Router running on port ${port}`));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.log(chalk.yellow(`\n⚠ Service configured, but nothing is answering on port ${port}.`));
|
|
90
|
+
console.log(chalk.gray(` Check the logs: cc-router logs`));
|
|
91
|
+
console.log(chalk.gray(` Then try again: cc-router start`));
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
82
95
|
}
|
|
83
96
|
else {
|
|
84
97
|
// background mode
|
package/dist/config/manager.js
CHANGED
|
@@ -240,13 +240,18 @@ function deserialize(records) {
|
|
|
240
240
|
expiresAt: a.expiresAt,
|
|
241
241
|
scopes: a.scopes ?? ["user:inference", "user:profile"],
|
|
242
242
|
},
|
|
243
|
-
|
|
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
|
}
|
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
|
}
|
|
@@ -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.
|
|
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,
|
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;
|
|
@@ -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" });
|
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