maxpool 1.19.0 → 1.19.2

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 +105 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.0",
3
+ "version": "1.19.2",
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
@@ -1355,8 +1355,16 @@ async function forwardRequest(
1355
1355
  res.end();
1356
1356
  }
1357
1357
  } else {
1358
- if (!res.headersSent) res.writeHead(upstreamRes.status, responseHeaders);
1359
- res.end(buf);
1358
+ // MODEL ECHO: rewrite the provider's model id back to the client's before the
1359
+ // body reaches Claude Code. Computed BEFORE writeHead so a length change lands
1360
+ // in the SAME header block — setting content-length after writeHead is a no-op
1361
+ // and would ship a body/length mismatch (the client then hangs or truncates).
1362
+ const outBuf = normalizeModelEcho(buf, requestInfo.model);
1363
+ const outHeaders = outBuf === buf
1364
+ ? responseHeaders
1365
+ : { ...responseHeaders, 'content-length': String(outBuf.length) };
1366
+ if (!res.headersSent) res.writeHead(upstreamRes.status, outHeaders);
1367
+ res.end(outBuf);
1360
1368
  }
1361
1369
  }
1362
1370
  } catch (err) {
@@ -1657,7 +1665,7 @@ function isContextLengthError(errorBody) {
1657
1665
  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
1666
  }
1659
1667
 
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 };
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 };
1661
1669
 
1662
1670
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1663
1671
  if (!upstreamRes.body) return '';
