maxpool 1.19.3 → 1.19.4
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 +25 -0
- package/src/thread-gate.js +107 -0
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import { ThreadOwners, readThreadIntent, threadRefusalBody } from './thread-gate.js';
|
|
2
3
|
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { modelFamily } from './oauth.js';
|
|
@@ -586,6 +587,24 @@ async function forwardRequest(
|
|
|
586
587
|
const method = req.method;
|
|
587
588
|
const upstreamBody = rewriteBodyForAccount(body, account);
|
|
588
589
|
|
|
590
|
+
// A threaded follow-up routed to an account that does not hold the thread cannot be
|
|
591
|
+
// served by it: a different Anthropic account 404s, a provider rejects the truncated
|
|
592
|
+
// transcript. Hand the client the signal it already knows how to act on — it resends
|
|
593
|
+
// the turn stateless and stops threading for the session — instead of letting the
|
|
594
|
+
// upstream produce an error the user sees.
|
|
595
|
+
const threadIntent = THREAD_GATE_ENABLED ? readThreadIntent(body) : { kind: 'none' };
|
|
596
|
+
if (THREAD_GATE_ENABLED && threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent)) {
|
|
597
|
+
threadOwners.noteRefused(requestInfo.sessionKey);
|
|
598
|
+
accountManager.releaseAccount(lease, { neutral: true });
|
|
599
|
+
console.log(`[Maxpool] thread not held by "${account.name}" — asking the client to resend this turn stateless [sess ${String(requestInfo.sessionKey || '?').slice(0, 8)}]`);
|
|
600
|
+
ctx.status = 400;
|
|
601
|
+
sendErrorResponse(res, requestInfo, 400, threadRefusalBody(account.name));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// This account is about to serve the turn, so it holds the thread from here on.
|
|
605
|
+
// Recorded optimistically: if the turn fails, the next one is refused anyway.
|
|
606
|
+
if (THREAD_GATE_ENABLED) threadOwners.noteServed(requestInfo.sessionKey, account.name, threadIntent);
|
|
607
|
+
|
|
589
608
|
// Build log sections
|
|
590
609
|
const logSections = [];
|
|
591
610
|
if (logDir) {
|
|
@@ -2901,6 +2920,12 @@ function describeBodyShape(buf) {
|
|
|
2901
2920
|
}
|
|
2902
2921
|
}
|
|
2903
2922
|
|
|
2923
|
+
// THREAD GATE (2026-09-11). Runs AFTER routing has chosen, so it never influences the
|
|
2924
|
+
// choice — it only decides what to say to the account that was picked. See
|
|
2925
|
+
// src/thread-gate.js for why this replaces rebuilding the transcript.
|
|
2926
|
+
const threadOwners = new ThreadOwners();
|
|
2927
|
+
const THREAD_GATE_ENABLED = process.env.MAXPOOL_THREAD_GATE !== '0';
|
|
2928
|
+
|
|
2904
2929
|
function rewriteBodyForAccount(body, account) {
|
|
2905
2930
|
if (!body.length || (!account.model && !account.modelMap)) return body;
|
|
2906
2931
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Thread gate — let Claude Code fall back to stateless when maxpool routed a threaded
|
|
2
|
+
// turn somewhere that cannot serve it.
|
|
3
|
+
//
|
|
4
|
+
// WHY (2026-09-10). Claude Code >= 2.1.265 keeps the conversation on Anthropic's servers
|
|
5
|
+
// and sends only the tail plus `thread:{type:"continue", previous_message_id}`. That
|
|
6
|
+
// removed the self-containment every maxpool routing capability rests on: a different
|
|
7
|
+
// Anthropic account 404s ("No thread state was found"), and GLM/Kimi reject a transcript
|
|
8
|
+
// that opens mid-tool-call (z.ai `[1214]`). Measured: 10 of 12 live requests carry it.
|
|
9
|
+
//
|
|
10
|
+
// HOW. We do NOT rebuild the transcript. The client already knows how to fall back, and
|
|
11
|
+
// Anthropic built the signal for exactly this case — a proxy that cannot honour threads.
|
|
12
|
+
// A 400 carrying `error.details.error_code = "thread_unsupported_request"` makes the
|
|
13
|
+
// client resend that turn stateless AND stop using threads for that agent+model for the
|
|
14
|
+
// rest of the session. So the client replays with the transcript it already holds; we
|
|
15
|
+
// never reuse a thread reference and therefore can never serve a stale conversation.
|
|
16
|
+
//
|
|
17
|
+
// Routing is NOT consulted or constrained. This runs after the account has been chosen;
|
|
18
|
+
// it only decides what to say to it.
|
|
19
|
+
|
|
20
|
+
export const THREAD_UNSUPPORTED_CODE = 'thread_unsupported_request';
|
|
21
|
+
|
|
22
|
+
/** What kind of thread intent a request body carries. Cheap: only the head of the body
|
|
23
|
+
* is JSON-parsed, and a non-JSON body is simply 'none'. */
|
|
24
|
+
export function readThreadIntent(body) {
|
|
25
|
+
try {
|
|
26
|
+
const j = JSON.parse(body.toString('utf8'));
|
|
27
|
+
const t = j?.thread;
|
|
28
|
+
if (!t || typeof t !== 'object') {
|
|
29
|
+
// `previous_message_id` can also ride in `diagnostics`; that alone is not a thread.
|
|
30
|
+
return { kind: 'none' };
|
|
31
|
+
}
|
|
32
|
+
if (t.type === 'continue') return { kind: 'continue', previousMessageId: t.previous_message_id || null };
|
|
33
|
+
if (t.type === 'create') return { kind: 'create' };
|
|
34
|
+
return { kind: 'none' };
|
|
35
|
+
} catch {
|
|
36
|
+
return { kind: 'none' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The exact body the client's classifier reads. `details.error_code` is the field it
|
|
41
|
+
* keys on; the message is free text and is never shown to a person. */
|
|
42
|
+
export function threadRefusalBody(accountName) {
|
|
43
|
+
return {
|
|
44
|
+
type: 'error',
|
|
45
|
+
error: {
|
|
46
|
+
type: 'invalid_request_error',
|
|
47
|
+
message: `maxpool routed this turn to "${accountName}", which does not hold this thread. Resend it stateless.`,
|
|
48
|
+
details: { error_code: THREAD_UNSUPPORTED_CODE },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sessions whose last threaded turn we served, and how many times we have refused them.
|
|
54
|
+
// Two short strings per entry; bounded and LRU-evicted.
|
|
55
|
+
const MAX_SESSIONS = 500;
|
|
56
|
+
// A session that keeps sending threaded turns after being refused is one whose client
|
|
57
|
+
// did NOT take the downgrade (a different agent id, a model switch, an older build).
|
|
58
|
+
// Refusing forever would double its request volume, so stop and forward instead.
|
|
59
|
+
const MAX_CONSECUTIVE_REFUSALS = 2;
|
|
60
|
+
|
|
61
|
+
export class ThreadOwners {
|
|
62
|
+
constructor({ maxSessions = MAX_SESSIONS, maxRefusals = MAX_CONSECUTIVE_REFUSALS } = {}) {
|
|
63
|
+
this.map = new Map(); // sessionKey -> { owner, refusals }
|
|
64
|
+
this.maxSessions = maxSessions;
|
|
65
|
+
this.maxRefusals = maxRefusals;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_touch(key) {
|
|
69
|
+
const v = this.map.get(key);
|
|
70
|
+
if (v !== undefined) { this.map.delete(key); this.map.set(key, v); } // LRU bump
|
|
71
|
+
return v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_set(key, value) {
|
|
75
|
+
this.map.delete(key);
|
|
76
|
+
this.map.set(key, value);
|
|
77
|
+
while (this.map.size > this.maxSessions) this.map.delete(this.map.keys().next().value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Decide AFTER routing has chosen. Returns true only for a `continue` turn that the
|
|
81
|
+
* chosen account cannot serve, and only while refusals are still under the bound. */
|
|
82
|
+
shouldRefuse(sessionKey, accountName, intent) {
|
|
83
|
+
if (!sessionKey || !accountName) return false;
|
|
84
|
+
if (intent?.kind !== 'continue') return false; // `create` carries the full transcript
|
|
85
|
+
const entry = this._touch(sessionKey);
|
|
86
|
+
if (entry && entry.owner === accountName) return false; // the account that holds it
|
|
87
|
+
if (entry && entry.refusals >= this.maxRefusals) return false; // bounded fail-open
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Record a refusal we are about to emit. */
|
|
92
|
+
noteRefused(sessionKey) {
|
|
93
|
+
if (!sessionKey) return;
|
|
94
|
+
const entry = this._touch(sessionKey) || { owner: null, refusals: 0 };
|
|
95
|
+
this._set(sessionKey, { owner: entry.owner, refusals: entry.refusals + 1 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Record that `accountName` served a threaded turn for this session — it now holds
|
|
99
|
+
* the thread. Any refusal streak ends here. */
|
|
100
|
+
noteServed(sessionKey, accountName, intent) {
|
|
101
|
+
if (!sessionKey || !accountName) return;
|
|
102
|
+
if (intent?.kind !== 'create' && intent?.kind !== 'continue') return;
|
|
103
|
+
this._set(sessionKey, { owner: accountName, refusals: 0 });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get size() { return this.map.size; }
|
|
107
|
+
}
|