maxpool 1.19.2 → 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 +102 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.2",
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".
@@ -1660,12 +1685,30 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1660
1685
  // phrasings, NOT a bare "token limit" which a rate-limit body also carries) so the
1661
1686
  // pin-to-Claude heal only fires on a genuine size overflow, not any 400. Rate-limit
1662
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
+
1663
1706
  function isContextLengthError(errorBody) {
1664
1707
  if (!errorBody) return false;
1665
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);
1666
1709
  }
1667
1710
 
1668
- 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, normalizeModelEcho };
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 };
1669
1712
 
1670
1713
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1671
1714
  if (!upstreamRes.body) return '';
@@ -2800,6 +2843,64 @@ function trimTrailingSlash(value) {
2800
2843
  return String(value).replace(/\/+$/, '');
2801
2844
  }
2802
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
+
2803
2904
  function rewriteBodyForAccount(body, account) {
2804
2905
  if (!body.length || (!account.model && !account.modelMap)) return body;
2805
2906