maxpool 1.19.2 → 1.19.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.2",
3
+ "version": "1.19.4",
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
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import { ThreadOwners, readThreadIntent, threadRefusalBody } from './thread-gate.js';
2
3
  import { writeFile, mkdir } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { modelFamily } from './oauth.js';
@@ -586,6 +587,24 @@ async function forwardRequest(
586
587
  const method = req.method;
587
588
  const upstreamBody = rewriteBodyForAccount(body, account);
588
589
 
590
+ // A threaded follow-up routed to an account that does not hold the thread cannot be
591
+ // served by it: a different Anthropic account 404s, a provider rejects the truncated
592
+ // transcript. Hand the client the signal it already knows how to act on — it resends
593
+ // the turn stateless and stops threading for the session — instead of letting the
594
+ // upstream produce an error the user sees.
595
+ const threadIntent = THREAD_GATE_ENABLED ? readThreadIntent(body) : { kind: 'none' };
596
+ if (THREAD_GATE_ENABLED && threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent)) {
597
+ threadOwners.noteRefused(requestInfo.sessionKey);
598
+ accountManager.releaseAccount(lease, { neutral: true });
599
+ console.log(`[Maxpool] thread not held by "${account.name}" — asking the client to resend this turn stateless [sess ${String(requestInfo.sessionKey || '?').slice(0, 8)}]`);
600
+ ctx.status = 400;
601
+ sendErrorResponse(res, requestInfo, 400, threadRefusalBody(account.name));
602
+ return;
603
+ }
604
+ // This account is about to serve the turn, so it holds the thread from here on.
605
+ // Recorded optimistically: if the turn fails, the next one is refused anyway.
606
+ if (THREAD_GATE_ENABLED) threadOwners.noteServed(requestInfo.sessionKey, account.name, threadIntent);
607
+
589
608
  // Build log sections
590
609
  const logSections = [];
591
610
  if (logDir) {
@@ -991,6 +1010,9 @@ async function forwardRequest(
991
1010
  // 262144". Detect it ONLY on a provider (a Claude account's context-length 400 is
992
1011
  // terminal — nothing bigger to fall to) so we can pin the session to Claude.
993
1012
  const providerTooSmall = account.type === 'provider' && isContextLengthError(errorBody);
1013
+ // Same shape as providerTooSmall: the PROVIDER cannot take this body, Claude can.
1014
+ const providerRejectedShape = account.type === 'provider'
1015
+ && !providerTooSmall && isProviderParamRejection(errorBody);
994
1016
  // DETERMINISTIC signature rejection (exact Anthropic wording) — the only trigger
995
1017
  // for the strip-and-recover retry below. Deliberately NOT the fuzzy
996
1018
  // isAnthropicIncompatBody heuristic, so a merely malformed request can never cause
@@ -1016,11 +1038,16 @@ async function forwardRequest(
1016
1038
  try { return JSON.parse(errorBody)?.error?.message || errorBody; } catch { return errorBody; }
1017
1039
  })();
1018
1040
  console.log(`[Maxpool] ${upstreamRes.status} from "${account.name}": ${String(why).slice(0, 300)}`);
1041
+ // Providers answer with a code and no field name, so record what WE sent.
1042
+ if (account.type === 'provider') {
1043
+ console.log(`[Maxpool] request shape: ${describeBodyShape(upstreamBody || body).slice(0, 600)}`);
1044
+ }
1019
1045
  }
1020
1046
  const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
1021
1047
  ? 'invalid_thinking_signature'
1022
1048
  : anthropicIncompat ? 'anthropic_incompatible_transcript'
1023
1049
  : providerTooSmall ? 'provider_context_too_small'
1050
+ : providerRejectedShape ? 'provider_rejected_request_shape'
1024
1051
  : `HTTP ${upstreamRes.status}`;
1025
1052
  const effortMode = classifyEffortRejection(errorBody);
1026
1053
  // A rejected effort level is a REQUEST-shaped fault, not an account-health signal —
@@ -1195,6 +1222,23 @@ async function forwardRequest(
1195
1222
  );
1196
1223
  }
1197
1224
 
