maxpool 1.3.1 → 1.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",
@@ -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)
@@ -860,6 +870,62 @@ export class AccountManager {
860
870
  || ['reserve', 'critical', 'exhausted'].includes(this._weeklyRawState(account));
861
871
  }
862
872
 
873
+ // ── Session rebalancing (issue #1) ────────────────────────────────────────
874
+ // A bound session normally sticks to its account (continuity + Anthropic signed-
875
+ // thinking signature validity). These let it migrate OFF a hot account onto fresh
876
+ // capacity, but only when it's provably safe and clearly worth it.
877
+
878
+ /** Per-request safety gate: a request is safe to migrate to a DIFFERENT account
879
+ * iff its body carries NO signed thinking (replaying a signed thinking block to
880
+ * another account → non-retryable "invalid signature"). Uses the PER-REQUEST
881
+ * body signal only — never the session-sticky policy — and fails CLOSED on any
882
+ * body we couldn't fully scan (non-JSON / parse error → bodyThinkingScanned unset). */
883
+ _migrationSafeForRequest(requestInfo = {}) {
884
+ return requestInfo.bodyThinkingScanned === true
885
+ && requestInfo.requiresAnthropicThinkingIntegrity !== true;
886
+ }
887
+
888
+ /** Flap-stable "hot": weekly reserve/critical or 5h-cap pressure — signals that
889
+ * do NOT flip the instant a request migrates (unlike live in-flight, left to the
890
+ * score loop). A healthy bound account is never hot, so it never migrates → no
891
+ * ping-pong. */
892
+ _isBoundAccountHot(account) {
893
+ return this._isNearQuota(account);
894
+ }
895
+
896
+ /** Decide whether a bound session should leave its (hot) account THIS request.
897
+ * All gates must hold: thinking-safe + not mid queue-admission + bound is hot +
898
+ * a genuinely-healthy alternative that is BOTH much cheaper AND a strictly
899
+ * healthier weekly tier (so concurrency jitter alone can never trigger a move). */
900
+ _shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx) {
901
+ if (!this._migrationSafeForRequest(requestInfo)) return false;
902
+ if (requestInfo.queueTicket || requestInfo.queueAdmitted) return false;
903
+ if (!this._isBoundAccountHot(bound)) return false;
904
+
905
+ const boundScore = this._scoreAccount(bound, requestInfo, scoringCtx);
906
+ const boundTier = WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0;
907
+
908
+ let bestScore = Infinity;
909
+ let bestTier = Infinity;
910
+ for (const account of this.accounts) {
911
+ if (account.index === bound.index) continue;
912
+ if (excludedIndexes.has(account.index)) continue;
913
+ if (!this._matchesRequest(account, profile, requestInfo)) continue;
914
+ // Genuinely-healthy alternatives only (normal/soft/unknown weekly).
915
+ if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false })) continue;
916
+ const score = this._scoreAccount(account, requestInfo, scoringCtx);
917
+ if (score < bestScore) {
918
+ bestScore = score;
919
+ bestTier = WEEKLY_TIER[this._weeklyRawState(account)] ?? 0;
920
+ }
921
+ }
922
+ if (!Number.isFinite(bestScore)) return false; // no healthy alternative
923
+
924
+ return bestScore <= boundScore * REBALANCE_SCORE_MARGIN
925
+ && (boundScore - bestScore) >= REBALANCE_MIN_ABS_GAP
926
+ && bestTier < boundTier;
927
+ }
928
+
863
929
  _retryInfo(account) {
864
930
  const now = Date.now();
865
931
  const q = account.quota || {};
@@ -989,7 +1055,13 @@ export class AccountManager {
989
1055
  }
990
1056
  }
991
1057
  const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
992
- if (bound && !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)) return bound;
1058
+ if (bound
1059
+ && !this._hasHigherPriorityAvailable(bound, profile, excludedIndexes, requestInfo)
1060
+ && !this._shouldRebalanceBoundSession(bound, profile, excludedIndexes, requestInfo, scoringCtx)) {
1061
+ return bound;
1062
+ }
1063
+ // Else fall through to the candidate score loop, which re-homes the session
1064
+ // onto the best healthy account via _bindSession on acquire.
993
1065
 
994
1066
  const weeklyPasses = hasBinding
995
1067
  ? [
@@ -1106,6 +1178,16 @@ export class AccountManager {
1106
1178
  if (!binding.homeName || priority < binding.homePriority) {
1107
1179
  binding.homeName = account.name;
1108
1180
  binding.homePriority = priority;
1181
+ } else if (priority === binding.homePriority && account.name !== binding.homeName) {
1182
+ // Same-priority move. If the previous home is still AVAILABLE we left it by
1183
+ // CHOICE (session rebalancing off a hot-but-usable account) → re-home, so
1184
+ // _boundAccount (which prefers homeName) doesn't snap the session straight
1185
+ // back to the hot home next request. If the old home is UNAVAILABLE this is a
1186
+ // FAILOVER → keep homeName so the session returns to it once it recovers.
1187
+ const oldHome = this.accounts.find(a => a.name === binding.homeName);
1188
+ if (oldHome && this._isAvailable(oldHome, { allowWeeklyReserve: true })) {
1189
+ binding.homeName = account.name;
1190
+ }
1109
1191
  }
1110
1192
  binding.currentName = account.name;
1111
1193
  this.sessionBindings.set(sessionKey, binding);
package/src/server.js CHANGED
@@ -930,7 +930,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
930
930
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
931
931
  }
932
932
 
933
- export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat };
933
+ export const __serverTest = { unavailableMessage, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest };
934
934
 
935
935
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
936
936
  if (!upstreamRes.body) return '';
@@ -1375,6 +1375,11 @@ function describeRequest(req, body) {
1375
1375
  if (requiresAnthropicThinkingIntegrity(json)) {
1376
1376
  info.requiresAnthropicThinkingIntegrity = true;
1377
1377
  }
1378
+ // We fully scanned this body for signed-thinking content. Only a successfully
1379
+ // scanned, thinking-free body is safe to migrate to another account (session
1380
+ // rebalancing); an unparsed body leaves this false → treated as NOT safe
1381
+ // (fail-closed) so we never replay a signed thinking block to a new account.
1382
+ info.bodyThinkingScanned = true;
1378
1383
  } catch {
1379
1384
  // Non-JSON requests are rare; body size still gives a useful load signal.
1380
1385
  }