maxpool 1.5.6 → 1.5.8

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.6",
3
+ "version": "1.5.8",
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",
@@ -1432,11 +1432,15 @@ export class AccountManager {
1432
1432
 
1433
1433
  const ramp = this._recoveryRamp(account, now);
1434
1434
  const failurePenalty = account.consecutiveFailures * 5;
1435
- // Bias toward an account whose weekly quota is still unknown so it gets
1436
- // probed and learned (matches the legacy unknown-quota exploration nudge).
1437
- const explorationBonus = account.quota.unified7dReset == null ? -0.5 : 0;
1438
-
1439
- return concurrency + capPenalty + paceCost + spread + ramp + failurePenalty + explorationBonus;
1435
+ // NO unknown-quota bonus. An account whose quota we cannot see must never be
1436
+ // MORE attractive than a known-healthy one — the old -0.5 nudge (safe only
1437
+ // while the prober quickly resolved "unknown") turned into a relentless pull
1438
+ // toward blind accounts once probing was off, driving an out-of-band-burned
1439
+ // account to exhaustion. Unknown now scores neutral; the prober (on by
1440
+ // default) learns the real number within a cycle. `probing`/requalify still
1441
+ // flags a never-seen account for learning — that path is unchanged.
1442
+
1443
+ return concurrency + capPenalty + paceCost + spread + ramp + failurePenalty;
1440
1444
  }
1441
1445
 