1225
+ // A provider that will not take this body: retry on Claude instead of handing the
1226
+ // user an opaque provider code. Unlike the context-too-small case this does NOT
1227
+ // latch the session — the fault is one request's shape, not a durable property of
1228
+ // the conversation, and latching would evict a session from GLM on a single blip.
1229
+ if (providerRejectedShape && claudeAvailable
1230
+ && canRetryBufferedBody && retryCount + 1 < maxAttempts && !res.headersSent) {
1231
+ for (const a of (accountManager.accounts || [])) {
1232
+ if (a.type === 'provider') excludedIndexes.add(a.index);
1233
+ }
1234
+ console.log(`[Maxpool] Provider "${account.name}" rejected this request's shape (${String(errorBody).slice(0, 120)}); retrying on Claude`);
1235
+ return forwardRequest(
1236
+ req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
1237
+ retryConfig, queueConfig, requestInfo,
1238
+ canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
1239
+ );
1240
+ }
1241
+
1198
1242
  // Nothing could heal it — replace the cryptic upstream 400 with the real cause and
1199
1243
  // the actual way out. This is what the user saw for hours as a bare
1200
1244
  // "400 messages.51.content.8: Invalid `signature` in `thinking` block".
@@ -1660,12 +1704,30 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
1660
1704
  // phrasings, NOT a bare "token limit" which a rate-limit body also carries) so the
1661
1705
  // pin-to-Claude heal only fires on a genuine size overflow, not any 400. Rate-limit
1662
1706
  // 429s are intercepted earlier (classifyRateLimit) and never reach this check.
1707
+ /** A PROVIDER rejecting the request shape with a code and no field name.
1708
+ *
1709
+ * z.ai answers `[1210][Invalid API parameter, please check the documentation.]` —
1710
+ * a code family, not one fault: probing it on 2026-09-09 showed `max_tokens` too
1711
+ * large gets its own 1210 text, while other members return only the generic line.
1712
+ * Claude Code does not emit malformed requests, so on a provider this means "this
1713
+ * provider will not take a body Anthropic accepts", which is the same class as
1714
+ * `isContextLengthError` — repairable by moving the request to Claude, not by
1715
+ * surfacing a 400 the user can do nothing with.
1716
+ *
1717
+ * Matched by CODE, never by prose: an error whose message names its field (Anthropic's
1718
+ * own 400s do) is a real client fault and keeps its own clear message.
1719
+ */
1720
+ function isProviderParamRejection(errorBody) {
1721
+ if (!errorBody) return false;
1722
+ return /\[1210\]|"code"\s*:\s*"?1210"?/.test(errorBody);
1723
+ }
1724
+
1663
1725
  function isContextLengthError(errorBody) {
1664
1726
  if (!errorBody) return false;
1665
1727
  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
1728
  }
1667
1729
 
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 };
1730
+ 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
1731
 
1670
1732
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1671
1733
  if (!upstreamRes.body) return '';
@@ -2800,6 +2862,70 @@ function trimTrailingSlash(value) {
2800
2862
  return String(value).replace(/\/+$/, '');
2801
2863
  }
2802
2864
 
