@timo972/cc-router 0.12.3 → 0.12.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,47 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - Accounts whose refresh token the provider rejected permanently are now
14
+ reported as `re-auth required` in `cc-router accounts list` — in both the
15
+ live and the stored view — and named once with the command that recovers
16
+ them. Previously they were indistinguishable from an account holding a
17
+ merely stale access token, which the next refresh tick replaces on its own.
18
+ `authExpired` is exposed through the health endpoint and in
19
+ `cc-router accounts list --json` — with and without a running proxy — for
20
+ the same reason: both states otherwise read as nothing but a past
21
+ `expiresAt`.
22
+
23
+ ### Changed
24
+
25
+ - A permanently rejected OpenAI account is no longer retried on the same
26
+ credentials. `authExpired` now persists for OpenAI as it already did for
27
+ Claude, so the rejection survives a restart, and such an account loads
28
+ quarantined rather than being handed live traffic. Recovery is by
29
+ re-authentication rather than by a retry that can only fail again.
30
+
31
+ ### Fixed
32
+
33
+ - Re-authenticating an existing account id while the proxy is running no
34
+ longer discards the new credentials. `cc-router accounts add` already
35
+ replaced by id on disk, but the live pool refused the id and the CLI
36
+ aborted before writing anything — so the OAuth login that had just
37
+ completed, and the refresh token it minted, were lost. This was the
38
+ documented recovery for an account needing re-authentication.
39
+ `POST /cc-router/accounts` accepts an opt-in `replace` flag; without it the
40
+ endpoint still answers 409, and replacement is refused across providers.
41
+ - A dead OpenAI refresh token no longer generates an OAuth request every five
42
+ minutes indefinitely. One account produced 1754 identical 401 diagnostics
43
+ over four days and dominated the proxy log.
44
+ - `cc-router start --accounts <path>` now writes Claude accounts back to that
45
+ file. Every Anthropic write — token rotation, re-authentication, add, patch,
46
+ rename, delete, and the shutdown save — targeted the default
47
+ `~/.cc-router/accounts.json` regardless of the file the pool was loaded from.
48
+ Rotated refresh tokens therefore never reached the selected file and were
49
+ lost on the next restart, while the default file was overwritten with a pool
50
+ it does not describe. OpenAI accounts were already persisted correctly.
51
+
11
52
  ---
12
53
 
13
54
  ## [0.12.3] — 2026-09-17
@@ -10,6 +10,7 @@ import { loginXaiWithDeviceCode } from "../providers/xai/device-oauth.js";
10
10
  import { isValidAccountId } from "../proxy/account-rename.js";
11
11
  import { createSetupAttempt, failAttemptFromError, withSetupTelemetryFlush, } from "../telemetry/setup-diagnostics.js";
12
12
  import { sanitizeAccountInfo, formatAccountInfo } from "../providers/account-info.js";
13
+ import { needsReauthentication } from "../providers/auth-state.js";
13
14
  export function registerAccounts(program) {
14
15
  const accounts = program
15
16
  .command("accounts")
@@ -43,6 +44,13 @@ export function registerAccounts(program) {
43
44
  console.log(chalk.bold(liveStats
44
45
  ? `\n Accounts (${liveStats.length} in the running proxy)\n`
45
46
  : `\n Accounts (${stored.length + openAIStored.length + xaiStored.length} configured)\n`));
47
+ /**
48
+ * Accounts only re-authentication can restore, tagged with the provider
49
+ * whose sign-in command actually recovers them — `accounts add` runs the
50
+ * Claude Max flow, so pointing an OpenAI or Grok operator at it would
51
+ * re-add the id under the wrong provider.
52
+ */
53
+ const reauthNeeded = [];
46
54
  if (liveStats) {
47
55
  console.log(chalk.green(" ● Proxy is running — showing live stats\n"));
48
56
  for (const s of liveStats) {
@@ -51,9 +59,16 @@ export function registerAccounts(program) {
51
59
  : s.provider === "xai_subscription"
52
60
  ? chalk.magenta("grok".padEnd(9))
53
61
  : chalk.gray("claude".padEnd(9));
54
- const status = s.healthy
55
- ? chalk.green("✓ healthy")
56
- : chalk.red("✗ unhealthy");
62
+ // "unhealthy" covers everything from a five-minute network blip to a
63
+ // permanently rejected refresh token. Only the latter needs the
64
+ // operator, so it gets its own label rather than hiding in the crowd.
65
+ if (needsReauthentication(s))
66
+ reauthNeeded.push({ id: s.id, provider: s.provider });
67
+ const status = needsReauthentication(s)
68
+ ? chalk.red("✗ re-auth required")
69
+ : s.healthy
70
+ ? chalk.green("✓ healthy")
71
+ : chalk.red("✗ unhealthy");
57
72
  const busy = s.busy ? chalk.yellow(" [busy]") : "";
58
73
  const exp = s.expiresInMs > 0
59
74
  ? chalk.yellow(formatMs(s.expiresInMs))
@@ -92,19 +107,27 @@ export function registerAccounts(program) {
92
107
  const expColor = a.tokens.expiresAt > Date.now()
93
108
  ? chalk.yellow(exp)
94
109
  : chalk.red(exp);
110
+ // `authExpired` is persisted, so the dead state is knowable without
111
+ // the proxy running — and this is exactly when an operator looks.
112
+ if (needsReauthentication(a))
113
+ reauthNeeded.push({ id: a.id, provider: "anthropic_subscription" });
95
114
  console.log(` ${chalk.bold(a.id.padEnd(24))}` +
96
115
  ` ${redactToken(a.tokens.accessToken).padEnd(26)}` +
97
116
  ` expires: ${expColor}` +
98
- ` scopes: ${chalk.gray(a.tokens.scopes.join(" "))}`);
117
+ ` scopes: ${chalk.gray(a.tokens.scopes.join(" "))}` +
118
+ (needsReauthentication(a) ? ` ${chalk.red("✗ re-auth required")}` : ""));
99
119
  }
100
120
  for (const a of openAIStored) {
101
121
  const exp = a.expiresAt > Date.now()
102
122
  ? chalk.yellow(formatExpiry(a.expiresAt))
103
123
  : chalk.red("EXPIRED");
124
+ if (needsReauthentication(a))
125
+ reauthNeeded.push({ id: a.id, provider: "openai_subscription" });
104
126
  console.log(` ${chalk.bold(a.id.padEnd(24))}` +
105
127
  ` ${chalk.magenta("openai".padEnd(10))}` +
106
128
  ` ${redactToken(a.accessToken).padEnd(26)}` +
107
- ` expires: ${exp}`);
129
+ ` expires: ${exp}` +
130
+ (needsReauthentication(a) ? ` ${chalk.red("✗ re-auth required")}` : ""));
108
131
  }
109
132
  for (const a of xaiStored) {
110
133
  const exp = a.expiresAt > Date.now()
@@ -116,6 +139,19 @@ export function registerAccounts(program) {
116
139
  ` expires: ${exp}`);
117
140
  }
118
141
  }
142
+ // A dead refresh token is the one failure the router cannot work its way
143
+ // out of: the refresh loop has deliberately stopped retrying, so nothing
144
+ // changes until someone re-authenticates. Say so, and say how.
145
+ if (reauthNeeded.length > 0) {
146
+ console.log(chalk.red(`\n ⚠ Needs re-authentication: ${reauthNeeded.map(a => a.id).join(", ")}`));
147
+ console.log(chalk.gray(" The provider rejected these refresh tokens permanently; they cannot\n"
148
+ + " be recovered and the refresh loop has stopped retrying them. Sign in\n"
149
+ + " again under the same account id to resume routing — the existing\n"
150
+ + " account is replaced, so there is nothing to remove first:"));
151
+ for (const { id, provider } of reauthNeeded) {
152
+ console.log(chalk.gray(` ${reauthCommand(provider)} (for ${id})`));
153
+ }
154
+ }
119
155
  console.log();
120
156
  });
