maxpool 1.3.1 → 1.5.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/package.json +2 -2
- package/src/account-manager.js +120 -2
- package/src/config.js +77 -5
- package/src/index.js +628 -59
- package/src/oauth.js +4 -1
- package/src/prober.js +19 -10
- package/src/reload-protocol.js +121 -0
- package/src/server.js +28 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxpool",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Multi-account Claude Code proxy with adaptive, rate-aware load balancing across Claude accounts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"start": "node src/index.js",
|
|
16
|
-
"test": "
|
|
16
|
+
"test": "bash scripts/run-tests.sh",
|
|
17
17
|
"lint": "eslint src/ test/",
|
|
18
18
|
"release": "bash scripts/release.sh"
|
|
19
19
|
},
|
package/src/account-manager.js
CHANGED
|
@@ -9,6 +9,16 @@ import { refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
|
9
9
|
// an Infinity session-kill / error-fast.
|
|
10
10
|
const BOUNDED_REPOLL_HOLD_MS = 60_000;
|
|
11
11
|
|
|
12
|
+
// Session rebalancing (issue #1): a bound session may migrate OFF a hot account
|
|
13
|
+
// onto a much-healthier one, but ONLY on a thinking-safe request (see
|
|
14
|
+
// _migrationSafeForRequest). These margins force a CLEAR, flap-stable win so a
|
|
15
|
+
// session can never ping-pong between two similarly-loaded accounts.
|
|
16
|
+
const REBALANCE_SCORE_MARGIN = 0.5; // candidate score must be ≤ 50% of the bound account's
|
|
17
|
+
const REBALANCE_MIN_ABS_GAP = 0.5; // …with a small absolute floor so near-zero scores don't micro-churn
|
|
18
|
+
// Weekly-pressure tiers, healthiest first. A fresh (unknown) account is the best
|
|
19
|
+
// migration target; migration requires the candidate be a STRICTLY healthier tier.
|
|
20
|
+
const WEEKLY_TIER = { unknown: 0, normal: 0, soft: 1, reserve: 2, critical: 3, exhausted: 4 };
|
|
21
|
+
|
|
12
22
|
function emptyQuota() {
|
|
13
23
|
return {
|
|
14
24
|
// Standard API rate limits (API key accounts)
|
|
@@ -191,6 +201,21 @@ export class AccountManager {
|
|
|
191
201
|
bytes: 0, // aggregate buffered body bytes across all held requests
|
|
192
202
|
};
|
|
193
203
|
this.admissionPaused = false;
|
|
204
|
+
// Single-writer baton: only the lease holder may rotate OAuth refresh tokens
|
|
205
|
+
// (refresh tokens are single-use; two refreshers brick the account). A worker
|
|
206
|
+
// booted headless during a reload starts WITHOUT the lease and refreshes
|
|
207
|
+
// nothing until it acquires the baton. Default true so the standalone /
|
|
208
|
+
// direct-listen (non-supervised, headless service) path is unchanged.
|
|
209
|
+
this.writerLease = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Acquire/release the single-writer baton. While released, ensureTokenFresh is
|
|
214
|
+
* a no-op (the worker serves on its existing access tokens but never rotates a
|
|
215
|
+
* single-use refresh token — that's the lease holder's job).
|
|
216
|
+
*/
|
|
217
|
+
setWriterLease(held) {
|
|
218
|
+
this.writerLease = Boolean(held);
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
/**
|
|
@@ -546,7 +571,7 @@ export class AccountManager {
|
|
|
546
571
|
console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
|
|
547
572
|
}
|
|
548
573
|
|
|
549
|
-
noteAmbiguousRateLimit(accountIndex, fingerprint,
|
|
574
|
+
noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
|
|
550
575
|
if (!fingerprint) return false;
|
|
551
576
|
const now = Date.now();
|
|
552
577
|
const windowMs = 30_000;
|
|
@@ -860,6 +885,62 @@ export class AccountManager {
|
|
|
860
885
|
|| ['reserve', 'critical', 'exhausted'].includes(this._weeklyRawState(account));
|
|
861
886
|
}
|
|
862
887
|
|
|
888
|
+
// ── Session rebalancing (issue #1) ────────────────────────────────────────
|
|
889
|
+
// A bound session normally sticks to its account (continuity + Anthropic signed-
|
|
890
|
+
// thinking signature validity). These let it migrate OFF a hot account onto fresh
|
|
891
|
+
// capacity, but only when it's provably safe and clearly worth it.
|
|
892
|
+
|
|
893
|
+
/** Per-request safety gate: a request is safe to migrate to a DIFFERENT account
|
|
894
|
+
* iff its body carries NO signed thinking (replaying a signed thinking block to
|
|
895
|
+
* another account → non-retryable "invalid signature"). Uses the PER-REQUEST
|
|
896
|
+
* body signal only — never the session-sticky policy — and fails CLOSED on any
|
|
897
|
+
* body we couldn't fully scan (non-JSON / parse error → bodyThinkingScanned unset). */
|
|
898
|
+
_migrationSafeForRequest(requestInfo = {}) {
|
|
899
|
+
return requestInfo.bodyThinkingScanned === true
|
|
900
|
+
&& requestInfo.requiresAnthropicThinkingIntegrity !== true;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Flap-stable "hot": weekly reserve/critical or 5h-cap pressure — signals that
|
|
904
|
+
* do NOT flip the instant a request migrates (unlike live in-flight, left to the
|
|
905
|
+
* score loop). A healthy bound account is never hot, so it never migrates → no
|
|
906
|
+
* ping-pong. */
|
|
907
|
+
_isBoundAccountHot(account) {
|
|
908
|
+
return this._isNearQuota(account);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Decide whether a bound session should leave its (hot) account THIS request.
|
|
912
|
+
* All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
|
|
913
|
+
* a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
|
|
914
|
+
* healthier weekly tier (so concurrency jitter alone can never trigger a move). */
|
|
915
|
+
_shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
|
|
916
|
+
if (!this._migrationSafeForRequest(requestInfo)) return false;
|
|
917
|
+
if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
|
|
918
|
+
if (!this._isBoundAccountHot(bound)) return false;
|
|
919
|
+
|
|
920
|
+
const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
|
|
921
|
+
const boundTier = WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0;
|
|
922
|
+
|
|
923
|
+
let bestScore = Infinity;
|
|
924
|
+
let bestTier = Infinity;
|
|
925
|
+
for (const account of this.accounts) {
|
|
926
|
+
if (account.index === bound.index) continue;
|
|
927
|
+
if (excludedIndexes.has(account.index)) continue;
|
|
928
|
+
if (!this._matchesRequest(account, profile, requestInfo)) continue;
|
|
929
|
+
// Genuinely-healthy alternatives only (normal/soft/unknown weekly).
|
|
930
|
+
if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false })) continue;
|
|
931
|
+
const score = this._scoreAccount(account, requestInfo, scoringCtx);
|
|
932
|
+
if (score < bestScore) {
|
|
933
|
+
bestScore = score;
|
|
934
|
+
bestTier = WEEKLY_TIER[this._weeklyRawState(account)] ?? 0;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!Number.isFinite(bestScore)) return false; // no healthy alternative
|
|
938
|
+
|
|
939
|
+
return bestScore <= boundScore * REBALANCE_SCORE_MARGIN
|
|
940
|
+
&& (boundScore - bestScore) >= REBALANCE_MIN_ABS_GAP
|
|
941
|
+
&& bestTier < boundTier;
|
|
942
|
+
}
|
|
943
|
+
|
|
863
944
|
_retryInfo(account) {
|
|
864
945
|
const now = Date.now();
|
|
865
946
|
const q = account.quota || {};
|
|
@@ -989,7 +1070,13 @@ export class AccountManager {
|
|
|
989
1070
|
}
|
|
990
1071
|
}
|
|
991
1072
|
const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
|
|
992
|
-
if (bound
|
|
1073
|
+
if (bound
|
|
1074
|
+
&& !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
|
|
1075
|
+
&& !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
|
|
1076
|
+
return bound;
|
|
1077
|
+
}
|
|
1078
|
+
// Else fall through to the candidate score loop, which re-homes the session
|
|
1079
|
+
// onto the best healthy account via _bindSession on acquire.
|
|
993
1080
|
|
|
994
1081
|
const weeklyPasses = hasBinding
|
|
995
1082
|
? [
|
|
@@ -1106,6 +1193,16 @@ export class AccountManager {
|
|
|
1106
1193
|
if (!binding.homeName || priority < binding.homePriority) {
|
|
1107
1194
|
binding.homeName = account.name;
|
|
1108
1195
|
binding.homePriority = priority;
|
|
1196
|
+
} else if (priority === binding.homePriority && account.name !== binding.homeName) {
|
|
1197
|
+
// Same-priority move. If the previous home is still AVAILABLE we left it by
|
|
1198
|
+
// CHOICE (session rebalancing off a hot-but-usable account) → re-home, so
|
|
1199
|
+
// _boundAccount (which prefers homeName) doesn't snap the session straight
|
|
1200
|
+
// back to the hot home next request. If the old home is UNAVAILABLE this is a
|
|
1201
|
+
// FAILOVER → keep homeName so the session returns to it once it recovers.
|
|
1202
|
+
const oldHome = this.accounts.find(a => a.name === binding.homeName);
|
|
1203
|
+
if (oldHome && this._isAvailable(oldHome, { allowWeeklyReserve: true })) {
|
|
1204
|
+
binding.homeName = account.name;
|
|
1205
|
+
}
|
|
1109
1206
|
}
|
|
1110
1207
|
binding.currentName = account.name;
|
|
1111
1208
|
this.sessionBindings.set(sessionKey, binding);
|
|
@@ -1635,6 +1732,12 @@ export class AccountManager {
|
|
|
1635
1732
|
const account = this.accounts[accountIndex];
|
|
1636
1733
|
if (!account || account.type !== 'oauth' || !account.refreshToken) return true;
|
|
1637
1734
|
|
|
1735
|
+
// Single-writer baton: a worker without the lease NEVER rotates a single-use
|
|
1736
|
+
// refresh token (doing so would invalidate the lease holder's token →
|
|
1737
|
+
// invalid_grant → bricked account). It serves on its existing access token
|
|
1738
|
+
// for the bounded drain; the lease holder owns all rotation.
|
|
1739
|
+
if (!this.writerLease) return true;
|
|
1740
|
+
|
|
1638
1741
|
if (!force && !isTokenExpiringSoon(account.expiresAt)) return true;
|
|
1639
1742
|
|
|
1640
1743
|
// Coalesce concurrent refreshes
|
|
@@ -1642,6 +1745,9 @@ export class AccountManager {
|
|
|
1642
1745
|
|
|
1643
1746
|
account._refreshPromise = (async () => {
|
|
1644
1747
|
console.log(`[Maxpool] Refreshing token for account "${account.name}"...`);
|
|
1748
|
+
// Record the token we're rotating FROM so the persistence layer's
|
|
1749
|
+
// generation guard can detect another writer having already advanced it.
|
|
1750
|
+
account._refreshedFrom = account.refreshToken;
|
|
1645
1751
|
try {
|
|
1646
1752
|
const newTokens = await this._refreshAccessToken(account.refreshToken);
|
|
1647
1753
|
account.credential = newTokens.accessToken;
|
|
@@ -1673,6 +1779,18 @@ export class AccountManager {
|
|
|
1673
1779
|
return account._refreshPromise;
|
|
1674
1780
|
}
|
|
1675
1781
|
|
|
1782
|
+
/**
|
|
1783
|
+
* Await every in-flight OAuth token refresh to settle. The single-writer baton
|
|
1784
|
+
* uses this on RELEASE: a refresh that passed the `if(!writerLease) return` gate
|
|
1785
|
+
* BEFORE the lease was dropped is still awaiting its OAuth POST; the new worker
|
|
1786
|
+
* must not acquire the lease and rotate the SAME single-use token until these
|
|
1787
|
+
* settle, or the upstream invalidates one token → invalid_grant → bricked.
|
|
1788
|
+
*/
|
|
1789
|
+
async drainRefreshes() {
|
|
1790
|
+
const pending = this.accounts.map(a => a._refreshPromise).filter(Boolean);
|
|
1791
|
+
if (pending.length) await Promise.allSettled(pending);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1676
1794
|
/**
|
|
1677
1795
|
* Set a callback to persist refreshed tokens to config.
|
|
1678
1796
|
*/
|
package/src/config.js
CHANGED
|
@@ -171,28 +171,100 @@ export async function loadState() {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Read just the `_generation` integer of an on-disk JSON file (config or state)
|
|
176
|
+
* without parsing it as a typed object. Returns 0 when the file is missing,
|
|
177
|
+
* unreadable, or carries no generation (legacy files), so a first write always
|
|
178
|
+
* advances to >=1. Never throws.
|
|
179
|
+
*/
|
|
180
|
+
export async function readGeneration(path) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(await readFile(path, 'utf-8'));
|
|
183
|
+
const g = Number(parsed?._generation);
|
|
184
|
+
return Number.isFinite(g) && g > 0 ? g : 0;
|
|
185
|
+
} catch {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Generation-guarded state write (defense-in-depth single-writer enforcement).
|
|
192
|
+
*
|
|
193
|
+
* `state.quota` carries the learned quota. We stamp a monotonic `_generation`
|
|
194
|
+
* and refuse to clobber a file whose on-disk generation is NEWER than the one
|
|
195
|
+
* we last observed — that means another writer (a stale ex-lease worker) raced
|
|
196
|
+
* us, and overwriting would revert fresher quota. The baton sequencing already
|
|
197
|
+
* guarantees a single writer; this guard catches a sequencing bug.
|
|
198
|
+
*
|
|
199
|
+
* Pass `{ expectedGeneration }` to assert the on-disk file is still at the
|
|
200
|
+
* generation you read; omit it to read-then-bump unconditionally (single-owner
|
|
201
|
+
* fast path). Returns the generation written, or null when refused.
|
|
202
|
+
*/
|
|
203
|
+
let _stateWriteChain = Promise.resolve();
|
|
204
|
+
|
|
205
|
+
export function saveState(state, { expectedGeneration = null } = {}) {
|
|
206
|
+
// Serialize state writes (a 60s interval flush can otherwise race the final
|
|
207
|
+
// baton flush, read the same on-disk generation, and double-write).
|
|
208
|
+
const run = async () => {
|
|
209
|
+
const path = getStatePath();
|
|
210
|
+
const onDisk = await readGeneration(path);
|
|
211
|
+
if (expectedGeneration != null && onDisk > expectedGeneration) {
|
|
212
|
+
// A newer writer advanced the file under us — refuse the stale flush.
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const next = onDisk + 1;
|
|
216
|
+
await atomicWrite(path, JSON.stringify({ ...state, _generation: next }, null, 2) + '\n');
|
|
217
|
+
return next;
|
|
218
|
+
};
|
|
219
|
+
const result = _stateWriteChain.then(run, run);
|
|
220
|
+
_stateWriteChain = result.then(() => {}, () => {});
|
|
221
|
+
return result;
|
|
176
222
|
}
|
|
177
223
|
|
|
224
|
+
/** Barrier: resolves once every queued state write has settled. */
|
|
225
|
+
export const flushStateWrites = () => _stateWriteChain;
|
|
226
|
+
|
|
178
227
|
let _configWriteChain = Promise.resolve();
|
|
179
228
|
|
|
229
|
+
/** Barrier: resolves once every queued config write (e.g. a fire-and-forget
|
|
230
|
+
* token-refresh persist) has settled. Awaited on baton release so a rotated
|
|
231
|
+
* token can't be left unwritten when the new worker boots from disk (M3). */
|
|
232
|
+
export const flushConfigWrites = () => _configWriteChain;
|
|
233
|
+
|
|
180
234
|
/**
|
|
181
235
|
* Serialize an in-process read-modify-write of the config so concurrent updates
|
|
182
236
|
* cannot lose each other's writes — e.g. a background OAuth token refresh
|
|
183
237
|
* (fire-and-forget) racing a TUI account add/delete. Each update re-reads the
|
|
184
238
|
* latest config, applies updater(config), then saves atomically.
|
|
185
239
|
*
|
|
240
|
+
* Defense-in-depth single-writer guard: the config carries a monotonic
|
|
241
|
+
* `_generation`. If `guardGeneration` is supplied and the on-disk generation is
|
|
242
|
+
* already NEWER than it, the write is REFUSED (a stale ex-lease worker raced the
|
|
243
|
+
* current writer). The updater additionally receives the on-disk generation as
|
|
244
|
+
* `config._generation` so a refresh updater can SKIP a token rotation it sees a
|
|
245
|
+
* fresher writer already performed. The generation is bumped on every write.
|
|
246
|
+
*
|
|
186
247
|
* NOTE: this serializes writes within THIS process only. A separate
|
|
187
248
|
* `maxpool import`/`login` process writing concurrently is not coordinated
|
|
188
249
|
* (that would require a lockfile) — but those are short, rare, human-driven.
|
|
189
250
|
*/
|
|
190
|
-
export function atomicConfigUpdate(updater) {
|
|
251
|
+
export function atomicConfigUpdate(updater, { guardGeneration = null } = {}) {
|
|
191
252
|
const run = async () => {
|
|
192
253
|
const config = await loadConfig() || createDefaultConfig();
|
|
193
|
-
|
|
254
|
+
const onDisk = Number(config._generation) || 0;
|
|
255
|
+
if (guardGeneration != null && onDisk > guardGeneration) {
|
|
256
|
+
// Refuse: a newer writer already advanced the config. Surface the live
|
|
257
|
+
// config so the caller can reconcile, but write nothing.
|
|
258
|
+
const refused = new Error('config generation advanced; stale write refused');
|
|
259
|
+
refused.code = 'STALE_GENERATION';
|
|
260
|
+
refused.onDiskGeneration = onDisk;
|
|
261
|
+
refused.config = config;
|
|
262
|
+
throw refused;
|
|
263
|
+
}
|
|
264
|
+
const result = await updater(config);
|
|
265
|
+
config._generation = onDisk + 1;
|
|
194
266
|
await saveConfig(config);
|
|
195
|
-
return config;
|
|
267
|
+
return result === undefined ? config : result;
|
|
196
268
|
};
|
|
197
269
|
// Run after any in-flight update regardless of whether it resolved or
|
|
198
270
|
// rejected, so one failure doesn't poison the chain; the caller still sees
|