maxpool 1.5.46 → 1.5.48

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/README.md CHANGED
@@ -194,7 +194,7 @@ Provider fallback credentials can be supplied per Claude Code process with `ANTH
194
194
 
195
195
  `x-maxpool-session: <id>` enables session affinity. With this header, the first request for a Claude Code process is routed by the adaptive load balancer, then later requests from the same process keep using that home account while it remains available. If the home account is rate-limited, exhausted, in cooldown, or removed, the session temporarily uses another eligible route. When the home account becomes available again, the session returns to it. For the `all` profile, a Claude session that had to spill onto GLM/Kimi moves back to Claude once a Claude account frees up — **as long as it produced no provider-format tool calls while there**.
196
196
 
197
- **Cross-provider fallback + compatibility (`all` profile).** Claude, GLM (z.ai), and Kimi (Moonshot) interoperate for ordinary sessions — a regular tool-use id (`call_`/`tool_`) passes Anthropic's loose validation, so a Kimi or GLM session runs fine on Claude and a Claude session spills onto GLM/Kimi when Claude is unavailable. `scheduler.crossProviderFallbackPolicy` controls it, cyclable live in the TUI Routing menu (`m` → `f`): `never` = strict same-family (Claude→Claude, GLM→GLM, Kimi→Kimi), `when-exhausted` (default) = cross only once the home family is exhausted, `always` = providers peer with Claude.
197
+ **Cross-provider fallback + compatibility (`all` profile).** Claude, GLM (z.ai), and Kimi (Moonshot) interoperate for ordinary sessions — a regular tool-use id (`call_`/`tool_`) passes Anthropic's loose validation, so a Kimi or GLM session that used no server tools runs on Claude (its *thinking* blocks are rejected, but Maxpool strips them automatically — see self-heal below) and a Claude session spills onto GLM/Kimi when Claude is unavailable. `scheduler.crossProviderFallbackPolicy` controls it, cyclable live in the TUI Routing menu (`m` → `f`): `never` (default) = **Claude sessions never spill onto a provider** (note: this governs the Claude→provider direction only — it is NOT a same-family pin), `when-exhausted` = cross only once the home family is exhausted, `always` = providers peer with Claude.
198
198
 
199
199
  The one hard incompatibility Maxpool guards is a **`server_tool_use` id** that isn't `srvtoolu_` — Anthropic 400s that on replay (e.g. a `cc glm` session that used a server tool, resumed under `cc all`). Maxpool predicts that case ahead (pins the session to GLM/Kimi) and, for anything it can't predict (a rejected thinking signature), **self-heals**: the 400 is pre-stream, so Maxpool latches the session provider-only and transparently retries on GLM/Kimi — the client never sees the error. (Note: Claude Code may warn a resumed `glm-*` model "could not be restored" and fall back to `claude-opus-4-8`; that's a client-side message — routing is unaffected.)
200
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.46",
3
+ "version": "1.5.48",
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",
@@ -136,8 +136,9 @@ const DEFAULT_SCHEDULER = {
136
136
  crossAccountThinkingMigration: true,
137
137
  // Cross-PROVIDER fallback policy for 'cc all' (profile=all), i.e. whether a session
138
138
  // may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
139
- // 'never' — strict pin: a Claude session uses Claude only; a GLM session
140
- // uses GLM only; a Kimi session uses Kimi only.
139
+ // 'never' — (DEFAULT) a Claude session never spills onto a provider. NOTE:
140
+ // this governs the Claude→provider direction ONLY — it is NOT a
141
+ // same-family pin (a provider-origin session still reaches Claude).
141
142
  // 'when-exhausted'— home family preferred; a Claude session falls back to GLM/Kimi
142
143
  // only once all Claude accounts are unavailable, and a GLM session
143
144
  // may fall to Kimi once GLM is exhausted.
@@ -157,6 +158,18 @@ const DEFAULT_SCHEDULER = {
157
158
  // kept even while Claude→provider is 'never'. Only has effect under policy:'never' (the
158
159
  // 'when-exhausted'/'always' paths never pinned to home). Set false for a strict home-pin.
159
160
  providerCrossFallback: true,
161
+ // PER-PROVIDER Claude→provider control. One knob per provider so GLM and Kimi are
162
+ // steered independently (their reliability and quota differ). Each takes the same
163
+ // 'never' | 'when-exhausted' | 'always'. Undefined ⇒ inherit crossProviderFallbackPolicy,
164
+ // so an existing config upgrades to byte-identical behavior. Default 'never': letting a
165
+ // Claude session finish on a provider contaminates its transcript (BOTH providers emit
166
+ // thinking blocks Anthropic rejects — measured 2026-07-25), and while Maxpool now repairs
167
+ // that automatically, a provider server-tool call is NOT repairable.
168
+ // Ships EMPTY on purpose: an unset provider INHERITS crossProviderFallbackPolicy (whose
169
+ // default is already 'never'), so off-by-default holds without a per-provider entry
170
+ // shadowing the global one. Seeding explicit values here would mean cycling the global
171
+ // policy silently did nothing — the two-gate trap.
172
+ providers: {},
160
173
  };
161
174
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
162
175
  // Consecutive failed recovery probes before we stop trusting the SHARED breaker and
@@ -1717,7 +1730,7 @@ export class AccountManager {
1717
1730
  _effectivePriority(account, requestInfo = {}) {
1718
1731
  const base = Number.isFinite(account.priority) ? account.priority : 0;
1719
1732
  if (account.type === 'provider'
1720
- && this._crossProviderFallbackPolicy() === 'always'
1733
+ && this._claudeFallbackFor(account.provider) === 'always'
1721
1734
  && !this._effectiveIncompatible(requestInfo).incompatible) {
1722
1735
  return 0;
1723
1736
  }
@@ -1745,6 +1758,23 @@ export class AccountManager {
1745
1758
  };
1746
1759
  }
1747
1760
 
1761
+ /** Claude→provider policy for ONE provider. Falls back to the legacy global policy when
1762
+ * unset, so an existing config keeps its exact behavior on upgrade. */
1763
+ _claudeFallbackFor(providerKey) {
1764
+ const per = this.scheduler.providers?.[providerKey]?.claudeFallback;
1765
+ const valid = new Set(['never', 'when-exhausted', 'always']);
1766
+ return valid.has(per) ? per : this._crossProviderFallbackPolicy();
1767
+ }
1768
+
1769
+ setClaudeFallbackForProvider(providerKey, policy) {
1770
+ const valid = new Set(['never', 'when-exhausted', 'always']);
1771
+ if (!providerKey || !valid.has(policy)) return false;
1772
+ const providers = { ...(this.scheduler.providers || {}) };
1773
+ providers[providerKey] = { ...(providers[providerKey] || {}), claudeFallback: policy };
1774
+ this.scheduler.providers = providers;
1775
+ return true;
1776
+ }
1777
+
1748
1778
  _isRequestCompatible(account, profile, requestInfo = {}) {
1749
1779
  if (!this._matchesProfile(account, profile)) return false;
1750
1780
 
@@ -1789,7 +1819,9 @@ export class AccountManager {
1789
1819
  // lets providers serve as a priority-fallback; 'always' peers them
1790
1820
  // (_effectivePriority). Signed thinking no longer bars providers here — but its
1791
1821
  // live MIGRATION stays Claude-only (see the rebalance guard).
1792
- if (account.type === 'provider' && policy === 'never') return false;
1822
+ // PER-PROVIDER Claude→provider gate (GLM and Kimi steer independently). Unset ⇒
1823
+ // inherits the legacy global policy, so behavior is unchanged on upgrade.
1824
+ if (account.type === 'provider' && this._claudeFallbackFor(account.provider) === 'never') return false;
1793
1825
  return true;
1794
1826
  }
1795
1827
 
@@ -1835,6 +1867,37 @@ export class AccountManager {
1835
1867
  this.sessionPolicies.set(sessionKey, { ...existing, largeContext: true });
1836
1868
  }
1837
1869
 
1870
+ // Latch a session whose transcript carries provider-authored thinking blocks Anthropic
1871
+ // rejects. Without this the repair is per-REQUEST: the client resends the whole poisoned
1872
+ // history every turn, so each turn pays another rejected round-trip before the strip
1873
+ // (measured: ~9 rejections per contaminated session). Sticky → later turns are stripped
1874
+ // BEFORE the first attempt. In-memory only, like the other session policies.
1875
+ markSessionThinkingContaminated(sessionKey) {
1876
+ if (!sessionKey) return;
1877
+ const existing = this.sessionPolicies.get(sessionKey) || {};
1878
+ if (!existing.thinkingContaminated) {
1879
+ console.log(`[Maxpool] Session "${sessionKey}" carries provider-authored thinking — stripping it up front from now on`);
1880
+ }
1881
+ this.sessionPolicies.set(sessionKey, { ...existing, thinkingContaminated: true });
1882
+ }
1883
+
1884
+ /** Release a session pinned provider-only by an earlier turn. The incompatible latch is
1885
+ * deliberately sticky (it never downgrades), which is right while the transcript really
1886
+ * is unrepairable — but a repaired body IS replayable on Claude, so the pin must lift
1887
+ * or every previously-broken session stays exiled forever. */
1888
+ clearSessionIncompatible(sessionKey) {
1889
+ if (!sessionKey) return;
1890
+ const existing = this.sessionPolicies.get(sessionKey);
1891
+ if (!existing?.anthropicIncompatible) return;
1892
+ this.sessionPolicies.set(sessionKey, { ...existing, anthropicIncompatible: false });
1893
+ console.log(`[Maxpool] Session "${sessionKey}" repaired — Claude routes re-enabled`);
1894
+ }
1895
+
1896
+ isSessionThinkingContaminated(sessionKey) {
1897
+ if (!sessionKey) return false;
1898
+ return Boolean(this.sessionPolicies.get(sessionKey)?.thinkingContaminated);
1899
+ }
1900
+
1838
1901
  _isSessionLargeContext(requestInfo = {}) {
1839
1902
  if (requestInfo.largeContext) return true;
1840
1903
  if (!requestInfo.sessionKey) return false;
package/src/config.js CHANGED
@@ -110,6 +110,14 @@ export function createDefaultConfig() {
110
110
  // (GLM/Kimi) session cross to the OTHER provider (GLM↔Kimi)? Default ON — reliable,
111
111
  // both legs accept each other's ids. Only has effect under policy:'never'.
112
112
  providerCrossFallback: true,
113
+ // Per-provider Claude→provider control (TUI routing: g = GLM, k = Kimi). Same values
114
+ // as the policy above; unset inherits it. Default 'never' — a Claude session that
115
+ // finishes on a provider comes back with thinking blocks Anthropic rejects (Maxpool
116
+ // repairs that automatically, but a provider server-tool call is unrepairable).
117
+ // Left EMPTY: an unset provider inherits the policy above, so off-by-default holds
118
+ // without shadowing it. The TUI writes an entry here only when you steer one
119
+ // provider differently from the other.
120
+ providers: {},
113
121
  },
114
122
  retry: {
115
123
  maxAttemptsPerRequest: 0,
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  runReloadBaton,
18
18
  RELOAD_SWAPPED, RELOAD_ROLLED_BACK,
19
19
  MSG_LISTEN, MSG_RELEASE, MSG_TAKEOVER, MSG_PROBE_READY,
20
- MSG_RELOAD_REQUEST, MSG_READY, MSG_FAILED, MSG_RELEASED, MSG_PRIMARY,
20
+ MSG_RELOAD_REQUEST, MSG_READY, MSG_FAILED, MSG_RELEASED, MSG_PRIMARY, MSG_ROLLED_BACK,
21
21
  } from './reload-protocol.js';
22
22
 
23
23
  const args = process.argv.slice(2);
@@ -626,7 +626,9 @@ async function serverWorkerCommand() {
626
626
  // before (or without) the npm update check. The cold worker's update check below
627
627
  // fills in latest/hasUpdate.
628
628
  getCurrentVersion()
629
- .then(v => { accountManager.versionInfo ||= { current: v, latest: null, hasUpdate: false, checkedAt: null }; })
629
+ .then(v => {
630
+ accountManager.versionInfo ||= { current: v, latest: null, hasUpdate: false, checkedAt: null };
631
+ })
630
632
  .catch(() => {});
631
633
 
632
634
  // Persist refreshed tokens back to config. Defense-in-depth: the updater reads
@@ -727,6 +729,10 @@ async function serverWorkerCommand() {
727
729
  // Self-heal timer for a seamless reload that rolls back (new worker fails to boot,
728
730
  // we're never released). Cleared on MSG_RELEASE; fires cancelRestart otherwise.
729
731
  let reloadWatchdog = null;
732
+ // One cold-restart fallback per reload request — see the rollback watchdog below.
733
+ let coldFallbackUsed = false;
734
+ let reloadIsForUpdate = false;
735
+ let lastRollbackReason = null;
730
736
 
731
737
  // Best-effort terminal restore on ANY abnormal exit path (uncaughtException,
732
738
  // a bare process.exit, a crash) so the user's shell is never left in raw mode
@@ -857,6 +863,8 @@ async function serverWorkerCommand() {
857
863
  || process.env.MAXPOOL_TUI_COLD_RESTART === '1';
858
864
  if (!forceCold && reloadStrategy({ supervised }) === 'seamless') {
859
865
  try {
866
+ coldFallbackUsed = false; // fresh reload → fresh cold-fallback budget
867
+ lastRollbackReason = null;
860
868
  process.send({ type: MSG_RELOAD_REQUEST });
861
869
  // Arm the rollback self-heal: restartController already latched
862
870
  // pending/restarting + paused admission (in _restart, before we got here).
@@ -868,7 +876,29 @@ async function serverWorkerCommand() {
868
876
  reloadWatchdog = null;
869
877
  if (draining) return; // MSG_RELEASE already arrived → a real reload, not a rollback
870
878
  if (restartController?.cancelRestart()) {
871
- console.log('[Maxpool] Reload rolled back (new worker never took over) — resumed serving.');
879
+ console.log('[Maxpool] Reload rolled back (new worker never took over).');
880
+ }
881
+ // FALL BACK TO A COLD RESTART — but ONLY when a newer build is actually on disk
882
+ // waiting to be picked up. Resuming the old worker is what made "press u → c"
883
+ // look like nothing happened: the graceful swap failed under machine load and
884
+ // the update was abandoned, forever. Gating on a real pending update preserves
885
+ // the safety property that a plain failed reload leaves a HEALTHY worker serving
886
+ // (never kill a working process for nothing), while an update still lands.
887
+ // One attempt per reload request, so a build that cannot boot can't crash-loop.
888
+ // Cold-restart ONLY when the new build was merely SLOW to signal ready (a
889
+ // loaded machine — the reported case). If it reported failure or died before
890
+ // ready, the build cannot boot: restarting into it would leave the user with no
891
+ // working proxy at all, strictly worse than the bug being fixed. Absent reason
892
+ // (older supervisor) is treated as unsafe.
893
+ const swapWasSlowNotBroken = lastRollbackReason === 'timeout';
894
+ if (reloadIsForUpdate && swapWasSlowNotBroken && !coldFallbackUsed) {
895
+ coldFallbackUsed = true;
896
+ console.log('[Maxpool] Applying the update with a full restart instead — one moment.');
897
+ restartWorkerNow();
898
+ } else if (reloadIsForUpdate && !swapWasSlowNotBroken) {
899
+ console.log('[Maxpool] The new version failed to start — resumed serving on the current version. Update NOT applied.');
900
+ } else {
901
+ console.log('[Maxpool] Rollback complete — resumed serving on the current version.');
872
902
  }
873
903
  }, RELOAD_ROLLBACK_SELFHEAL_MS);
874
904
  reloadWatchdog.unref?.();
@@ -974,10 +1004,14 @@ async function serverWorkerCommand() {
974
1004
  // policy cycled with the TUI 'f' key). Without this, the toggle takes effect
975
1005
  // in memory but silently reverts on the next config write / restart. Merge
976
1006
  // onto the existing disk scheduler block so other scheduler keys survive.
977
- if (config.scheduler?.crossProviderFallbackPolicy) {
1007
+ if (config.scheduler?.crossProviderFallbackPolicy || config.scheduler?.providers) {
978
1008
  diskConfig.scheduler = {
979
1009
  ...diskConfig.scheduler,
980
- crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy,
1010
+ ...(config.scheduler.crossProviderFallbackPolicy
1011
+ ? { crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy } : {}),
1012
+ // Per-provider Claude→provider settings (TUI routing g / k). Must be listed
1013
+ // here explicitly or the toggle takes effect in memory and silently reverts.
1014
+ ...(config.scheduler.providers ? { providers: config.scheduler.providers } : {}),
981
1015
  };
982
1016
  }
983
1017
  // Persist live-toggled automatic-update flags (the TUI 'u' Updates menu). Same
@@ -1056,6 +1090,11 @@ async function serverWorkerCommand() {
1056
1090
  if (r?.applicable && config?.autoApply && restartController) {
1057
1091
  markApplied(r.installedVersion);
1058
1092
  notifyUpdate('Applying update — seamless reload…');
1093
+ // Mark this reload as UPDATE-driven: if the seamless swap rolls back (machine
1094
+ // under load), the watchdog cold-restarts so the update actually lands instead of
1095
+ // silently resuming the old build. A plain 'r' restart never does that — there a
1096
+ // rollback should leave the healthy worker serving.
1097
+ reloadIsForUpdate = true;
1059
1098
  restartController.requestRestart();
1060
1099
  }
1061
1100
  };
@@ -1067,6 +1106,11 @@ async function serverWorkerCommand() {
1067
1106
  if (r?.applicable && restartController) {
1068
1107
  markApplied(r.installedVersion);
1069
1108
  notifyUpdate('Applying update — seamless reload…');
1109
+ // Mark this reload as UPDATE-driven: if the seamless swap rolls back (machine
1110
+ // under load), the watchdog cold-restarts so the update actually lands instead of
1111
+ // silently resuming the old build. A plain 'r' restart never does that — there a
1112
+ // rollback should leave the healthy worker serving.
1113
+ reloadIsForUpdate = true;
1070
1114
  restartController.requestRestart();
1071
1115
  }
1072
1116
  };
@@ -1280,6 +1324,9 @@ async function serverWorkerCommand() {
1280
1324
  // sends MSG_PRIMARY (the baton waits on it).
1281
1325
  await listenOnHandle(handle);
1282
1326
  await becomePrimary({ viaTakeover: true });
1327
+ } else if (msg?.type === MSG_ROLLED_BACK) {
1328
+ // Why the swap failed decides whether a cold restart is safe (see the watchdog).
1329
+ lastRollbackReason = msg.reason || null;
1283
1330
  } else if (msg?.type === MSG_RELEASE) {
1284
1331
  // Baton release: stop accepting NEW (keep in-flight), retire keep-alive
1285
1332
  // sockets with Connection: close, stop writing, flush once, drop TUI.
@@ -14,6 +14,11 @@
14
14
 
15
15
  // Supervisor → worker
16
16
  export const MSG_LISTEN = 'listen'; // (with handle) accept on this socket + take TUI/lease
17
+ // Tells the surviving old worker WHY a reload rolled back. 'timeout' = the new worker
18
+ // was merely slow to signal ready (a loaded machine) — safe to retry with a cold
19
+ // restart. 'failed' = it reported failure or died before ready — a cold restart there
20
+ // would commit to a build that cannot boot and leave NO working proxy.
21
+ export const MSG_ROLLED_BACK = 'rolled-back';
17
22
  export const MSG_RELEASE = 'release'; // stop accepting, stop writing, flush, give up TUI
18
23
  export const MSG_TAKEOVER = 'takeover'; // (with handle) start accepting + acquire writer lease + TUI
19
24
  export const MSG_PROBE_READY = 'probe-ready'; // ask a headless worker to confirm it booted OK
@@ -70,11 +75,13 @@ export async function runReloadBaton({
70
75
  ready = await newWorker.waitFor([MSG_READY, MSG_FAILED], readyTimeoutMs);
71
76
  } catch (err) {
72
77
  log(`reload: readiness wait failed (${err.message}); rolling back`);
78
+ try { oldWorker.send({ type: MSG_ROLLED_BACK, reason: 'timeout' }); } catch { /* old worker gone */ }
73
79
  return RELOAD_ROLLED_BACK;
74
80
  }
75
81
  // (b) New worker did not come up cleanly → roll back fully, old stays primary.
76
82
  if (!ready || ready.type === MSG_FAILED) {
77
83
  log(`reload: new worker reported failed (${ready?.reason || 'no ready'}); rolling back`);
84
+ try { oldWorker.send({ type: MSG_ROLLED_BACK, reason: 'failed' }); } catch { /* old worker gone */ }
78
85
  return RELOAD_ROLLED_BACK;
79
86
  }
80
87
 
package/src/server.js CHANGED
@@ -354,6 +354,34 @@ async function forwardRequest(
354
354
  const configuredAttempts = Number(retryConfig.maxAttemptsPerRequest) || accountManager.accounts.length;
355
355
  const maxAttempts = Math.max(1, configuredAttempts);
356
356
 
357
+ // PRE-STRIP a session already known to carry provider-authored thinking. The client
358
+ // resends the whole poisoned history every turn, so without this each turn pays another
359
+ // rejected round-trip before the reactive repair kicks in. Latched by the first repair.
360
+ // ALSO repairs a transcript maxpool predicts Anthropic will reject (a provider web
361
+ // search). That prediction happens BEFORE any request is sent and bars every Claude
362
+ // account, so the reactive repair in the 4xx handler could never be reached for it —
363
+ // the session stayed exiled to GLM/Kimi, or got NO ROUTE AT ALL when they're disabled
364
+ // (8 healthy Claude accounts idle while the user is told nothing is available).
365
+ // Repairing here, ahead of routing, is what makes that case recoverable.
366
+ if (retryCount === 0 && !requestInfo.thinkingStripped
367
+ && (requestInfo.anthropicIncompatible
368
+ || accountManager.isSessionThinkingContaminated?.(requestInfo.sessionKey))) {
369
+ const pre = stripForeignThinkingBlocks(body);
370
+ if (pre.body) {
371
+ body = pre.body;
372
+ requestInfo = { ...requestInfo, thinkingStripped: true };
373
+ if (pre.converted) {
374
+ // The body now replays cleanly on Claude, so drop the predictive verdict —
375
+ // otherwise routing still exiles it and _noteRequestPolicy latches it sticky.
376
+ requestInfo = { ...requestInfo, anthropicIncompatible: false };
377
+ // And un-latch a session pinned by an EARLIER turn: the sticky policy is ORed in
378
+ // and never downgrades, so without this the user's already-broken sessions stay
379
+ // pinned forever even though we can now repair them.
380
+ accountManager.clearSessionIncompatible?.(requestInfo.sessionKey);
381
+ }
382
+ }
383
+ }
384
+
357
385
  // Select account
358
386
  const lease = accountManager.acquireAccount(requestInfo, excludedIndexes);
359
387
  const account = lease?.account;
@@ -896,8 +924,18 @@ async function forwardRequest(
896
924
  // for the strip-and-recover retry below. Deliberately NOT the fuzzy
897
925
  // isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
898
926
  // us to rewrite a user's transcript.
927
+ // Two deterministic shapes, both repairable by stripping: a signature Anthropic
928
+ // can't validate, AND a provider block that carries NO signature field at all
929
+ // ("messages.4.content.0.thinking.signature: Field required"). The second variant
930
+ // used to fall straight through to a PERMANENT provider pin even though stripping
931
+ // fixes it. Still deterministic — never the fuzzy isAnthropicIncompatBody heuristic.
899
932
  const isSignatureRejection = account.type !== 'provider'
900
- && /invalid `signature` in `thinking`/i.test(errorBody);
933
+ && (/invalid `signature` in `thinking`/i.test(errorBody)
934
+ || /content\.\d+\.thinking\.signature/i.test(errorBody)
935
+ // A provider web search: `server_tool_use.id: String should match pattern
936
+ // '^srvtoolu_…'`. Repairable by converting the pair to text (verified 200 OK) —
937
+ // it used to fall through to a PERMANENT provider pin.
938
+ || /server_tool_use\.id: String should match pattern/i.test(errorBody));
901
939
  const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