121
157
  // ── accounts add ─────────────────────────────────────────────────────────
@@ -137,8 +173,10 @@ export function registerAccounts(program) {
137
173
  ];
138
174
  let mode;
139
175
  try {
176
+ // Only `addStored` is overridden: this flow merges by id across the
177
+ // whole Claude pool. `tryAddLive` must stay the default so the live
178
+ // pool is asked to replace an existing id rather than reject it.
140
179
  ({ mode } = await addAccountRuntimeAware(serialize([account])[0], {
141
- tryAddLive: tryAddAccountToRunningProxy,
142
180
  addStored: () => saveAccounts(merged),
143
181
  }));
144
182
  }
@@ -455,12 +493,14 @@ export function buildStoredAccountsJson(anthropicAccounts, openAIAccounts, xaiAc
455
493
  enabled: a.enabled,
456
494
  expiresAt: a.tokens.expiresAt,
457
495
  scopes: a.tokens.scopes,
496
+ ...(needsReauthentication(a) ? { authExpired: true } : {}),
458
497
  })),
459
498
  ...openAIAccounts.map(a => ({
460
499
  id: a.id,
461
500
  provider: "openai_subscription",
462
501
  enabled: a.enabled !== false,
463
502
  expiresAt: a.expiresAt,
503
+ ...(needsReauthentication(a) ? { authExpired: true } : {}),
464
504
  })),
465
505
  ...xaiAccounts.map(a => ({
466
506
  id: a.id,
@@ -587,7 +627,7 @@ export async function tryAddAccountToRunningProxy(record, options = {}) {
587
627
  "content-type": "application/json",
588
628
  ...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
589
629
  },
590
- body: JSON.stringify(record),
630
+ body: JSON.stringify(options.replace ? { ...record, replace: true } : record),
591
631
  signal: AbortSignal.timeout(3_000),
592
632
  });
593
633
  }
