maxpool 1.19.5 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maxpool",
3
- "version": "1.19.5",
3
+ "version": "1.19.6",
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,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);
@@ -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
- shouldRefuse(sessionKey, accountName, intent) {
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