maxpool 1.5.14 → 1.5.15

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,9 @@ 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 session pinning (`all` profile).** Maxpool classifies each session's home provider from its transcript: Anthropic tool-use ids are `toolu_`/`srvtoolu_`, GLM (z.ai) emits `call_`, Kimi (Moonshot) emits `tool_`. A session whose replayed history contains any non-Anthropic tool-use id (e.g. resuming a `cc glm` session under `cc all`) is **pinned to GLM/Kimi** and never routed to a Claude account — Anthropic rejects a non-`srvtoolu_` `server_tool_use.id` with a 400, so that direction is a hard incompatibility, not a preference. The reverse (a Claude session spilling onto GLM/Kimi) is safe. This is governed by `scheduler.crossProviderFallbackPolicy` (`never` | `when-exhausted` (default) | `always`), cyclable live in the TUI Routing menu (`m` → `f`): `never` = strict same-family pin, `when-exhausted` = cross only once the home family is exhausted, `always` = providers peer with Claude for a Claude session. A GLM/Kimi-origin session is never sent to Claude under any policy. (Note: Claude Code itself may warn that a resumed `glm-*` model "could not be restored" and fall back to `claude-opus-4-8`; that is a client-side session-store message — Maxpool still routes the session to GLM/Kimi correctly by its transcript.)
196
198
 
197
199
  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
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.5.14",
3
+ "version": "1.5.15",
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,6 +1034,10 @@ 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
+ // A foreign (GLM/Kimi) session is provider-pinned; the only cross it could make
1038
+ // is GLM↔Kimi, whose mismatched thinking formats risk a reasoning-loop — keep it
1039
+ // on its bound provider rather than rebalancing.
1040
+ if (this._effectiveOrigin(requestInfo).class === 'foreign') 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
1043
  // bound, and the rebalance candidate filter (_matchesRequest → _isRequestCompatible)
