maxpool 1.19.3 → 1.19.5

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.3",
3
+ "version": "1.19.5",
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",
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,26 @@ 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
+ // `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.
597
+ const threadIntent = THREAD_GATE_ENABLED ? readThreadIntent(body) : { kind: 'none' };
598
+ if (threadOwners.shouldRefuse(requestInfo.sessionKey, account.name, threadIntent)) {
599
+ threadOwners.noteRefused(requestInfo.sessionKey, account.name);
600
+ accountManager.releaseAccount(lease, { neutral: true });
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)}]`);
602
+ ctx.status = 400;
603
+ sendErrorResponse(res, requestInfo, 400, threadRefusalBody(account.name));
604
+ return;
605
+ }
606
+ // This account is about to serve the turn, so it holds the thread from here on.
607
+ // Recorded optimistically: if the turn fails, the next one is refused anyway.
608
+ threadOwners.noteServed(requestInfo.sessionKey, account.name, threadIntent);
609
+
589
610
  // Build log sections
590
611
  const logSections = [];
591
612
  if (logDir) {
@@ -2901,6 +2922,12 @@ function describeBodyShape(buf) {
2901
2922
  }
2902
2923
  }
2903
2924
 
2925
+ // THREAD GATE (2026-09-11). Runs AFTER routing has chosen, so it never influences the
2926
+ // choice — it only decides what to say to the account that was picked. See
2927
+ // src/thread-gate.js for why this replaces rebuilding the transcript.
2928
+ const threadOwners = new ThreadOwners();
2929
+ const THREAD_GATE_ENABLED = process.env.MAXPOOL_THREAD_GATE !== '0';
2930
+
2904
2931
  function rewriteBodyForAccount(body, account) {
2905
2932
  if (!body.length || (!account.model && !account.modelMap)) return body;
2906
2933
 
@@ -0,0 +1,121 @@
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' };
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
+ // Bucket for requests that carry no session header — keyed per account so the storm
61
+ // bound still applies without pretending we know whose conversation it is.
62
+ const NO_SESSION_PREFIX = '\u0000nosession:';
63
+
64
+ export class ThreadOwners {
65
+ constructor({ maxSessions = MAX_SESSIONS, maxRefusals = MAX_CONSECUTIVE_REFUSALS } = {}) {
66
+ this.map = new Map(); // sessionKey -> { owner, refusals }
67
+ this.maxSessions = maxSessions;
68
+ this.maxRefusals = maxRefusals;
69
+ }
70
+
71
+ _touch(key) {
72
+ const v = this.map.get(key);
73
+ if (v !== undefined) { this.map.delete(key); this.map.set(key, v); } // LRU bump
74
+ return v;
75
+ }
76
+
77
+ _set(key, value) {
78
+ this.map.delete(key);
79
+ this.map.set(key, value);
80
+ while (this.map.size > this.maxSessions) this.map.delete(this.map.keys().next().value);
81
+ }
82
+
83
+ /** 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
+ shouldRefuse(sessionKey, accountName, intent) {
86
+ if (!accountName) return false;
87
+ if (intent?.kind !== 'continue') return false; // `create` carries the full transcript
88
+ // A request with NO session header is invisible to ownership tracking, and measured
89
+ // 2026-09-11 those are the majority of traffic — 14 of 20 consecutive /v1/messages
90
+ // lines carried no `[sess …]`. Skipping them left threaded turns reaching GLM and
91
+ // failing exactly as before the gate existed. We cannot know the owner, so treat it
92
+ // as an unknown session (refuse, the safe direction) and bucket the refusal COUNT
93
+ // per account so the storm bound still applies.
94
+ const key = sessionKey || `${NO_SESSION_PREFIX}${accountName}`;
95
+ const entry = this._touch(key);
96
+ if (entry && entry.owner === accountName) return false; // the account that holds it
97
+ if (entry && entry.refusals >= this.maxRefusals) return false; // bounded fail-open
98
+ return true;
99
+ }
100
+
101
+ /** Record a refusal we are about to emit. */
102
+ noteRefused(sessionKey, accountName = null) {
103
+ const key = sessionKey || (accountName ? `${NO_SESSION_PREFIX}${accountName}` : null);
104
+ if (!key) return;
105
+ const entry = this._touch(key) || { owner: null, refusals: 0 };
106
+ this._set(key, { owner: entry.owner, refusals: entry.refusals + 1 });
107
+ }
108
+
109
+ /** Record that `accountName` served a threaded turn for this session — it now holds
110
+ * the thread. Any refusal streak ends here. */
111
+ noteServed(sessionKey, accountName, intent) {
112
+ if (!accountName) return;
113
+ if (intent?.kind !== 'create' && intent?.kind !== 'continue') return;
114
+ // Without a session header there is no conversation to attribute ownership to; only
115
+ // clear the per-account refusal streak so a served turn re-arms the bound.
116
+ const key = sessionKey || `${NO_SESSION_PREFIX}${accountName}`;
117
+ this._set(key, { owner: sessionKey ? accountName : null, refusals: 0 });
118
+ }
119
+
120
+ get size() { return this.map.size; }
121
+ }
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
- export const __tuiTest = { formatReset, quotaLabel, bar, emptyBar, strip, loadText, countdown, acctHeader, fitLine, providerLabel };
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
- this.hideDisabled = false;
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)'}`);