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 +1 -1
- package/package.json +1 -1
- package/src/account-manager.js +67 -4
- package/src/config.js +8 -0
- package/src/index.js +52 -5
- package/src/reload-protocol.js +7 -0
- package/src/server.js +126 -11
- package/src/tui.js +33 -2
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
|
|
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
package/src/account-manager.js
CHANGED
|
@@ -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' —
|
|
140
|
-
//
|
|
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.
|
|
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
|
-
|
|
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 => {
|
|
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)
|
|
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
|
-
|
|
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.
|
package/src/reload-protocol.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1013
|
-
: "Anthropic rejected
|
|
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 (
|
|
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 =
|
|
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) {
|
|
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
|
|
1463
|
-
|
|
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'
|