maxpool 1.19.4 → 1.19.6
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 +34 -4
- package/src/thread-gate.js +47 -11
- package/src/tui.js +47 -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';
|
|
@@ -592,9 +592,11 @@ async function forwardRequest(
|
|
|
592
592
|
// transcript. Hand the client the signal it already knows how to act on — it resends
|
|
593
593
|
// the turn stateless and stops threading for the session — instead of letting the
|
|
594
594
|
// upstream produce an error the user sees.
|
|
595
|
+
// `kind:'none'` when the gate is off makes every branch below a no-op, so the
|
|
596
|
+
// disabled path costs one comparison and needs no further guarding.
|
|
595
597
|
const threadIntent = THREAD_GATE_ENABLED ? readThreadIntent(body) : { kind: 'none' };
|
|
596
|
-
if (
|
|
597
|
-
threadOwners.noteRefused(requestInfo.sessionKey);
|
|
598
|
+
if (threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent, isThreadlessAccount(account))) {
|
|
599
|
+
threadOwners.noteRefused(requestInfo.sessionKey, account.name);
|
|
598
600
|
accountManager.releaseAccount(lease, { neutral: true });
|
|
599
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)}]`);
|
|
600
602
|
ctx.status = 400;
|
|
@@ -603,7 +605,7 @@ async function forwardRequest(
|
|
|
603
605
|
}
|
|
604
606
|
// This account is about to serve the turn, so it holds the thread from here on.
|
|
605
607
|
// Recorded optimistically: if the turn fails, the next one is refused anyway.
|
|
606
|
-
|
|
608
|
+
threadOwners.noteServed(requestInfo.sessionKey, account.name, threadIntent);
|
|
607
609
|
|
|
608
610
|
// Build log sections
|
|
609
611
|
const logSections = [];
|
|
@@ -1041,6 +1043,27 @@ async function forwardRequest(
|
|
|
1041
1043
|
// Providers answer with a code and no field name, so record what WE sent.
|
|
1042
1044
|
if (account.type === 'provider') {
|
|
1043
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
|
+
}
|
|
1044
1067
|
}
|
|
1045
1068
|
}
|
|
1046
1069
|
const errorType = errorBody.includes('Invalid `signature` in `thinking` block')
|
|
@@ -1717,6 +1740,13 @@ function unavailableMessage(accountManager, requestInfo = {}, retryAfter, willRe
|
|
|
1717
1740
|
* Matched by CODE, never by prose: an error whose message names its field (Anthropic's
|
|
1718
1741
|
* own 400s do) is a real client fault and keeps its own clear message.
|
|
1719
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
|
+
|
|
1720
1750
|
function isProviderParamRejection(errorBody) {
|
|
1721
1751
|
if (!errorBody) return false;
|
|
1722
1752
|
return /\[1210\]|"code"\s*:\s*"?1210"?/.test(errorBody);
|
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) {
|
|
@@ -29,7 +36,7 @@ export function readThreadIntent(body) {
|
|
|
29
36
|
// `previous_message_id` can also ride in `diagnostics`; that alone is not a thread.
|
|
30
37
|
return { kind: 'none' };
|
|
31
38
|
}
|
|
32
|
-
if (t.type === 'continue') return { kind: 'continue'
|
|
39
|
+
if (t.type === 'continue') return { kind: 'continue' };
|
|
33
40
|
if (t.type === 'create') return { kind: 'create' };
|
|
34
41
|
return { kind: 'none' };
|
|
35
42
|
} catch {
|
|
@@ -57,6 +64,9 @@ const MAX_SESSIONS = 500;
|
|
|
57
64
|
// did NOT take the downgrade (a different agent id, a model switch, an older build).
|
|
58
65
|
// Refusing forever would double its request volume, so stop and forward instead.
|
|
59
66
|
const MAX_CONSECUTIVE_REFUSALS = 2;
|
|
67
|
+
// Bucket for requests that carry no session header — keyed per account so the storm
|
|
68
|
+
// bound still applies without pretending we know whose conversation it is.
|
|
69
|
+
const NO_SESSION_PREFIX = '\u0000nosession:';
|
|
60
70
|
|
|
61
71
|
export class ThreadOwners {
|
|
62
72
|
constructor({ maxSessions = MAX_SESSIONS, maxRefusals = MAX_CONSECUTIVE_REFUSALS } = {}) {
|
|
@@ -78,29 +88,55 @@ export class ThreadOwners {
|
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
/** 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
|
-
|
|
83
|
-
|
|
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) {
|
|
107
|
+
if (!accountName) return false;
|
|
84
108
|
if (intent?.kind !== 'continue') return false; // `create` carries the full transcript
|
|
85
|
-
|
|
109
|
+
if (isProvider) return true; // can never serve an Anthropic thread
|
|
110
|
+
// A request with NO session header is invisible to ownership tracking, and measured
|
|
111
|
+
// 2026-09-11 those are the majority of traffic — 14 of 20 consecutive /v1/messages
|
|
112
|
+
// lines carried no `[sess …]`. Skipping them left threaded turns reaching GLM and
|
|
113
|
+
// failing exactly as before the gate existed. We cannot know the owner, so treat it
|
|
114
|
+
// as an unknown session (refuse, the safe direction) and bucket the refusal COUNT
|
|
115
|
+
// per account so the storm bound still applies.
|
|
116
|
+
const key = sessionKey || `${NO_SESSION_PREFIX}${accountName}`;
|
|
117
|
+
const entry = this._touch(key);
|
|
86
118
|
if (entry && entry.owner === accountName) return false; // the account that holds it
|
|
87
119
|
if (entry && entry.refusals >= this.maxRefusals) return false; // bounded fail-open
|
|
88
120
|
return true;
|
|
89
121
|
}
|
|
90
122
|
|
|
91
123
|
/** Record a refusal we are about to emit. */
|
|
92
|
-
noteRefused(sessionKey) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.
|
|
124
|
+
noteRefused(sessionKey, accountName = null) {
|
|
125
|
+
const key = sessionKey || (accountName ? `${NO_SESSION_PREFIX}${accountName}` : null);
|
|
126
|
+
if (!key) return;
|
|
127
|
+
const entry = this._touch(key) || { owner: null, refusals: 0 };
|
|
128
|
+
this._set(key, { owner: entry.owner, refusals: entry.refusals + 1 });
|
|
96
129
|
}
|
|
97
130
|
|
|
98
131
|
/** Record that `accountName` served a threaded turn for this session — it now holds
|
|
99
132
|
* the thread. Any refusal streak ends here. */
|
|
100
133
|
noteServed(sessionKey, accountName, intent) {
|
|
101
|
-
if (!
|
|
134
|
+
if (!accountName) return;
|
|
102
135
|
if (intent?.kind !== 'create' && intent?.kind !== 'continue') return;
|
|
103
|
-
|
|
136
|
+
// Without a session header there is no conversation to attribute ownership to; only
|
|
137
|
+
// clear the per-account refusal streak so a served turn re-arms the bound.
|
|
138
|
+
const key = sessionKey || `${NO_SESSION_PREFIX}${accountName}`;
|
|
139
|
+
this._set(key, { owner: sessionKey ? accountName : null, refusals: 0 });
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
get size() { return this.map.size; }
|
package/src/tui.js
CHANGED
|
@@ -332,7 +332,28 @@ function emptyBar(label, w = 10) {
|
|
|
332
332
|
return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
|
|
335
|
+
/** Write a provider's enabled flag into the CONFIG entry `loadConfigProviders` reads.
|
|
336
|
+
*
|
|
337
|
+
* A config-sourced provider is re-created from config on every reload
|
|
338
|
+
* (`enabled: entry.enabled !== false`), so a runtime-only flag is undone by the next
|
|
339
|
+
* auto-update. Measured 2026-09-11: Kimi, disabled by the owner, came back enabled
|
|
340
|
+
* after an update because its config entry carried no `enabled` key.
|
|
341
|
+
*
|
|
342
|
+
* Returns `{changed, previous}` so the caller can roll back if the save fails.
|
|
343
|
+
* `changed:false` means there is no config entry — a header-derived provider, whose
|
|
344
|
+
* runtime-only flag IS durable for it.
|
|
345
|
+
*/
|
|
346
|
+
export function applyProviderEnabledToConfig(config, name, enabled) {
|
|
347
|
+
const list = config?.providers;
|
|
348
|
+
if (!Array.isArray(list)) return { changed: false, previous: undefined };
|
|
349
|
+
const i = list.findIndex(p => p?.name === name);
|
|
350
|
+
if (i < 0) return { changed: false, previous: undefined };
|
|
351
|
+
const previous = list[i].enabled;
|
|
352
|
+
list[i].enabled = enabled;
|
|
353
|
+
return { changed: true, previous };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const __tuiTest = { applyProviderEnabledToConfig, formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine, providerLabel };
|
|
336
357
|
|
|
337
358
|
function timestamp() {
|
|
338
359
|
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
@@ -359,7 +380,8 @@ export class TUI {
|
|
|
359
380
|
this.capacityWindow = 'ses'; // capacity page window: 'ses' (5h) | 'wk' (weekly)
|
|
360
381
|
// Hide disabled accounts from the table. With 8 dead/disabled accounts the live
|
|
361
382
|
// ones scroll off the top; `h` collapses them to a one-line summary.
|
|
362
|
-
|
|
383
|
+
// Restored from config so an auto-update/reload does not un-hide what the user hid.
|
|
384
|
+
this.hideDisabled = this.config?.ui?.hideDisabled === true;
|
|
363
385
|
this.selAction = null; // prefer | toggle | delete
|
|
364
386
|
this.selIdx = 0;
|
|
365
387
|
this.inputPrompt = '';
|
|
@@ -556,6 +578,13 @@ export class TUI {
|
|
|
556
578
|
} else if (k === 'h') {
|
|
557
579
|
this.hideDisabled = !this.hideDisabled;
|
|
558
580
|
this._addLog(this.hideDisabled ? 'Hiding disabled accounts' : 'Showing all accounts');
|
|
581
|
+
// Persist the view choice; a reload otherwise resets it to "show all" every time.
|
|
582
|
+
// Fully defensive: a VIEW preference must never break the toggle itself, and the
|
|
583
|
+
// TUI is constructed without a config in several tests and in early startup.
|
|
584
|
+
if (this.config) {
|
|
585
|
+
this.config.ui = { ...(this.config.ui || {}), hideDisabled: this.hideDisabled };
|
|
586
|
+
try { this.saveConfig?.(this.config)?.catch?.(() => {}); } catch { /* view-only */ }
|
|
587
|
+
}
|
|
559
588
|
}
|
|
560
589
|
// Enable/disable lives ONLY under [a] Accounts now (with rename/delete/login) —
|
|
561
590
|
// one home for every account mutation, instead of a duplicate top-level toggle.
|
|
@@ -1540,6 +1569,22 @@ export class TUI {
|
|
|
1540
1569
|
// on the next save, so it stays benched across a restart too. Re-enable it here the
|
|
1541
1570
|
// same way whenever the user wants it back — there's no "removed forever" state.
|
|
1542
1571
|
if (account.type === 'provider') {
|
|
1572
|
+
// A CONFIG-SOURCED provider (one with a `providers:` entry) is re-created from
|
|
1573
|
+
// config on every reload — `loadConfigProviders` computes `enabled: entry.enabled
|
|
1574
|
+
// !== false` — so a runtime-only flag is silently undone by the next update or
|
|
1575
|
+
// restart. Persist it where that read happens. Measured 2026-09-11: Kimi, disabled
|
|
1576
|
+
// by the owner, came back enabled after an auto-update because its config entry
|
|
1577
|
+
// had no `enabled` key. Header-derived providers (no config entry) keep the
|
|
1578
|
+
// runtime-only path below, which is durable for them.
|
|
1579
|
+
const applied = applyProviderEnabledToConfig(this.config, account.name, enabled);
|
|
1580
|
+
if (applied.changed) {
|
|
1581
|
+
try {
|
|
1582
|
+
await this.saveConfig(this.config);
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
applyProviderEnabledToConfig(this.config, account.name, applied.previous);
|
|
1585
|
+
throw error;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1543
1588
|
this.am.setAccountEnabled(idx, enabled);
|
|
1544
1589
|
if (!enabled && this.am.preferredAccountName === account.name) this.am.setRoutingMode?.('automatic');
|
|
1545
1590
|
this._addLog(`${enabled ? 'Enabled' : 'Disabled'} provider "${account.name}" — ${enabled ? 'routing resumed' : 'benched (stays off across cc all + restart; re-enable here anytime)'}`);
|