@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 +41 -0
- package/dist/cli/cmd-accounts.js +76 -13
- package/dist/config/manager.js +19 -3
- package/dist/providers/auth-state.js +9 -0
- package/dist/providers/openai/account-state.js +8 -2
- package/dist/providers/openai/token-refresher.js +39 -1
- package/dist/proxy/account-replace.js +108 -0
- package/dist/proxy/server.js +108 -16
- package/dist/proxy/token-pool.js +78 -24
- package/dist/proxy/token-refresher.js +4 -4
- package/package.json +1 -1
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
|
package/dist/cli/cmd-accounts.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
616
|
-
addStored
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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();
|
package/dist/config/manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(), {
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1501
|
+
persistAnthropicAccounts(pool.getAll());
|
|
1410
1502
|
restartSelf();
|
|
1411
1503
|
}
|
|
1412
1504
|
}
|
package/dist/proxy/token-pool.js
CHANGED
|
@@ -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