@@ -2856,6 +2864,41 @@ function startIdleRequestReaper(res, reqId, idleMs, { now = Date.now, setInterva
2856
2864
  return timer;
2857
2865
  }
2858
2866
 
2867
+
2868
+ /** Rewrite a non-streaming JSON body's `model` back to the CLIENT'S requested model.
2869
+ * Twin of the SSE model-echo normalization in streamResponse: provider upstreams
2870
+ * (z.ai/GLM, Kimi) echo their own id, Claude Code persists it, and a resumed session
2871
+ * then prints "Session model glm-5.3 could not be restored". The streaming half
2872
+ * shipped in v1.19.1; this covers the buffered path, which is where the remaining
2873
+ * 5,719 contaminated turns came from (measured 2026-09-02). No-op when the ids
2874
+ * already match, when the body is not JSON, or when no client model is known. */
2875
+ function normalizeModelEcho(buf, clientModel) {
2876
+ if (!clientModel || !buf?.length) return buf;
2877
+ try {
2878
+ const json = JSON.parse(buf.toString('utf8'));
2879
+ if (!json || typeof json !== 'object' || typeof json.model !== 'string') return buf;
2880
+ if (json.model === clientModel) return buf;
2881
+ json.model = clientModel;
2882
+ return Buffer.from(JSON.stringify(json), 'utf8');
2883
+ } catch {
2884
+ return buf; // non-JSON (or truncated) body — forward untouched
2885
+ }
2886
+ }
2887
+
2888
+ function concatUint8(chunks) {
2889
+ let n = 0;
2890
+ for (const c of chunks) n += c.length;
2891
+ const out = new Uint8Array(n);
2892
+ let o = 0;
2893
+ for (const c of chunks) { out.set(c, o); o += c.length; }
2894
+ return out;
2895
+ }
2896
+ function totalLen(chunks) {
2897
+ let n = 0;
2898
+ for (const c of chunks) n += c.length;
2899
+ return n;
2900
+ }
2901
+
2859
2902
  async function streamResponse(webStream, res, status, responseHeaders, accountIndex, accountManager, streamLog, requestInfo = {}, idleMs = STREAM_IDLE_MS) {
2860
2903
  const reader = webStream.getReader();
2861
2904
  const decoder = new TextDecoder();
@@ -2880,6 +2923,10 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
2880
2923
  const onClose = () => { reader.cancel().catch(() => {}); };
2881
2924
  res.once('close', onClose);
2882
2925
 
2926
+ // MODEL ECHO NORMALIZATION state (rationale at the write site).
2927
+ let modelEchoPending = true;
2928
+ let modelEchoBuffer = null;
2929
+
2883
2930
  try {
2884
2931
  while (true) {
2885
2932
  // Idle guard: a half-open upstream (headers, then silence, never closes) would
@@ -2915,10 +2962,44 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
2915
2962
  committed = true;
2916
2963
  }
2917
2964
 
2965
+ // MODEL ECHO NORMALIZATION (2026-08-31): provider upstreams echo their own model
2966
+ // id ("glm-5.3", kimi-*) in message_start, and Claude Code persists that id into
2967
+ // the session transcript — on resume the id fails model-family resolution and the
2968
+ // session prints "Session model ... could not be restored (not a model this
2969
+ // version of Claude Code recognizes)" (278 sessions affected, measured).
2970
+ // Rewrite the model field back to the CLIENT'S requested model (requestInfo.model,
2971
+ // captured before the per-account rewrite) inside the first complete SSE event
2972
+ // carrying a "model" key; afterwards chunks pass through untouched. ReadableStream
2973
+ // chunks are Uint8Array — Buffer.concat/toString would yield comma-joined byte
2974
+ // numbers (caught by the normalization tests), so hold an array of chunks and
2975
+ // concat with decoder-safe helpers.
2976
+ let out = value;
2977
+ if (requestInfo?.model && modelEchoPending) {
2978
+ modelEchoBuffer = modelEchoBuffer ? [...modelEchoBuffer, value] : [value];
2979
+ const s = decoder.decode(concatUint8(modelEchoBuffer));
2980
+ if (s.includes('"model"') && s.includes('\n\n')) {
2981
+ const normalized = s.replace(
2982
+ /("model":")[^"]+(")/,
2983
+ `$1${requestInfo.model.replace(/["\\]/g, '\\$&')}$2`,
2984
+ );
2985
+ out = Buffer.from(normalized, 'utf8');
2986
+ modelEchoBuffer = null;
2987
+ modelEchoPending = false;
2988
+ } else if (totalLen(modelEchoBuffer) > 64 * 1024) {
2989
+ // Pathological upstream: 64KB with no complete model-bearing event. Flush
2990
+ // verbatim — the warning is the worst outcome of a missed rewrite.
2991
+ out = Buffer.from(concatUint8(modelEchoBuffer));
2992
+ modelEchoBuffer = null;
2993
+ modelEchoPending = false;
2994
+ } else {
2995
+ continue; // hold until the model-bearing event is complete
2996
+ }
2997
+ }
2998
+
2918
2999
  // Forward chunk immediately
2919
- const ok = res.write(value);
3000
+ const ok = res.write(out);
2920
3001
 
2921
- const text = decoder.decode(value, { stream: true });
3002
+ const text = decoder.decode(out, { stream: true });
2922
3003
 
2923
3004
  // Capture for logging
2924
3005
  if (streamLog) streamLog.push(text);
@@ -2974,6 +3055,25 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
2974
3055
  readFailed = true;
2975
3056
  throw err;
2976
3057
  } finally {
3058
+ // MODEL ECHO NORMALIZATION last-resort flush: the read loop can exit via done,
3059
+ // error, or client disconnect while chunks are still HELD for the rewrite.
3060
+ // Whatever the exit path, deliver the held bytes and accrue their usage — a
3061
+ // mid-flight death must not swallow delivered tokens (H4) nor truncate the
3062
+ // body (empty '' responses, C1).
3063
+ if (modelEchoBuffer) {
3064
+ const held = Buffer.from(concatUint8(modelEchoBuffer));
3065
+ modelEchoBuffer = null;
3066
+ try {
3067
+ if (!readFailed && !res.destroyed) res.write(held);
3068
+ } catch { /* client already gone */ }
3069
+ const heldText = held.toString('utf8');
3070
+ if (streamLog) streamLog.push(heldText);
3071
+ sseBuffer += heldText;
3072
+ for (const ev of sseBuffer.split('\n\n')) {
3073
+ if (ev.trim()) parseSSEEvent(ev, accountIndex, accountManager, requestInfo);
3074
+ }
3075
+ sseBuffer = '';
3076
+ }
2977
3077
  res.off('close', onClose);
2978
3078
  // Cancel upstream reader to stop consuming data nobody needs
2979
3079
  reader.cancel().catch(() => {});