maxpool 1.19.6 → 1.19.8

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 +191 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.6",
3
+ "version": "1.19.8",
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
@@ -48,6 +48,12 @@ const QUEUE_KEEPALIVE = 'event: ping\ndata: {}\n\n';
48
48
  const STREAM_IDLE_MS = Math.max(30_000, Number(process.env.MAXPOOL_STREAM_IDLE_MS) || 240_000); // max gap BETWEEN streamed chunks (reset per chunk)
49
49
  const UPSTREAM_BODY_MS = Math.max(30_000, Number(process.env.MAXPOOL_BODY_MS) || 300_000); // non-streaming body read
50
50
  const CLIENT_DRAIN_MS = Math.max(5_000, Number(process.env.MAXPOOL_DRAIN_MS) || 60_000); // max wait for a backpressured client to drain (half-open client → free the lease)
51
+ // SAFEGUARD-REFUSAL REROUTE caps: how long streamResponse may hold the FIRST events
52
+ // unsent while deciding refusal-vs-content. Both fail OPEN (release + stream verbatim)
53
+ // so a pathological upstream can only cost a missed reroute, never a stall. Real
54
+ // refusals resolve in the first 2-3 events (<4KB, <5s — measured on 9 captures).
55
+ const REFUSAL_HOLD_MAX_BYTES = 64 * 1024;
56
+ const REFUSAL_HOLD_MAX_MS = 30_000;
51
57
  // A provider 403 is (unlike a 401) almost always transient QUOTA/PLAN exhaustion — cool
52
58
  // the provider down RECOVERABLY for this window, then re-probe, instead of permanently
53
59
  // disabling it. Short (not reset-length) so a provider-pinned session's hold window