@@ -1266,7 +1284,7 @@ export class AccountManager {
1266
1284
  if (!this._matchesRequest(account, profile, requestInfo)) continue;
1267
1285
  if (!this._isAvailable(account, { ...weeklyOptions, model: requestInfo.model })) continue;
1268
1286
 
1269
- const priority = Number.isFinite(account.priority) ? account.priority : 0;
1287
+ const priority = this._effectivePriority(account, requestInfo);
1270
1288
  const score = this._scoreAccount(account, requestInfo, scoringCtx);
1271
1289
  if (priority < bestPriority || (priority === bestPriority && score < bestScore)) {
1272
1290
  bestPriority = priority;
@@ -1461,15 +1479,107 @@ export class AccountManager {
1461
1479
  return true;
1462
1480
  }
1463
1481
 
1482
+ _crossProviderFallbackPolicy() {
1483
+ const p = this.scheduler.crossProviderFallbackPolicy;
1484
+ return (p === 'never' || p === 'always') ? p : 'when-exhausted';
1485
+ }
1486
+
1487
+ // Selection priority with the 'always' cross-provider policy applied: a provider
1488
+ // account peers with oauth (priority 0) for an Anthropic/unknown session so a
1489
+ // Claude session load-balances across Claude+GLM+Kimi rather than using providers
1490
+ // only as last-resort. 'never'/'when-exhausted' keep the provider's own priority
1491
+ // (10/20) → fallback-only. A foreign session is provider-only regardless.
1492
+ _effectivePriority(account, requestInfo = {}) {
1493
+ const base = Number.isFinite(account.priority) ? account.priority : 0;
1494
+ if (account.type === 'provider'
1495
+ && this._crossProviderFallbackPolicy() === 'always'
1496
+ && this._effectiveOrigin(requestInfo).class !== 'foreign') {
1497
+ return 0;
1498
+ }
1499
+ return base;
1500
+ }
1501
+
1502
+ setCrossProviderFallbackPolicy(policy) {
1503
+ if (!['never', 'when-exhausted', 'always'].includes(policy)) return false;
1504
+ this.scheduler.crossProviderFallbackPolicy = policy;
1505
+ console.log(`[Maxpool] Cross-provider fallback policy set to "${policy}"`);
1506
+ return true;
1507
+ }
1508
+
1509
+ // Effective home-provider origin for a request: the request's own transcript
1510
+ // classification, OR a sticky 'foreign' latch on the session (which never
1511
+ // downgrades — once a GLM/Kimi tool-use id is seen, later no-tool follow-up turns
1512
+ // of that session stay provider-pinned). Both the selector AND the retry oracle
1513
+ // read this so they never disagree (foreign-barred here == foreign-barred there).
1514
+ _effectiveOrigin(requestInfo = {}) {
1515
+ const sticky = requestInfo.sessionKey ? this.sessionPolicies.get(requestInfo.sessionKey) : null;
1516
+ if (sticky?.originClass === 'foreign') {
1517
+ return { class: 'foreign', provider: sticky.originProvider || requestInfo.originProvider || null };
1518
+ }
1519
+ if (requestInfo.originClass === 'foreign') {
1520
+ return { class: 'foreign', provider: requestInfo.originProvider || null };
1521
+ }
1522
+ return {
1523
+ class: requestInfo.originClass || sticky?.originClass || null,
1524
+ provider: requestInfo.originProvider || sticky?.originProvider || null,
1525
+ };
1526
+ }
1527
+
1464
1528
  _isRequestCompatible(account, profile, requestInfo = {}) {
1465
1529
  if (!this._matchesProfile(account, profile)) return false;
1466
- if (account.type === 'provider' && this._requiresAnthropicThinkingIntegrity(requestInfo)) return false;
1530
+
1531
+ const origin = this._effectiveOrigin(requestInfo);
1532
+
1533
+ if (origin.class === 'foreign') {
1534
+ // HARD, policy-INDEPENDENT: a GLM/Kimi transcript can never replay to an
1535
+ // Anthropic account (400 on the non-`srvtoolu_` id). Provider accounts only.
1536
+ // This branch also SUPERSEDES the Anthropic-thinking bar below — a foreign
1537
+ // session's (unsigned) thinking must go BACK to its provider, never be
1538
+ // force-pinned to Claude (the misroute that caused the reported 400).
1539
+ if (account.type !== 'provider') return false;
1540
+ // 'never' pins to the ORIGIN provider family when known (GLM→GLM, Kimi→Kimi).
1541
+ // 'when-exhausted'/'always' let the sibling provider serve too (priority 10<20
1542
+ // keeps it a fallback). Unknown origin provider ⇒ any provider allowed.
1543
+ if (this._crossProviderFallbackPolicy() === 'never'
1544
+ && origin.provider && account.provider !== origin.provider) return false;
1545
+ return true;
1546
+ }
1547
+
1548
+ // Anthropic-origin or unknown: oauth (Claude) is always eligible. Provider
1549
+ // fallback (Claude→GLM/Kimi, the SAFE direction) is policy-gated and still
1550
+ // barred for signed thinking (an Anthropic signature can't be validated by a
1551
+ // provider). 'never' = Claude session uses Claude only.
1552
+ if (account.type === 'provider') {
1553
+ if (this._crossProviderFallbackPolicy() === 'never') return false;
1554
+ if (this._requiresAnthropicThinkingIntegrity(requestInfo)) return false;
1555
+ }
1467
1556
  return true;
1468
1557
  }
1469
1558
 
1470
1559
  _noteRequestPolicy(requestInfo = {}) {
1471
- if (!requestInfo.sessionKey || !requestInfo.requiresAnthropicThinkingIntegrity) return;
1472
- this.markSessionThinkingProtected(requestInfo.sessionKey, requestInfo.model);
1560
+ if (!requestInfo.sessionKey) return;
1561
+ if (requestInfo.requiresAnthropicThinkingIntegrity) {
1562
+ this.markSessionThinkingProtected(requestInfo.sessionKey, requestInfo.model);
1563
+ }
1564
+ if (requestInfo.originClass === 'foreign') {
1565
+ this.markSessionOrigin(requestInfo.sessionKey, 'foreign', requestInfo.originProvider);
1566
+ }
1567
+ }
1568
+
1569
+ // Latch a session's foreign (GLM/Kimi) origin. Only 'foreign' is sticky — an
1570
+ // Anthropic classification never bars anything, and never-downgrade keeps a
1571
+ // provider-pinned session correct across a later no-tool follow-up turn.
1572
+ markSessionOrigin(sessionKey, originClass, originProvider = null) {
1573
+ if (!sessionKey || originClass !== 'foreign') return;
1574
+ const existing = this.sessionPolicies.get(sessionKey) || {};
1575
+ if (existing.originClass !== 'foreign') {
1576
+ console.log(`[Maxpool] Session "${sessionKey}" is ${originProvider || 'provider'}-origin (foreign transcript) — pinned to provider accounts`);
1577
+ }
1578
+ this.sessionPolicies.set(sessionKey, {
1579
+ ...existing,
1580
+ originClass: 'foreign',
1581
+ originProvider: existing.originProvider || originProvider || null,
1582
+ });
1473
1583
  }
1474
1584
 
1475
1585
  markSessionThinkingProtected(sessionKey, model = null) {
@@ -2236,6 +2346,7 @@ export class AccountManager {
2236
2346
  routing: {
2237
2347
  mode: this.routingMode,
2238
2348
  preferredAccount: this.preferredAccountName,
2349
+ crossProviderFallbackPolicy: this._crossProviderFallbackPolicy(),
2239
2350
  },
2240
2351
  accounts: this.accounts.map(a => ({
2241
2352
  name: a.name,
@@ -2305,6 +2416,7 @@ export class AccountManager {
2305
2416
  sessions: {
2306
2417
  stickyBindings: this.sessionBindings.size,
2307
2418
  thinkingProtected: [...this.sessionPolicies.values()].filter(p => p.requiresAnthropicThinkingIntegrity).length,
2419
+ foreignPinned: [...this.sessionPolicies.values()].filter(p => p.originClass === 'foreign').length,
2308
2420
  },
2309
2421
  };
2310
2422
  }
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
@@ -987,6 +987,17 @@ function computeQueueWindowMs({
987
987
  }
988
988
 
989
989
  function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRecoverSoon = true) {
990
+ const origin = accountManager._effectiveOrigin?.(requestInfo) || { class: null, provider: null };
991
+
992
+ // A foreign (GLM/Kimi)-origin session is pinned to provider accounts — Anthropic
993
+ // would 400 on its tool-use ids. "All Claude at limit" would be the wrong story
994
+ // (the Claude accounts are irrelevant to it); say what actually blocks it.
995
+ if (origin.class === 'foreign') {
996
+ const fam = origin.provider === 'zai' ? 'GLM' : origin.provider === 'kimi' ? 'Kimi' : 'GLM/Kimi';
997
+ const eta = Number.isFinite(retryAfter) && retryAfter > 0 ? ` Retry in ${retryAfter}s.` : '';
998
+ return `This session started on ${fam}, so only GLM/Kimi can serve it (a GLM/Kimi transcript can't run on Claude — Anthropic rejects its tool-call ids). No GLM/Kimi provider is available right now.${eta} Check the x-maxpool-zai-token / x-maxpool-kimi-token headers, or resume this session with 'cc glm'/'cc kimi'.`;
999
+ }
1000
+
990
1001
  const thinking = requestInfo.requiresAnthropicThinkingIntegrity
991
1002
  || accountManager._requiresAnthropicThinkingIntegrity?.(requestInfo);
992
1003
  const n = accountManager.accounts.length;
@@ -1010,7 +1021,7 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1010
1021
  return `All ${n} accounts exhausted. Retry in ${retryAfter}s.`;
1011
1022
  }
1012
1023
 
1013
- export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit };
1024
+ export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin };
1014
1025
 
1015
1026
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1016
1027
  if (!upstreamRes.body) return '';
@@ -1478,6 +1489,15 @@ function describeRequest(req, body) {
1478
1489
  // rebalancing); an unparsed body leaves this false → treated as NOT safe
1479
1490
  // (fail-closed) so we never replay a signed thinking block to a new account.
1480
1491
  info.bodyThinkingScanned = true;
1492
+ // Home-provider origin of the resumed transcript (from tool-use id shapes).
1493
+ // A 'foreign' (GLM/Kimi) session can NEVER be replayed to an Anthropic account
1494
+ // (Anthropic 400s on a non-srvtoolu_ server_tool_use id) — routing pins it to
1495
+ // provider accounts. A missing originClass = no tool blocks yet (ambiguous) OR
1496
+ // an unparsed body; both fail OPEN in routing (never stranded), and stickiness
1497
+ // (once a foreign id is seen) keeps a latched session pinned regardless.
1498
+ const origin = detectTranscriptOrigin(json);
1499
+ if (origin.class) info.originClass = origin.class;
1500
+ if (origin.provider) info.originProvider = origin.provider;
1481
1501
  } catch {
1482
1502
  // Non-JSON requests are rare; body size still gives a useful load signal.
1483
1503
  }
@@ -1491,6 +1511,47 @@ function requiresAnthropicThinkingIntegrity(json) {
1491
1511
  return containsThinkingBlock(json.messages);
1492
1512
  }
1493
1513
 
1514
+ // Anthropic tool-use ids: client tool_use is 'toolu_…', server_tool_use is 'srvtoolu_…'
1515
+ // (the latter is REQUIRED — Anthropic 400s on replay of any other shape). GLM (z.ai)
1516
+ // emits OpenAI-style 'call_…'; Kimi (moonshot) emits 'tool_…'. Allowlist, not denylist:
1517
+ // anything NOT matching this is treated as foreign (fail-closed classification).
1518
+ const ANTHROPIC_TOOL_ID = /^(toolu|srvtoolu)_/;
1519
+
1520
+ // Classify a resumed transcript's HOME PROVIDER from the tool-use id shapes in its
1521
+ // replayed `messages`. Returns { class: 'foreign' | 'anthropic' | null, provider:
1522
+ // 'zai' | 'kimi' | null }. ANY non-Anthropic tool-use / server_tool_use / tool_result
1523
+ // id ⇒ 'foreign' (that session can never be replayed to Anthropic). 'anthropic' if
1524
+ // only Anthropic ids seen; null if no tool blocks at all (ambiguous — a fresh session
1525
+ // with no incompatible history yet). The request MODEL is NOT used — Claude Code
1526
+ // rewrites it to claude-opus-4-8 on resume, so only the transcript is trustworthy.
1527
+ function detectTranscriptOrigin(json) {
1528
+ if (!json || typeof json !== 'object') return { class: null, provider: null };
1529
+ let sawAnthropic = false;
1530
+ let foreignProvider = null; // 'zai' | 'kimi' | 'foreign'
1531
+ const classifyForeign = id =>
1532
+ id.startsWith('call_') ? 'zai' : id.startsWith('tool_') ? 'kimi' : 'foreign';
1533
+ const visit = value => {
1534
+ if (foreignProvider) return; // one foreign id is decisive — early-exit the walk
1535
+ if (Array.isArray(value)) { for (const v of value) { visit(v); if (foreignProvider) return; } return; }
1536
+ if (!value || typeof value !== 'object') return;
1537
+ const t = value.type;
1538
+ if ((t === 'tool_use' || t === 'server_tool_use') && typeof value.id === 'string') {
1539
+ if (ANTHROPIC_TOOL_ID.test(value.id)) sawAnthropic = true;
1540
+ else { foreignProvider = classifyForeign(value.id); return; }
1541
+ }
1542
+ if (t === 'tool_result' && typeof value.tool_use_id === 'string') {
1543
+ if (ANTHROPIC_TOOL_ID.test(value.tool_use_id)) sawAnthropic = true;
1544
+ else { foreignProvider = classifyForeign(value.tool_use_id); return; }
1545
+ }
1546
+ if (value.content) visit(value.content);
1547
+ if (value.messages) visit(value.messages);
1548
+ };
1549
+ visit(json.messages);
1550
+ if (foreignProvider) return { class: 'foreign', provider: foreignProvider === 'foreign' ? null : foreignProvider };
1551
+ if (sawAnthropic) return { class: 'anthropic', provider: null };
1552
+ return { class: null, provider: null };
1553
+ }
1554
+
1494
1555
  function containsThinkingBlock(value) {
1495
1556
  if (!value) return false;
1496
1557
  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;
@@ -1034,7 +1058,7 @@ export class TUI {
1034
1058
  case 'accounts':
1035
1059
  return ` ${bold('l')} Login (browser) ${bold('k')} API key ${bold('n')} Rename ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
1036
1060
  case 'routing':
1037
- return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('Esc')} Back`;
1061
+ return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('f')} Cross-provider fallback ${bold('Esc')} Back`;
1038
1062
  case 'select': {
1039
1063
  const act = this.selAction === 'prefer'
1040
1064
  ? 'prefer'