maxpool 1.19.1 → 1.19.3
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/server.js +132 -3
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -991,6 +991,9 @@ async function forwardRequest(
|
|
|
991
991
|
// 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
|
|
992
992
|
// terminal — nothing bigger to fall to) so we can pin the session to Claude.
|
|
993
993
|
const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
|
|
994
|
+
// Same shape as providerTooSmall: the PROVIDER cannot take this body, Claude can.
|
|
995
|
+
const providerRejectedShape = account.type === 'provider'
|
|
996
|
+
&& !providerTooSmall && isProviderParamRejection(errorBody);
|
|
994
997
|
// DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
|
|
995
998
|
// for the strip-and-recover retry below. Deliberately NOT the fuzzy
|
|
996
999
|
// isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
|
|
@@ -1016,11 +1019,16 @@ async function forwardRequest(
|
|
|
1016
1019
|
try { return JSON.parse(errorBody)?.error?.message || errorBody; } catch { return errorBody; }
|
|
1017
1020
|
})();
|
|
1018
1021
|
console.log(`[Maxpool] ${upstreamRes.status} from "${account.name}": ${String(why).slice(0, 300)}`);
|
|
1022
|
+
// Providers answer with a code and no field name, so record what WE sent.
|
|
1023
|
+
if (account.type === 'provider') {
|
|
1024
|
+
console.log(`[Maxpool] request shape: ${describeBodyShape(upstreamBody || body).slice(0, 600)}`);
|
|
1025
|
+
}
|
|
1019
1026
|
}
|
|
1020
1027
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
1021
1028
|
? 'invalid_thinking_signature'
|
|
1022
1029
|
: anthropicIncompat ? 'anthropic_incompatible_transcript'
|
|
1023
1030
|
: providerTooSmall ? 'provider_context_too_small'
|
|
1031
|
+
: providerRejectedShape ? 'provider_rejected_request_shape'
|
|
1024
1032
|
: `HTTP ${upstreamRes.status}`;
|
|
1025
1033
|
const effortMode = classifyEffortRejection(errorBody);
|
|
1026
1034
|
// A rejected effort level is a REQUEST-shaped fault, not an account-health signal —
|
|
@@ -1195,6 +1203,23 @@ async function forwardRequest(
|
|
|
1195
1203
|
);
|
|
1196
1204
|
}
|
|
1197
1205
|
|
|
1206
|
+
// A provider that will not take this body: retry on Claude instead of handing the
|
|
1207
|
+
// user an opaque provider code. Unlike the context-too-small case this does NOT
|
|
1208
|
+
// latch the session — the fault is one request's shape, not a durable property of
|
|
1209
|
+
// the conversation, and latching would evict a session from GLM on a single blip.
|
|
1210
|
+
if (providerRejectedShape && claudeAvailable
|
|
1211
|
+
&& canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
|
|
1212
|
+
for (const a of (accountManager.accounts || [])) {
|
|
1213
|
+
if (a.type === 'provider') excludedIndexes.add(a.index);
|
|
1214
|
+
}
|
|
1215
|
+
console.log(`[Maxpool] Provider "${account.name}" rejected this request's shape (${String(errorBody).slice(0, 120)}); retrying on Claude`);
|
|
1216
|
+
return forwardRequest(
|
|
1217
|
+
req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
|
|
1218
|
+
retryConfig, queueConfig, requestInfo,
|
|
1219
|
+
canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1198
1223
|
// Nothing could heal it — replace the cryptic upstream 400 with the real cause and
|
|
1199
1224
|
// the actual way out. This is what the user saw for hours as a bare
|
|
1200
1225
|
// "400 messages.51.content.8: Invalid `signature` in `thinking` block".
|
|
@@ -1355,8 +1380,16 @@ async function forwardRequest(
|
|
|
1355
1380
|
res.end();
|
|
1356
1381
|
}
|
|
1357
1382
|
} else {
|
|
1358
|
-
|
|
1359
|
-
|
|
1383
|
+
// MODEL ECHO: rewrite the provider's model id back to the client's before the
|
|
1384
|
+
// body reaches Claude Code. Computed BEFORE writeHead so a length change lands
|
|
1385
|
+
// in the SAME header block — setting content-length after writeHead is a no-op
|
|
1386
|
+
// and would ship a body/length mismatch (the client then hangs or truncates).
|
|
1387
|
+
const outBuf = normalizeModelEcho(buf, requestInfo.model);
|
|
1388
|
+
const outHeaders = outBuf === buf
|
|
1389
|
+
? responseHeaders
|
|
1390
|
+
: { ...responseHeaders, 'content-length': String(outBuf.length) };
|
|
1391
|
+
if (!res.headersSent) res.writeHead(upstreamRes.status, outHeaders);
|
|
1392
|
+
res.end(outBuf);
|
|
1360
1393
|
}
|
|
1361
1394
|
}
|
|
1362
1395
|
} catch (err) {
|
|
@@ -1652,12 +1685,30 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1652
1685
|
// phrasings, NOT a bare "token limit" which a rate-limit body also carries) so the
|
|
1653
1686
|
// pin-to-Claude heal only fires on a genuine size overflow, not any 400. Rate-limit
|
|
1654
1687
|
// 429s are intercepted earlier (classifyRateLimit) and never reach this check.
|
|
1688
|
+
/** A PROVIDER rejecting the request shape with a code and no field name.
|
|
1689
|
+
*
|
|
1690
|
+
* z.ai answers `[1210][Invalid API parameter, please check the documentation.]` —
|
|
1691
|
+
* a code family, not one fault: probing it on 2026-09-09 showed `max_tokens` too
|
|
1692
|
+
* large gets its own 1210 text, while other members return only the generic line.
|
|
1693
|
+
* Claude Code does not emit malformed requests, so on a provider this means "this
|
|
1694
|
+
* provider will not take a body Anthropic accepts", which is the same class as
|
|
1695
|
+
* `isContextLengthError` — repairable by moving the request to Claude, not by
|
|
1696
|
+
* surfacing a 400 the user can do nothing with.
|
|
1697
|
+
*
|
|
1698
|
+
* Matched by CODE, never by prose: an error whose message names its field (Anthropic's
|
|
1699
|
+
* own 400s do) is a real client fault and keeps its own clear message.
|
|
1700
|
+
*/
|
|
1701
|
+
function isProviderParamRejection(errorBody) {
|
|
1702
|
+
if (!errorBody) return false;
|
|
1703
|
+
return /\[1210\]|"code"\s*:\s*"?1210"?/.test(errorBody);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1655
1706
|
function isContextLengthError(errorBody) {
|
|
1656
1707
|
if (!errorBody) return false;
|
|
1657
1708
|
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);
|
|
1658
1709
|
}
|
|
1659
1710
|
|
|
1660
|
-
export const __serverTest = { reanchorOrphanedSystemMessages, unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, classifyEffortRejection, repairEffort, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, parseRejectedBlockPath, stripRejectedBlockClass, peekRejectedBlockType, describeRejectedBlock, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, streamResponse, startIdleRequestReaper };
|
|
1711
|
+
export const __serverTest = { reanchorOrphanedSystemMessages, unavailableMessage, computeQueueWindowMs, isRetriableUpstreamStatus, classifyEffortRejection, repairEffort, isCapacitySignalStatus, isStrippableThinkingBlock, stripForeignThinkingBlocks, parseRejectedBlockPath, stripRejectedBlockClass, peekRejectedBlockType, describeRejectedBlock, headerValue, getMaxpoolProfile, ensureQueueHeartbeat, clearQueueHeartbeat, commitStreamGraceHeartbeat, describeRequest, classifyRateLimit, detectTranscriptOrigin, isAnthropicIncompatBody, isContextLengthError, isProviderParamRejection, describeBodyShape, streamResponse, startIdleRequestReaper, normalizeModelEcho };
|
|
1661
1712
|
|
|
1662
1713
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1663
1714
|
if (!upstreamRes.body) return '';
|
|
@@ -2792,6 +2843,64 @@ function trimTrailingSlash(value) {
|
|
|
2792
2843
|
return String(value).replace(/\/+$/, '');
|
|
2793
2844
|
}
|
|
2794
2845
|
|
|
2846
|
+
/** Compact, CONTENT-FREE shape of a request body, for diagnosing a provider 4xx.
|
|
2847
|
+
* Carries no message text, tool input, or system prompt — only structure: which
|
|
2848
|
+
* fields were sent and how large they were.
|
|
2849
|
+
*
|
|
2850
|
+
* WHY (2026-09-09): z.ai answers a bad request with `[1210][Invalid API parameter,
|
|
2851
|
+
* please check the documentation.]` and does NOT name the field. Two of those reached
|
|
2852
|
+
* a session on 09-09 and nothing on disk could say which parameter was at fault —
|
|
2853
|
+
* `config.logDir` writes the whole body, so it stays off, and the log line recorded
|
|
2854
|
+
* only the provider's own unhelpful message. Probing z.ai showed 1210 is a FAMILY
|
|
2855
|
+
* (`max_tokens` illegal is one member, each with its own text), so the field has to
|
|
2856
|
+
* come from our side of the call.
|
|
2857
|
+
*/
|
|
2858
|
+
function describeBodyShape(buf) {
|
|
2859
|
+
try {
|
|
2860
|
+
const j = JSON.parse(buf.toString('utf8'));
|
|
2861
|
+
if (!j || typeof j !== 'object') return 'non-object body';
|
|
2862
|
+
const parts = [];
|
|
2863
|
+
parts.push(`bytes=${buf.length}`);
|
|
2864
|
+
parts.push(`keys=[${Object.keys(j).sort().join(',')}]`);
|
|
2865
|
+
if (j.model) parts.push(`model=${j.model}`);
|
|
2866
|
+
if (j.max_tokens !== undefined) parts.push(`max_tokens=${j.max_tokens}`);
|
|
2867
|
+
if (j.stream !== undefined) parts.push(`stream=${j.stream}`);
|
|
2868
|
+
if (j.thinking) parts.push(`thinking=${j.thinking.type}/${j.thinking.budget_tokens}`);
|
|
2869
|
+
if (j.system !== undefined) {
|
|
2870
|
+
parts.push(Array.isArray(j.system)
|
|
2871
|
+
? `system=blocks(${j.system.length})`
|
|
2872
|
+
: `system=str(${String(j.system).length})`);
|
|
2873
|
+
}
|
|
2874
|
+
if (Array.isArray(j.tools)) parts.push(`tools=${j.tools.length}`);
|
|
2875
|
+
if (j.tool_choice) parts.push(`tool_choice=${j.tool_choice.type}`);
|
|
2876
|
+
let cacheMarks = 0, emptyBlocks = 0;
|
|
2877
|
+
if (Array.isArray(j.messages)) {
|
|
2878
|
+
const shapes = j.messages.map(m => {
|
|
2879
|
+
const c = m?.content;
|
|
2880
|
+
if (typeof c === 'string') { if (!c.length) emptyBlocks++; return `${m.role}:str(${c.length})`; }
|
|
2881
|
+
if (!Array.isArray(c)) return `${m.role}:?`;
|
|
2882
|
+
if (!c.length) emptyBlocks++;
|
|
2883
|
+
const types = {};
|
|
2884
|
+
for (const b of c) {
|
|
2885
|
+
const t = b?.type || '?';
|
|
2886
|
+
types[t] = (types[t] || 0) + 1;
|
|
2887
|
+
if (b?.cache_control) cacheMarks++;
|
|
2888
|
+
if (t === 'text' && !String(b.text || '').length) emptyBlocks++;
|
|
2889
|
+
}
|
|
2890
|
+
return `${m.role}:${Object.entries(types).map(([t, n]) => n > 1 ? `${t}x${n}` : t).join('+')}`;
|
|
2891
|
+
});
|
|
2892
|
+
parts.push(`msgs=${j.messages.length}`);
|
|
2893
|
+
// Only the tail: a long transcript's head is never the new thing that broke.
|
|
2894
|
+
parts.push(`tail=[${shapes.slice(-4).join(' ')}]`);
|
|
2895
|
+
}
|
|
2896
|
+
if (cacheMarks) parts.push(`cache_control=${cacheMarks}`);
|
|
2897
|
+
if (emptyBlocks) parts.push(`EMPTY_BLOCKS=${emptyBlocks}`);
|
|
2898
|
+
return parts.join(' ');
|
|
2899
|
+
} catch {
|
|
2900
|
+
return 'unparseable body';
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2795
2904
|
function rewriteBodyForAccount(body, account) {
|
|
2796
2905
|
if (!body.length || (!account.model && !account.modelMap)) return body;
|
|
2797
2906
|
|
|
@@ -2857,6 +2966,26 @@ function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setInterva
|
|
|
2857
2966
|
}
|
|
2858
2967
|
|
|
2859
2968
|
|
|
2969
|
+
/** Rewrite a non-streaming JSON body's `model` back to the CLIENT'S requested model.
|
|
2970
|
+
* Twin of the SSE model-echo normalization in streamResponse: provider upstreams
|
|
2971
|
+
* (z.ai/GLM, Kimi) echo their own id, Claude Code persists it, and a resumed session
|
|
2972
|
+
* then prints "Session model glm-5.3 could not be restored". The streaming half
|
|
2973
|
+
* shipped in v1.19.1; this covers the buffered path, which is where the remaining
|
|
2974
|
+
* 5,719 contaminated turns came from (measured 2026-09-02). No-op when the ids
|
|
2975
|
+
* already match, when the body is not JSON, or when no client model is known. */
|
|
2976
|
+
function normalizeModelEcho(buf, clientModel) {
|
|
2977
|
+
if (!clientModel || !buf?.length) return buf;
|
|
2978
|
+
try {
|
|
2979
|
+
const json = JSON.parse(buf.toString('utf8'));
|
|
2980
|
+
if (!json || typeof json !== 'object' || typeof json.model !== 'string') return buf;
|
|
2981
|
+
if (json.model === clientModel) return buf;
|
|
2982
|
+
json.model = clientModel;
|
|
2983
|
+
return Buffer.from(JSON.stringify(json), 'utf8');
|
|
2984
|
+
} catch {
|
|
2985
|
+
return buf; // non-JSON (or truncated) body — forward untouched
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2860
2989
|
function concatUint8(chunks) {
|
|
2861
2990
|
let n = 0;
|
|
2862
2991
|
for (const c of chunks) n += c.length;
|