maxpool 1.5.14 → 1.5.16

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
@@ -192,7 +192,11 @@ The proxy also understands an optional internal header profile:
192
192
 
193
193
  Provider fallback credentials can be supplied per Claude Code process with `ANTHROPIC_CUSTOM_HEADERS`. Maxpool strips all `x-maxpool-*` headers before forwarding upstream.
194
194
 
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, fallback priority still wins: a session that had to use GLM or Kimi can move back to Claude when a Claude account becomes available again.
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
+
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.
198
+
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.)
196
200
 
197
201
  Provider rows do not use Claude Max session/week bars unless the provider returns compatible quota headers. For GLM/Kimi, Maxpool always tracks operational telemetry (`Act`, `OK`, `Fail`, `Last`) and also parses common `x-ratelimit-*` / `ratelimit-*` headers if present.
198
202
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.14",
3
+ "version": "1.5.16",
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",
@@ -83,6 +83,20 @@ const DEFAULT_SCHEDULER = {
83
83
  // makes this safe even if Anthropic ever account-binds signatures — a rejected
84
84
  // replay self-heals to the issuer instead of poisoning the session.
85
85
  crossAccountThinkingMigration: true,
86
+ // Cross-PROVIDER fallback policy for 'cc all' (profile=all), i.e. whether a session
87
+ // may be served by a provider FAMILY other than its home (Claude ↔ GLM ↔ Kimi).
88
+ // 'never' — strict pin: a Claude session uses Claude only; a GLM session
89
+ // uses GLM only; a Kimi session uses Kimi only.
90
+ // 'when-exhausted'— (default) home family preferred; a Claude session falls back
91
+ // to GLM/Kimi only once all Claude accounts are unavailable
92
+ // (providers are already lower-priority fallback), and a GLM
93
+ // session may fall to Kimi once GLM is exhausted.
94
+ // 'always' — providers peer with Claude for a Claude/unknown session
95
+ // (load-balanced, not last-resort).
96
+ // INVARIANT (all policies): a GLM/Kimi-origin session NEVER routes to an Anthropic
97
+ // account — Anthropic 400s on a non-`srvtoolu_` server_tool_use id; that direction
98
+ // is unfixable, not policy-tunable.
99
+ crossProviderFallbackPolicy: 'when-exhausted',
86
100
  };
87
101
  const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
