maxpool 1.5.79 → 1.5.80

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.5.79",
3
+ "version": "1.5.80",
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",
@@ -111,6 +111,7 @@ const DEFAULT_SCHEDULER = {
111
111
  perAccountConcurrencyTarget: 3, // D: soft per-account in-flight target; past it, capPenalty bites
112
112
  capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
113
113
  paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
114
+ utilizationWeight: 3, // RAW utilization cost — drives load balancing in the mid-range
114
115
  scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
115
116
  // Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
116
117
  // sit idle behind a healthy-only first pass; now it's eligible in the first pass but
@@ -140,22 +141,30 @@ const DEFAULT_SCHEDULER = {
140
141
  crossAccountThinkingMigration: true,
141
142
  // Cross-PROVIDER fallback policy for 'cc all' (profile=all), i.e. whether a session
142
143
  // may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
143
- // 'never' — (DEFAULT) a Claude session never spills onto a provider. NOTE:
144
- // this governs the Claude→provider direction ONLY — it is NOT a
145
- // same-family pin (a provider-origin session still reaches Claude).
146
- // 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
147
- // only once all Claude accounts are unavailable, and a GLM session
148
- // may fall to Kimi once GLM is exhausted.
149
- // 'always' — providers peer with Claude for a Claude/unknown session
150
- // (load-balanced, not last-resort).
151
- // DEFAULT is 'never' (2026-07-24): a Claude Code session stays on Anthropic. Routing
152
- // Claude→GLM/Kimi proved unreliable (the coding legs 403/429 + ignore the model id),
153
- // so cross-routing OUT of Claude is OFF by default; re-enable via the TUI routing
154
- // cycle or config. This governs ONLY the Claude→provider direction.
155
- // INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
156
- // account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
157
- // is unfixable, not policy-tunable.
144
+ //
145
+ // SUPERSEDED by `routingMode` below — kept for backward-compat migration only. An
146
+ // old config carrying `crossProviderFallbackPolicy` but no `routingMode` is upgraded
147
+ // at boot: 'always' → 'balance', 'when-exhausted'/'never' → 'prefer-claude'. The
148
+ // per-provider `providers[<key>].claudeFallback` field still controls the same thing
149
+ // for its one provider under the legacy modes.
158
150
  crossProviderFallbackPolicy: 'never',
151
+ // ROUTING MODE — the single knob that governs how sessions are distributed across
152
+ // the account pool. Replaces the hidden per-session binding that made 'always' not
153
+ // actually balance. Reported 2026-08-10: with cross-provider 'always' set, 20
154
+ // long-lived sessions that happened to start on the same Anthropic account hammered
155
+ // it at 82% while two GLM accounts at 2%/9% sat idle — because 'always' only governed
156
+ // the FIRST request; after that the session was pinned to one account.
157
+ //
158
+ // 'balance' — score EVERY request across the full pool. No session binding.
159
+ // Accounts drain evenly. The behaviour 'always' should always
160
+ // have been.
161
+ // 'prefer-claude' — score every request, but Anthropic accounts outrank providers
162
+ // unless they are all loaded/exhausted.
163
+ // 'prefer-zai' — GLM accounts preferred; Claude/Kimi fill overflow.
164
+ // 'prefer-kimi' — Kimi preferred; Claude/GLM fill overflow.
165
+ // 'sticky' — the old behaviour, made explicit. Sessions stay on the account
166
+ // they first land on until it goes hot, then rebalance.
167
+ routingMode: 'sticky',
159
168
  // The OTHER cross direction, independent of the policy above: may a provider-origin
160
169
  // (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — both legs are
161
170
  // lenient and accept each other's ids, and it's the reliable direction the user wants
@@ -244,6 +253,20 @@ function parseResetHeader(value) {
244
253
  export class AccountManager {
245
254
  constructor(accounts, switchThreshold = 0.90, schedulerOptions = {}, dependencies = {}) {
246
255
  this.scheduler = { ...DEFAULT_SCHEDULER, ...schedulerOptions };
256
+ // Migrate the legacy single-value policy to the new mode if the caller (config)
257
+ // set `crossProviderFallbackPolicy` but not `routingMode`. The per-provider
258
+ // `providers[<key>].claudeFallback` still works under the prefer-* modes.
259
+ // IMPORTANT: when schedulerOptions is empty (no explicit crossProviderFallbackPolicy),
260
+ // the DEFAULT_SCHEDULER value 'never' triggers migration. But the old behaviour
261
+ // under 'never' WAS sticky pinning — so the default must map to 'sticky', not
262
+ // 'prefer-claude'. Only an EXPLICIT 'always' in the caller's config changes the mode.
263
+ if (!schedulerOptions.routingMode) {
264
+ const leg = schedulerOptions.crossProviderFallbackPolicy;
265
+ if (leg === 'always') this.scheduler.routingMode = 'balance';
266
+ else if (leg === 'when-exhausted') this.scheduler.routingMode = 'prefer-claude';
267
+ // 'never' or unset → sticky (the historical default: sessions pin to one account)
268
+ else this.scheduler.routingMode = 'sticky';
269
+ }
247
270
  this._refreshAccessToken = dependencies.refreshAccessToken || refreshAccessToken;
248
271
  this.accounts = accounts.map((acct, index) => ({
249
272
  index,
@@ -1230,7 +1253,29 @@ export class AccountManager {
1230
1253
  * score loop), so a healthy bound account never ping-pongs. */
1231
1254
  _isBoundAccountHot(account) {
1232
1255
  return this._isSessionQuotaUnavailable(account)
1233
- || ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account));
1256
+ || ['reserve', 'critical', 'exhausted'].includes(this._weeklyPaceState(account))
1257
+ // SESSION-window pressure counts too. _isSessionQuotaUnavailable only fires at
1258
+ // switchThreshold (0.90), and the weekly bands don't see the 5h window at all —
1259
+ // so an account at 82% of a 5h window was "not hot" and every bound session
1260
+ // stayed on it while idle accounts sat at 2%. Measured 2026-08-10: Anthropic at
1261
+ // Ses 82% / Wk 66% hammered flat-out beside two GLM accounts at 2% and 9%.
1262
+ // Uses the SOFT band (0.65), not reserve (0.85): the point is to shed load while
1263
+ // there is still headroom, not at the cliff edge. _shouldRebalanceBoundSession
1264
+ // still requires a clearly-cheaper, strictly-healthier target, so a hot account
1265
+ // with no better alternative keeps its sessions — this only opens the question.
1266
+ || this._sessionWindowUsage(account) >= this.scheduler.weeklySoftThreshold;
1267
+ }
1268
+
1269
+ /** Fraction of the SESSION (5h) window consumed, across both quota shapes.
1270
+ * Anthropic reports unified5h; a provider reports providerSes. Returns 0 when
1271
+ * unknown — an unreadable window must never make an account look hot. */
1272
+ _sessionWindowUsage(account) {
1273
+ const q = account?.quota;
1274
+ if (!q) return 0;
1275
+ const vals = [];
1276
+ if (q.unified5h != null) vals.push(clamp01(q.unified5h));
1277
+ if (q.providerSes != null) vals.push(clamp01(q.providerSes));
1278
+ return vals.length ? Math.max(...vals) : 0;
1234
1279
  }
1235
1280
 
1236
1281
  /** Decide whether a bound session should leave its (hot) account THIS request.
@@ -1334,6 +1379,18 @@ export class AccountManager {
1334
1379
  // candidate loop lands on the least-loaded healthy account, spreading the move.
1335
1380
  if ((WEEKLY_TIER[this._weeklyRawState(bound)] ?? 0) >= WEEKLY_TIER.reserve) return true;
1336
1381
 
1382
+ // Same absolute escape for the SESSION window. The tier test below compares WEEKLY
1383
+ // tiers, so a bound account burning down its 5h window can never satisfy it while
1384
+ // its weekly is merely soft — which is exactly how an account at Ses 82% kept every
1385
+ // session while idle accounts sat at 2%. Requires a MATERIALLY cheaper target so
1386
+ // this can't churn between two similarly-loaded accounts.
1387
+ // No score margin here, for the reason the weekly escape documents directly above:
1388
+ // boundScore*0.5 on an IDLE near-cap account sits below every candidate's minimum
1389
+ // score, so the margin is unsatisfiable and pins the session to the account it is
1390
+ // meant to relieve. The candidate loop has already kept only RAW-healthy targets,
1391
+ // and preserving the last of a 5h window beats concurrency spread.
1392
+ if (this._sessionWindowUsage(bound) >= this.scheduler.weeklyReserveThreshold) return true;
1393
+
1337
1394
  // Otherwise the trigger was PACE-only on a RAW-healthy account — a fast-burner
1338
1395
  // that still has real absolute headroom (RAW soft but pace reserve/critical,
1339
1396
  // e.g. 79% used resetting in ~3.5d). Keep the conservative gate so it isn't
@@ -1531,7 +1588,15 @@ export class AccountManager {
1531
1588
  return preferred;
1532
1589
  }
1533
1590
  }
1534
- const bound = this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo);
1591
+ // Session binding only applies in 'sticky' mode. Every other mode scores each
1592
+ // request independently — the whole point of 'balance' is that 20 sessions that
1593
+ // happened to start on the same account do NOT keep hammering it while others
1594
+ // idle. Warmup-pull still works under the non-sticky modes because it keys on
1595
+ // `_isWarming`, not on a binding.
1596
+ const isStickyMode = this.scheduler.routingMode === 'sticky';
1597
+ const bound = isStickyMode
1598
+ ? this._boundAccount(requestInfo.sessionKey, profile, excludedIndexes, requestInfo)
1599
+ : null;
1535
1600
  if (bound) {
1536
1601
  // Warmup-pull: onboard a freshly-ADDED account (added mid-session, no reload)
1537
1602
  // by DIRECTLY re-homing this migration-safe session onto the warming account,
@@ -1787,18 +1852,52 @@ export class AccountManager {
1787
1852
  // (10/20) → fallback-only. A foreign session is provider-only regardless.
1788
1853
  _effectivePriority(account, requestInfo = {}) {
1789
1854
  const base = Number.isFinite(account.priority) ? account.priority : 0;
1855
+ const mode = this.scheduler.routingMode;
1856
+ const incompatible = this._effectiveIncompatible(requestInfo).incompatible;
1857
+ // An incompatible session is pinned to its provider family — no mode overrides that.
1858
+ if (incompatible) {
1859
+ const isHome = account.type === 'provider'
1860
+ && (mode === 'prefer-zai' ? account.provider === 'zai'
1861
+ : mode === 'prefer-kimi' ? account.provider === 'kimi' : true);
1862
+ return isHome ? 0 : base;
1863
+ }
1864
+ // Balance: every account peers at priority 0. Accounts are ranked by score alone.
1865
+ if (mode === 'balance') return 0;
1866
+ // Prefer-* modes: the preferred family sits at 0, everything else at its base
1867
+ // priority (10 for GLM, 20 for Kimi). So the preferred family is chosen first and
1868
+ // the others only when every preferred account is unavailable (the score loop's
1869
+ // pass-1 admits reserve accounts, so a loaded preferred account DOES give way).
1870
+ if (mode === 'prefer-claude') {
1871
+ return account.type === 'provider' ? base : 0;
1872
+ }
1873
+ if (mode === 'prefer-zai') {
1874
+ return account.provider === 'zai' ? 0 : base;
1875
+ }
1876
+ if (mode === 'prefer-kimi') {
1877
+ return account.provider === 'kimi' ? 0 : base;
1878
+ }
1879
+ // Sticky: legacy behaviour — the 'always' policy promoted providers to 0.
1790
1880
  if (account.type === 'provider'
1791
- && this._claudeFallbackFor(account.provider) === 'always'
1792
- && !this._effectiveIncompatible(requestInfo).incompatible) {
1881
+ && this._claudeFallbackFor(account.provider) === 'always') {
1793
1882
  return 0;
1794
1883
  }
1795
1884
  return base;
1796
1885
  }
1797
1886
 
1887
+ setProviderRoutingMode(mode) {
1888
+ if (!['balance', 'prefer-claude', 'prefer-zai', 'prefer-kimi', 'sticky'].includes(mode)) return false;
1889
+ this.scheduler.routingMode = mode;
1890
+ console.log(`[Maxpool] Routing mode set to "${mode}"`);
1891
+ return true;
1892
+ }
1893
+
1894
+ // Legacy shim — the TUI routing screen still cycles this. Maps to the new mode.
1798
1895
  setCrossProviderFallbackPolicy(policy) {
1799
1896
  if (!['never', 'when-exhausted', 'always'].includes(policy)) return false;
1800
1897
  this.scheduler.crossProviderFallbackPolicy = policy;
1801
- console.log(`[Maxpool] Cross-provider fallback policy set to "${policy}"`);
1898
+ // Map to the new mode so the binding/priority logic agrees.
1899
+ this.scheduler.routingMode = policy === 'always' ? 'balance' : policy === 'when-exhausted' ? 'prefer-claude' : 'sticky';
1900
+ console.log(`[Maxpool] Cross-provider fallback policy set to "${policy}" (routing mode: ${this.scheduler.routingMode})`);
1802
1901
  return true;
1803
1902
  }
1804
1903
 
@@ -1872,15 +1971,20 @@ export class AccountManager {
1872
1971
 
1873
1972
  // Compatible session — includes Kimi and GLM-without-server-tools, whose regular
1874
1973
  // tool_use ids pass Anthropic's loose validation, AND ordinary Claude sessions.
1875
- // Claude is eligible + preferred (priority 0). Provider fallback is the SAFE
1876
- // direction (lenient providers accept Anthropic ids/signatures) and is
1877
- // policy-gated ONLY: 'never' keeps a Claude session on Claude; 'when-exhausted'
1878
- // lets providers serve as a priority-fallback; 'always' peers them
1879
- // (_effectivePriority). Signed thinking no longer bars providers here — but its
1880
- // live MIGRATION stays Claude-only (see the rebalance guard).
1881
- // PER-PROVIDER Claude→provider gate (GLM and Kimi steer independently). Unset ⇒
1882
- // inherits the legacy global policy, so behavior is unchanged on upgrade.
1883
- if (account.type === 'provider' && this._claudeFallbackFor(account.provider) === 'never') return false;
1974
+ // Under the new routing modes, providers are eligible whenever the mode allows
1975
+ // them to peer (balance + prefer-{zai,kimi}) or serve as fallback
1976
+ // (prefer-claude + sticky). The legacy per-provider `claudeFallback: 'never'`
1977
+ // gate only applies under 'sticky' — the old behaviour it was written for.
1978
+ if (account.type === 'provider') {
1979
+ const mode = this.scheduler.routingMode;
1980
+ // Balance and prefer-{zai,kimi}: providers are always eligible (scored, not gated).
1981
+ if (mode === 'balance' || mode === 'prefer-zai' || mode === 'prefer-kimi') return true;
1982
+ // Prefer-claude: providers serve as overflow. Still eligible — priority handles the
1983
+ // preference; an available Claude account always wins on priority.
1984
+ if (mode === 'prefer-claude') return true;
1985
+ // Sticky: the legacy per-provider gate applies — this is the mode it was written for.
1986
+ if (this._claudeFallbackFor(account.provider) === 'never') return false;
1987
+ }
1884
1988
  return true;
1885
1989
  }
1886
1990
 
@@ -2057,6 +2161,14 @@ export class AccountManager {
2057
2161
  // soft de-preference of accounts burning ahead of an even pace. Never a bench.
2058
2162
  const paceCost = this._accountScarcity(account, now) * this.scheduler.paceCostWeight;
2059
2163
 
2164
+ // RAW utilization cost — direct, not pace-adjusted. The pace cost above discounts
2165
+ // by how far into the window you are, so an account at 80% with 2h left is only
2166
+ // "slightly ahead of pace" → tiny cost. That's right for avoiding premature
2167
+ // benching, but wrong for load balancing: an account at 80% should be clearly less
2168
+ // attractive than one at 10% even if both are "on pace". Measured 2026-08-10: cc at
2169
+ // 80% scored 52.30 vs glm at 10% at 52.15 — a 0.15 gap drowned by round-robin.
2170
+ const utilizationCost = this._rawUtilization(account) * this.scheduler.utilizationWeight;
2171
+
2060
2172
  // Per-model weekly de-preference: an account whose scoped weekly for THIS
2061
2173
  // request's model (e.g. Fable) is high-but-not-exhausted is a poor pick for
2062
2174
  // that model — shed its load toward healthier accounts BEFORE the hard bench
@@ -2083,7 +2195,7 @@ export class AccountManager {
2083
2195
  // default) learns the real number within a cycle. `probing`/requalify still
2084
2196
  // flags a never-seen account for learning — that path is unchanged.
2085
2197
 
2086
- return concurrency + capPenalty + paceCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
2198
+ return concurrency + capPenalty + paceCost + utilizationCost + scopedPace + spread + ramp + reserveCost + failurePenalty;
2087
2199
  }
2088
2200
 
2089
2201
  /**
@@ -2137,6 +2249,29 @@ export class AccountManager {
2137
2249
  return scarcity;
2138
2250
  }
2139
2251
 
2252
+ /** RAW utilization (0..1) — not pace-adjusted. Reads the same fields as
2253
+ * _accountScarcity but WITHOUT the elapsed-fraction discount. This is the signal
2254
+ * the load balancer needs: an account at 80% is more expensive than one at 10%,
2255
+ * full stop. */
2256
+ _rawUtilization(account) {
2257
+ const q = account?.quota;
2258
+ if (!q) return 0;
2259
+ // SESSION windows use raw utilization — headroom is consumed immediately and an
2260
+ // 80%-used 5h window is genuinely more expensive than a 10%-used one right now.
2261
+ let util = 0;
2262
+ if (q.unified5h != null) util = Math.max(util, clamp01(q.unified5h));
2263
+ if (q.providerSes != null) util = Math.max(util, clamp01(q.providerSes));
2264
+ // WEEKLY windows use PACE-ADJUSTED utilization (via _windowScarcity), not raw —
2265
+ // a 79% account resetting in 2h has plenty of headroom and should be cheap to
2266
+ // spend (the use-it-or-lose-it principle). Using raw weekly would break that.
2267
+ // The pace cost already carries this signal; here we add only the session signal
2268
+ // the pace cost was too weak to express.
2269
+ if (q.tokensLimit != null && q.tokensLimit > 0 && q.tokensRemaining != null) {
2270
+ util = Math.max(util, 1 - q.tokensRemaining / q.tokensLimit);
2271
+ }
2272
+ return util;
2273
+ }
2274
+
2140
2275
  _windowScarcity(util, resetMs, windowLen, now = Date.now()) {
2141
2276
  const used = clamp01(util);
2142
2277
  if (!resetMs || resetMs <= now) return used; // unknown / just-reset → face value