@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 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
- ## [0.10.0] — 2026-08-17
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
- | 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 => ({
@@ -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
  }
@@ -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",
@@ -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
@@ -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.1",
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": {