902
940
  ? 'invalid_thinking_signature'
903
941
  : anthropicIncompat ? 'anthropic_incompatible_transcript'
@@ -941,9 +979,12 @@ async function forwardRequest(
941
979
  // still fails, the provider pin below is the fallback.
942
980
  if (isSignatureRejection && !requestInfo.thinkingStripped
943
981
  && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
944
- const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
982
+ const { body: cleanBody, removed, converted } = stripForeignThinkingBlocks(body);
945
983
  if (cleanBody) {
946
- console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
984
+ // Latch it so EVERY later turn is stripped up front instead of re-paying this
985
+ // rejected round-trip (the client resends the full poisoned history each turn).
986
+ accountManager.markSessionThinkingContaminated?.(requestInfo.sessionKey);
987
+ console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider thinking block(s), converted ${converted} provider search block(s) to text`);
947
988
  return forwardRequest(
948
989
  req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
949
990
  retryConfig, queueConfig, { ...requestInfo, thinkingStripped: true },
@@ -1009,8 +1050,8 @@ async function forwardRequest(
1009
1050
  // ran; without it we found nothing provider-shaped, so claiming we stripped —
1010
1051
  // or blaming GLM/Kimi — would misdirect the user.
1011
1052
  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.";
1053
+ ? 'This session ran on GLM/Kimi earlier, and Anthropic will not accept parts of what they wrote. Maxpool repaired what it could and retried on Claude, but Anthropic still rejected the history.'
1054
+ : "Anthropic rejected part of this session's history that maxpool could not repair automatically.";
1014
1055
  const hint = provs.length === 0
1015
1056
  ? ''
1016
1057
  : provs.every(a => a.enabled === false)
@@ -1535,22 +1576,96 @@ function isStrippableThinkingBlock(block) {
1535
1576
  *
1536
1577
  * Returns { body, removed } — `body` is null when nothing needed stripping.
1537
1578
  */
1579
+ // A provider's web search leaves a `server_tool_use` id Anthropic rejects (it demands
1580
+ // ^srvtoolu_). Verified 2026-07-26 against the live API: renaming the id is NOT enough —
1581
+ // a second gate rejects the result's `encrypted_content`, which only Anthropic can mint.
1582
+ // But converting the pair into plain TEXT is accepted (200 OK) and keeps what the search
1583
+ // actually found, so the session survives with its information intact. This is what made
1584
+ // the web-search case look permanently unrepairable.
1585
+ /** Collect foreign server-tool ids across the WHOLE transcript first — a call sits on the
1586
+ * assistant turn but its result is often carried on the FOLLOWING user turn, so a
1587
+ * per-message scan would leave that result behind (and it alone still 400s). */
1588
+ function collectForeignServerToolIds(messages) {
1589
+ const ids = new Set();
1590
+ const clientIds = new Set();
1591
+ const walk = (blocks) => {
1592
+ if (!Array.isArray(blocks)) return;
1593
+ for (const b of blocks) {
1594
+ if (b?.type === 'tool_use' && b.id) clientIds.add(b.id);
1595
+ if (b?.type === 'server_tool_use' && !ANTHROPIC_TOOL_ID.test(String(b.id || ''))) ids.add(b.id);
1596
+ if (Array.isArray(b?.content)) walk(b.content); // nested, mirrors detectTranscriptOrigin
1597
+ }
1598
+ };
1599
+ for (const msg of messages) walk(msg?.content);
1600
+ // NEVER treat an id that a real client tool_use also owns as foreign: converting its
1601
+ // tool_result would orphan that tool_use and Anthropic 400s ("tool_use ids without
1602
+ // tool_result"), which the sticky pre-strip would then re-inflict every turn — a
1603
+ // permanent loop. Providers with per-turn counters (call_0, call_1) make the collision
1604
+ // likely, not theoretical.
1605
+ for (const id of clientIds) ids.delete(id);
1606
+ return ids;
1607
+ }
1608
+
1609
+ function convertForeignServerTools(content, foreignIds) {
1610
+ if (!foreignIds || foreignIds.size === 0) return { content, converted: 0 };
1611
+ let converted = 0;
1612
+ const out = [];
1613
+ for (const b of content) {
1614
+ if (b?.type === 'server_tool_use' && foreignIds.has(b.id)) {
1615
+ const q = b.input?.query;
1616
+ out.push({ type: 'text', text: q ? `[searched the web for: ${q}]` : `[used ${b.name || 'a tool'}]` });
1617
+ converted++;
1618
+ continue;
1619
+ }
1620
+ // Any result block referring to a foreign call — web_search_tool_result and friends.
1621
+ if (b?.tool_use_id && foreignIds.has(b.tool_use_id)) {
1622
+ const rows = Array.isArray(b.content) ? b.content : [];
1623
+ // Fall back through the shapes a PROVIDER may use — the point of converting rather
1624
+ // than dropping is to keep what the search found; assuming Anthropic's {title,url}
1625
+ // would silently discard a GLM row that carries text instead.
1626
+ const found = rows
1627
+ .map(r => [r?.title, r?.url].filter(Boolean).join(' — ')
1628
+ || (typeof r === 'string' ? r : (r?.text ?? r?.content ?? r?.snippet ?? '')))
1629
+ .map(t => (typeof t === 'string' ? t.slice(0, 300) : ''))
1630
+ .filter(Boolean).slice(0, 10);
1631
+ out.push({ type: 'text', text: found.length ? `[search results: ${found.join(' | ')}]` : '[search returned no usable results]' });
1632
+ converted++;
1633
+ continue;
1634
+ }
1635
+ out.push(b);
1636
+ }
1637
+ return { content: out, converted };
1638
+ }
1639
+
1538
1640
  function stripForeignThinkingBlocks(body) {
1539
1641
  try {
1540
1642
  const json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf8') : String(body));
1541
1643
  if (!Array.isArray(json?.messages)) return { body: null, removed: 0 };
1542
1644
  let removed = 0;
1645
+ let converted = 0;
1646
+ const foreignToolIds = collectForeignServerToolIds(json.messages);
1543
1647
  const messages = [];
1544
1648
  for (const msg of json.messages) {
1545
- if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
1649
+ if (!Array.isArray(msg?.content)) { messages.push(msg); continue; }
1650
+ // Foreign server-tool pairs are converted on EVERY role: the call sits on the
1651
+ // assistant turn but its result can be carried on the following user turn.
1652
+ const tools = convertForeignServerTools(msg.content, foreignToolIds);
1653
+ converted += tools.converted;
1654
+ if (msg.role !== 'assistant') {
1655
+ messages.push(tools.converted ? { ...msg, content: tools.content } : msg);
1656
+ continue;
1657
+ }
1546
1658
  let localRemoved = 0;
1547
- const kept = msg.content.filter(block => {
1659
+ const kept = tools.content.filter(block => {
1548
1660
  if (!isStrippableThinkingBlock(block)) return true;
1549
1661
  localRemoved++;
1550
1662
  return false;
1551
1663
  });
1552
1664
  removed += localRemoved;
1553
- if (!localRemoved) { messages.push(msg); continue; }
1665
+ if (!localRemoved) {
1666
+ messages.push(tools.converted ? { ...msg, content: tools.content } : msg);
1667
+ continue;
1668
+ }
1554
1669
  // A turn that stripping empties is DROPPED, not left with its poisoned block:
1555
1670
  // leaving it would resend the exact body that just 400'd while reporting success
1556
1671
  // and burning the single recovery attempt. Verified against the live API — the
@@ -1558,11 +1673,11 @@ function stripForeignThinkingBlocks(body) {
1558
1673
  if (kept.length === 0) continue;
1559
1674
  messages.push({ ...msg, content: kept });
1560
1675
  }
1561
- if (!removed) return { body: null, removed: 0 };
1676
+ if (!removed && !converted) return { body: null, removed: 0, converted: 0 };
1562
1677
  json.messages = messages;
1563
- return { body: Buffer.from(JSON.stringify(json)), removed };
1678
+ return { body: Buffer.from(JSON.stringify(json)), removed, converted };
1564
1679
  } catch {
1565
- return { body: null, removed: 0 }; // non-JSON / unparseable → no rewrite
1680
+ return { body: null, removed: 0, converted: 0 }; // non-JSON / unparseable → no rewrite
1566
1681
  }
1567
1682
  }
1568
1683
 
package/src/tui.js CHANGED
@@ -559,6 +559,8 @@ export class TUI {
559
559
  );
560
560
  } else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
561
561
  this._startSelection('prefer');
562
+ } else if (k === 'g' || k === 'k') {
563
+ this._cycleProviderClaudeFallback(k === 'g' ? 'zai' : 'kimi');
562
564
  } else if (k === 'f' || k === 'F') {
563
565
  // Cycle the cross-provider fallback policy in place — reversible + non-destructive,
564
566
  // so no confirm dialog (unlike restart/delete). Accept F too (Shift-f muscle memory).
@@ -568,6 +570,34 @@ export class TUI {
568
570
  }
569
571
  }
570
572
 
573
+ /** Cycle ONE provider's Claude→provider setting. Turning it ON is the risky direction
574
+ * (a Claude session can finish on that provider), so it asks first; turning it back off
575
+ * is always safe and never prompts. */
576
+ _cycleProviderClaudeFallback(providerKey) {
577
+ const order = ['never', 'when-exhausted', 'always'];
578
+ const label = providerKey === 'zai' ? 'GLM' : 'Kimi';
579
+ const cur = this.am._claudeFallbackFor?.(providerKey) || 'never';
580
+ const next = order[(order.indexOf(cur) + 1) % order.length];
581
+ const apply = async () => {
582
+ this.am.setClaudeFallbackForProvider?.(providerKey, next);
583
+ const sched = { ...(this.config.scheduler || {}) };
584
+ sched.providers = { ...(sched.providers || {}) };
585
+ sched.providers[providerKey] = { ...(sched.providers[providerKey] || {}), claudeFallback: next };
586
+ this.config.scheduler = sched;
587
+ try { await this.saveConfig(this.config); } catch (e) { this._addLog(`Could not save: ${e.message}`); }
588
+ this._addLog(`${label} takes over when Claude is out: ${next}`);
589
+ };
590
+ if (cur === 'never') {
591
+ this._confirm(
592
+ `Let ${label} take over when Claude is out?`,
593
+ `A session that starts on Claude can finish on ${label}. Most move back to Claude fine. If ${label} runs a web search, that session stays on ${label} until you start a new one.`,
594
+ apply,
595
+ );
596
+ return;
597
+ }
598
+ apply();
599
+ }
600
+
571
601
  async _cycleCrossProviderPolicy() {
572
602
  const order = ['never', 'when-exhausted', 'always'];
573
603
  const cur = this.am._crossProviderFallbackPolicy();
@@ -1459,8 +1489,9 @@ export class TUI {
1459
1489
  // Show the CURRENT cross-provider policy inline so pressing f visibly changes it
1460
1490
  // right here at the footer (the policy also renders in the header, far from the
1461
1491
  // keypress — the "f does nothing" report).
1462
- const xp = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
1463
- return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider: ${cyan(xp)} ↻ ${bold('Esc')} Back`;
1492
+ const g = this.am._claudeFallbackFor?.('zai') || 'never';
1493
+ const k = this.am._claudeFallbackFor?.('kimi') || 'never';
1494
+ return ` ${bold('a')} Automatic ${bold('p')} Preference ${bold('g')} GLM: ${cyan(g)} ↻ ${bold('k')} Kimi: ${cyan(k)} ↻ ${bold('Esc')} Back`;
1464
1495
  }
1465
1496
  case 'select': {
1466
1497
  const act = this.selAction === 'prefer'