maxpool 1.5.59 → 1.5.61

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.59",
3
+ "version": "1.5.61",
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",
package/src/server.js CHANGED
@@ -33,6 +33,10 @@ const NETWORK_ERROR_CODES = new Set([
33
33
  'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'ECONNABORTED', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
34
34
  ]);
35
35
  const isNetworkCode = c => Boolean(c) && NETWORK_ERROR_CODES.has(c);
36
+ // How long a NON-STREAMING request may soak through a network blip. Short by construction:
37
+ // with no keepalive the client's hard 300s floor applies, so we must error first.
38
+ const NETWORK_SOAK_NONSTREAM_MS = Math.max(5_000,
39
+ Number(process.env.MAXPOOL_NETWORK_SOAK_NONSTREAM_MS) || 45_000);
36
40
  const QUEUE_KEEPALIVE = 'event: ping\ndata: {}\n\n';
37
41
  // 240s, strictly BELOW Claude Code's hard 300s stall floor. At the old 300_000 the two
38
42
  // timers were a dead heat and the client always won — maxpool's clock starts when a chunk
@@ -1958,7 +1962,26 @@ async function queueAndRetry(
1958
1962
  // a real outage doesn't spin. A non-streaming network failure (no keepalive) still
1959
1963
  // fails fast — it would die on the client's own timeout anyway.
1960
1964
  if (cause === 'proxy') return false;
1961
- if (cause === 'network' && (!requestInfo.stream || !canQueueBufferedBody)) return false;
1965
+ if (cause === 'network' && !canQueueBufferedBody) return false;
1966
+ // NETWORK SOAK, hard-bounded. A brief connectivity blip should not throw away a turn —
1967
+ // but an unbounded soak is worse than a fast error (it presents as a hang, which is the
1968
+ // failure mode the user explicitly fears). So a network-caused hold carries its OWN
1969
+ // absolute deadline, stamped once and NEVER reset by a re-queue, independent of the
1970
+ // per-attempt queue window. Past it, we stop soaking and return the honest error.
1971
+ //
1972
+ // Non-streaming gets a short budget: it has no keepalive, so the client's hard 300s
1973
+ // floor applies with nothing resetting it — we must give up well before that so the user
1974
+ // sees a real message instead of a client-side timeout.
1975
+ if (cause === 'network') {
1976
+ const budgetMs = requestInfo.stream
1977
+ ? Math.max(60_000, Number(queueConfig.networkMaxWaitMs) || 120_000)
1978
+ : NETWORK_SOAK_NONSTREAM_MS;
1979
+ requestInfo.networkSoakDeadline ||= Date.now() + budgetMs;
1980
+ if (Date.now() >= requestInfo.networkSoakDeadline) {
1981
+ console.log(`[Maxpool] Network soak budget spent (${Math.round(budgetMs / 1000)}s) — returning the connection error instead of holding longer`);
1982
+ return false;
1983
+ }
1984
+ }
1962
1985
 
1963
1986
  const maxWaitMs = Math.max(0, Number(queueConfig.maxWaitMs) || 0);
1964
1987
  const autoMaxWaitMs = queueConfig.autoMaxWaitMs == null
package/src/tui.js CHANGED
@@ -480,6 +480,38 @@ export class TUI {
480
480
 
481
481
  /** The visible Updates panel. Answers, at a glance: what am I running, is anything
482
482
  * newer, is it automatic, and what is happening right now. */
483
+ /** The Routing header line. Split out so a test can assert the RENDERED string —
484
+ * matching source text is how a header that contradicted routing survived review. */
485
+ /** The "Cross-provider: …" fragment. Its own method so a test can assert what the user
486
+ * actually sees without needing a TTY — the previous version read a superseded global
487
+ * and contradicted routing, and only a source-text test would have "passed" on it. */
488
+ _crossProviderText() {
489
+ // Show the EFFECTIVE policy, i.e. what actually governs routing. Since per-provider
490
+ // settings were added, `_claudeFallbackFor(provider)` decides — and the legacy global
491
+ // knob this used to read is only its fallback when a provider has no entry of its own.
492
+ // Reading the global made the header contradict both the footer and reality: it said
493
+ // "when-exhausted" while GLM and Kimi were each set to "always" and routing agreed
494
+ // with the footer, not the header.
495
+ const provs = this.am.accounts.filter(a => a.type === 'provider');
496
+ if (!provs.length) return ''; // no GLM/Kimi in this pool — the whole line is meaningless
497
+ const eff = [...new Set(provs.map(a => this.am._claudeFallbackFor?.(a.provider)
498
+ || this.am._crossProviderFallbackPolicy?.() || 'when-exhausted'))];
499
+ const colour = v => (v === 'never' ? yellow(v) : v === 'always' ? green(v) : cyan(v));
500
+ if (eff.length === 1) {
501
+ return ` ${dim('·')} ${dim('Cross-provider:')} ${colour(eff[0])}`;
502
+ } else {
503
+ // Providers disagree — naming each is the only honest summary; a single word here
504
+ // would misdescribe at least one of them.
505
+ const parts = provs.map(a => `${a.provider === 'zai' ? 'GLM' : a.provider === 'kimi' ? 'Kimi' : a.provider}: `
506
+ + colour(this.am._claudeFallbackFor?.(a.provider) || 'never'));
507
+ return ` ${dim('·')} ${dim('Cross-provider:')} ${parts.join(dim(' / '))}`;
508
+ }
509
+ }
510
+
511
+ _routingLine(routing, xpText) {
512
+ return ` Routing ${cyan(routing)}${xpText}`;
513
+ }
514
+
483
515
  _renderUpdatesDetail() {
484
516
  const v = this.am.versionInfo;
485
517
  const running = v?.current ? `v${v.current}` : 'unknown';
@@ -1137,9 +1169,7 @@ export class TUI {
1137
1169
  const hasProviders = this.am.accounts.some(a => a.type === 'provider');
1138
1170
  let xpText = '';
1139
1171
  if (hasProviders) {
1140
- const p = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
1141
- const pc = p === 'never' ? yellow(p) : p === 'always' ? green(p) : cyan(p);
1142
- xpText = ` ${dim('·')} ${dim('Cross-provider:')} ${pc}`;
1172
+ xpText = this._crossProviderText();
1143
1173
  // Overflow visibility: when GLM/Kimi actually served requests recently, surface the
1144
1174
  // volume so provider traffic isn't a mystery — it's overflow that spilled here while
1145
1175
  // OAuth was saturated. Reuses the SAME 15m load window as the per-row "15m Nr" column.
@@ -1150,7 +1180,7 @@ export class TUI {
1150
1180
  }
1151
1181
  if (provReq > 0) xpText += ` ${dim('·')} ${yellow(`providers: ${provReq} req/15m`)}`;
1152
1182
  }
1153
- lines.push(` Routing ${cyan(routing)}${xpText}`);
1183
+ lines.push(this._routingLine(routing, xpText));
1154
1184
  const queuedCount = this.am.queueState?.waiting?.length || 0;
1155
1185
  // Throttle and WAITING are different things and used to share one line labelled
1156
1186
  // "Anthropic upstream throttled" — so a request parked purely because every account