@@ -611,15 +651,38 @@ export async function tryAddAccountToRunningProxy(record, options = {}) {
611
651
  * running the record is handed to it (live pool + disk in one step); otherwise
612
652
  * it is written to disk via `addStored` and picked up on the next start.
613
653
  */
614
- export async function addAccountRuntimeAware(record, dependencies = {
615
- tryAddLive: tryAddAccountToRunningProxy,
616
- addStored: upsertAccountRecord,
617
- }) {
618
- if (await dependencies.tryAddLive(record))
654
+ export async function addAccountRuntimeAware(record, dependencies = {}) {
655
+ // Each dependency defaults independently. Taking the whole object as one
656
+ // default meant a caller that only needed its own `addStored` — the Claude
657
+ // `accounts add` flow does, to merge by id — had to restate `tryAddLive`
658
+ // too, and that restatement silently dropped the replacement request. The
659
+ // path that most needs replacement was the one path not asking for it.
660
+ const tryAddLive = dependencies.tryAddLive
661
+ // `upsertAccountRecord` already replaces by id on disk; asking the live
662
+ // pool for the same thing is what keeps the two halves of an `accounts
663
+ // add` in agreement instead of failing on the account that most needs it.
664
+ ?? (live => tryAddAccountToRunningProxy(live, { replace: true }));
665
+ const addStored = dependencies.addStored ?? upsertAccountRecord;
666
+ if (await tryAddLive(record))
619
667
  return { mode: "live" };
620
- dependencies.addStored(record);
668
+ addStored(record);
621
669
  return { mode: "stored" };
622
670
  }
671
+ /**
672
+ * The sign-in command that recovers an account of this provider.
673
+ *
674
+ * `accounts add` is the Claude Max flow specifically; the other providers have
675
+ * their own. Each re-registers under the id the operator types, and the live
676
+ * pool now replaces rather than rejects it, so no deletion step is needed —
677
+ * which also avoids the running proxy refusing to delete a lone Claude account.
678
+ */
679
+ function reauthCommand(provider) {
680
+ if (provider === "openai_subscription")
681
+ return "cc-router accounts login-openai";
682
+ if (provider === "xai_subscription")
683
+ return "cc-router accounts login-grok";
684
+ return "cc-router accounts add";
685
+ }
623
686
  async function fetchLiveStats() {
624
687
  try {
625
688
  const { proxySecret } = readConfig();
@@ -66,11 +66,23 @@ function writeAccountsAtomicToPath(path, data) {
66
66
  // accounts.json holds plaintext OAuth access + refresh tokens — owner-only.
67
67
  writeFileSecureSync(path, JSON.stringify(data, null, 2));
68
68
  }
69
- export function writeAnthropicAccountsPreservingOtherProviders(data) {
69
+ /**
70
+ * Replace the Anthropic records in an accounts file, leaving every other
71
+ * provider's records in it untouched.
72
+ *
73
+ * `path` defaults to `ACCOUNTS_PATH`, but a server started with
74
+ * `--accounts <path>` must write back to the file it read: sending rotated
75
+ * refresh tokens to the default file instead loses them on the next restart
76
+ * *and* overwrites a file describing a different pool. Mirrors
77
+ * `saveOpenAIAccountsToPath`, which has always taken the path.
78
+ */
79
+ export function writeAnthropicAccountsPreservingOtherProviders(data, path = ACCOUNTS_PATH) {
70
80
  ensureConfigDir();
71
- const existing = readAccountsRaw();
81
+ // Read from the same file being written, or the merge would carry another
82
+ // file's non-Anthropic records into this one.
83
+ const existing = readRawFromPath(path);
72
84
  const nonAnthropic = existing.filter(a => a.provider !== undefined && a.provider !== "anthropic_subscription");
73
- writeAccountsAtomicToPath(ACCOUNTS_PATH, [...data, ...nonAnthropic]);
85
+ writeAccountsAtomicToPath(path, [...data, ...nonAnthropic]);
74
86
  }
75
87
  export function upsertAccountRecord(record) {
76
88
  ensureConfigDir();
@@ -158,6 +170,9 @@ export function loadOpenAIAccounts(path) {
158
170
  refreshToken: a.refreshToken,
159
171
  expiresAt: a.expiresAt,
160
172
  enabled: a.enabled !== false,
173
+ // Without this the flag is lost on restart and the dead refresh token is
174
+ // POSTed again from scratch — the whole point of persisting it.
175
+ ...(a.authExpired === true ? { authExpired: true } : {}),
161
176
  ...(Array.isArray(a.scopes) ? { scopes: a.scopes } : {}),
162
177
  ...(a.sessionLimitPercent !== undefined ? { sessionLimitPercent: a.sessionLimitPercent } : {}),
163
178
  ...(a.weeklyLimitPercent !== undefined ? { weeklyLimitPercent: a.weeklyLimitPercent } : {}),
@@ -178,6 +193,7 @@ export function saveOpenAIAccountsToPath(accounts, path) {
178
193
  expiresAt: a.expiresAt,
179
194
  scopes: a.scopes ?? ["openid", "profile", "email", "offline_access"],
180
195
  enabled: a.enabled,
196
+ ...(a.authExpired ? { authExpired: true } : {}),
181
197
  ...(a.sessionLimitPercent !== undefined ? { sessionLimitPercent: a.sessionLimitPercent } : {}),
182
198
  ...(a.weeklyLimitPercent !== undefined ? { weeklyLimitPercent: a.weeklyLimitPercent } : {}),
183
199
  }));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * True when an account can only be restored by re-authenticating it.
3
+ *
4
+ * A bare quarantine is deliberately not enough: a transient rejection also
5
+ * quarantines, and the next refresh tick can clear it on its own.
6
+ */
7
+ export function needsReauthentication(account) {
8
+ return account.authExpired === true || account.authFailure === "permanent";
9
+ }
@@ -45,7 +45,11 @@ export function createOpenAIAccount(record) {
45
45
  weeklyLimitPercent: record.weeklyLimitPercent !== undefined
46
46
  ? clampPercent(record.weeklyLimitPercent)
47
47
  : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
48
- healthy: true,
48
+ // A record persisted as authExpired must come back out of rotation. The
49
+ // refresh path deliberately never retries it, so defaulting to healthy
50
+ // would route live traffic at a token already known to be dead and spend
51
+ // a request discovering it. Mirrors `deserialize()` on the Anthropic side.
52
+ healthy: record.authExpired !== true,
49
53
  requestCount: 0,
50
54
  errorCount: 0,
51
55
  consecutiveErrors: 0,
@@ -53,7 +57,9 @@ export function createOpenAIAccount(record) {
53
57
  lastRefresh: 0,
54
58
  rateLimits,
55
59
  modelBuckets: new Map(),
56
- authState: "ok",
60
+ ...(record.authExpired === true
61
+ ? { authState: "quarantined", authFailure: "permanent" }
62
+ : { authState: "ok" }),
57
63
  };
58
64
  }
59
65
  /**
@@ -65,6 +65,13 @@ function persistCredentials(account, allAccounts, saveAccounts) {
65
65
  return false;
66
66
  }
67
67
  }
68
+ /**
69
+ * True when this account's refresh token was terminally rejected. A refresh
70
+ * must never be attempted for it — see `authExpired` above.
71
+ */
72
+ export function isOpenAIAuthExpired(account) {
73
+ return account.authExpired === true;
74
+ }
68
75
  export function needsOpenAIRefresh(account) {
69
76
  return account.expiresAt - Date.now() < REFRESH_BUFFER_MS;
70
77
  }
@@ -88,6 +95,19 @@ export async function refreshOpenAISubscriptionToken(account) {
88
95
  }
89
96
  export async function prepareOpenAIAccountForRequest(account, allAccounts, saveAccounts) {
90
97
  const runtime = account;
98
+ // A terminally rejected refresh token can only fail again, and the
99
+ // quarantine branch below would otherwise retry it on every request and
100
+ // every scheduled tick — thousands of dead POSTs on one client_id. The way
101
+ // back is replacement credentials (re-add the account), not another retry.
102
+ if (isOpenAIAuthExpired(account)) {
103
+ // The flag itself still has to reach disk. If the write that recorded it
104
+ // failed, returning here without retrying would strand `authExpired` in
105
+ // memory, and the next start would POST the dead token all over again —
106
+ // exactly what persisting it prevents. Cheap: no request goes out.
107
+ if (hasPendingCredentialWrite(account))
108
+ persistCredentials(account, allAccounts, saveAccounts);
109
+ return false;
110
+ }
91
111
  // A revoked but unexpired access token must not bypass the refresh gate.
92
112
  if (!needsOpenAIRefresh(account) && runtime.authState !== "quarantined") {
93
113
  // No refresh due, but a previous rotation from this account never made it
@@ -112,9 +132,16 @@ export async function prepareOpenAIAccountForRequest(account, allAccounts, saveA
112
132
  * turns a successful refresh into a `false` result.
113
133
  */
114
134
  export async function refreshAndPersistOpenAIAccount(account, allAccounts, saveAccounts) {
135
+ const wasAuthExpired = isOpenAIAuthExpired(account);
115
136
  const ok = await refreshOpenAISubscriptionToken(account);
116
- if (ok)
137
+ // Persist on a rotated credential, and also on a newly terminal rejection:
138
+ // an `authExpired` that never reaches disk is re-tried from scratch after
139
+ // every restart, which is how a dead token keeps generating OAuth traffic
140
+ // for days. A failed write here is best-effort — the flag is still live in
141
+ // memory for this process, and the next start re-derives it from one POST.
142
+ if (ok || (!wasAuthExpired && isOpenAIAuthExpired(account))) {
117
143
  persistCredentials(account, allAccounts, saveAccounts);
144
+ }
118
145
  return ok;
119
146
  }
120
147
  /**
@@ -173,6 +200,13 @@ function markRefreshFailure(account, permanent) {
173
200
  if (permanent) {
174
201
  runtime.authFailure = "permanent";
175
202
  runtime.authState = "quarantined";
203
+ // Tell the operator exactly once. Before this, a dead token produced one
204
+ // indistinguishable 401 diagnostic line per tick and nothing that said
205
+ // the account would never recover on its own.
206
+ if (account.authExpired !== true) {
207
+ account.authExpired = true;
208
+ console.error(` Account ${account.id} needs re-authentication: its refresh token was rejected permanently. Re-add the account to resume routing.`);
209
+ }
176
210
  }
177
211
  else if (runtime.authState !== "quarantined") {
178
212
  runtime.authFailure = "transient";
@@ -282,6 +316,10 @@ async function doRefresh(account, span) {
282
316
  runtime.lastRefresh = Date.now();
283
317
  runtime.authState = "ok";
284
318
  runtime.authFailure = undefined;
319
+ // Replacement credentials proved themselves, so the account is no longer
320
+ // stranded. Written rather than deleted so the cleared state is persisted
321
+ // over a previously stored `authExpired: true`.
322
+ account.authExpired = false;
285
323
  // The rotated access token can carry a different plan than the one decoded
286
324
  // at account creation (e.g. a Plus->Pro upgrade). Mirrors createOpenAIAccount's
287
325
  // semantics: only overwrite when the new token actually decodes a plan claim —
@@ -0,0 +1,108 @@
1
+ import { reserveAccountForDeletion } from "./token-refresher.js";
2
+ import { createOpenAIAccount } from "../providers/openai/account-state.js";
3
+ /**
4
+ * Replacing an account's credentials in the live pool — what re-authenticating
5
+ * an existing account id does.
6
+ *
7
+ * This exists because a terminally rejected refresh token has exactly one
8
+ * recovery: install new credentials under the same id. Adding was previously
9
+ * the only live write, and it refused an id already in the pool, so the OAuth
10
+ * login the operator had just completed was discarded and its brand-new
11
+ * refresh token lost. Replacement closes that path.
12
+ *
13
+ * Both transactions mutate the live array and persist, rolling the mutation
14
+ * back if the write throws — the same shape as `addOpenAIAccountTransaction`.
15
+ * Leaving the pool ahead of disk would let a later whole-pool write persist
16
+ * credentials the operator never installed.
17
+ */
18
+ export class AccountReplacementConflictError extends Error {
19
+ constructor(id) {
20
+ super(`Account "${id}" changed during replacement`);
21
+ this.name = "AccountReplacementConflictError";
22
+ }
23
+ }
24
+ export function accountReplacementStatusCode(error) {
25
+ return error instanceof AccountReplacementConflictError ? 409 : 500;
26
+ }
27
+ /**
28
+ * Swap new credentials in under an existing id.
29
+ *
30
+ * The reservation is the load-bearing part. A refresh already in flight holds
31
+ * the *old* refresh token, and on success it writes the rotated result onto
32
+ * its account object and persists the whole pool. Replacing without waiting
33
+ * would let that completion land after the swap and put dead credentials back
34
+ * over the ones just installed. `reserveAccountForDeletion` both waits for
35
+ * that work and blocks new refreshes for the old object, so the window closes.
36
+ */
37
+ export async function replaceAnthropicAccountTransaction(options) {
38
+ const { id } = options.record;
39
+ const previous = options.pool.findById(id);
40
+ if (!previous)
41
+ throw new Error(`Account "${id}" not found`);
42
+ const release = await (options.reserve ?? reserveAccountForDeletion)(previous);
43
+ try {
44
+ // Re-check after the await: a concurrent delete or replace may have landed
45
+ // while this one waited for the in-flight refresh to settle.
46
+ if (options.pool.findById(id) !== previous) {
47
+ throw new AccountReplacementConflictError(id);
48
+ }
49
+ // One pool operation rather than remove-then-add: the swap discards the
50
+ // old incarnation's in-flight count and cooldowns (the replacement must
51
+ // not inherit a bench it never earned) and hands back a rollback that
52
+ // restores every bit of it if the write below fails.
53
+ const { added, rollback } = options.pool.replaceAccount(options.record);
54
+ try {
55
+ options.persist(options.pool.getAll());
56
+ }
57
+ catch (error) {
58
+ rollback();
59
+ throw error;
60
+ }
61
+ // Only after the swap is durable: sticky sessions pinned to the dead
62
+ // incarnation would otherwise keep routing at an object out of the pool.
63
+ options.sessionRouter.invalidateAccount(id);
64
+ return added;
65
+ }
66
+ finally {
67
+ release();
68
+ }
69
+ }
70
+ /**
71
+ * OpenAI counterpart. No reservation is needed: refresh locks are keyed by
72
+ * object identity, so a refresh still running against the replaced object
73
+ * mutates an object no longer in the array — it cannot overwrite the new
74
+ * credentials, and the whole-pool write it may trigger sees the new ones.
75
+ */
76
+ export function replaceOpenAIAccountTransaction(options) {
77
+ const index = options.accounts.findIndex(candidate => candidate.id === options.record.id);
78
+ if (index < 0)
79
+ throw new Error(`Account "${options.record.id}" not found`);
80
+ const previous = options.accounts[index];
81
+ const account = createOpenAIAccount({
82
+ id: options.record.id,
83
+ provider: "openai_subscription",
84
+ accessToken: options.record.accessToken,
85
+ refreshToken: options.record.refreshToken,
86
+ expiresAt: options.record.expiresAt,
87
+ enabled: options.record.enabled !== false,
88
+ ...(options.record.sessionLimitPercent !== undefined
89
+ ? { sessionLimitPercent: options.record.sessionLimitPercent }
90
+ : {}),
91
+ ...(options.record.weeklyLimitPercent !== undefined
92
+ ? { weeklyLimitPercent: options.record.weeklyLimitPercent }
93
+ : {}),
94
+ });
95
+ // Splice in place so the replacement keeps the old account's position; the
96
+ // array reference is the one the pool, router, and refresh loop all hold.
97
+ options.accounts.splice(index, 1, account);
98
+ try {
99
+ options.persist(options.accounts);
100
+ }
101
+ catch (error) {
102
+ options.accounts.splice(index, 1, previous);
103
+ throw error;
104
+ }
105
+ options.forgetAccount?.(previous);
106
+ options.invalidateAccount?.(options.record.id);
107
+ return account;
108
+ }
@@ -38,6 +38,7 @@ import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldow
38
38
  import { persistProviderEnabledState } from "./provider-routing.js";
39
39
  import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpenAIAccountTransaction, } from "./account-deletion.js";
40
40
  import { addOpenAIAccountTransaction } from "./account-add.js";
41
+ import { accountReplacementStatusCode, replaceAnthropicAccountTransaction, replaceOpenAIAccountTransaction, } from "./account-replace.js";
41
42
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
42
43
  import { createAllowanceView } from "./allowance.js";
43
44
  import { AccountInfoCache } from "./account-info-cache.js";
@@ -119,7 +120,12 @@ function publicAnthropicAccountView(a, metrics) {
119
120
  enabled: a.enabled,
120
121
  sessionLimitPercent: a.sessionLimitPercent,
121
122
  weeklyLimitPercent: a.weeklyLimitPercent,
122
- healthy: a.enabled !== false && a.healthy,
123
+ // `authExpired` is checked here rather than relying on `healthy` alone:
124
+ // a dead refresh token must never read as healthy regardless of which
125
+ // code path last touched the flag. Mirrors the OpenAI view's treatment
126
+ // of `authState === "quarantined"`.
127
+ healthy: a.enabled !== false && a.healthy && a.authExpired !== true,
128
+ ...(a.authExpired ? { authExpired: true } : {}),
123
129
  busy: a.busy || metrics.coolingDown,
124
130
  cooldownUntilMs: metrics.cooldownUntilMs ?? 0,
125
131
  globalCooldownUntilMs: metrics.globalCooldownUntilMs ?? 0,
@@ -243,6 +249,7 @@ function publicOpenAIAccountView(a, routing) {
243
249
  lastRefreshMs: a.lastRefresh,
244
250
  codexRateLimits: publicCodexRateLimits(a, routing.cooldowns),
245
251
  ...(hasPendingCredentialWrite(a) ? { credentialsPendingWrite: true } : {}),
252
+ ...(a.authExpired ? { authExpired: true } : {}),
246
253
  ...(a.authState === "quarantined" ? { authState: "quarantined" } : {}),
247
254
  ...(a.authFailure ? { authFailure: a.authFailure } : {}),
248
255
  };
@@ -331,6 +338,18 @@ export { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers
331
338
  * a rotation that failed to persist earlier on disk even though no refresh was
332
339
  * involved, and the pending-write bookkeeping has to clear with it.
333
340
  */
341
+ /**
342
+ * Bind Anthropic persistence to the accounts file this server was started
343
+ * with. Every Anthropic write has to go through it: a server running on
344
+ * `--accounts <path>` that writes to the default file loses each rotated
345
+ * refresh token on restart and clobbers a file describing a different pool.
346
+ * The OpenAI side has always done this — see `createOpenAIPersister`.
347
+ */
348
+ export function createAnthropicPersister(accountsPath) {
349
+ return (accountsToSave) => {
350
+ saveAccounts(accountsToSave, accountsPath ?? ACCOUNTS_PATH);
351
+ };
352
+ }
334
353
  export function createOpenAIPersister(accountsPath) {
335
354
  return (accountsToSave) => {
336
355
  saveOpenAIAccountsToPath(accountsToSave, accountsPath ?? ACCOUNTS_PATH);
@@ -349,6 +368,9 @@ export async function startServer(opts = {}) {
349
368
  const mode = litellmUrl ? "litellm" : "standalone";
350
369
  const accountsPath = opts.accountsPath;
351
370
  const persistOpenAIAccounts = createOpenAIPersister(accountsPath);
371
+ // Every Anthropic write goes through this, never `saveAccounts` directly, so
372
+ // a server started with `--accounts <path>` writes back to the file it read.
373
+ const persistAnthropicAccounts = createAnthropicPersister(accountsPath);
352
374
  if (!accountsFileExists(accountsPath)) {
353
375
  console.error(chalk.red("\n✗ accounts.json not found."));
354
376
  console.error(chalk.yellow(" Run: cc-router setup\n"));
@@ -422,7 +444,10 @@ export async function startServer(opts = {}) {
422
444
  openAIPool.onCooldownExpired = (a) => {
423
445
  stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "route", details: `${a.id} cooldown expired — rate limit cleared` });
424
446
  };
425
- startRefreshLoop(accounts);
447
+ // The loop rotates refresh tokens, so it is the write that matters most for
448
+ // a custom accounts file: without the bound persister every rotation lands
449
+ // in the default file and the selected one goes stale within hours.
450
+ startRefreshLoop(accounts, { persist: persistAnthropicAccounts });
426
451
  startOpenAIRefreshLoop(openAIAccounts, persistOpenAIAccounts);
427
452
  const usageRefresher = new AnthropicUsageRefresher(pool);
428
453
  usageRefresher.start();
@@ -578,7 +603,10 @@ export async function startServer(opts = {}) {
578
603
  getAll: () => pool.getAll(),
579
604
  refreshTokens: async () => {
580
605
  let failed = 0;
581
- await refreshAccountsOnce(pool.getAll(), { onError: error => { failed++; onError("anthropic", error); } });
606
+ await refreshAccountsOnce(pool.getAll(), {
607
+ persist: persistAnthropicAccounts,
608
+ onError: error => { failed++; onError("anthropic", error); },
609
+ });
582
610
  return { failed };
583
611
  },
584
612
  refreshUsage: account => usageRefresher.refreshNow(account),
@@ -747,7 +775,7 @@ export async function startServer(opts = {}) {
747
775
  */
748
776
  const tryPersist = (rollback) => {
749
777
  try {
750
- saveAccounts(pool.getAll());
778
+ persistAnthropicAccounts(pool.getAll());
751
779
  return { ok: true };
752
780
  }
753
781
  catch (err) {
@@ -789,7 +817,7 @@ export async function startServer(opts = {}) {
789
817
  ? {
790
818
  rename: (oldId, nextId) => pool.renameAccount(oldId, nextId) !== null,
791
819
  renameSessions: (oldId, nextId) => { sessionRouter.renameAccount(oldId, nextId); },
792
- persist: () => saveAccounts(pool.getAll()),
820
+ persist: () => persistAnthropicAccounts(pool.getAll()),
793
821
  }
794
822
  : {
795
823
  rename: (oldId, nextId) => openAIPool.renameAccount(oldId, nextId) !== null,
@@ -876,8 +904,13 @@ export async function startServer(opts = {}) {
876
904
  account: publicOpenAIAccountView(updatedOpenAI, resolveOpenAIRouting(updatedOpenAI.id)),
877
905
  });
878
906
  });
879
- accountsRouter.post("/", (req, res) => {
907
+ accountsRouter.post("/", async (req, res) => {
880
908
  const body = (req.body ?? {});
909
+ // Opt-in upsert. Re-authenticating an existing id is the only recovery for
910
+ // a terminally rejected refresh token, and refusing it here discarded the
911
+ // OAuth login the operator had just completed. It stays opt-in so an
912
+ // accidental id collision from any other API client still gets its 409.
913
+ const wantsReplace = req.body?.replace === true;
881
914
  const required = ["id", "accessToken", "refreshToken", "expiresAt"];
882
915
  for (const k of required) {
883
916
  if (body[k] === undefined || body[k] === null || body[k] === "") {
@@ -902,9 +935,68 @@ export async function startServer(opts = {}) {
902
935
  }
903
936
  // IDs are unique across providers, so a new account may not collide with an
904
937
  // existing account in either the Claude pool or the OpenAI pool.
905
- if (pool.findById(body.id) || openAIAccounts.some(a => a.id === body.id)) {
906
- res.status(409).json({ error: `Account "${body.id}" already exists` });
907
- return;
938
+ const existingAnthropic = pool.findById(body.id);
939
+ const existingOpenAI = openAIAccounts.find(a => a.id === body.id);
940
+ if (existingAnthropic || existingOpenAI) {
941
+ // Replacement is within a provider only: swapping a Claude account for
942
+ // an OpenAI one under the same id is an id collision, not a re-auth.
943
+ const sameProvider = body.provider === "openai_subscription"
944
+ ? existingOpenAI !== undefined
945
+ : body.provider !== "xai_subscription" && existingAnthropic !== null;
946
+ if (!wantsReplace || !sameProvider) {
947
+ res.status(409).json({ error: `Account "${body.id}" already exists` });
948
+ return;
949
+ }
950
+ try {
951
+ if (existingOpenAI) {
952
+ const replaced = replaceOpenAIAccountTransaction({
953
+ record: {
954
+ id: body.id,
955
+ accessToken: body.accessToken,
956
+ refreshToken: body.refreshToken,
957
+ expiresAt: body.expiresAt,
958
+ enabled: body.enabled,
959
+ sessionLimitPercent: body.sessionLimitPercent,
960
+ weeklyLimitPercent: body.weeklyLimitPercent,
961
+ },
962
+ accounts: openAIAccounts,
963
+ persist: persistOpenAIAccounts,
964
+ forgetAccount: account => openAIPool.forgetAccount(account),
965
+ invalidateAccount: accountId => { openAIRouter.invalidateAccount(accountId); },
966
+ });
967
+ res.json({ account: publicOpenAIAccountView(replaced, resolveOpenAIRouting(replaced.id)) });
968
+ return;
969
+ }
970
+ const replaced = await replaceAnthropicAccountTransaction({
971
+ record: {
972
+ id: body.id,
973
+ provider: "anthropic_subscription",
974
+ accessToken: body.accessToken,
975
+ refreshToken: body.refreshToken,
976
+ expiresAt: body.expiresAt,
977
+ scopes: Array.isArray(body.scopes) ? body.scopes : ["user:inference", "user:profile"],
978
+ enabled: body.enabled,
979
+ sessionLimitPercent: body.sessionLimitPercent,
980
+ weeklyLimitPercent: body.weeklyLimitPercent,
981
+ },
982
+ pool,
983
+ sessionRouter,
984
+ persist: persistAnthropicAccounts,
985
+ });
986
+ res.json({ account: publicAnthropicAccountView(replaced, createRoutingMetricsResolver()(replaced.id)) });
987
+ return;
988
+ }
989
+ catch (err) {
990
+ const message = err instanceof Error ? err.message : String(err);
991
+ const status = accountReplacementStatusCode(err);
992
+ if (status === 409) {
993
+ res.status(409).json({ error: message });
994
+ return;
995
+ }
996
+ logError("accounts", 0, `Failed to replace account: ${message}`);
997
+ res.status(500).json({ error: `Failed to replace account: ${message}` });
998
+ return;
999
+ }
908
1000
  }
909
1001
  if (body.provider === "xai_subscription") {
910
1002
  try {
@@ -1046,7 +1138,7 @@ export async function startServer(opts = {}) {
1046
1138
  id,
1047
1139
  pool,
1048
1140
  sessionRouter,
1049
- persist: saveAccounts,
1141
+ persist: persistAnthropicAccounts,
1050
1142
  });
1051
1143
  }
1052
1144
  catch (err) {
@@ -1134,14 +1226,14 @@ export async function startServer(opts = {}) {
1134
1226
  sessionRouter,
1135
1227
  ...upstreamAttempts,
1136
1228
  needsRefresh,
1137
- refresh: account => refreshAccountIfCurrent(account, pool),
1229
+ refresh: account => refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts }),
1138
1230
  onRefreshFailure: onAnthropicRefreshFailure,
1139
1231
  onEmptyPool: onAnthropicEmptyPool,
1140
1232
  onNoEligibleAccount: onAnthropicNoEligibleAccount,
1141
1233
  // A relayed 401 means the token is stale — refresh in the background so
1142
1234
  // the next request succeeds without making this client wait on it.
1143
1235
  onUpstream401: account => {
1144
- void refreshAccountIfCurrent(account, pool).catch(console.error);
1236
+ void refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts }).catch(console.error);
1145
1237
  },
1146
1238
  // Refresh in the background to narrow only ambiguity-owned global state
1147
1239
  // when fresh usage proves a requested-model exhaustion.
@@ -1227,7 +1319,7 @@ export async function startServer(opts = {}) {
1227
1319
  ? routeFailureDetails(route, "token-invalid")
1228
1320
  : "token-invalid";
1229
1321
  logError(account.id, 401, "Token invalid — scheduling background refresh");
1230
- void refreshAccountIfCurrent(account, pool).catch(console.error);
1322
+ void refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts }).catch(console.error);
1231
1323
  }
1232
1324
  else if (status === 429) {
1233
1325
  // Rate limited — put account on cooldown for Retry-After seconds.
@@ -1327,7 +1419,7 @@ export async function startServer(opts = {}) {
1327
1419
  onNoEligibleAccount: onAnthropicNoEligibleAccount,
1328
1420
  }), createAnthropicRefreshMiddleware({
1329
1421
  needsRefresh,
1330
- refresh: account => refreshAccountIfCurrent(account, pool),
1422
+ refresh: account => refreshAccountIfCurrent(account, pool, { persist: persistAnthropicAccounts }),
1331
1423
  onRefreshFailure: onAnthropicRefreshFailure,
1332
1424
  }), (req, _res, next) => {
1333
1425
  const route = req._ccRoute;
@@ -1377,7 +1469,7 @@ export async function startServer(opts = {}) {
1377
1469
  usageRefresher.stop();
1378
1470
  openAIUsageRefresher.stop();
1379
1471
  accountInfoCache.stop();
1380
- saveAccounts(pool.getAll());
1472
+ persistAnthropicAccounts(pool.getAll());
1381
1473
  if (managesPidFile()) {
1382
1474
  removePid();
1383
1475
  }
@@ -1406,7 +1498,7 @@ export async function startServer(opts = {}) {
1406
1498
  const ok = await performUpdate(check.latest);
1407
1499
  if (ok) {
1408
1500
  console.log(chalk.green("[auto-update] Restarting with new version..."));
1409
- saveAccounts(pool.getAll());
1501
+ persistAnthropicAccounts(pool.getAll());
1410
1502
  restartSelf();
1411
1503
  }
1412
1504
  }
@@ -229,6 +229,38 @@ function clearExpiredRateLimitWindows(a, nowMs) {
229
229
  }
230
230
  }
231
231
  }
232
+ /**
233
+ * Build a runtime account from a stored record. Shared by `addAccount` and
234
+ * `replaceAccount` so a replaced account is constructed exactly like a newly
235
+ * added one — including starting healthy, which is what clears a previous
236
+ * incarnation's terminal auth state.
237
+ */
238
+ function buildAccount(record) {
239
+ return {
240
+ id: record.id,
241
+ tokens: {
242
+ accessToken: record.accessToken,
243
+ refreshToken: record.refreshToken,
244
+ expiresAt: record.expiresAt,
245
+ scopes: record.scopes ?? ["user:inference", "user:profile"],
246
+ },
247
+ healthy: true,
248
+ busy: false,
249
+ requestCount: 0,
250
+ errorCount: 0,
251
+ lastUsed: 0,
252
+ lastRefresh: 0,
253
+ consecutiveErrors: 0,
254
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
255
+ enabled: record.enabled !== false,
256
+ sessionLimitPercent: record.sessionLimitPercent !== undefined
257
+ ? clampPercent(record.sessionLimitPercent)
258
+ : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
259
+ weeklyLimitPercent: record.weeklyLimitPercent !== undefined
260
+ ? clampPercent(record.weeklyLimitPercent)
261
+ : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
262
+ };
263
+ }
232
264
  export class TokenPool {
233
265
  accounts;
234
266
  inFlight = new Map();
@@ -836,6 +868,10 @@ export class TokenPool {
836
868
  lastRefreshMs: a.lastRefresh,
837
869
  rateLimits: a.rateLimits,
838
870
  enabled: a.enabled,
871
+ // Without this, a terminally rejected refresh token is indistinguishable
872
+ // from a token that is merely stale: both read as unhealthy with an
873
+ // expired timestamp, but only this one needs the operator to re-auth.
874
+ authExpired: a.authExpired === true,
839
875
  sessionLimitPercent: a.sessionLimitPercent,
840
876
  weeklyLimitPercent: a.weeklyLimitPercent,
841
877
  }));
@@ -894,33 +930,51 @@ export class TokenPool {
894
930
  if (this.findById(record.id)) {
895
931
  throw new Error(`Account "${record.id}" already exists`);
896
932
  }
897
- const account = {
898
- id: record.id,
899
- tokens: {
900
- accessToken: record.accessToken,
901
- refreshToken: record.refreshToken,
902
- expiresAt: record.expiresAt,
903
- scopes: record.scopes ?? ["user:inference", "user:profile"],
904
- },
905
- healthy: true,
906
- busy: false,
907
- requestCount: 0,
908
- errorCount: 0,
909
- lastUsed: 0,
910
- lastRefresh: 0,
911
- consecutiveErrors: 0,
912
- rateLimits: { ...DEFAULT_RATE_LIMITS },
913
- enabled: record.enabled !== false,
914
- sessionLimitPercent: record.sessionLimitPercent !== undefined
915
- ? clampPercent(record.sessionLimitPercent)
916
- : ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
917
- weeklyLimitPercent: record.weeklyLimitPercent !== undefined
918
- ? clampPercent(record.weeklyLimitPercent)
919
- : ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
920
- };
933
+ const account = buildAccount(record);
921
934
  this.accounts.push(account);
922
935
  return account;
923
936
  }
937
+ /**
938
+ * Swap `record` in for the account currently holding its id, returning the
939
+ * replacement and a rollback that undoes the whole swap.
940
+ *
941
+ * `removeAccount` deliberately discards the departing account's in-flight
942
+ * count and cooldowns — for a real removal that state is garbage. A
943
+ * replacement whose persistence then fails has to put all of it back, or a
944
+ * rate-limited account returns to the pool looking idle and takes traffic it
945
+ * is still benched for. Capturing that state is only possible in here, which
946
+ * is why the swap is one pool operation rather than remove-then-add.
947
+ *
948
+ * The account keeps its position, so `currentIndex` stays valid and the
949
+ * rotation order is undisturbed.
950
+ */
951
+ replaceAccount(record) {
952
+ const index = this.accounts.findIndex(a => a.id === record.id);
953
+ if (index === -1)
954
+ throw new Error(`Account "${record.id}" not found`);
955
+ const previous = this.accounts[index];
956
+ const previousInFlight = this.inFlight.get(record.id);
957
+ const previousCooldowns = this.cooldowns.get(previous);
958
+ const added = buildAccount(record);
959
+ this.accounts.splice(index, 1, added);
960
+ // The replacement starts clean: cooldowns and in-flight counts belonged to
961
+ // credentials that no longer exist.
962
+ this.inFlight.delete(record.id);
963
+ this.cooldowns.delete(previous);
964
+ return {
965
+ added,
966
+ rollback: () => {
967
+ this.accounts.splice(index, 1, previous);
968
+ this.cooldowns.delete(added);
969
+ if (previousInFlight === undefined)
970
+ this.inFlight.delete(record.id);
971
+ else
972
+ this.inFlight.set(record.id, previousInFlight);
973
+ if (previousCooldowns !== undefined)
974
+ this.cooldowns.set(previous, previousCooldowns);
975
+ },
976
+ };
977
+ }
924
978
  /**
925
979
  * Remove an account by id. Returns true if something was removed.
926
980
  *
@@ -221,8 +221,8 @@ async function _doRefresh(account, span) {
221
221
  * Uses atomic write (tmp + rename) to prevent corruption if process dies mid-write.
222
222
  * Must be called after every successful refresh since refresh_token ROTATES.
223
223
  */
224
- export function saveAccounts(accounts) {
225
- writeAnthropicAccountsPreservingOtherProviders(serialize(accounts));
224
+ export function saveAccounts(accounts, path) {
225
+ writeAnthropicAccountsPreservingOtherProviders(serialize(accounts), path);
226
226
  }
227
227
  /** Run one ownership-aware scheduled refresh pass. */
228
228
  export async function refreshAccountsOnce(accounts, options = {}) {
@@ -247,8 +247,8 @@ export async function refreshAccountsOnce(accounts, options = {}) {
247
247
  * Background refresh loop: checks every 5 minutes and refreshes any
248
248
  * token expiring within the REFRESH_BUFFER_MS window.
249
249
  */
250
- export function startRefreshLoop(accounts) {
251
- const check = () => refreshAccountsOnce(accounts);
250
+ export function startRefreshLoop(accounts, options = {}) {
251
+ const check = () => refreshAccountsOnce(accounts, options);
252
252
  // Run immediately on startup (catches already-expired tokens)
253
253
  check().catch(console.error);
254
254
  setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.3",
3
+ "version": "0.12.4-rc.0",
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": {