@@ -516,6 +522,14 @@ async function forwardRequest(
516
522
  ctx.account = account.name;
517
523
  hooks.onRequestRouted?.(reqId, { account: account.name });
518
524
 
525
+ // SAFEGUARD-REFUSAL REROUTE (2026-09-14): arm the stream-side hold only for Anthropic
526
+ // accounts on a streaming turn we could still fail over (nothing written yet). A
527
+ // provider has its own classifier and never emits this stop_reason, so holding its
528
+ // first events would be pure latency. See streamResponse + classifyHeldStreamPrefix.
529
+ requestInfo._refusalRerouteEligible = account.type !== 'provider'
530
+ && canRetryBufferedBody
531
+ && !res.headersSent;
532
+
519
533
  // Refresh OAuth token if needed
520
534
  const tokenReady = await accountManager.ensureTokenFresh(account.index);
521
535
  if (clientGone.signal.aborted) { releaseOnClientGone(); return; }
@@ -1370,8 +1384,7 @@ async function forwardRequest(
1370
1384
  logSections.push(`=== RESPONSE BODY (streamed) ===\n${streamLog.join('')}`);
1371
1385
  writeRequestLog(logDir, reqId, logSections);
1372
1386
  }
1373
- } else {
1374
- // Bound the non-streaming body read so a mid-body upstream stall can't hang
1387
+ } else { // Bound the non-streaming body read so a mid-body upstream stall can't hang
1375
1388
  // the request forever and leak the lease (same class as the streaming idle
1376
1389
  // guard). On timeout → UPSTREAM_BODY → the caller frees the lease.
1377
1390
  const bodyP = upstreamRes.arrayBuffer();
@@ -1459,6 +1472,35 @@ async function forwardRequest(
1459
1472
  console.error(`[Maxpool] Upstream error (account "${account.name}"):`, err.message
1460
1473
  + (rootCause && !String(err.message).includes(rootCause) ? ` (cause: ${rootCause})` : ''));
1461
1474
 
1475
+ // SAFEGUARD-REFUSAL REROUTE (2026-09-14): streamResponse detected a
1476
+ // stop_reason:"refusal" while still holding the first events — nothing was ever
1477
+ // written to the client (usage is 0/0 on these; no model output exists). Every
1478
+ // Anthropic account shares the classifier, so retrying another Anthropic account
1479
+ // is guaranteed re-refusal: exclude ALL Anthropic accounts and re-dispatch onto a
1480
+ // provider. If no provider is available,
1481
+ // fall through to the normal error path — the client sees the same message it
1482
+ // would have seen before this feature existed.
1483
+ if (err.code === 'REFUSAL_RETRY' && canRetryBufferedBody && !res.headersSent) {
1484
+ const category = err.refusalCategory || 'unknown';
1485
+ const anthropicIndexes = accountManager.accounts
1486
+ .filter(a => a.type !== 'provider')
1487
+ .map(a => a.index);
1488
+ anthropicIndexes.forEach(i => excludedIndexes.add(i));
1489
+ const providerAvailable = accountManager.accounts.some(a => a.type === 'provider' && !excludedIndexes.has(a.index));
1490
+ // No re-reroute latch: once rerouted, the turn is served by a PROVIDER, which never
1491
+ // arms the hold (above), so it cannot refuse-and-reroute again. Mutation-tested
1492
+ // 2026-09-14: removing a latch changed nothing on any constructible path.
1493
+ if (providerAvailable && retryCount + 1 <= maxAttempts) {
1494
+ console.log(`[Maxpool] Anthropic safeguard refusal (${category}) on "${account.name}" — rerouting turn to a provider account`);
1495
+ accountManager.releaseAccount(lease, { success: true, status: 200, refusal: category });
1496
+ return forwardRequest(
1497
+ req, res, body, accountManager, upstream, retryCount + 1, hooks, reqId, ctx, logDir,
1498
+ retryConfig, queueConfig, requestInfo, canRetryBufferedBody, canQueueBufferedBody, excludedIndexes,
1499
+ );
1500
+ }
1501
+ console.log(`[Maxpool] Anthropic safeguard refusal (${category}) — no provider route available; surfacing error`);
1502
+ }
1503
+
1462
1504
  if (logDir) {
1463
1505
  logSections.push(`=== ERROR ===\n${err.stack || err.message}`);
1464
1506
  writeRequestLog(logDir, reqId, logSections);
@@ -1757,7 +1799,7 @@ function isContextLengthError(errorBody) {
1757
1799
  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);
1758
1800
  }
1759
1801
 
1760
- 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 };
1802
+ export const __serverTest = { rewriteBodyForAccount, sanitizeBlocksForProvider, 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, classifyHeldStreamPrefix };
1761
1803
 
1762
1804
  async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
1763
1805
  if (!upstreamRes.body) return '';
@@ -2957,18 +2999,54 @@ const threadOwners = new ThreadOwners();
2957
2999
  const THREAD_GATE_ENABLED = process.env.MAXPOOL_THREAD_GATE !== '0';
2958
3000
 
2959
3001
  function rewriteBodyForAccount(body, account) {
2960
- if (!body.length || (!account.model && !account.modelMap)) return body;
3002
+ const needsProviderSanitize = account.type === 'provider';
3003
+ if (!body.length || (!account.model && !account.modelMap && !needsProviderSanitize)) return body;
2961
3004
 
2962
3005
  try {
2963
3006
  const json = JSON.parse(body.toString());
2964
3007
  if (!json || typeof json !== 'object' || !json.model) return body;
2965
- json.model = mappedModel(json.model, account);
3008
+ if (account.model || account.modelMap) json.model = mappedModel(json.model, account);
3009
+ if (needsProviderSanitize) sanitizeBlocksForProvider(json);
2966
3010
  return Buffer.from(JSON.stringify(json));
2967
3011
  } catch {
2968
3012
  return body;
2969
3013
  }
2970
3014
  }
2971
3015
 
3016
+ // Content-block types Anthropic's own client emits that a provider's validator rejects
3017
+ // outright, taking the WHOLE request with it. Measured 2026-09-13 against z.ai: a body
3018
+ // carrying one `tool_reference` block (Claude Code writes these into `tool_result` when
3019
+ // ToolSearch loads a deferred tool) returns `[1210] Invalid API parameter` — the entire
3020
+ // 5.5MB transcript refused over 12 blocks. Counterfactual on the owner's real failing
3021
+ // session: identical body with ONLY these blocks rewritten to text returned 200 OK.
3022
+ //
3023
+ // It is not a size or token limit — z.ai has a distinct code for that (`[1261] Prompt
3024
+ // too long`), and a 34KB body with the block fails while 5.5MB without it passes.
3025
+ //
3026
+ // We rewrite rather than drop: the block carries a tool NAME the conversation refers to,
3027
+ // so replacing it with the equivalent sentence keeps the transcript truthful. Anthropic
3028
+ // accounts are untouched — they understand the block natively.
3029
+ const PROVIDER_UNSUPPORTED_BLOCKS = new Set(['tool_reference']);
3030
+
3031
+ function sanitizeBlocksForProvider(json) {
3032
+ const messages = json?.messages;
3033
+ if (!Array.isArray(messages)) return;
3034
+ for (const message of messages) {
3035
+ const content = message?.content;
3036
+ if (!Array.isArray(content)) continue;
3037
+ for (const block of content) {
3038
+ // The blocks live INSIDE tool_result content, not at the message top level.
3039
+ if (!block || typeof block !== 'object' || !Array.isArray(block.content)) continue;
3040
+ for (let i = 0; i < block.content.length; i++) {
3041
+ const inner = block.content[i];
3042
+ if (!inner || typeof inner !== 'object') continue;
3043
+ if (!PROVIDER_UNSUPPORTED_BLOCKS.has(inner.type)) continue;
3044
+ block.content[i] = { type: 'text', text: `Tool loaded: ${inner.tool_name || 'unknown'}` };
3045
+ }
3046
+ }
3047
+ }
3048
+ }
3049
+
2972
3050
  function mappedModel(originalModel, account) {
2973
3051
  if (account.model) return account.model;
2974
3052
  const map = account.modelMap || {};
@@ -3083,6 +3161,25 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
3083
3161
  let modelEchoPending = true;
3084
3162
  let modelEchoBuffer = null;
3085
3163
 
3164
+ // SAFEGUARD-REFUSAL REROUTE (2026-09-14). Anthropic's server-side classifier can
3165
+ // terminate a turn with HTTP 200 + `stop_reason:"refusal"` + stop_details.category
3166
+ // (reasoning_extraction / cyber / bio / frontier_llm) and usage 0/0 — no model output
3167
+ // is produced, and the CLI renders "API Error: … safeguards flagged this message",
3168
+ // killing the turn. Every Anthropic account shares that classifier, so account
3169
+ // failover cannot help; only a PROVIDER hop escapes it.
3170
+ //
3171
+ // Because usage is 0/0 the client has seen no model text, so the turn is safely
3172
+ // retryable — but only while nothing has been WRITTEN yet. This rides the model-echo
3173
+ // hold above: we keep buffering until a DECISIVE event (real content → flush and
3174
+ // stream normally; refusal → throw REFUSAL_RETRY so the caller re-dispatches on a
3175
+ // provider). `ping` is not decisive. Fail-open on either cap, exactly as the
3176
+ // model-echo hold does — a missed reroute is a bad turn, a stall is a dead session.
3177
+ // Task: work/tooling/**/task-2026-09-14-maxpool-reroute-anthropic-refusals-to-provider
3178
+ let refusalRerouting = false;
3179
+ const refusalWatch = requestInfo?._refusalRerouteEligible === true;
3180
+ let refusalHoldBuffer = refusalWatch ? [] : null;
3181
+ const refusalHoldStart = Date.now();
3182
+
3086
3183
  try {
3087
3184
  while (true) {
3088
3185
  // Idle guard: a half-open upstream (headers, then silence, never closes) would
@@ -3107,12 +3204,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
3107
3204
  } finally {
3108
3205
  clearTimeout(idleTimer); // single live timer at a time — no per-chunk timer accumulation
3109
3206
  }
3110
- const { done, value } = result;
3207
+ const { done, value: chunkValue } = result;
3111
3208
  if (done) break;
3209
+ let value = chunkValue;
3112
3210
 
3113
3211
  // Client disconnected — stop reading from upstream
3114
3212
  if (res.destroyed) break;
3115
3213
 
3214
+ // SAFEGUARD-REFUSAL HOLD (see the state block above). Runs BEFORE writeHead so a
3215
+ // rerouted turn leaves the client socket completely untouched — the caller can
3216
+ // then re-dispatch onto a provider as if the Anthropic attempt never happened.
3217
+ if (refusalHoldBuffer) {
3218
+ refusalHoldBuffer.push(value);
3219
+ const held = decoder.decode(concatUint8(refusalHoldBuffer));
3220
+ const verdict = classifyHeldStreamPrefix(held);
3221
+ if (verdict.decision === 'refusal') {
3222
+ throw Object.assign(
3223
+ new Error(`anthropic safeguard refusal (${verdict.category || 'unknown'})`),
3224
+ { code: 'REFUSAL_RETRY', refusalCategory: verdict.category || 'unknown' },
3225
+ );
3226
+ }
3227
+ if (verdict.decision === 'hold'
3228
+ && totalLen(refusalHoldBuffer) <= REFUSAL_HOLD_MAX_BYTES
3229
+ && Date.now() - refusalHoldStart <= REFUSAL_HOLD_MAX_MS) {
3230
+ continue; // nothing decisive yet — keep holding, write nothing
3231
+ }
3232
+ // Decisive content, or either cap hit → fail open: release the held bytes as
3233
+ // this chunk and never hold again on this stream.
3234
+ value = concatUint8(refusalHoldBuffer);
3235
+ refusalHoldBuffer = null;
3236
+ }
3237
+
3116
3238
  if (!committed) {
3117
3239
  res.writeHead(status, responseHeaders);
3118
3240
  committed = true;
@@ -3209,8 +3331,40 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
3209
3331
  }
3210
3332
  } catch (err) {
3211
3333
  readFailed = true;
3334
+ if (err?.code === 'REFUSAL_RETRY') refusalRerouting = true;
3212
3335
  throw err;
3213
3336
  } finally {
3337
+ // SAFEGUARD-REFUSAL HOLD last-resort flush. A stream can end while the hold is
3338
+ // still undecided — a `usage`-only stream (message_delta with no content blocks
3339
+ // and no message_stop) reaches `done` having never produced a decisive event, and
3340
+ // an upstream that DIES mid-flight throws with bytes still held. Both must still
3341
+ // deliver and account for what arrived; only the refusal reroute discards, because
3342
+ // that turn is being re-run on another account and its usage will be counted there.
3343
+ // Caught by server.test.js "streaming response is not committed until first upstream
3344
+ // chunk" and capacity-integration H4 on the first full-suite run — the held bytes
3345
+ // were being dropped, truncating good responses and losing delivered tokens.
3346
+ if (refusalHoldBuffer) {
3347
+ const heldChunks = refusalHoldBuffer;
3348
+ refusalHoldBuffer = null;
3349
+ if (!refusalRerouting) {
3350
+ const held = Buffer.from(concatUint8(heldChunks));
3351
+ const heldText = held.toString('utf8');
3352
+ // Account for delivered tokens even when the write is impossible (dead socket):
3353
+ // capacity is about what the UPSTREAM produced, not what the client received.
3354
+ if (streamLog) streamLog.push(heldText);
3355
+ sseBuffer += heldText;
3356
+ for (const ev of sseBuffer.split('\n\n')) {
3357
+ if (ev.trim()) parseSSEEvent(ev, accountIndex, accountManager, requestInfo);
3358
+ }
3359
+ sseBuffer = '';
3360
+ if (!readFailed && !res.destroyed) {
3361
+ try {
3362
+ if (!committed && !res.headersSent) { res.writeHead(status, responseHeaders); committed = true; }
3363
+ res.write(held);
3364
+ } catch { /* client already gone */ }
3365
+ }
3366
+ }
3367
+ }
3214
3368
  // MODEL ECHO NORMALIZATION last-resort flush: the read loop can exit via done,
3215
3369
  // error, or client disconnect while chunks are still HELD for the rewrite.
3216
3370
  // Whatever the exit path, deliver the held bytes and accrue their usage — a
@@ -3287,6 +3441,37 @@ function sseEventContainsThinking(data) {
3287
3441
  || data?.delta?.type === 'signature_delta';
3288
3442
  }
3289
3443
 
3444
+ // SAFEGUARD-REFUSAL REROUTE — classify the held stream prefix (see streamResponse).
3445
+ // Decisive events, on the first COMPLETE SSE event boundary:
3446
+ // - `stop_reason:"refusal"` (+ optional stop_details.category) → { decision: 'refusal', category }
3447
+ // - any content_block_start/content_block_delta → { decision: 'content' }
3448
+ // - message_stop without a prior refusal → { decision: 'content' } (normal end)
3449
+ // Non-decisive: message_start, ping, partial/binary garbage → { decision: 'hold' }.
3450
+ // Works on the RAW prefix (not parsed events) so it can run per-chunk; a refusal
3451
+ // `message_delta` always lands after `message_start` and before any content, and no
3452
+ // provider-echo variant matters here because the hold is only armed on Anthropic
3453
+ // accounts (requestInfo._refusalRerouteEligible).
3454
+ function classifyHeldStreamPrefix(prefix) {
3455
+ for (const event of prefix.split('\n\n')) {
3456
+ const dataLine = event.split('\n').find(l => l.startsWith('data: '));
3457
+ if (!dataLine) continue; // ping/comment — not decisive
3458
+ let data;
3459
+ try { data = JSON.parse(dataLine.slice(6)); } catch { continue; }
3460
+ if (data?.delta?.stop_reason === 'refusal'
3461
+ || (data?.type === 'message' && data?.stop_reason === 'refusal')) {
3462
+ return { decision: 'refusal', category: data?.stop_details?.category || data?.delta?.stop_details?.category };
3463
+ }
3464
+ if (data?.type === 'content_block_start' || data?.type === 'content_block_delta') {
3465
+ return { decision: 'content' };
3466
+ }
3467
+ if (data?.type === 'message_stop') {
3468
+ // Terminal without refusal and without content blocks (usage-only edge) — release.
3469
+ return { decision: 'content' };
3470
+ }
3471
+ }
3472
+ return { decision: 'hold' };
3473
+ }
3474
+
3290
3475
  function extractUsageFromBody(buffer, accountIndex, accountManager, requestInfo = {}) {
3291
3476
  try {
3292
3477
  const json = JSON.parse(buffer.toString());