2865
+ /** Compact, CONTENT-FREE shape of a request body, for diagnosing a provider 4xx.
2866
+ * Carries no message text, tool input, or system prompt — only structure: which
2867
+ * fields were sent and how large they were.
2868
+ *
2869
+ * WHY (2026-09-09): z.ai answers a bad request with `[1210][Invalid API parameter,
2870
+ * please check the documentation.]` and does NOT name the field. Two of those reached
2871
+ * a session on 09-09 and nothing on disk could say which parameter was at fault —
2872
+ * `config.logDir` writes the whole body, so it stays off, and the log line recorded
2873
+ * only the provider's own unhelpful message. Probing z.ai showed 1210 is a FAMILY
2874
+ * (`max_tokens` illegal is one member, each with its own text), so the field has to
2875
+ * come from our side of the call.
2876
+ */
2877
+ function describeBodyShape(buf) {
2878
+ try {
2879
+ const j = JSON.parse(buf.toString('utf8'));
2880
+ if (!j || typeof j !== 'object') return 'non-object body';
2881
+ const parts = [];
2882
+ parts.push(`bytes=${buf.length}`);
2883
+ parts.push(`keys=[${Object.keys(j).sort().join(',')}]`);
2884
+ if (j.model) parts.push(`model=${j.model}`);
2885
+ if (j.max_tokens !== undefined) parts.push(`max_tokens=${j.max_tokens}`);
2886
+ if (j.stream !== undefined) parts.push(`stream=${j.stream}`);
2887
+ if (j.thinking) parts.push(`thinking=${j.thinking.type}/${j.thinking.budget_tokens}`);
2888
+ if (j.system !== undefined) {
2889
+ parts.push(Array.isArray(j.system)
2890
+ ? `system=blocks(${j.system.length})`
2891
+ : `system=str(${String(j.system).length})`);
2892
+ }
2893
+ if (Array.isArray(j.tools)) parts.push(`tools=${j.tools.length}`);
2894
+ if (j.tool_choice) parts.push(`tool_choice=${j.tool_choice.type}`);
2895
+ let cacheMarks = 0, emptyBlocks = 0;
2896
+ if (Array.isArray(j.messages)) {
2897
+ const shapes = j.messages.map(m => {
2898
+ const c = m?.content;
2899
+ if (typeof c === 'string') { if (!c.length) emptyBlocks++; return `${m.role}:str(${c.length})`; }
2900
+ if (!Array.isArray(c)) return `${m.role}:?`;
2901
+ if (!c.length) emptyBlocks++;
2902
+ const types = {};
2903
+ for (const b of c) {
2904
+ const t = b?.type || '?';
2905
+ types[t] = (types[t] || 0) + 1;
2906
+ if (b?.cache_control) cacheMarks++;
2907
+ if (t === 'text' && !String(b.text || '').length) emptyBlocks++;
2908
+ }
2909
+ return `${m.role}:${Object.entries(types).map(([t, n]) => n > 1 ? `${t}x${n}` : t).join('+')}`;
2910
+ });
2911
+ parts.push(`msgs=${j.messages.length}`);
2912
+ // Only the tail: a long transcript's head is never the new thing that broke.
2913
+ parts.push(`tail=[${shapes.slice(-4).join(' ')}]`);
2914
+ }
2915
+ if (cacheMarks) parts.push(`cache_control=${cacheMarks}`);
2916
+ if (emptyBlocks) parts.push(`EMPTY_BLOCKS=${emptyBlocks}`);
2917
+ return parts.join(' ');
2918
+ } catch {
2919
+ return 'unparseable body';
2920
+ }
2921
+ }
2922
+
2923
+ // THREAD GATE (2026-09-11). Runs AFTER routing has chosen, so it never influences the
2924
+ // choice — it only decides what to say to the account that was picked. See
2925
+ // src/thread-gate.js for why this replaces rebuilding the transcript.
2926
+ const threadOwners = new ThreadOwners();
2927
+ const THREAD_GATE_ENABLED = process.env.MAXPOOL_THREAD_GATE !== '0';
2928
+
2803
2929
  function rewriteBodyForAccount(body, account) {
2804
2930
  if (!body.length || (!account.model && !account.modelMap)) return body;
2805
2931
 
@@ -0,0 +1,107 @@
1
+ // Thread gate — let Claude Code fall back to stateless when maxpool routed a threaded
2
+ // turn somewhere that cannot serve it.
3
+ //
4
+ // WHY (2026-09-10). Claude Code >= 2.1.265 keeps the conversation on Anthropic's servers
5
+ // and sends only the tail plus `thread:{type:"continue", previous_message_id}`. That
6
+ // removed the self-containment every maxpool routing capability rests on: a different
7
+ // Anthropic account 404s ("No thread state was found"), and GLM/Kimi reject a transcript
8
+ // that opens mid-tool-call (z.ai `[1214]`). Measured: 10 of 12 live requests carry it.
9
+ //
10
+ // HOW. We do NOT rebuild the transcript. The client already knows how to fall back, and
11
+ // Anthropic built the signal for exactly this case — a proxy that cannot honour threads.
12
+ // A 400 carrying `error.details.error_code = "thread_unsupported_request"` makes the
13
+ // client resend that turn stateless AND stop using threads for that agent+model for the
14
+ // rest of the session. So the client replays with the transcript it already holds; we
15
+ // never reuse a thread reference and therefore can never serve a stale conversation.
16
+ //
17
+ // Routing is NOT consulted or constrained. This runs after the account has been chosen;
18
+ // it only decides what to say to it.
19
+
20
+ export const THREAD_UNSUPPORTED_CODE = 'thread_unsupported_request';
21
+
22
+ /** What kind of thread intent a request body carries. Cheap: only the head of the body
23
+ * is JSON-parsed, and a non-JSON body is simply 'none'. */
24
+ export function readThreadIntent(body) {
25
+ try {
26
+ const j = JSON.parse(body.toString('utf8'));
27
+ const t = j?.thread;
28
+ if (!t || typeof t !== 'object') {
29
+ // `previous_message_id` can also ride in `diagnostics`; that alone is not a thread.
30
+ return { kind: 'none' };
31
+ }
32
+ if (t.type === 'continue') return { kind: 'continue', previousMessageId: t.previous_message_id || null };
33
+ if (t.type === 'create') return { kind: 'create' };
34
+ return { kind: 'none' };
35
+ } catch {
36
+ return { kind: 'none' };
37
+ }
38
+ }
39
+
40
+ /** The exact body the client's classifier reads. `details.error_code` is the field it
41
+ * keys on; the message is free text and is never shown to a person. */
42
+ export function threadRefusalBody(accountName) {
43
+ return {
44
+ type: 'error',
45
+ error: {
46
+ type: 'invalid_request_error',
47
+ message: `maxpool routed this turn to "${accountName}", which does not hold this thread. Resend it stateless.`,
48
+ details: { error_code: THREAD_UNSUPPORTED_CODE },
49
+ },
50
+ };
51
+ }
52
+
53
+ // Sessions whose last threaded turn we served, and how many times we have refused them.
54
+ // Two short strings per entry; bounded and LRU-evicted.
55
+ const MAX_SESSIONS = 500;
56
+ // A session that keeps sending threaded turns after being refused is one whose client
57
+ // did NOT take the downgrade (a different agent id, a model switch, an older build).
58
+ // Refusing forever would double its request volume, so stop and forward instead.
59
+ const MAX_CONSECUTIVE_REFUSALS = 2;
60
+
61
+ export class ThreadOwners {
62
+ constructor({ maxSessions = MAX_SESSIONS, maxRefusals = MAX_CONSECUTIVE_REFUSALS } = {}) {
63
+ this.map = new Map(); // sessionKey -> { owner, refusals }
64
+ this.maxSessions = maxSessions;
65
+ this.maxRefusals = maxRefusals;
66
+ }
67
+
68
+ _touch(key) {
69
+ const v = this.map.get(key);
70
+ if (v !== undefined) { this.map.delete(key); this.map.set(key, v); } // LRU bump
71
+ return v;
72
+ }
73
+
74
+ _set(key, value) {
75
+ this.map.delete(key);
76
+ this.map.set(key, value);
77
+ while (this.map.size > this.maxSessions) this.map.delete(this.map.keys().next().value);
78
+ }
79
+
80
+ /** Decide AFTER routing has chosen. Returns true only for a `continue` turn that the
81
+ * chosen account cannot serve, and only while refusals are still under the bound. */
82
+ shouldRefuse(sessionKey, accountName, intent) {
83
+ if (!sessionKey || !accountName) return false;
84
+ if (intent?.kind !== 'continue') return false; // `create` carries the full transcript
85
+ const entry = this._touch(sessionKey);
86
+ if (entry && entry.owner === accountName) return false; // the account that holds it
87
+ if (entry && entry.refusals >= this.maxRefusals) return false; // bounded fail-open
88
+ return true;
89
+ }
90
+
91
+ /** Record a refusal we are about to emit. */
92
+ noteRefused(sessionKey) {
93
+ if (!sessionKey) return;
94
+ const entry = this._touch(sessionKey) || { owner: null, refusals: 0 };
95
+ this._set(sessionKey, { owner: entry.owner, refusals: entry.refusals + 1 });
96
+ }
97
+
98
+ /** Record that `accountName` served a threaded turn for this session — it now holds
99
+ * the thread. Any refusal streak ends here. */
100
+ noteServed(sessionKey, accountName, intent) {
101
+ if (!sessionKey || !accountName) return;
102
+ if (intent?.kind !== 'create' && intent?.kind !== 'continue') return;
103
+ this._set(sessionKey, { owner: accountName, refusals: 0 });
104
+ }
105
+
106
+ get size() { return this.map.size; }
107
+ }