maxpool 1.19.5 → 1.19.7
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 +69 -5
- package/src/thread-gate.js +24 -2
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import { ThreadOwners, readThreadIntent, threadRefusalBody } from './thread-gate.js';
|
|
2
|
+
import { ThreadOwners, readThreadIntent, threadRefusalBody, isThreadlessAccount } from './thread-gate.js';
|
|
3
3
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { modelFamily } from './oauth.js';
|
|
@@ -595,7 +595,7 @@ async function forwardRequest(
|
|
|
595
595
|
// `kind:'none'` when the gate is off makes every branch below a no-op, so the
|
|
596
596
|
// disabled path costs one comparison and needs no further guarding.
|
|
597
597
|
const threadIntent = THREAD_GATE_ENABLED ? readThreadIntent(body) : { kind: 'none' };
|
|
598
|
-
if (threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent)) {
|
|
598
|
+
if (threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent, isThreadlessAccount(account))) {
|
|
599
599
|
threadOwners.noteRefused(requestInfo.sessionKey, account.name);
|
|
600
600
|
accountManager.releaseAccount(lease, { neutral: true });
|
|
601
601
|
console.log(`[Maxpool] thread not held by "${account.name}" — asking the client to resend this turn stateless [sess ${String(requestInfo.sessionKey || '?').slice(0, 8)}]`);
|
|
@@ -1043,6 +1043,27 @@ async function forwardRequest(
|
|
|
1043
1043
|
// Providers answer with a code and no field name, so record what WE sent.
|
|
1044
1044
|
if (account.type === 'provider') {
|
|
1045
1045
|
console.log(`[Maxpool] request shape: ${describeBodyShape(upstreamBody || body).slice(0, 600)}`);
|
|
1046
|
+
// OPT-IN BODY CAPTURE. The shape line is content-free by design, which is right
|
|
1047
|
+
// for a log that runs always — but it cannot diagnose a rejection that lives in
|
|
1048
|
+
// the message CONTENT. Measured 2026-09-11: every top-level field of a failing
|
|
1049
|
+
// [1210] body, and the full combination of them, returned 200 OK when replayed;
|
|
1050
|
+
// the cause is inside the 940-message transcript and invisible from a summary.
|
|
1051
|
+
// Writes the WHOLE request (the user's transcript) so it is OFF unless a human
|
|
1052
|
+
// sets the directory, and stops after a handful of samples.
|
|
1053
|
+
if (PROVIDER_4XX_CAPTURE_DIR && _provider4xxCaptured < PROVIDER_4XX_CAPTURE_MAX) {
|
|
1054
|
+
_provider4xxCaptured += 1;
|
|
1055
|
+
const n = _provider4xxCaptured;
|
|
1056
|
+
(async () => {
|
|
1057
|
+
try {
|
|
1058
|
+
await mkdir(PROVIDER_4XX_CAPTURE_DIR, { recursive: true });
|
|
1059
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1060
|
+
await writeFile(join(PROVIDER_4XX_CAPTURE_DIR, `${stamp}-${account.provider || 'provider'}-${n}.json`),
|
|
1061
|
+
JSON.stringify({ status: upstreamRes.status, error: errorBody.slice(0, 2000),
|
|
1062
|
+
account: account.name, body: (upstreamBody || body).toString('utf8') }), 'utf-8');
|
|
1063
|
+
console.log(`[Maxpool] captured failing body ${n}/${PROVIDER_4XX_CAPTURE_MAX} -> ${PROVIDER_4XX_CAPTURE_DIR}`);
|
|
1064
|
+
} catch (e) { console.log(`[Maxpool] capture failed: ${e?.message || e}`); }
|
|
1065
|
+
})();
|
|
1066
|
+
}
|
|
1046
1067
|
}
|
|
1047
1068
|
}
|
|
1048
1069
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
@@ -1719,6 +1740,13 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1719
1740
|
* Matched by CODE, never by prose: an error whose message names its field (Anthropic's
|
|
1720
1741
|
* own 400s do) is a real client fault and keeps its own clear message.
|
|
1721
1742
|
*/
|
|
1743
|
+
// Opt-in capture of a failing provider request, for the class of rejection that lives in
|
|
1744
|
+
// message CONTENT and is therefore invisible to the content-free shape line. Writes the
|
|
1745
|
+
// user's transcript, so it stays OFF unless a human names a directory.
|
|
1746
|
+
const PROVIDER_4XX_CAPTURE_DIR = process.env.MAXPOOL_CAPTURE_PROVIDER_4XX || '';
|
|
1747
|
+
const PROVIDER_4XX_CAPTURE_MAX = Number(process.env.MAXPOOL_CAPTURE_PROVIDER_4XX_MAX || 3);
|
|
1748
|
+
let _provider4xxCaptured = 0;
|
|
1749
|
+
|
|
1722
1750
|
function isProviderParamRejection(errorBody) {
|
|
1723
1751
|
if (!errorBody) return false;
|
|
1724
1752
|
return /\[1210\]|"code"\s*:\s*"?1210"?/.test(errorBody);
|
|
@@ -1729,7 +1757,7 @@ function isContextLengthError(errorBody) {
|
|
|
1729
1757
|
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);
|
|
1730
1758
|
}
|
|
1731
1759
|
|
|
1732
|
-
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 };
|
|
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 };
|
|
1733
1761
|
|
|
1734
1762
|
async function readErrorBody(upstreamRes, limitBytes = 64 * 1024) {
|
|
1735
1763
|
if (!upstreamRes.body) return '';
|
|
@@ -2929,18 +2957,54 @@ const threadOwners = new ThreadOwners();
|
|
|
2929
2957
|
const THREAD_GATE_ENABLED = process.env.MAXPOOL_THREAD_GATE !== '0';
|
|
2930
2958
|
|
|
2931
2959
|
function rewriteBodyForAccount(body, account) {
|
|
2932
|
-
|
|
2960
|
+
const needsProviderSanitize = account.type === 'provider';
|
|
2961
|
+
if (!body.length || (!account.model && !account.modelMap && !needsProviderSanitize)) return body;
|
|
2933
2962
|
|
|
2934
2963
|
try {
|
|
2935
2964
|
const json = JSON.parse(body.toString());
|
|
2936
2965
|
if (!json || typeof json !== 'object' || !json.model) return body;
|
|
2937
|
-
json.model = mappedModel(json.model, account);
|
|
2966
|
+
if (account.model || account.modelMap) json.model = mappedModel(json.model, account);
|
|
2967
|
+
if (needsProviderSanitize) sanitizeBlocksForProvider(json);
|
|
2938
2968
|
return Buffer.from(JSON.stringify(json));
|
|
2939
2969
|
} catch {
|
|
2940
2970
|
return body;
|
|
2941
2971
|
}
|
|
2942
2972
|
}
|
|
2943
2973
|
|
|
2974
|
+
// Content-block types Anthropic's own client emits that a provider's validator rejects
|
|
2975
|
+
// outright, taking the WHOLE request with it. Measured 2026-09-13 against z.ai: a body
|
|
2976
|
+
// carrying one `tool_reference` block (Claude Code writes these into `tool_result` when
|
|
2977
|
+
// ToolSearch loads a deferred tool) returns `[1210] Invalid API parameter` — the entire
|
|
2978
|
+
// 5.5MB transcript refused over 12 blocks. Counterfactual on the owner's real failing
|
|
2979
|
+
// session: identical body with ONLY these blocks rewritten to text returned 200 OK.
|
|
2980
|
+
//
|
|
2981
|
+
// It is not a size or token limit — z.ai has a distinct code for that (`[1261] Prompt
|
|
2982
|
+
// too long`), and a 34KB body with the block fails while 5.5MB without it passes.
|
|
2983
|
+
//
|
|
2984
|
+
// We rewrite rather than drop: the block carries a tool NAME the conversation refers to,
|
|
2985
|
+
// so replacing it with the equivalent sentence keeps the transcript truthful. Anthropic
|
|
2986
|
+
// accounts are untouched — they understand the block natively.
|
|
2987
|
+
const PROVIDER_UNSUPPORTED_BLOCKS = new Set(['tool_reference']);
|
|
2988
|
+
|
|
2989
|
+
function sanitizeBlocksForProvider(json) {
|
|
2990
|
+
const messages = json?.messages;
|
|
2991
|
+
if (!Array.isArray(messages)) return;
|
|
2992
|
+
for (const message of messages) {
|
|
2993
|
+
const content = message?.content;
|
|
2994
|
+
if (!Array.isArray(content)) continue;
|
|
2995
|
+
for (const block of content) {
|
|
2996
|
+
// The blocks live INSIDE tool_result content, not at the message top level.
|
|
2997
|
+
if (!block || typeof block !== 'object' || !Array.isArray(block.content)) continue;
|
|
2998
|
+
for (let i = 0; i < block.content.length; i++) {
|
|
2999
|
+
const inner = block.content[i];
|
|
3000
|
+
if (!inner || typeof inner !== 'object') continue;
|
|
3001
|
+
if (!PROVIDER_UNSUPPORTED_BLOCKS.has(inner.type)) continue;
|
|
3002
|
+
block.content[i] = { type: 'text', text: `Tool loaded: ${inner.tool_name || 'unknown'}` };
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
|
|
2944
3008
|
function mappedModel(originalModel, account) {
|
|
2945
3009
|
if (account.model) return account.model;
|
|
2946
3010
|
const map = account.modelMap || {};
|
package/src/thread-gate.js
CHANGED
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
|
|
20
20
|
export const THREAD_UNSUPPORTED_CODE = 'thread_unsupported_request';
|
|
21
21
|
|
|
22
|
+
/** Whether an account is a non-Anthropic provider (GLM/Kimi) that can never hold an
|
|
23
|
+
* Anthropic-side thread. The ONE place this is decided, so the call site and the
|
|
24
|
+
* tests cannot disagree about it. */
|
|
25
|
+
export function isThreadlessAccount(account) {
|
|
26
|
+
return account?.type === 'provider';
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
/** What kind of thread intent a request body carries. Cheap: only the head of the body
|
|
23
30
|
* is JSON-parsed, and a non-JSON body is simply 'none'. */
|
|
24
31
|
export function readThreadIntent(body) {
|
|
@@ -81,10 +88,25 @@ export class ThreadOwners {
|
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/** Decide AFTER routing has chosen. Returns true only for a `continue` turn that the
|
|
84
|
-
* chosen account cannot serve, and only while refusals are still under the bound.
|
|
85
|
-
|
|
91
|
+
* chosen account cannot serve, and only while refusals are still under the bound.
|
|
92
|
+
*
|
|
93
|
+
* 2026-09-13: `isProvider` (GLM/Kimi) short-circuits to refuse — measured, not
|
|
94
|
+
* guessed: 118 of 118 provider-routed continues 400'd, the client never downgrades
|
|
95
|
+
* on a plain provider 400 (its downgrade trigger is exactly the code this gate
|
|
96
|
+
* returns), and when a slice tail is standalone-valid the provider ANSWERS it from
|
|
97
|
+
* a ~2-message orphan context (the "amnesia" bug: 2,844 effective input tokens
|
|
98
|
+
* where the prior turn had 478,596). Ownership tracking is meaningless for
|
|
99
|
+
* providers — they can never hold an Anthropic-side thread — so every provider
|
|
100
|
+
* continue is refused unconditionally: no owner record, no bound. The bound
|
|
101
|
+
* existed to stop storms when a client ignores refusals, but the classifier that
|
|
102
|
+
* acts on them ships in every continue-capable build (>= 2.1.265), and one
|
|
103
|
+
* refusal ends the session's slicing for good; a no-session-header storm is
|
|
104
|
+
* bounded by that same downgrade. Anthropic accounts keep the owner logic below —
|
|
105
|
+
* a same-account chain preserves the vendor's thread saving (64% of turns). */
|
|
106
|
+
shouldRefuse(sessionKey, accountName, intent, isProvider = false) {
|
|
86
107
|
if (!accountName) return false;
|
|
87
108
|
if (intent?.kind !== 'continue') return false; // `create` carries the full transcript
|
|
109
|
+
if (isProvider) return true; // can never serve an Anthropic thread
|
|
88
110
|
// A request with NO session header is invisible to ownership tracking, and measured
|
|
89
111
|
// 2026-09-11 those are the majority of traffic — 14 of 20 consecutive /v1/messages
|
|
90
112
|
// lines carried no `[sess …]`. Skipping them left threaded turns reaching GLM and
|