1442
1446
  /**
package/src/config.js CHANGED
@@ -62,7 +62,11 @@ export function createDefaultConfig() {
62
62
  // Raise toward 0.97 to squeeze more out of accounts before rotating
63
63
  // (less margin, slightly higher 429 risk); lower to rotate more eagerly.
64
64
  switchThreshold: 0.90,
65
- quotaProbeSeconds: 0, // background quota probe; 0 = off (opt-in)
65
+ // Background quota probe: poll every account's real 5h/7d utilization from the
66
+ // zero-spend usage endpoint every N seconds. ON by default — without it maxpool
67
+ // is blind to idle / out-of-band-used accounts and the scorer will pile traffic
68
+ // onto exactly the account it cannot see. 0 = off.
69
+ quotaProbeSeconds: 60,
66
70
  routing: {
67
71
  mode: 'automatic',
68
72
  preferredAccount: null,
@@ -135,8 +139,9 @@ export async function loadConfig() {
135
139
  if (err.code === 'ENOENT') return null;
136
140
  throw err;
137
141
  }
142
+ let parsed;
138
143
  try {
139
- return JSON.parse(raw);
144
+ parsed = JSON.parse(raw);
140
145
  } catch (err) {
141
146
  // With atomic temp+rename writes a torn read is impossible, so a parse
142
147
  // failure means genuine corruption. Surface it clearly rather than as a
@@ -144,6 +149,18 @@ export async function loadConfig() {
144
149
  // the file with defaults and lose recoverable OAuth credentials.
145
150
  throw new Error(`config at ${path} is not valid JSON (corrupt?): ${err.message}`);
146
151
  }
152
+ // Backfill top-level keys ABSENT from an older on-disk config with the current
153
+ // defaults, so a changed default (e.g. quotaProbeSeconds flipping on) actually
154
+ // reaches existing installs instead of shipping inert. A key that is present —
155
+ // even falsy, like an explicit `0` — is an intentional user choice and always
156
+ // wins; only genuinely-missing keys inherit the default.
157
+ if (parsed && typeof parsed === 'object') {
158
+ const defaults = createDefaultConfig();
159
+ for (const key of Object.keys(defaults)) {
160
+ if (!(key in parsed)) parsed[key] = defaults[key];
161
+ }
162
+ }
163
+ return parsed;
147
164
  }
148
165
 
149
166
  export async function loadOrCreateConfig() {
package/src/index.js CHANGED
@@ -530,8 +530,13 @@ async function serverWorkerCommand() {
530
530
  // Persist quota every minute; unref so it never keeps the process alive.
531
531
  let quotaSaveInterval = null;
532
532
 
533
- // Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
534
- const prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
533
+ // Background quota probe (config.quotaProbeSeconds, default 60s). Keeps every
534
+ // account's real 5h/7d utilization fresh so the scorer is never blind to an idle
535
+ // or out-of-band-used account. MAXPOOL_DISABLE_QUOTA_PROBE=1 forces it off — used
536
+ // by spawned integration tests so a startup probe can't race their refresh/rotation
537
+ // assertions (mirrors MAXPOOL_DISABLE_SLEEP_GUARD).
538
+ const probeSeconds = process.env.MAXPOOL_DISABLE_QUOTA_PROBE === '1' ? 0 : (config.quotaProbeSeconds || 0);
539
+ const prober = new Prober(accountManager, { intervalMs: probeSeconds * 1000 });
535
540
 
536
541
  // Persist refreshed tokens back to config. Defense-in-depth: the updater reads
537
542
  // the on-disk refresh token and SKIPS the rotation if a fresher writer already
package/src/prober.js CHANGED
@@ -1,10 +1,11 @@
1
- // Opt-in background quota probe.
1
+ // Background quota probe.
2
2
  //
3
- // DISABLED BY DEFAULT. When enabled (config.quotaProbeSeconds > 0), periodically
3
+ // ON BY DEFAULT (config.quotaProbeSeconds, default 60s; 0 = off). Periodically
4
4
  // reads each OAuth account's quota from the zero-spend /api/oauth/usage endpoint
5
- // so idle accounts' utilization/reset stay fresh without waiting to be rotated
6
- // to — and without consuming any message quota. This is the one sanctioned
7
- // active-upstream feature; the proxy is otherwise passive.
5
+ // so idle / out-of-band-used accounts' utilization/reset stay fresh without waiting
6
+ // to be rotated to — and without consuming any message quota. Without it the scorer
7
+ // is blind to an account it isn't actively routing to and will pile traffic onto it.
8
+ // This is the one sanctioned active-upstream feature; the proxy is otherwise passive.
8
9
 
9
10
  import { fetchUsage } from './oauth.js';
10
11
 
@@ -1,14 +1,30 @@
1
1
  const NON_UPSTREAM_ROUTES = new Set(['(queued)', '(none available)']);
2
2
 
3
3
  export class RestartController {
4
- constructor({ pauseAdmission, restartNow, log = console.log }) {
4
+ constructor({
5
+ pauseAdmission,
6
+ restartNow,
7
+ log = console.log,
8
+ // Bounded drain: wait at most this long for in-flight upstream requests to
9
+ // finish, then force the restart anyway. Without a bound, a single long
10
+ // streaming/thinking request (minutes) — or a held stream on the 7-day
11
+ // heartbeat — pins the restart on "Restart pending…" forever (the stuck-`r`
12
+ // bug). Dropped requests reconnect after restart, as the message promises.
13
+ drainTimeoutMs = 10_000,
14
+ setTimeoutFn = setTimeout,
15
+ clearTimeoutFn = clearTimeout,
16
+ }) {
5
17
  this.pauseAdmission = pauseAdmission;
6
18
  this.restartNow = restartNow;
7
19
  this.log = log;
20
+ this.drainTimeoutMs = drainTimeoutMs;
21
+ this.setTimeoutFn = setTimeoutFn;
22
+ this.clearTimeoutFn = clearTimeoutFn;
8
23
  this.activeRequests = new Set();
9
24
  this.upstreamRequests = new Set();
10
25
  this.pending = false;
11
26
  this.restarting = false;
27
+ this._drainTimer = null;
12
28
  }
13
29
 
14
30
  requestStarted(id) {
@@ -41,7 +57,14 @@ export class RestartController {
41
57
 
42
58
  this.pending = true;
43
59
  const queuedOrIdle = Math.max(0, this.activeRequests.size - this.upstreamRequests.size);
44
- this.log(`[Maxpool] Restart pending; admission paused while ${this.upstreamRequests.size} upstream request(s) finish. ${queuedOrIdle} queued/idle request(s) will reconnect after restart.`);
60
+ this.log(`[Maxpool] Restart pending; admission paused while ${this.upstreamRequests.size} upstream request(s) finish (up to ${Math.round(this.drainTimeoutMs / 1000)}s). ${queuedOrIdle} queued/idle request(s) will reconnect after restart.`);
61
+ // Force the restart if the drain overruns — never hang on a long stream.
62
+ this._drainTimer = this.setTimeoutFn(() => {
63
+ if (!this.pending || this.restarting) return;
64
+ this.log(`[Maxpool] Restart drain timed out; forcing restart with ${this.upstreamRequests.size} upstream request(s) still in flight (they will reconnect).`);
65
+ this._restart();
66
+ }, this.drainTimeoutMs);
67
+ this._drainTimer?.unref?.();
45
68
  }
46
69
 
47
70
  _maybeRestart() {
@@ -53,6 +76,10 @@ export class RestartController {
53
76
  if (this.restarting) return;
54
77
  this.restarting = true;
55
78
  this.pending = false;
79
+ if (this._drainTimer) {
80
+ this.clearTimeoutFn(this._drainTimer);
81
+ this._drainTimer = null;
82
+ }
56
83
  this.restartNow();
57
84
  }
58
85
  }