maxpool 1.19.7 → 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 +153 -4
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 = { 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 };
|
|
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 '';
|
|
@@ -3119,6 +3161,25 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3119
3161
|
let modelEchoPending = true;
|
|
3120
3162
|
let modelEchoBuffer = null;
|
|
3121
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
|
+
|
|
3122
3183
|
try {
|
|
3123
3184
|
while (true) {
|
|
3124
3185
|
// Idle guard: a half-open upstream (headers, then silence, never closes) would
|
|
@@ -3143,12 +3204,37 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3143
3204
|
} finally {
|
|
3144
3205
|
clearTimeout(idleTimer); // single live timer at a time — no per-chunk timer accumulation
|
|
3145
3206
|
}
|
|
3146
|
-
const { done, value } = result;
|
|
3207
|
+
const { done, value: chunkValue } = result;
|
|
3147
3208
|
if (done) break;
|
|
3209
|
+
let value = chunkValue;
|
|
3148
3210
|
|
|
3149
3211
|
// Client disconnected — stop reading from upstream
|
|
3150
3212
|
if (res.destroyed) break;
|
|
3151
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
|
+
|
|
3152
3238
|
if (!committed) {
|
|
3153
3239
|
res.writeHead(status, responseHeaders);
|
|
3154
3240
|
committed = true;
|
|
@@ -3245,8 +3331,40 @@ async function streamResponse(webStream, res, status, responseHeaders, accountIn
|
|
|
3245
3331
|
}
|
|
3246
3332
|
} catch (err) {
|
|
3247
3333
|
readFailed = true;
|
|
3334
|
+
if (err?.code === 'REFUSAL_RETRY') refusalRerouting = true;
|
|
3248
3335
|
throw err;
|
|
3249
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
|
+
}
|
|
3250
3368
|
// MODEL ECHO NORMALIZATION last-resort flush: the read loop can exit via done,
|
|
3251
3369
|
// error, or client disconnect while chunks are still HELD for the rewrite.
|
|
3252
3370
|
// Whatever the exit path, deliver the held bytes and accrue their usage — a
|
|
@@ -3323,6 +3441,37 @@ function sseEventContainsThinking(data) {
|
|
|
3323
3441
|
|| data?.delta?.type === 'signature_delta';
|
|
3324
3442
|
}
|
|
3325
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
|
+
|
|
3326
3475
|
function extractUsageFromBody(buffer, accountIndex, accountManager, requestInfo = {}) {
|
|
3327
3476
|
try {
|
|
3328
3477
|
const json = JSON.parse(buffer.toString());
|