maxpool 1.5.44 → 1.5.45
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/package.json +1 -1
- package/src/account-manager.js +30 -2
- package/src/server.js +153 -2
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -159,6 +159,10 @@ const DEFAULT_SCHEDULER = {
|
|
|
159
159
|
providerCrossFallback: true,
|
|
160
160
|
};
|
|
161
161
|
const LOAD_EVENT_MAX_AGE_MS = 60 * 60 * 1000;
|
|
162
|
+
// Consecutive failed recovery probes before we stop trusting the SHARED breaker and
|
|
163
|
+
// fall back to per-account handling. Bounds any future "poisoned probe" from becoming
|
|
164
|
+
// an indefinite fleet-wide outage (2026-07-25 incident).
|
|
165
|
+
const MAX_FAILED_PROBES = 4;
|
|
162
166
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
163
167
|
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
164
168
|
|
|
@@ -734,6 +738,7 @@ export class AccountManager {
|
|
|
734
738
|
this.upstreamThrottle.until = Math.max(this.upstreamThrottle.until || 0, until);
|
|
735
739
|
this.upstreamThrottle.reason = reason;
|
|
736
740
|
this.upstreamThrottle.probeInFlight = false;
|
|
741
|
+
this.upstreamThrottle.failedProbes = 0; // fresh breaker → fresh probe budget
|
|
737
742
|
this.upstreamThrottle.count++;
|
|
738
743
|
this.upstreamThrottle.lastAt = Date.now();
|
|
739
744
|
console.log(`[Maxpool] Anthropic upstream temporarily limiting requests for ${retryAfter}s; pausing Claude routes`);
|
|
@@ -744,6 +749,7 @@ export class AccountManager {
|
|
|
744
749
|
this.upstreamThrottle.until = null;
|
|
745
750
|
this.upstreamThrottle.reason = null;
|
|
746
751
|
this.upstreamThrottle.probeInFlight = false;
|
|
752
|
+
this.upstreamThrottle.failedProbes = 0;
|
|
747
753
|
this.queueState.rampUntil = Date.now() + 5000;
|
|
748
754
|
this.queueState.lastAdmissionAt = Date.now();
|
|
749
755
|
console.log(`[Maxpool] Anthropic upstream throttle cleared (${reason})`);
|
|
@@ -755,14 +761,36 @@ export class AccountManager {
|
|
|
755
761
|
lease.upstreamThrottleProbe = false;
|
|
756
762
|
}
|
|
757
763
|
|
|
764
|
+
/** Hand the recovery probe back UNUSED — the request never got an upstream answer
|
|
765
|
+
* (client disconnected, token refresh failed), so it is no evidence either way.
|
|
766
|
+
* Leaves the window open so the very next request can claim the probe, instead of
|
|
767
|
+
* scoring a failure that would re-arm the fleet-wide throttle. */
|
|
768
|
+
relinquishUpstreamProbe(lease) {
|
|
769
|
+
if (!lease?.upstreamThrottleProbe) return;
|
|
770
|
+
lease.upstreamThrottleProbe = false;
|
|
771
|
+
this.upstreamThrottle.probeInFlight = false;
|
|
772
|
+
}
|
|
773
|
+
|
|
758
774
|
deferUpstreamThrottleProbe(retryAfterSeconds = 5, reason = 'probe_failed') {
|
|
759
775
|
if (!this.upstreamThrottle.until && !this.upstreamThrottle.probeInFlight) return;
|
|
760
|
-
|
|
776
|
+
// PROBE BUDGET. A flat 5s retry with unbounded repetition is what let a single
|
|
777
|
+
// poisoned request hold the whole fleet down forever. Escalate the backoff, and
|
|
778
|
+
// after MAX_FAILED_PROBES consecutive failures give up on the shared breaker and
|
|
779
|
+
// fall back to per-account handling — the fleet then retries for real, and if
|
|
780
|
+
// Anthropic genuinely is throttling, shouldPromoteUpstreamFailure re-arms it.
|
|
781
|
+
// (This is a circuit breaker's half-open state, paced by the backoff.)
|
|
782
|
+
this.upstreamThrottle.failedProbes = (this.upstreamThrottle.failedProbes || 0) + 1;
|
|
783
|
+
if (this.upstreamThrottle.failedProbes >= MAX_FAILED_PROBES) {
|
|
784
|
+
this.clearUpstreamThrottle(`probe budget exhausted after ${this.upstreamThrottle.failedProbes} failures (${reason}) — deferring to per-account handling`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const backoff = Math.min(60, retryAfterSeconds * 2 ** (this.upstreamThrottle.failedProbes - 1));
|
|
788
|
+
const retryAfter = clampRetryAfterSeconds(backoff);
|
|
761
789
|
this.upstreamThrottle.until = Date.now() + retryAfter * 1000;
|
|
762
790
|
this.upstreamThrottle.reason = reason;
|
|
763
791
|
this.upstreamThrottle.probeInFlight = false;
|
|
764
792
|
this.upstreamThrottle.lastAt = Date.now();
|
|
765
|
-
console.log(`[Maxpool] Anthropic recovery probe failed; retrying in ${retryAfter}s (${reason})`);
|
|
793
|
+
console.log(`[Maxpool] Anthropic recovery probe failed (${this.upstreamThrottle.failedProbes}/${MAX_FAILED_PROBES}); retrying in ${retryAfter}s (${reason})`);
|
|
766
794
|
}
|
|
767
795
|
|
|
768
796
|
noteAmbiguousRateLimit(accountIndex, fingerprint, _retryAfterSeconds) {
|
package/src/server.js
CHANGED
|
@@ -402,6 +402,11 @@ async function forwardRequest(
|
|
|
402
402
|
res.once('close', onClientClose);
|
|
403
403
|
const releaseOnClientGone = () => {
|
|
404
404
|
res.off('close', onClientClose);
|
|
405
|
+
// The client vanished — we never got an upstream answer, so this carries ZERO
|
|
406
|
+
// evidence about Anthropic's health. Hand the recovery probe back instead of
|
|
407
|
+
// letting releaseAccount score it as a FAILED probe (which would re-arm the
|
|
408
|
+
// fleet-wide throttle for 5s on every disconnect — a second deadlock amplifier).
|
|
409
|
+
accountManager.relinquishUpstreamProbe?.(lease);
|
|
405
410
|
accountManager.releaseAccount(lease);
|
|
406
411
|
clearQueueHeartbeat(requestInfo);
|
|
407
412
|
accountManager.removeQueuedRequest?.(requestInfo);
|
|
@@ -421,6 +426,9 @@ async function forwardRequest(
|
|
|
421
426
|
// (MaxListenersExceededWarning + leak); the recursive/resumed frame registers
|
|
422
427
|
// its own.
|
|
423
428
|
res.off('close', onClientClose);
|
|
429
|
+
// Token refresh failed — the request never reached Anthropic, so this is no
|
|
430
|
+
// evidence about upstream health. Relinquish rather than fail the probe.
|
|
431
|
+
accountManager.relinquishUpstreamProbe?.(lease);
|
|
424
432
|
accountManager.releaseAccount(lease);
|
|
425
433
|
excludedIndexes.add(account.index);
|
|
426
434
|
if (
|
|
@@ -563,6 +571,18 @@ async function forwardRequest(
|
|
|
563
571
|
}
|
|
564
572
|
accountManager.updateQuota(account.index, rateLimitHeaders);
|
|
565
573
|
|
|
574
|
+
// SETTLE THE SHARED BREAKER AT THE HEADER BOUNDARY. Anthropic ANSWERED, so unless
|
|
575
|
+
// the status is itself a capacity signal, the upstream is provably reachable and
|
|
576
|
+
// serving — a per-request verdict (400 bad transcript, 401, 404, 413, 422) says
|
|
577
|
+
// NOTHING about capacity and must never keep the fleet-wide throttle armed.
|
|
578
|
+
// Without this, a poisoned request (e.g. a provider-authored thinking block that
|
|
579
|
+
// Anthropic 400s) claimed the recovery probe, "failed" it, re-armed the shared
|
|
580
|
+
// throttle every 5s, and benched EVERY healthy account indefinitely — a hard
|
|
581
|
+
// production-down deadlock (2026-07-25: 6 of 13 re-arms were client-side 400s
|
|
582
|
+
// while accounts sat at 2%/11%/25% weekly). confirmUpstreamProbe also clears
|
|
583
|
+
// lease.upstreamThrottleProbe, so the releaseAccount probe branch below no-ops.
|
|
584
|
+
if (!isCapacitySignalStatus(upstreamRes.status)) accountManager.confirmUpstreamProbe?.(lease);
|
|
585
|
+
|
|
566
586
|
// Retry/failover can only happen before response bytes are sent. Once a
|
|
567
587
|
// streaming response starts, rerouting would corrupt Claude Code's stream.
|
|
568
588
|
if (upstreamRes.status === 429) {
|
|
@@ -872,6 +892,12 @@ async function forwardRequest(
|
|
|
872
892
|
// 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
|
|
873
893
|
// terminal — nothing bigger to fall to) so we can pin the session to Claude.
|
|
874
894
|
const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
|
|
895
|
+
// DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
|
|
896
|
+
// for the strip-and-recover retry below. Deliberately NOT the fuzzy
|
|
897
|
+
// isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
|
|
898
|
+
// us to rewrite a user's transcript.
|
|
899
|
+
const isSignatureRejection = account.type !== 'provider'
|
|
900
|
+
&& /invalid `signature` in `thinking`/i.test(errorBody);
|
|
875
901
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
876
902
|
? 'invalid_thinking_signature'
|
|
877
903
|
: anthropicIncompat ? 'anthropic_incompatible_transcript'
|
|
@@ -906,6 +932,26 @@ async function forwardRequest(
|
|
|
906
932
|
}
|
|
907
933
|
}
|
|
908
934
|
|
|
935
|
+
// RECOVER-ON-CLAUDE (preferred over the provider pin below): the transcript
|
|
936
|
+
// carries provider-authored thinking blocks whose signature Anthropic rejects.
|
|
937
|
+
// Strip exactly those blocks and retry on Claude, so a session that took even one
|
|
938
|
+
// GLM/Kimi fallback turn is NOT bricked (or exiled to a provider) for the rest of
|
|
939
|
+
// its life. Verified against the real API — the stripped history returns 200, with
|
|
940
|
+
// text + tool_use preserved. Tried once per request (thinkingStripped guard); if it
|
|
941
|
+
// still fails, the provider pin below is the fallback.
|
|
942
|
+
if (isSignatureRejection && !requestInfo.thinkingStripped
|
|
943
|
+
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
944
|
+
const { body: cleanBody, removed } = stripForeignThinkingBlocks(body);
|
|
945
|
+
if (cleanBody) {
|
|
946
|
+
console.log(`[Maxpool] Recovering session on Claude: stripped ${removed} provider-authored thinking block(s) Anthropic rejected`);
|
|
947
|
+
return forwardRequest(
|
|
948
|
+
req, res, cleanBody, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
949
|
+
retryConfig, queueConfig, { ...requestInfo, thinkingStripped: true },
|
|
950
|
+
canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
909
955
|
// React-and-heal: this transcript can't run on Claude (foreign server_tool_use
|
|
910
956
|
// id / thinking Anthropic can't validate). The 400 is pre-stream and the body
|
|
911
957
|
// is buffered, so latch the session Anthropic-incompatible (sticky → once per
|
|
@@ -951,13 +997,44 @@ async function forwardRequest(
|
|
|
951
997
|
);
|
|
952
998
|
}
|
|
953
999
|
|
|
1000
|
+
// Nothing could heal it — replace the cryptic upstream 400 with the real cause and
|
|
1001
|
+
// the actual way out. This is what the user saw for hours as a bare
|
|
1002
|
+
// "400 messages.51.content.8: Invalid `signature` in `thinking` block".
|
|
1003
|
+
// Trigger on the DETERMINISTIC signature rejection only — never the fuzzy
|
|
1004
|
+
// isAnthropicIncompatBody heuristic, so an unrelated malformed request keeps its
|
|
1005
|
+
// own upstream error instead of being mislabelled a provider-contamination.
|
|
1006
|
+
if (isSignatureRejection && !res.headersSent) {
|
|
1007
|
+
const provs = (accountManager.accounts || []).filter(a => a.type === 'provider');
|
|
1008
|
+
// Say only what ACTUALLY happened. `thinkingStripped` is set only when the strip
|
|
1009
|
+
// ran; without it we found nothing provider-shaped, so claiming we stripped —
|
|
1010
|
+
// or blaming GLM/Kimi — would misdirect the user.
|
|
1011
|
+
const what = requestInfo.thinkingStripped
|
|
1012
|
+
? 'This session ran on GLM/Kimi earlier and Anthropic rejects the thinking blocks they wrote. Maxpool removed them and retried on Claude, but Anthropic still rejected the history.'
|
|
1013
|
+
: "Anthropic rejected a thinking block in this session's history, and maxpool could not identify it as provider-authored, so it could not be repaired automatically.";
|
|
1014
|
+
const hint = provs.length === 0
|
|
1015
|
+
? ''
|
|
1016
|
+
: provs.every(a => a.enabled === false)
|
|
1017
|
+
? ' If this session previously ran on GLM/Kimi, re-enabling that provider in the maxpool TUI (a → t) lets it continue there.'
|
|
1018
|
+
: '';
|
|
1019
|
+
ctx.status = 400;
|
|
1020
|
+
sendErrorResponse(res, requestInfo, 400, {
|
|
1021
|
+
type: 'error',
|
|
1022
|
+
error: {
|
|
1023
|
+
type: 'invalid_request_error',
|
|
1024
|
+
message: `Start a new session to keep working — this one cannot continue. ${what}${hint}`,
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
954
1030
|
ctx.status = upstreamRes.status;
|
|
955
1031
|
sendErrorBody(res, requestInfo, upstreamRes.status, errorBody, upstreamRes.headers);
|
|
956
1032
|
return;
|
|
957
1033
|
}
|
|
958
1034
|
|
|
959
1035
|
if (upstreamRes.status < 400) {
|
|
960
|
-
|
|
1036
|
+
// (the probe was already settled at the header boundary above — this only
|
|
1037
|
+
// records the account-level "upstream accepted us" signal)
|
|
961
1038
|
accountManager.markUpstreamAccepted?.(account.index);
|
|
962
1039
|
}
|
|
963
1040
|
|
|
@@ -1257,7 +1334,7 @@ function isContextLengthError(errorBody) {
|
|
|
1257
1334
|
return /exceeded model token limit|maximum context length|context length exceeded|context window (?:size )?(?:exceeded|too)|prompt is too long|input is too long|reduce the length of|too many (?:input )?tokens|request too large/i.test(errorBody);
|
|
1258
1335
|
}
|
|
1259
1336
|
|
|
1260
|
-
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1337
|
+
export const __serverTest = { unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, isCapacitySignalStatus, isForeignThinkingSignature, stripForeignThinkingBlocks, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1261
1338
|
|
|
1262
1339
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1263
1340
|
if (!upstreamRes.body) return '';
|
|
@@ -1433,6 +1510,80 @@ function secondsUntilParsedTime(value) {
|
|
|
1433
1510
|
return null;
|
|
1434
1511
|
}
|
|
1435
1512
|
|
|
1513
|
+
// An Anthropic thinking `signature` is a long base64 protobuf blob (200+ chars, e.g.
|
|
1514
|
+
// "Eo8DCpQBCBAYAipAqutr+oAB…"). GLM's Anthropic-compatible endpoint emits a SHORT hex
|
|
1515
|
+
// digest instead (verified 2026-07-25: "8f8840affff743118e1f569d", 24 hex chars) — which
|
|
1516
|
+
// Anthropic rejects with `Invalid \`signature\` in \`thinking\` block`, permanently
|
|
1517
|
+
// bricking any session that took even ONE provider turn. Detect the foreign shape
|
|
1518
|
+
// conservatively: only a missing / short / pure-hex signature counts, so a genuine
|
|
1519
|
+
// Anthropic block is never stripped.
|
|
1520
|
+
function isForeignThinkingSignature(sig) {
|
|
1521
|
+
if (typeof sig !== 'string' || sig.length === 0) return true;
|
|
1522
|
+
if (sig.length < 60) return true;
|
|
1523
|
+
return /^[0-9a-f]+$/i.test(sig);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Recovery for a provider-contaminated transcript: drop the assistant `thinking` /
|
|
1528
|
+
* `redacted_thinking` blocks whose signature Anthropic can't validate, so the session
|
|
1529
|
+
* can CONTINUE ON CLAUDE instead of being pinned to a provider forever.
|
|
1530
|
+
*
|
|
1531
|
+
* Empirically verified 2026-07-25 against the real API: replaying a GLM-authored
|
|
1532
|
+
* thinking block to Anthropic returns 400; the SAME history with those blocks removed
|
|
1533
|
+
* returns 200 — including an assistant turn carrying `tool_use` followed by a
|
|
1534
|
+
* `tool_result` (thinking blocks are not required on replay for a new turn). Text and
|
|
1535
|
+
* tool_use blocks are preserved, so no conversation content or tool wiring is lost.
|
|
1536
|
+
*
|
|
1537
|
+
* Returns { body, removed } — `body` is null when nothing needed stripping.
|
|
1538
|
+
*/
|
|
1539
|
+
function stripForeignThinkingBlocks(body) {
|
|
1540
|
+
try {
|
|
1541
|
+
const json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf8') : String(body));
|
|
1542
|
+
if (!Array.isArray(json?.messages)) return { body: null, removed: 0 };
|
|
1543
|
+
let removed = 0;
|
|
1544
|
+
const messages = [];
|
|
1545
|
+
for (const msg of json.messages) {
|
|
1546
|
+
if (msg?.role !== 'assistant' || !Array.isArray(msg.content)) { messages.push(msg); continue; }
|
|
1547
|
+
let localRemoved = 0;
|
|
1548
|
+
const kept = msg.content.filter(block => {
|
|
1549
|
+
// ONLY `thinking`. NEVER `redacted_thinking`: a genuine one carries `data` and
|
|
1550
|
+
// legitimately has NO signature, so signature-based judging would strip 100% of
|
|
1551
|
+
// them (verified against the live API). GLM's contamination is always a
|
|
1552
|
+
// `thinking` block with a hex signature.
|
|
1553
|
+
if (block?.type !== 'thinking') return true;
|
|
1554
|
+
if (!isForeignThinkingSignature(block.signature)) return true;
|
|
1555
|
+
localRemoved++;
|
|
1556
|
+
return false;
|
|
1557
|
+
});
|
|
1558
|
+
removed += localRemoved;
|
|
1559
|
+
if (!localRemoved) { messages.push(msg); continue; }
|
|
1560
|
+
// A turn that stripping empties is DROPPED, not left with its poisoned block:
|
|
1561
|
+
// leaving it would resend the exact body that just 400'd while reporting success
|
|
1562
|
+
// and burning the single recovery attempt. Verified against the live API — the
|
|
1563
|
+
// resulting consecutive user messages are accepted (200 OK).
|
|
1564
|
+
if (kept.length === 0) continue;
|
|
1565
|
+
messages.push({ ...msg, content: kept });
|
|
1566
|
+
}
|
|
1567
|
+
if (!removed) return { body: null, removed: 0 };
|
|
1568
|
+
json.messages = messages;
|
|
1569
|
+
return { body: Buffer.from(JSON.stringify(json)), removed };
|
|
1570
|
+
} catch {
|
|
1571
|
+
return { body: null, removed: 0 }; // non-JSON / unparseable → no rewrite
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Is this status a CAPACITY signal (i.e. evidence the upstream can't serve us right
|
|
1577
|
+
* now), as opposed to a per-request verdict? Only these may keep the shared Anthropic
|
|
1578
|
+
* throttle armed. 403 is included deliberately: on these plans a 403 is almost always
|
|
1579
|
+
* quota/plan exhaustion, NOT bad credentials (see the provider-auth handler). 408 is a
|
|
1580
|
+
* latency/capacity signal. Everything else in 4xx (400/401/404/413/422…) means Anthropic
|
|
1581
|
+
* answered a specific request — the upstream is alive.
|
|
1582
|
+
*/
|
|
1583
|
+
function isCapacitySignalStatus(status) {
|
|
1584
|
+
return status === 429 || status === 403 || status === 408 || status >= 500;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1436
1587
|
function isRetriableUpstreamStatus(status) {
|
|
1437
1588
|
// 500 included: Anthropic 500s are transient server errors (same class as
|
|
1438
1589
|
// 502/503/504). Without this they were passed straight through to the client
|