maxpool 1.5.44 → 1.5.46

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.44",
3
+ "version": "1.5.46",
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",
@@ -159,6 +159,10 @@ const DEFAULT_SCHEDULER = {
159
159
  providerCrossFallback: true,
160
160
  };
161
161
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
162
+ // Consecutive failed recovery probes before we stop trusting the SHARED breaker and
163
+ // fall back to per-account handling. Bounds any future "poisoned probe" from becoming
164
+ // an indefinite fleet-wide outage (2026-07-25 incident).
165
+ const MAX_FAILED_PROBES = 4;
162
166
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
163
167
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
164
168
 
@@ -734,6 +738,7 @@ export class AccountManager {
734
738
  this.upstreamThrottle.until = Math.max(this.upstreamThrottle.until || 0, until);
735
739
  this.upstreamThrottle.reason = reason;
736
740
  this.upstreamThrottle.probeInFlight = false;
741
+ this.upstreamThrottle.failedProbes = 0; // fresh breaker → fresh probe budget
737
742
  this.upstreamThrottle.count++;
738
743
  this.upstreamThrottle.lastAt = Date.now();
739
744
  console.log(`[Maxpool] Anthropic upstream temporarily limiting requests for ${retryAfter}s; pausing Claude routes`);
@@ -744,6 +749,7 @@ export class AccountManager {
744
749
  this.upstreamThrottle.until = null;
745
750
  this.upstreamThrottle.reason = null;
746
751
  this.upstreamThrottle.probeInFlight = false;
752
+ this.upstreamThrottle.failedProbes = 0;
747
753
  this.queueState.rampUntil = Date.now() + 5000;
748
754
  this.queueState.lastAdmissionAt = Date.now();
749
755
  console.log(`[Maxpool] Anthropic upstream throttle cleared (${reason})`);
@@ -755,14 +761,36 @@ export class AccountManager {
755
761
  lease.upstreamThrottleProbe = false;
756
762
  }
757
763
 
764
+ /** Hand the recovery probe back UNUSED — the request never got an upstream answer
765
+ * (client disconnected, token refresh failed), so it is no evidence either way.
766
+ * Leaves the window open so the very next request can claim the probe, instead of
767
+ * scoring a failure that would re-arm the fleet-wide throttle. */
768
+ relinquishUpstreamProbe(lease) {
769
+ if (!lease?.upstreamThrottleProbe) return;
770
+ lease.upstreamThrottleProbe = false;
771
+ this.upstreamThrottle.probeInFlight = false;
772
+ }
773
+
758
774
  deferUpstreamThrottleProbe(retryAfterSeconds = 5, reason = 'probe_failed') {
759
775
  if (!this.upstreamThrottle.until && !this.upstreamThrottle.probeInFlight) return;
760
- const retryAfter = clampRetryAfterSeconds(retryAfterSeconds);
776
+ // PROBE BUDGET. A flat 5s retry with unbounded repetition is what let a single
777
+ // poisoned request hold the whole fleet down forever. Escalate the backoff, and
778
+ // after MAX_FAILED_PROBES consecutive failures give up on the shared breaker and
779
+ // fall back to per-account handling — the fleet then retries for real, and if
780
+ // Anthropic genuinely is throttling, shouldPromoteUpstreamFailure re-arms it.
781
+ // (This is a circuit breaker's half-open state, paced by the backoff.)
782
+ this.upstreamThrottle.failedProbes = (this.upstreamThrottle.failedProbes || 0) + 1;
783
+ if (this.upstreamThrottle.failedProbes >= MAX_FAILED_PROBES) {
784
+ this.clearUpstreamThrottle(`probe budget exhausted after ${this.upstreamThrottle.failedProbes} failures (${reason}) — deferring to per-account handling`);
785
+ return;
786
+ }
787
+ const backoff = Math.min(60, retryAfterSeconds * 2 ** (this.upstreamThrottle.failedProbes - 1));
788
+ const retryAfter = clampRetryAfterSeconds(backoff);
761
789
  this.upstreamThrottle.until = Date.now() + retryAfter * 1000;
762
790
  this.upstreamThrottle.reason = reason;
763
791
  this.upstreamThrottle.probeInFlight = false;
764
792
  this.upstreamThrottle.lastAt = Date.now();
765
- console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
793
+ console.log(`[Maxpool] Anthropic recovery probe failed (${this.upstreamThrottle.failedProbes}/${MAX_FAILED_PROBES}); retrying in ${retryAfter}s (${reason})`);
766
794
  }
767
795
 
768
796
  noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
package/src/server.js CHANGED
@@ -402,6 +402,11 @@ async function forwardRequest(
402
402
  res.once('close', onClientClose);
403
403
  const releaseOnClientGone = () => {
404
404
  res.off('close', onClientClose);
405
+ // The client vanished — we never got an upstream answer, so this carries ZERO
406
+ // evidence about Anthropic's health. Hand the recovery probe back instead of
407
+ // letting releaseAccount score it as a FAILED probe (which would re-arm the
408
+ // fleet-wide throttle for 5s on every disconnect — a second deadlock amplifier).
409
+ accountManager.relinquishUpstreamProbe?.(lease);
405
410
  accountManager.releaseAccount(lease);
406
411
  clearQueueHeartbeat(requestInfo);
407
412
  accountManager.removeQueuedRequest?.(requestInfo);
@@ -421,6 +426,9 @@ async function forwardRequest(
421
426
  // (MaxListenersExceededWarning + leak); the recursive/resumed frame registers
422
427
  // its own.
423
428
  res.off('close', onClientClose);
429
+ // Token refresh failed — the request never reached Anthropic, so this is no
430
+ // evidence about upstream health. Relinquish rather than fail the probe.
431
+ accountManager.relinquishUpstreamProbe?.(lease);
424
432
  accountManager.releaseAccount(lease);
425
433
  excludedIndexes.add(account.index);
426
434
  if (
@@ -563,6 +571,18 @@ async function forwardRequest(
563
571
  }
564
572
  accountManager.updateQuota(account.index, rateLimitHeaders);
565
573
 
574
+ // SETTLE THE SHARED BREAKER AT THE HEADER BOUNDARY. Anthropic ANSWERED, so unless
575
+ // the status is itself a capacity signal, the upstream is provably reachable and
576
+ // serving — a per-request verdict (400 bad transcript, 401, 404, 413, 422) says
577
+ // NOTHING about capacity and must never keep the fleet-wide throttle armed.
578
+ // Without this, a poisoned request (e.g. a provider-authored thinking block that
579
+ // Anthropic 400s) claimed the recovery probe, "failed" it, re-armed the shared
580
+ // throttle every 5s, and benched EVERY healthy account indefinitely — a hard
581
+ // production-down deadlock (2026-07-25: 6 of 13 re-arms were client-side 400s
582
+ // while accounts sat at 2%/11%/25% weekly). confirmUpstreamProbe also clears
583
+ // lease.upstreamThrottleProbe, so the releaseAccount probe branch below no-ops.
584
+ if (!isCapacitySignalStatus(upstreamRes.status)) accountManager.confirmUpstreamProbe?.(lease);
585
+
566
586
  // Retry/failover can only happen before response bytes are sent. Once a
567
587
  // streaming response starts, rerouting would corrupt Claude Code's stream.
568
588
  if (upstreamRes.status === 429) {
@@ -872,6 +892,12 @@ async function forwardRequest(
872
892
  // 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
873
893
  // terminal — nothing bigger to fall to) so we can pin the session to Claude.
874
894
  const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
895
+ // DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
896
+ // for the strip-and-recover retry below. Deliberately NOT the fuzzy
897
+ // isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
898
+ // us to rewrite a user's transcript.
899
+ const isSignatureRejection = account.type !== 'provider'
900
+ && /invalid `signature` in `thinking`/i.test(errorBody);
875
901
  const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
876
902
  ? 'invalid_thinking_signature'
877
903
  : anthropicIncompat ? 'anthropic_incompatible_transcript'
@@ -906,6 +932,26 @@ async function forwardRequest(
906
932
  }
907
933
  }
908
934
 
935
+ // RECOVER-ON-CLAUDE (preferred over the provider pin below): the transcript
936
+ // carries provider-authored thinking blocks whose signature Anthropic rejects.
937
+ // Strip exactly those blocks and retry on Claude, so a session that took even one
938
+ // GLM/Kimi fallback turn is NOT bricked (or exiled to a provider) for the rest of
939
+ // its life. Verified against the real API — the stripped history returns 200, with
940
+ // text + tool_use preserved. Tried once per request (thinkingStripped guard); if it
941
+ // still fails, the provider pin below is the fallback.
942
+ if (isSignatureRejection && !requestInfo.thinkingStripped
943
+ && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
944
+ const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
945
+ if (cleanBody) {
946
+ console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
947
+ return forwardRequest(
948
+ req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
949
+ retryConfig, queueConfig, { ...requestInfo, thinkingStripped: true },
950
+ canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
951
+ );
952
+ }
953
+ }
954
+
909
955
  // React-and-heal: this transcript can't run on Claude (foreign server_tool_use
910
956
  // id / thinking Anthropic can't validate). The 400 is pre-stream and the body
911
957
  // is buffered, so latch the session Anthropic-incompatible (sticky → once per
@@ -951,13 +997,44 @@ async function forwardRequest(
951
997
  );
952
998
  }
953
999
 
1000
+ // Nothing could heal it — replace the cryptic upstream 400 with the real cause and
1001
+ // the actual way out. This is what the user saw for hours as a bare
1002
+ // "400 messages.51.content.8: Invalid `signature` in `thinking` block".
1003
+ // Trigger on the DETERMINISTIC signature rejection only — never the fuzzy
1004
+ // isAnthropicIncompatBody heuristic, so an unrelated malformed request keeps its
1005
+ // own upstream error instead of being mislabelled a provider-contamination.
1006
+ if (isSignatureRejection && !res.headersSent) {
1007
+ const provs = (accountManager.accounts || []).filter(a => a.type === 'provider');
1008
+ // Say only what ACTUALLY happened. `thinkingStripped` is set only when the strip
1009
+ // ran; without it we found nothing provider-shaped, so claiming we stripped —
1010
+ // or blaming GLM/Kimi — would misdirect the user.
1011
+ const what = requestInfo.thinkingStripped
1012
+ ? 'This session ran on GLM/Kimi earlier and Anthropic rejects the thinking blocks they wrote. Maxpool removed them and retried on Claude, but Anthropic still rejected the history.'
1013
+ : "Anthropic rejected a thinking block in this session's history, and maxpool could not identify it as provider-authored, so it could not be repaired automatically.";
1014
+ const hint = provs.length === 0
1015
+ ? ''
1016
+ : provs.every(a => a.enabled === false)
1017
+ ? ' If this session previously ran on GLM/Kimi, re-enabling that provider in the maxpool TUI (a → t) lets it continue there.'
1018
+ : '';
1019
+ ctx.status = 400;
1020
+ sendErrorResponse(res, requestInfo, 400, {
1021
+ type: 'error',
1022
+ error: {
1023
+ type: 'invalid_request_error',
1024
+ message: `Start a new session to keep working — this one cannot continue. ${what}${hint}`,
1025
+ },
1026
+ });
1027
+ return;
1028
+ }
1029
+
954
1030
  ctx.status = upstreamRes.status;
955
1031
  sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
956
1032
  return;
957
1033
  }
958
1034
 
959
1035
  if (upstreamRes.status < 400) {
960
- accountManager.confirmUpstreamProbe?.(lease);
1036
+ // (the probe was already settled at the header boundary above — this only
1037
+ // records the account-level "upstream accepted us" signal)
961
1038
  accountManager.markUpstreamAccepted?.(account.index);
962
1039
  }
963
1040
 
@@ -1257,7 +1334,7 @@ function isContextLengthError(errorBody) {
1257
1334
  return /exceeded model token limit|maximum context length|context length exceeded|context window (?:size )?(?:exceeded|too)|prompt is too long|input is too long|reduce the length of|too many (?:input )?tokens|request too large/i.test(errorBody);
1258
1335
  }
1259
1336
 
1260
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
1337
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
1261
1338
 
1262
1339
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1263
1340
  if (!upstreamRes.body) return '';
@@ -1433,6 +1510,74 @@ function secondsUntilParsedTime(value) {
1433
1510
  return null;
1434
1511
  }
1435
1512
 
1513
+ // Provider thinking signatures CANNOT be told apart from Anthropic's by shape — measured
1514
+ // against the live APIs 2026-07-25: Anthropic ~200-400 char base64, GLM a 24-char hex
1515
+ // digest, Kimi(K3) a 12,946-char base64 blob. A shape heuristic tuned to GLM silently
1516
+ // missed Kimi entirely. So the repair below does NOT guess: it strips EVERY `thinking`
1517
+ // block. That is safe precisely because it only ever runs in response to Anthropic's own
1518
+ // signature-rejection 400 — a healthy Claude-only session never reaches it — and a history
1519
+ // with thinking removed is accepted (200 OK, verified, incl. tool_use → tool_result).
1520
+ // `redacted_thinking` is never touched: it legitimately carries `data` and no signature.
1521
+ function isStrippableThinkingBlock(block) {
1522
+ return block?.type === 'thinking';
1523
+ }
1524
+
1525
+ /**
1526
+ * Recovery for a provider-contaminated transcript: drop the assistant `thinking` /
1527
+ * `redacted_thinking` blocks whose signature Anthropic can't validate, so the session
1528
+ * can CONTINUE ON CLAUDE instead of being pinned to a provider forever.
1529
+ *
1530
+ * Empirically verified 2026-07-25 against the real API: replaying a GLM-authored
1531
+ * thinking block to Anthropic returns 400; the SAME history with those blocks removed
1532
+ * returns 200 — including an assistant turn carrying `tool_use` followed by a
1533
+ * `tool_result` (thinking blocks are not required on replay for a new turn). Text and
1534
+ * tool_use blocks are preserved, so no conversation content or tool wiring is lost.
1535
+ *
1536
+ * Returns { body, removed } — `body` is null when nothing needed stripping.
1537
+ */
1538
+ function stripForeignThinkingBlocks(body) {
1539
+ try {
1540
+ const json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf8') : String(body));
1541
+ if (!Array.isArray(json?.messages)) return { body: null, removed: 0 };
1542
+ let removed = 0;
1543
+ const messages = [];
1544
+ for (const msg of json.messages) {
1545
+ if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
1546
+ let localRemoved = 0;
1547
+ const kept = msg.content.filter(block => {
1548
+ if (!isStrippableThinkingBlock(block)) return true;
1549
+ localRemoved++;
1550
+ return false;
1551
+ });
1552
+ removed += localRemoved;
1553
+ if (!localRemoved) { messages.push(msg); continue; }
1554
+ // A turn that stripping empties is DROPPED, not left with its poisoned block:
1555
+ // leaving it would resend the exact body that just 400'd while reporting success
1556
+ // and burning the single recovery attempt. Verified against the live API — the
1557
+ // resulting consecutive user messages are accepted (200 OK).
1558
+ if (kept.length === 0) continue;
1559
+ messages.push({ ...msg, content: kept });
1560
+ }
1561
+ if (!removed) return { body: null, removed: 0 };
1562
+ json.messages = messages;
1563
+ return { body: Buffer.from(JSON.stringify(json)), removed };
1564
+ } catch {
1565
+ return { body: null, removed: 0 }; // non-JSON / unparseable → no rewrite
1566
+ }
1567
+ }
1568
+
1569
+ /**
1570
+ * Is this status a CAPACITY signal (i.e. evidence the upstream can't serve us right
1571
+ * now), as opposed to a per-request verdict? Only these may keep the shared Anthropic
1572
+ * throttle armed. 403 is included deliberately: on these plans a 403 is almost always
1573
+ * quota/plan exhaustion, NOT bad credentials (see the provider-auth handler). 408 is a
1574
+ * latency/capacity signal. Everything else in 4xx (400/401/404/413/422…) means Anthropic
1575
+ * answered a specific request — the upstream is alive.
1576
+ */
1577
+ function isCapacitySignalStatus(status) {
1578
+ return status === 429 || status === 403 || status === 408 || status >= 500;
1579
+ }
1580
+
1436
1581
  function isRetriableUpstreamStatus(status) {
1437
1582
  // 500 included: Anthropic 500s are transient server errors (same class as
1438
1583
  // 502/503/504). Without this they were passed straight through to the client
package/src/tui.js CHANGED
@@ -253,6 +253,7 @@ export class TUI {
253
253
  // Set by index.js after construction (a deferred closure, not a constructor literal —
254
254
  // avoids the const TDZ on applyUpdateIfReady, which is defined after `new TUI`).
255
255
  this.checkNow = null;
256
+ this.updateBusy = null; // live progress text while an update check/apply runs
256
257
 
257
258
  this.log = []; // completed activity entries
258
259
  this.active = new Map(); // in-flight requests
@@ -456,10 +457,20 @@ export class TUI {
456
457
  _keyUpdates(k) {
457
458
  if (k === 'c') {
458
459
  // Check & apply now — the dance-killer: pull the latest + seamless-reload in place,
459
- // no quit/relaunch. index.js wires this.checkNow (applies regardless of autoApply).
460
- this.mode = 'normal';
461
- if (this.checkNow) this.checkNow();
462
- else this._addLog('Update check unavailable on this worker');
460
+ // no quit/relaunch. STAY on this screen and show live progress: bouncing straight
461
+ // back to the dashboard was indistinguishable from "nothing happened".
462
+ if (!this.checkNow) { this._addLog('Update check unavailable on this worker'); return; }
463
+ if (this.updateBusy) return; // already running
464
+ this.updateBusy = 'Checking npm…';
465
+ this.render();
466
+ Promise.resolve(this.checkNow())
467
+ .catch(() => {})
468
+ .finally(() => {
469
+ // If an update was found+applied, the seamless reload replaces this worker and
470
+ // this never renders. Otherwise report the outcome in place.
471
+ this.updateBusy = null;
472
+ if (this.mode === 'updates') this.render();
473
+ });
463
474
  } else if (k === 't') {
464
475
  this._toggleAutoUpdate();
465
476
  } else if (k === 'esc' || k === 'q') {
@@ -467,6 +478,29 @@ export class TUI {
467
478
  }
468
479
  }
469
480
 
481
+ /** The visible Updates panel. Answers, at a glance: what am I running, is anything
482
+ * newer, is it automatic, and what is happening right now. */
483
+ _renderUpdatesDetail() {
484
+ const v = this.am.versionInfo;
485
+ const running = v?.current ? `v${v.current}` : 'unknown';
486
+ const auto = this._autoUpdateOn();
487
+ const out = [];
488
+ out.push(` ${bold('Updates')}`);
489
+ if (!v) {
490
+ out.push(` ${dim('Running')} ${running} ${dim('· checking npm for a newer version…')}`);
491
+ } else if (v.hasUpdate && v.latest) {
492
+ out.push(` ${dim('Running')} ${running} ${yellow(`v${v.latest} is available`)}`);
493
+ out.push(auto
494
+ ? ` ${dim('It installs itself automatically. Press')} ${bold('c')} ${dim('to get it right now.')}`
495
+ : ` ${dim('Automatic updates are off. Press')} ${bold('c')} ${dim('to install it now, or')} ${bold('t')} ${dim('to turn automatic on.')}`);
496
+ } else {
497
+ out.push(` ${dim('Running')} ${running} ${green('up to date')}`);
498
+ out.push(` ${dim(auto ? 'New versions install themselves.' : 'Automatic updates are off — press t to turn them on.')}`);
499
+ }
500
+ if (this.updateBusy) out.push(` ${cyan(SPINNER[this.frame])} ${dim(this.updateBusy)}`);
501
+ return out;
502
+ }
503
+
470
504
  async _toggleAutoUpdate() {
471
505
  const turnOn = !this._autoUpdateOn();
472
506
  // ON = the full hands-free chain; OFF = keep checking (banner still shows) but never
@@ -1158,7 +1192,10 @@ export class TUI {
1158
1192
  }
1159
1193
 
1160
1194
  // Completed log
1161
- const footerH = this.mode === 'confirm' ? 3 : 2;
1195
+ // 2 = separator + footer; confirm adds its detail line; updates adds its detail block.
1196
+ const footerH = this.mode === 'confirm' ? 3
1197
+ : this.mode === 'updates' ? 2 + this._renderUpdatesDetail().length
1198
+ : 2;
1162
1199
  const space = Math.max(0, H - lines.length - footerH);
1163
1200
  for (let i = 0; i < space && i < this.log.length; i++) {
1164
1201
  lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
@@ -1170,6 +1207,10 @@ export class TUI {
1170
1207
  // ── Footer
1171
1208
  lines.push(' ' + dim('─'.repeat(W - 2)));
1172
1209
  if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
1210
+ // Updates DETAIL — without this, pressing 'u' changed only the one-line footer at the
1211
+ // very bottom of a busy screen, which reads as "nothing happened" (reported twice).
1212
+ // Every other mode draws something visible; this one must too.
1213
+ if (this.mode === 'updates') lines.push(...this._renderUpdatesDetail());
1173
1214
  lines.push(this._renderFooter());
1174
1215
 
1175
1216
  // Write buffer