maxpool 1.19.7 → 1.20.0

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.7",
3
+ "version": "1.20.0",
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",
@@ -147,6 +147,13 @@ const DEFAULT_SCHEDULER = {
147
147
  capPenaltyWeight: 10, // steep penalty per unit of in-flight depth past D (throttle safety floor)
148
148
  paceCostWeight: 1.5, // soft de-preference of accounts burning ahead of pace (was the ×6 term)
149
149
  utilizationWeight: 3, // RAW utilization cost — drives load balancing in the mid-range
150
+ // WEEKLY-AWARE SCORING (2026-09-16): when true, _rawUtilization also folds in the
151
+ // WEEKLY utilization (unified7d / providerWk), not just the 5h session. Before this,
152
+ // a Claude account at 89% weekly with a freshly-reset 5h window scored as CHEAP —
153
+ // weekly-burning accounts kept winning the lease all day (backtest: 62%→35% mean
154
+ // weekly burn once weekly-aware). Boolean; default ON was chosen because the
155
+ // pre-flag behavior is the bug this fixes.
156
+ weeklyAwareScoring: true,
150
157
  scarcityWeight: 6, // legacy; superseded by paceCostWeight (kept so old configs don't error)
151
158
  // Reserve-account OVERFLOW model. A weekly-RESERVE account (util 0.85-0.95) used to
152
159
  // sit idle behind a healthy-only first pass; now it's eligible in the first pass but
@@ -2692,7 +2699,7 @@ export class AccountManager {
2692
2699
  * _accountScarcity but WITHOUT the elapsed-fraction discount. This is the signal
2693
2700
  * the load balancer needs: an account at 80% is more expensive than one at 10%,
2694
2701
  * full stop. */
2695
- _rawUtilization(account) {
2702
+ _rawUtilization(account, now = Date.now()) {
2696
2703
  const q = account?.quota;
2697
2704
  if (!q) return 0;
2698
2705
  // SESSION windows use raw utilization — headroom is consumed immediately and an
@@ -2708,6 +2715,24 @@ export class AccountManager {
2708
2715
  if (q.tokensLimit != null && q.tokensLimit > 0 && q.tokensRemaining != null) {
2709
2716
  util = Math.max(util, 1 - q.tokensRemaining / q.tokensLimit);
2710
2717
  }
2718
+ // WEEKLY-AWARE (2026-09-16): fold the WEEKLY number in too, behind its own
2719
+ // flag — via _windowScarcity (reset-aware), NEVER raw. v1 of this block used
2720
+ // raw max(), which fixed the 89%-weekly-wins-all-day bug but broke the
2721
+ // near-reset contracts that predate it: the use-it-or-lose-it pin, the
2722
+ // preReset drain (X2/X5), and S10's flag-off parity. Pace-adjusted, a
2723
+ // 60%-weekly account mid-window adds (0.60 − elapsedFrac) here at weight 3
2724
+ // ON TOP of the paceCost's weight-1.5 copy — doubling the weekly steering
2725
+ // signal (the actual fix) while capacity dying at reset stays free (the
2726
+ // invariant the older tests pin). Unknown/absent reset → face value, same as
2727
+ // _accountScarcity. Turn the flag off to restore session-only exactly.
2728
+ if (this.scheduler.weeklyAwareScoring !== false) {
2729
+ if (q.unified7d != null) {
2730
+ util = Math.max(util, this._windowScarcity(q.unified7d, q.unified7dReset, WEEK_MS, now));
2731
+ }
2732
+ if (q.providerWk != null) {
2733
+ util = Math.max(util, this._windowScarcity(q.providerWk, q.providerWkReset, WEEK_MS, now));
2734
+ }
2735
+ }
2711
2736
  return util;
2712
2737
  }
2713
2738
 
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());
package/src/tui.js CHANGED
@@ -977,6 +977,11 @@ export class TUI {
977
977
  // mode — under balance/prefer-* the mode itself controls eligibility. Still
978
978
  // safe to set (it'll apply if you switch back to sticky).
979
979
  this._cycleProviderClaudeFallback(k === 'g' ? 'zai' : 'kimi');
980
+ } else if (k === 'w' || k === 'W') {
981
+ // WEEKLY-AWARE SCORING (2026-09-16): fold weekly utilization into the routing
982
+ // score, not just the 5h session. Without it, an account at 89% weekly with a
983
+ // fresh session window scores as cheap and keeps winning all day.
984
+ this._toggleWeeklyAware();
980
985
  } else if (k === 'esc' || k === 'q') {
981
986
  this.mode = 'normal';
982
987
  }
@@ -1066,6 +1071,22 @@ export class TUI {
1066
1071
  : `Peak cap: bench a GLM account once it passes ${Math.round(next * 100)}% of its weekly quota`);
1067
1072
  }
1068
1073
 
1074
+ /** Toggle weekly-aware routing for the whole fleet. One scheduler flag — when OFF the
1075
+ * score sees only the 5h session (the pre-2026-09-16 behavior); when ON the weekly
1076
+ * number is folded in, so a nearly-exhausted account loses to a fresh one at ALL
1077
+ * hours, not just after its session window resets. */
1078
+ async _toggleWeeklyAware() {
1079
+ const next = this.am.scheduler.weeklyAwareScoring === false;
1080
+ this.am.scheduler.weeklyAwareScoring = next;
1081
+ const sched = { ...(this.config.scheduler || {}) };
1082
+ sched.weeklyAwareScoring = next;
1083
+ this.config.scheduler = sched;
1084
+ await this.saveConfig(this.config);
1085
+ this._addLog(next
1086
+ ? 'Weekly-aware routing: ON — accounts near their weekly limit rank last, all day'
1087
+ : 'Weekly-aware routing: OFF — score sees only the 5h session window again');
1088
+ }
1089
+
1069
1090
  async _cycleRoutingMode() {
1070
1091
  const modes = TUI.ROUTING_MODES;
1071
1092
  const cur = this.am.scheduler?.routingMode || 'sticky';
@@ -2369,7 +2390,8 @@ export class TUI {
2369
2390
  const now = st?.inPeak ? red(' NOW') : '';
2370
2391
  const dep = ps.depreference ? yellow('GLM last') : cyan('normal');
2371
2392
  const cap = ps.cap >= 1 ? 'off' : ps.cap === 0 ? 'never' : `${Math.round(ps.cap * 100)}%`;
2372
- peakPart = ` ${dim('│')} ${bold(' d ')}Peak${now}: ${dep} ${bold(' c ')}cap ${cyan(cap)}`;
2393
+ const wk = this.am.scheduler.weeklyAwareScoring === false ? yellow('5h-only') : cyan('weekly');
2394
+ peakPart = ` ${dim('│')} ${bold(' d ')}Peak${now}: ${dep} ${bold(' c ')}cap ${cyan(cap)} ${bold(' w ')}score:${wk}`;
2373
2395
  }
2374
2396
  return ` ${bold('f')} Routing: ${cyan(mode.label)} ↻${provPart}${peakPart} ${bold('p')} Preference ${bold('Esc')} Back`;
2375
2397
  }