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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +132 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.1",
3
+ "version": "1.19.3",
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",
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
- if (!res.headersSent) res.writeHead(upstreamRes.status, responseHeaders);
1359
- res.end(buf);
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;