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 +3 -1
- package/package.json +1 -1
- package/src/account-manager.js +116 -4
- package/src/config.js +11 -0
- package/src/index.js +10 -0
- package/src/server.js +62 -1
- package/src/tui.js +26 -2
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,
|
|
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
package/src/account-manager.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
1472
|
-
|
|
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
|
-
|
|
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'
|