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.
- package/package.json +1 -1
- package/src/server.js +191 -6
package/package.json
CHANGED
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
|
-
|
|
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());
|