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 +5 -1
- package/package.json +1 -1
- package/src/account-manager.js +120 -11
- package/src/config.js +11 -0
- package/src/index.js +10 -0
- package/src/server.js +102 -1
- package/src/tui.js +31 -2
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,
|
|
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
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,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
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1472
|
-
|
|
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
|
-
|
|
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.
|
|
1479
|
-
console.log(`[Maxpool] Session "${sessionKey}"
|
|
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
|
-
|
|
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'
|