88
102
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1020,12 +1034,16 @@ export class AccountManager {
1020
1034
  _migrationSafeForRequest(requestInfo = {}) {
1021
1035
  // Fail closed on any body we couldn't fully scan (non-JSON / parse error).
1022
1036
  if (requestInfo.bodyThinkingScanned !== true) return false;
1037
+ // An Anthropic-incompatible (provider-pinned) session's only possible cross is
1038
+ // GLM↔Kimi, whose mismatched thinking formats risk a reasoning-loop — keep it on
1039
+ // its bound provider rather than rebalancing.
1040
+ if (this._effectiveIncompatible(requestInfo).incompatible) return false;
1023
1041
  // A signed-thinking request is migration-safe when cross-account thinking
1024
1042
  // migration is enabled: the signature is content/model integrity, not account-
1025
- // bound, and the rebalance candidate filter (_matchesRequest → _isRequestCompatible)
1026
- // already bars providers for thinking, so every eligible target is a Claude
1027
- // account. When the flag is off, keep the conservative bar (never migrate signed
1028
- // thinking) until the revert-to-issuer fail-safe lands (#16).
1043
+ // bound. The rebalance candidate loop (_shouldRebalanceBoundSession) additionally
1044
+ // skips PROVIDER targets for a signed-thinking request, so every migration target
1045
+ // stays a Claude account (a signed block isn't shuttled to a provider mid-session).
1046
+ // When the flag is off, keep the conservative bar (never migrate signed thinking).
1029
1047
  if (requestInfo.requiresAnthropicThinkingIntegrity === true) {
1030
1048
  return this.scheduler.crossAccountThinkingMigration === true;
1031
1049
  }
@@ -1066,6 +1084,10 @@ export class AccountManager {
1066
1084
  for (const account of this.accounts) {
1067
1085
  if (account.index === bound.index) continue;
1068
1086
  if (excludedIndexes.has(account.index)) continue;
1087
+ // Keep a signed-thinking session's live migration on Claude accounts only —
1088
+ // don't shuttle an Anthropic-signed block onto a provider mid-session even
1089
+ // though _isRequestCompatible now allows providers for thinking under policy.
1090
+ if (requestInfo.requiresAnthropicThinkingIntegrity === true && account.type === 'provider') continue;
1069
1091
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1070
1092
  // Genuinely-healthy alternatives only (normal/soft/unknown weekly + model headroom).
1071
1093
  if (!this._isAvailable(account, { allowWeeklyReserve: false, allowWeeklyCritical: false, model: requestInfo.model })) continue;
@@ -1266,7 +1288,7 @@ export class AccountManager {
1266
1288
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1267
1289
  if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model })) continue;
1268
1290
 
1269
- const priority = Number.isFinite(account.priority) ? account.priority : 0;
1291
+ const priority = this._effectivePriority(account, requestInfo);
1270
1292
  const score = this._scoreAccount(account, requestInfo, scoringCtx);
1271
1293
  if (priority < bestPriority || (priority === bestPriority && score < bestScore)) {
1272
1294
  bestPriority = priority;
@@ -1461,23 +1483,108 @@ export class AccountManager {
1461
1483
  return true;
1462
1484
  }
1463
1485
 
1486
+ _crossProviderFallbackPolicy() {
1487
+ const p = this.scheduler.crossProviderFallbackPolicy;
1488
+ return (p === 'never' || p === 'always') ? p : 'when-exhausted';
1489
+ }
1490
+
1491
+ // Selection priority with the 'always' cross-provider policy applied: a provider
1492
+ // account peers with oauth (priority 0) for an Anthropic/unknown session so a
1493
+ // Claude session load-balances across Claude+GLM+Kimi rather than using providers
1494
+ // only as last-resort. 'never'/'when-exhausted' keep the provider's own priority
1495
+ // (10/20) → fallback-only. A foreign session is provider-only regardless.
1496
+ _effectivePriority(account, requestInfo = {}) {
1497
+ const base = Number.isFinite(account.priority) ? account.priority : 0;
1498
+ if (account.type === 'provider'
1499
+ && this._crossProviderFallbackPolicy() === 'always'
1500
+ && !this._effectiveIncompatible(requestInfo).incompatible) {
1501
+ return 0;
1502
+ }
1503
+ return base;
1504
+ }
1505
+
1506
+ setCrossProviderFallbackPolicy(policy) {
1507
+ if (!['never', 'when-exhausted', 'always'].includes(policy)) return false;
1508
+ this.scheduler.crossProviderFallbackPolicy = policy;
1509
+ console.log(`[Maxpool] Cross-provider fallback policy set to "${policy}"`);
1510
+ return true;
1511
+ }
1512
+
1513
+ // Effective Anthropic-incompatibility for a request: the request's own transcript
1514
+ // verdict, OR a sticky latch on the session — set once a foreign server_tool_use
1515
+ // id is seen, or once Anthropic REJECTED the transcript on replay (react-and-heal
1516
+ // in server.js). Never downgrades, so a later no-tool follow-up turn stays
1517
+ // provider-pinned. Both the selector AND the retry oracle read this so they never
1518
+ // disagree. homeProvider is a SOFT hint (first foreign id shape) for 'never' only.
1519
+ _effectiveIncompatible(requestInfo = {}) {
1520
+ const sticky = requestInfo.sessionKey ? this.sessionPolicies.get(requestInfo.sessionKey) : null;
1521
+ return {
1522
+ incompatible: Boolean(requestInfo.anthropicIncompatible || sticky?.anthropicIncompatible),
1523
+ homeProvider: requestInfo.homeProvider || sticky?.homeProvider || null,
1524
+ };
1525
+ }
1526
+
1464
1527
  _isRequestCompatible(account, profile, requestInfo = {}) {
1465
1528
  if (!this._matchesProfile(account, profile)) return false;
1466
- if (account.type === 'provider' && this._requiresAnthropicThinkingIntegrity(requestInfo)) return false;
1529
+
1530
+ const { incompatible, homeProvider } = this._effectiveIncompatible(requestInfo);
1531
+ const policy = this._crossProviderFallbackPolicy();
1532
+
1533
+ if (incompatible) {
1534
+ // The transcript can't replay to Claude (a foreign server_tool_use id, or
1535
+ // content Anthropic rejected on replay) — provider accounts ONLY, regardless
1536
+ // of policy. Providers are lenient and accept each other's ids (GLM↔Kimi is
1537
+ // fine); 'never' pins to the detected home provider when known.
1538
+ if (account.type !== 'provider') return false;
1539
+ if (policy === 'never' && homeProvider && account.provider !== homeProvider) return false;
1540
+ return true;
1541
+ }
1542
+
1543
+ // Compatible session — includes Kimi and GLM-without-server-tools, whose regular
1544
+ // tool_use ids pass Anthropic's loose validation, AND ordinary Claude sessions.
1545
+ // Claude is eligible + preferred (priority 0). Provider fallback is the SAFE
1546
+ // direction (lenient providers accept Anthropic ids/signatures) and is
1547
+ // policy-gated ONLY: 'never' keeps a Claude session on Claude; 'when-exhausted'
1548
+ // lets providers serve as a priority-fallback; 'always' peers them
1549
+ // (_effectivePriority). Signed thinking no longer bars providers here — but its
1550
+ // live MIGRATION stays Claude-only (see the rebalance guard).
1551
+ if (account.type === 'provider' && policy === 'never') return false;
1467
1552
  return true;
1468
1553
  }
1469
1554
 
1470
1555
  _noteRequestPolicy(requestInfo = {}) {
1471
- if (!requestInfo.sessionKey || !requestInfo.requiresAnthropicThinkingIntegrity) return;
1472
- this.markSessionThinkingProtected(requestInfo.sessionKey, requestInfo.model);
1556
+ if (!requestInfo.sessionKey) return;
1557
+ if (requestInfo.requiresAnthropicThinkingIntegrity) {
1558
+ this.markSessionThinkingProtected(requestInfo.sessionKey, requestInfo.model);
1559
+ }
1560
+ if (requestInfo.anthropicIncompatible) {
1561
+ this.markSessionIncompatible(requestInfo.sessionKey, requestInfo.homeProvider);
1562
+ }
1473
1563
  }
1474
1564
 
1475
- markSessionThinkingProtected(sessionKey, model = null) {
1565
+ // Latch a session as Anthropic-incompatible (a foreign server_tool_use id, or a
1566
+ // transcript Anthropic rejected on replay). Sticky + never-downgrades so the
1567
+ // session stays provider-pinned across later follow-up turns.
1568
+ markSessionIncompatible(sessionKey, homeProvider = null) {
1476
1569
  if (!sessionKey) return;
1477
1570
  const existing = this.sessionPolicies.get(sessionKey) || {};
1478
- if (!existing.requiresAnthropicThinkingIntegrity) {
1479
- console.log(`[Maxpool] Session "${sessionKey}" contains Anthropic signed thinking; provider fallback disabled`);
1571
+ if (!existing.anthropicIncompatible) {
1572
+ console.log(`[Maxpool] Session "${sessionKey}" is Anthropic-incompatible (${homeProvider || 'provider'} transcript) — pinned to GLM/Kimi`);
1480
1573
  }
1574
+ this.sessionPolicies.set(sessionKey, {
1575
+ ...existing,
1576
+ anthropicIncompatible: true,
1577
+ homeProvider: existing.homeProvider || homeProvider || null,
1578
+ });
1579
+ }
1580
+
1581
+ // Marks a session as containing Anthropic signed thinking. This no longer bars
1582
+ // provider fallback (a lenient provider accepts an Anthropic signature) — it only
1583
+ // keeps the session's live cross-account MIGRATION on Claude (the rebalance guard),
1584
+ // so a signed block isn't needlessly shuttled to a provider mid-session.
1585
+ markSessionThinkingProtected(sessionKey, model = null) {
1586
+ if (!sessionKey) return;
1587
+ const existing = this.sessionPolicies.get(sessionKey) || {};
1481
1588
  this.sessionPolicies.set(sessionKey, {
1482
1589
  ...existing,
1483
1590
  requiresAnthropicThinkingIntegrity: true,
@@ -2236,6 +2343,7 @@ export class AccountManager {
2236
2343
  routing: {
2237
2344
  mode: this.routingMode,
2238
2345
  preferredAccount: this.preferredAccountName,
2346
+ crossProviderFallbackPolicy: this._crossProviderFallbackPolicy(),
2239
2347
  },
2240
2348
  accounts: this.accounts.map(a => ({
2241
2349
  name: a.name,
@@ -2305,6 +2413,7 @@ export class AccountManager {
2305
2413
  sessions: {
2306
2414
  stickyBindings: this.sessionBindings.size,
2307
2415
  thinkingProtected: [...this.sessionPolicies.values()].filter(p => p.requiresAnthropicThinkingIntegrity).length,
2416
+ providerPinned: [...this.sessionPolicies.values()].filter(p => p.anthropicIncompatible).length,
2308
2417
  },
2309
2418
  };
2310
2419
  }
package/src/config.js CHANGED
@@ -91,6 +91,17 @@ export function createDefaultConfig() {
91
91
  weeklyReserveThreshold: 0.85,
92
92
  weeklyCriticalThreshold: 0.95,
93
93
  weeklyExhaustedThreshold: 0.985,
94
+ // Cross-PROVIDER fallback policy for 'cc all' (profile=all): whether a session
95
+ // may be served by a provider family other than its home (Claude ↔ GLM ↔ Kimi).
96
+ // 'never' — strict pin (Claude→Claude, GLM→GLM, Kimi→Kimi).
97
+ // 'when-exhausted'— (default) home family preferred; cross only once it's
98
+ // exhausted (a Claude session falls to GLM/Kimi when all
99
+ // Claude accounts are unavailable).
100
+ // 'always' — providers peer with Claude for a Claude/unknown session.
101
+ // A GLM/Kimi-origin session NEVER routes to Anthropic under ANY policy — that
102
+ // direction 400s on the non-`srvtoolu_` tool-use id and is unfixable. Toggle
103
+ // live in the TUI (Routing sub-mode, 'f' key).
104
+ crossProviderFallbackPolicy: 'when-exhausted',
94
105
  },
95
106
  retry: {
96
107
  maxAttemptsPerRequest: 0,
package/src/index.js CHANGED
@@ -850,6 +850,16 @@ async function serverWorkerCommand() {
850
850
  mode: config.routing?.mode || 'automatic',
851
851
  preferredAccount: config.routing?.preferredAccount || null,
852
852
  };
853
+ // Persist live-toggled scheduler policy (e.g. the cross-provider fallback
854
+ // policy cycled with the TUI 'f' key). Without this, the toggle takes effect
855
+ // in memory but silently reverts on the next config write / restart. Merge
856
+ // onto the existing disk scheduler block so other scheduler keys survive.
857
+ if (config.scheduler?.crossProviderFallbackPolicy) {
858
+ diskConfig.scheduler = {
859
+ ...diskConfig.scheduler,
860
+ crossProviderFallbackPolicy: config.scheduler.crossProviderFallbackPolicy,
861
+ };
862
+ }
853
863
  // Write in-memory accounts as the authoritative state, preserving
854
864
  // extra disk-only fields (e.g. importFrom) where the account still exists.
855
865
  // Use live tokens from AccountManager (not the stale config.accounts copy).
package/src/server.js CHANGED
@@ -736,8 +736,14 @@ async function forwardRequest(
736
736
 
737
737
  if (upstreamRes.status >= 400 && upstreamRes.status < 500) {
738
738
  const errorBody = await readErrorBody(upstreamRes);
739
+ // A transcript a lenient provider (GLM/Kimi) produced can be rejected by
740
+ // Anthropic on replay (a non-srvtoolu_ server_tool_use id, or a thinking
741
+ // signature it can't validate). Detect it on an Anthropic account so we can
742
+ // self-heal onto a provider instead of surfacing the 400.
743
+ const anthropicIncompat = account.type !== 'provider' && isAnthropicIncompatBody(errorBody);
739
744
  const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
740
745
  ? 'invalid_thinking_signature'
746
+ : anthropicIncompat ? 'anthropic_incompatible_transcript'
741
747
  : `HTTP ${upstreamRes.status}`;
742
748
  accountManager.releaseAccount(lease, { status: upstreamRes.status, error: errorType });
743
749
 
@@ -768,6 +774,24 @@ async function forwardRequest(
768
774
  }
769
775
  }
770
776
 
777
+ // React-and-heal: this transcript can't run on Claude (foreign server_tool_use
778
+ // id / thinking Anthropic can't validate). The 400 is pre-stream and the body
779
+ // is buffered, so latch the session Anthropic-incompatible (sticky → once per
780
+ // session) and retry PROVIDER-only, rather than surfacing the 400. Only worth
781
+ // it when a provider can actually serve it (profile=all with a GLM/Kimi token).
782
+ const providerAvailable = accountManager.accounts?.some(a => a.type === 'provider' && a.enabled !== false);
783
+ if (anthropicIncompat && requestInfo.sessionKey && providerAvailable
784
+ && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
785
+ accountManager.markSessionIncompatible?.(requestInfo.sessionKey, requestInfo.homeProvider);
786
+ excludedIndexes.add(account.index);
787
+ console.log(`[Maxpool] Anthropic rejected this transcript (server_tool_use/thinking); pinning session to GLM/Kimi and retrying`);
788
+ return forwardRequest(
789
+ req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
790
+ retryConfig, queueConfig, { ...requestInfo, anthropicIncompatible: true },
791
+ canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
792
+ );
793
+ }
794
+
771
795
  ctx.status = upstreamRes.status;
772
796
  sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
773
797
  return;
@@ -987,6 +1011,17 @@ function computeQueueWindowMs({
987
1011
  }
988
1012
 
989
1013
  function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
1014
+ const incompat = accountManager._effectiveIncompatible?.(requestInfo) || { incompatible: false, homeProvider: null };
1015
+
1016
+ // An Anthropic-incompatible session is pinned to provider accounts — Anthropic
1017
+ // 400s on its server_tool_use id / foreign thinking. "All Claude at limit" would
1018
+ // be the wrong story (Claude is irrelevant to it); say what actually blocks it.
1019
+ if (incompat.incompatible) {
1020
+ const fam = incompat.homeProvider === 'zai' ? 'GLM' : incompat.homeProvider === 'kimi' ? 'Kimi' : 'GLM/Kimi';
1021
+ const eta = Number.isFinite(retryAfter) && retryAfter > 0 ? ` Retry in ${retryAfter}s.` : '';
1022
+ return `This session's transcript can only run on GLM/Kimi — Claude rejects its server-tool ids/thinking on replay. No GLM/Kimi provider is available right now.${eta} Check the x-maxpool-zai-token / x-maxpool-kimi-token headers, or resume with 'cc ${incompat.homeProvider === 'kimi' ? 'kimi' : 'glm'}'.`;
1023
+ }
1024
+
990
1025
  const thinking = requestInfo.requiresAnthropicThinkingIntegrity
991
1026
  || accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
992
1027
  const n = accountManager.accounts.length;
@@ -1010,7 +1045,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1010
1045
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1011
1046
  }
1012
1047
 
1013
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit };
1048
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody };
1014
1049
 
1015
1050
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1016
1051
  if (!upstreamRes.body) return '';
@@ -1478,6 +1513,13 @@ function describeRequest(req, body) {
1478
1513
  // rebalancing); an unparsed body leaves this false → treated as NOT safe
1479
1514
  // (fail-closed) so we never replay a signed thinking block to a new account.
1480
1515
  info.bodyThinkingScanned = true;
1516
+ // Whether the resumed transcript can replay to a Claude account. A session with
1517
+ // a foreign server_tool_use id CANNOT (Anthropic 400s) → routing pins it to
1518
+ // providers. Everything else stays Claude-eligible (Kimi/GLM without server-tools
1519
+ // replay fine); a rare rejected-thinking 400 self-heals via react-and-heal.
1520
+ const origin = detectTranscriptOrigin(json);
1521
+ if (origin.anthropicIncompatible) info.anthropicIncompatible = true;
1522
+ if (origin.homeProvider) info.homeProvider = origin.homeProvider;
1481
1523
  } catch {
1482
1524
  // Non-JSON requests are rare; body size still gives a useful load signal.
1483
1525
  }
@@ -1491,6 +1533,65 @@ function requiresAnthropicThinkingIntegrity(json) {
1491
1533
  return containsThinkingBlock(json.messages);
1492
1534
  }
1493
1535
 
1536
+ // Anthropic tool-use ids: client tool_use is 'toolu_…', server_tool_use is 'srvtoolu_…'
1537
+ // (the latter is REQUIRED — Anthropic 400s on replay of any other shape). GLM (z.ai)
1538
+ // emits OpenAI-style 'call_…'; Kimi (moonshot) emits 'tool_…'. Allowlist, not denylist:
1539
+ // anything NOT matching this is treated as foreign (fail-closed classification).
1540
+ const ANTHROPIC_TOOL_ID = /^(toolu|srvtoolu)_/;
1541
+
1542
+ // Decide whether a resumed transcript can replay to an Anthropic (Claude) account,
1543
+ // from the tool-use id shapes in its `messages`. Returns { anthropicIncompatible,
1544
+ // homeProvider }.
1545
+ // anthropicIncompatible = the transcript has a `server_tool_use` id NOT matching
1546
+ // ^srvtoolu_ — the ONE DETERMINISTIC incompatibility (Anthropic 400s on replay,
1547
+ // the reported bug). Anthropic validates a client `tool_use.id` LOOSELY
1548
+ // (^[a-zA-Z0-9_-]+$), so GLM `call_…` / Kimi `tool_…` client ids PASS and a
1549
+ // Kimi/GLM session without server-tools is FINE on Claude — we do NOT predict
1550
+ // those (a rejected thinking signature, if it ever happens, self-heals via the
1551
+ // 4xx react-and-heal in forwardRequest). server_tool_use is rare (GLM ~15%,
1552
+ // Kimi 0%), so most sessions are compatible.
1553
+ // homeProvider = the first foreign tool-use id shape (call_→zai, tool_→kimi), a
1554
+ // SOFT hint for the 'never' same-family preference only; ambiguous/none → null.
1555
+ // The request MODEL is not used — Claude Code rewrites it to opus on resume.
1556
+ function detectTranscriptOrigin(json) {
1557
+ if (!json || typeof json !== 'object') return { anthropicIncompatible: false, homeProvider: null };
1558
+ let incompatible = false;
1559
+ let homeProvider = null;
1560
+ const fam = id => id.startsWith('call_') ? 'zai' : id.startsWith('tool_') ? 'kimi' : null;
1561
+ const noteForeign = id => { if (!homeProvider) homeProvider = fam(id); };
1562
+ const visit = value => {
1563
+ if (incompatible) return; // server_tool_use is decisive — stop
1564
+ if (Array.isArray(value)) { for (const v of value) { visit(v); if (incompatible) return; } return; }
1565
+ if (!value || typeof value !== 'object') return;
1566
+ const t = value.type;
1567
+ if (t === 'server_tool_use' && typeof value.id === 'string' && !ANTHROPIC_TOOL_ID.test(value.id)) {
1568
+ incompatible = true; noteForeign(value.id); return;
1569
+ }
1570
+ if (t === 'tool_use' && typeof value.id === 'string' && !ANTHROPIC_TOOL_ID.test(value.id)) noteForeign(value.id);
1571
+ if (t === 'tool_result' && typeof value.tool_use_id === 'string' && !ANTHROPIC_TOOL_ID.test(value.tool_use_id)) noteForeign(value.tool_use_id);
1572
+ if (value.content) visit(value.content);
1573
+ if (value.messages) visit(value.messages);
1574
+ };
1575
+ visit(json.messages);
1576
+ return { anthropicIncompatible: incompatible, homeProvider };
1577
+ }
1578
+
1579
+ // Does this upstream 4xx error body indicate the transcript is un-replayable to
1580
+ // Anthropic (a lenient GLM/Kimi provider produced content Anthropic rejects on
1581
+ // replay)? Matches the three known shapes: a non-srvtoolu_ server_tool_use id, or a
1582
+ // thinking-block signature Anthropic can't validate (invalid OR missing). Used to
1583
+ // self-heal onto a provider instead of surfacing the 400.
1584
+ function isAnthropicIncompatBody(body) {
1585
+ if (!body) return false;
1586
+ // The deterministic server-tool-id 400, the known invalid-signature error, or a
1587
+ // thinking-block signature VALIDATION error (missing/invalid) — the last gated on
1588
+ // a validation verb so a user message merely echoing "thinking"/"signature" can't
1589
+ // false-latch the session provider-pinned for life.
1590
+ return /srvtoolu_|server_tool_use/.test(body)
1591
+ || /invalid `signature` in `thinking`/i.test(body)
1592
+ || (/thinking/.test(body) && /signature/.test(body) && /(should match|required|invalid|expected|must )/i.test(body));
1593
+ }
1594
+
1494
1595
  function containsThinkingBlock(value) {
1495
1596
  if (!value) return false;
1496
1597
  if (Array.isArray(value)) return value.some(containsThinkingBlock);
package/src/tui.js CHANGED
@@ -415,11 +415,25 @@ export class TUI {
415
415
  );
416
416
  } else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
417
417
  this._startSelection('prefer');
418
+ } else if (k === 'f') {
419
+ // Cycle the cross-provider fallback policy in place — reversible + non-destructive,
420
+ // so no confirm dialog (unlike restart/delete).
421
+ this._cycleCrossProviderPolicy();
418
422
  } else if (k === 'esc' || k === 'q') {
419
423
  this.mode = 'normal';
420
424
  }
421
425
  }
422
426
 
427
+ async _cycleCrossProviderPolicy() {
428
+ const order = ['never', 'when-exhausted', 'always'];
429
+ const cur = this.am._crossProviderFallbackPolicy();
430
+ const next = order[(order.indexOf(cur) + 1) % order.length];
431
+ this.am.setCrossProviderFallbackPolicy(next);
432
+ this.config.scheduler = { ...(this.config.scheduler || {}), crossProviderFallbackPolicy: next };
433
+ await this.saveConfig(this.config);
434
+ this._addLog(`Cross-provider fallback: ${next}`);
435
+ }
436
+
423
437
  _keySelect(k) {
424
438
  const selectable = this._selectableIndexes(this.selAction);
425
439
  const position = Math.max(0, selectable.indexOf(this.selIdx));
@@ -820,7 +834,17 @@ export class TUI {
820
834
  const routing = this.am.routingMode === 'preferred'
821
835
  ? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
822
836
  : 'Automatic load balancing';
823
- lines.push(` Routing ${cyan(routing)}`);
837
+ // Cross-provider fallback policy — only meaningful when GLM/Kimi providers are in
838
+ // the pool (profile=all). never=strict pin (yellow), when-exhausted=default (cyan),
839
+ // always=peer (green).
840
+ const hasProviders = this.am.accounts.some(a => a.type === 'provider');
841
+ let xpText = '';
842
+ if (hasProviders) {
843
+ const p = this.am._crossProviderFallbackPolicy?.() || 'when-exhausted';
844
+ const pc = p === 'never' ? yellow(p) : p === 'always' ? green(p) : cyan(p);
845
+ xpText = ` ${dim('·')} ${dim('Cross-provider:')} ${pc}`;
846
+ }
847
+ lines.push(` Routing ${cyan(routing)}${xpText}`);
824
848
  const queuedCount = this.am.queueState?.waiting?.length || 0;
825
849
  if (this.am._isUpstreamThrottleBlocking?.() || queuedCount) {
826
850
  const throttle = this.am.upstreamThrottle;
@@ -840,6 +864,11 @@ export class TUI {
840
864
  lines.push(yellow(' No accounts configured. Press [a] to add one.'));
841
865
  } else {
842
866
  lines.push('');
867
+ // Column legend — the per-row numbers are otherwise cryptic. Ses/Wk are the
868
+ // two quota bars; Now is live concurrency; 15m/1h are recent throughput.
869
+ if (W >= 88) {
870
+ lines.push(' ' + dim('Ses/Wk = 5h/7d quota (used% · resets-in) · Now = in-flight (weight) · 15m/1h = requests served (avg latency · Nf=fails)'));
871
+ }
843
872
  const showBoth = W >= 70;
844
873
  const bw = showBoth
845
874
  ? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
@@ -1034,7 +1063,7 @@ export class TUI {
1034
1063
  case 'accounts':
1035
1064
  return ` ${bold('l')} Login (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1036
1065
  case 'routing':
1037
- return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('Esc')} Back`;
1066
+ return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider fallback ${bold('Esc')} Back`;
1038
1067
  case 'select': {
1039
1068
  const act = this.selAction === 'prefer'
1040
1069
  ? 'prefer'