@timo972/cc-router 0.10.0-rc.0 → 0.10.0-rc.1
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 +40 -1
- package/README.md +11 -2
- package/dist/cli/cmd-accounts.js +64 -6
- package/dist/config/manager.js +7 -1
- package/dist/protocol/model-ref.js +21 -2
- package/dist/proxy/token-refresher.js +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,13 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
## [
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
Nothing yet.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## [0.10.0] — 2026-08-18
|
|
10
16
|
|
|
11
17
|
### Added
|
|
12
18
|
|
|
@@ -19,6 +25,13 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
19
25
|
account-global otherwise; local 429/503 responses when no account is eligible.
|
|
20
26
|
- Dashboard: OpenAI accounts now show 5h/weekly bars, per-bucket rows, credits,
|
|
21
27
|
plan, request/error/in-flight/session counts, and cooldown state.
|
|
28
|
+
- Unprefixed `gpt-*` models route to OpenAI. The Codex CLI writes the bare slug
|
|
29
|
+
from its own registry — `model = "gpt-5.6-sol"` in `config.toml`, or whatever
|
|
30
|
+
its `/model` picker selects — and an unprefixed name went to the Claude path,
|
|
31
|
+
where `/v1/responses` answers `501 Not Implemented`. No configuration could
|
|
32
|
+
redirect it, because `openAIAliases` is only consulted for names that are
|
|
33
|
+
already prefixed; those aliases now apply to the bare form as well. Every
|
|
34
|
+
other unprefixed model still routes to Claude.
|
|
22
35
|
|
|
23
36
|
### Changed
|
|
24
37
|
|
|
@@ -29,6 +42,32 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
29
42
|
|
|
30
43
|
### Fixed
|
|
31
44
|
|
|
45
|
+
- A refresh token the OAuth server rejects as terminally expired
|
|
46
|
+
(`400 invalid_grant`) is no longer retried forever. Every rejection was
|
|
47
|
+
treated as transient, so the five-minute refresh loop kept re-POSTing a token
|
|
48
|
+
that could never succeed — one account issued roughly 2000 futile requests
|
|
49
|
+
over three weeks on the shared Claude Code `client_id`, and nothing marked it
|
|
50
|
+
as needing re-authentication. Such an account is now flagged, dropped from the
|
|
51
|
+
refresh loop, and reported once to the operator; the flag is persisted, so a
|
|
52
|
+
restart neither resumes the futile traffic nor returns the dead account to the
|
|
53
|
+
routing pool, where it would have answered every request with a `401`. Any
|
|
54
|
+
other rejection — a different 400, 401, 429, 5xx, a network error — is still
|
|
55
|
+
retried. Thanks to @ethanhawkes-gif.
|
|
56
|
+
- `accounts list` no longer prints a count that contradicts the rows beneath it.
|
|
57
|
+
The proxy reads `accounts.json` once at startup and holds that snapshot, so
|
|
58
|
+
the two sources diverge the moment anything rewrites the file underneath it;
|
|
59
|
+
the count came from disk while the rows came from the live pool, and a real
|
|
60
|
+
drift surfaced only as "Accounts (4 configured)" above six rows. An account
|
|
61
|
+
could be routing live while its refresh token existed nowhere on disk — one
|
|
62
|
+
restart from needing re-authentication — with nothing to indicate it. The
|
|
63
|
+
count now describes the rows it sits above, and both directions of drift are
|
|
64
|
+
named: accounts missing from disk (a credential-loss risk, with the recovery)
|
|
65
|
+
and accounts on disk the proxy has not loaded (merely stale).
|
|
66
|
+
- `accounts remove` accepts an account that exists only in the running proxy.
|
|
67
|
+
The guard validated the id against disk while the removal it guards prefers
|
|
68
|
+
the live pool — the same pool `list` displays — so an account you could see
|
|
69
|
+
and the code could remove was rejected as "not found". The inventory is now
|
|
70
|
+
the union of both sources.
|
|
32
71
|
- An unexpected failure partway through an OpenAI request — an upstream
|
|
33
72
|
connection error, a rejected token refresh, a mid-stream abort — no longer
|
|
34
73
|
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/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
|
}
|
|
@@ -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",
|
|
@@ -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/package.json
CHANGED