maxpool 1.5.68 → 1.5.69
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 +2 -1
- package/src/account-manager.js +4 -1
- package/src/index.js +42 -6
- package/src/oauth.js +35 -9
- package/src/prober.js +12 -3
- package/src/reload-protocol.js +8 -0
- package/src/tui.js +26 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "maxpool",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.69",
|
|
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",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"node": ">=20.3.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
+
"eslint": "^9.39.5",
|
|
44
45
|
"git-cliff": "2.13.1"
|
|
45
46
|
}
|
|
46
47
|
}
|
package/src/account-manager.js
CHANGED
|
@@ -2970,7 +2970,10 @@ export class AccountManager {
|
|
|
2970
2970
|
// Remove existing config-sourced providers that are no longer in the config
|
|
2971
2971
|
// (handles a config edit that removes an entry).
|
|
2972
2972
|
const wantedNames = new Set(entries.map(e => e.name).filter(Boolean));
|
|
2973
|
-
|
|
2973
|
+
// Iterate a COPY: removeAccount splices this.accounts, and splicing the array
|
|
2974
|
+
// being iterated makes the loop skip the element after each removal — so
|
|
2975
|
+
// dropping 2 of 3 config providers left one phantom behind.
|
|
2976
|
+
for (const a of [...this.accounts]) {
|
|
2974
2977
|
if (a.configSourced && !wantedNames.has(a.name)) {
|
|
2975
2978
|
const idx = this.accounts.indexOf(a);
|
|
2976
2979
|
if (idx >= 0 && this.accounts[idx].inFlight === 0) this.removeAccount(idx);
|
package/src/index.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
RELOAD_SWAPPED, RELOAD_ROLLED_BACK,
|
|
38
38
|
MSG_LISTEN, MSG_RELEASE, MSG_TAKEOVER, MSG_PROBE_READY,
|
|
39
39
|
MSG_RELOAD_REQUEST, MSG_READY, MSG_FAILED, MSG_RELEASED, MSG_PRIMARY, MSG_ROLLED_BACK,
|
|
40
|
+
MSG_TTY_REASSERT,
|
|
40
41
|
} from './reload-protocol.js';
|
|
41
42
|
|
|
42
43
|
const args = process.argv.slice(2);
|
|
@@ -370,7 +371,14 @@ async function supervisorCommand() {
|
|
|
370
371
|
// sent PRIMARY during the baton; re-assert the master-accept close.
|
|
371
372
|
monitorAsActive(newWorker);
|
|
372
373
|
closeMasterAccept().catch(() => {});
|
|
373
|
-
|
|
374
|
+
// Tell the new primary to re-assert raw mode ONCE the old worker has actually
|
|
375
|
+
// exited — its exit resets the shared terminal out from under the new worker
|
|
376
|
+
// (see MSG_TTY_REASSERT). Driven off the real exit event, never a timer: with
|
|
377
|
+
// in-flight streams the old worker can linger, so a timed guess would leave the
|
|
378
|
+
// terminal working for a while and then die mid-session.
|
|
379
|
+
reapOldWorker(oldWorker, () => {
|
|
380
|
+
try { newWorker.send({ type: MSG_TTY_REASSERT }); } catch { /* worker gone */ }
|
|
381
|
+
});
|
|
374
382
|
return;
|
|
375
383
|
}
|
|
376
384
|
|
|
@@ -460,14 +468,21 @@ async function supervisorCommand() {
|
|
|
460
468
|
|
|
461
469
|
// Drain + reap a released worker. The worker exits itself once its bounded
|
|
462
470
|
// in-flight finishes; the supervisor SIGKILLs it if it outlives the drain cap.
|
|
463
|
-
function reapOldWorker(worker) {
|
|
471
|
+
function reapOldWorker(worker, onExited = () => {}) {
|
|
464
472
|
if (!worker) return;
|
|
465
473
|
// +30s grace over the worker's own RELOAD_DRAIN_MS hardCap so its clean exit(0)
|
|
466
474
|
// (fired while its streaming socket keeps the loop alive) always wins the race —
|
|
467
475
|
// SIGKILL only ever reaps a genuinely wedged worker, never cuts a live stream.
|
|
468
476
|
const cap = RELOAD_DRAIN_MS + 30_000;
|
|
469
477
|
let reaped = false;
|
|
470
|
-
|
|
478
|
+
// `onExited` fires on BOTH paths (clean exit and SIGKILL): the terminal is clobbered
|
|
479
|
+
// by the exit itself, however that exit came about.
|
|
480
|
+
const finish = () => {
|
|
481
|
+
if (reaped) return;
|
|
482
|
+
reaped = true;
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
try { onExited(); } catch { /* never let the callback break reaping */ }
|
|
485
|
+
};
|
|
471
486
|
const timer = setTimeout(() => {
|
|
472
487
|
if (reaped) return;
|
|
473
488
|
console.error(`[Maxpool] Old worker outlived ${Math.ceil(cap / 1000)}s reload-drain cap; SIGKILL.`);
|
|
@@ -613,9 +628,7 @@ async function serverWorkerCommand() {
|
|
|
613
628
|
const { resolveSecrets } = await import('./secret-resolver.js');
|
|
614
629
|
// Split: GCP-sourced (secretName) vs direct (apiKey). Both produce a token
|
|
615
630
|
// the same way — the resolution path is the only difference.
|
|
616
|
-
const
|
|
617
|
-
const directEntries = config.providers.filter(p => p.apiKey && !p.secretName);
|
|
618
|
-
const secretNames = gcpEntries.map(p => p.secretName);
|
|
631
|
+
const secretNames = config.providers.filter(p => p.secretName).map(p => p.secretName);
|
|
619
632
|
const resolved = await resolveSecrets(secretNames);
|
|
620
633
|
const entries = config.providers.map(p => ({
|
|
621
634
|
...p,
|
|
@@ -1389,6 +1402,29 @@ async function serverWorkerCommand() {
|
|
|
1389
1402
|
// sends MSG_PRIMARY (the baton waits on it).
|
|
1390
1403
|
await listenOnHandle(handle);
|
|
1391
1404
|
await becomePrimary({ viaTakeover: true });
|
|
1405
|
+
} else if (msg?.type === MSG_TTY_REASSERT) {
|
|
1406
|
+
// The old worker has now EXITED, and its exit reset the shared terminal out
|
|
1407
|
+
// of raw mode (libuv's uv_tty_reset_mode on process exit). Reclaim it.
|
|
1408
|
+
//
|
|
1409
|
+
// The toggle OFF then ON is load-bearing: Node short-circuits setRawMode(true)
|
|
1410
|
+
// when it believes isRaw is already true — and it does believe that, because
|
|
1411
|
+
// the clobber happened in ANOTHER process and left our flag untouched. A bare
|
|
1412
|
+
// setRawMode(true) here is a silent no-op (verified on a real pty 2026-08-07).
|
|
1413
|
+
if (process.stdin.isTTY) {
|
|
1414
|
+
try {
|
|
1415
|
+
process.stdin.setRawMode(false);
|
|
1416
|
+
process.stdin.setRawMode(true);
|
|
1417
|
+
process.stdin.resume();
|
|
1418
|
+
} catch { /* terminal gone — nothing to reclaim */ }
|
|
1419
|
+
}
|
|
1420
|
+
// Re-assert the screen state too: the dying worker's alt-screen exit and
|
|
1421
|
+
// cursor-show can land after ours.
|
|
1422
|
+
if (tui?.running && process.stdout.isTTY) {
|
|
1423
|
+
try {
|
|
1424
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
1425
|
+
tui.render();
|
|
1426
|
+
} catch { /* ignore */ }
|
|
1427
|
+
}
|
|
1392
1428
|
} else if (msg?.type === MSG_ROLLED_BACK) {
|
|
1393
1429
|
// Why the swap failed decides whether a cold restart is safe (see the watchdog).
|
|
1394
1430
|
lastRollbackReason = msg.reason || null;
|
package/src/oauth.js
CHANGED
|
@@ -289,13 +289,22 @@ export async function fetchUsage(accessToken) {
|
|
|
289
289
|
// (account.upstream is the Anthropic-compat endpoint). Zero-spend read.
|
|
290
290
|
const ZAI_QUOTA_URL = 'https://api.z.ai/api/monitor/usage/quota/limit';
|
|
291
291
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
// z.ai reports a plan's consumption cap under DIFFERENT type names depending on the
|
|
293
|
+
// plan: `TOKENS_LIMIT` on older coding plans, `CREDIT_LIMIT` on newer ones. Both are
|
|
294
|
+
// the same thing for our purposes — "how much of your allowance is used" — and both
|
|
295
|
+
// carry the same `percentage` + `nextResetTime` + `unit` fields. Accepting only
|
|
296
|
+
// TOKENS_LIMIT left every CREDIT_LIMIT account with no quota reading at all, so the
|
|
297
|
+
// TUI showed "probing" forever even though the poll succeeded (measured 2026-08-07 on
|
|
298
|
+
// a newly-provisioned `max` plan returning CREDIT_LIMIT unit 3 + unit 6).
|
|
299
|
+
const ZAI_CONSUMPTION_TYPES = new Set(['TOKENS_LIMIT', 'CREDIT_LIMIT']);
|
|
300
|
+
|
|
301
|
+
/** Classify one z.ai `limits[]` entry into a Ses (5h) or Wk (weekly) consumption
|
|
302
|
+
* window. `TIME_LIMIT` is a tool-call cap (web-search/reader counts) and is
|
|
303
|
+
* intentionally ignored. `unit` is z.ai's window enum (3 = 5-hour session,
|
|
304
|
+
* 6 = weekly); we fall back to reset-distance when the code is unfamiliar so a new
|
|
305
|
+
* plan tier still classifies sanely. */
|
|
297
306
|
export function classifyZaiLimit(l, now = Date.now()) {
|
|
298
|
-
if (!l || l.type
|
|
307
|
+
if (!l || !ZAI_CONSUMPTION_TYPES.has(l.type)) return null;
|
|
299
308
|
const reset = Number(l.nextResetTime);
|
|
300
309
|
const resetAt = Number.isFinite(reset) && reset > 0 ? reset : null;
|
|
301
310
|
const pct = typeof l.percentage === 'number' ? l.percentage : parseFloat(l.percentage);
|
|
@@ -303,7 +312,13 @@ export function classifyZaiLimit(l, now = Date.now()) {
|
|
|
303
312
|
let bucket;
|
|
304
313
|
if (l.unit === 3) bucket = 'ses';
|
|
305
314
|
else if (l.unit === 6) bucket = 'wk';
|
|
306
|
-
else if (
|
|
315
|
+
else if (l.type === 'CREDIT_LIMIT') {
|
|
316
|
+
// The reset-distance fallback is NOT extended to CREDIT_LIMIT. `unit: 5` is what
|
|
317
|
+
// z.ai uses for the monthly TOOL-call cap; if it ever renames that cap to
|
|
318
|
+
// CREDIT_LIMIT the fallback would render a web-search allowance as the weekly
|
|
319
|
+
// model-quota bar. Only the two window enums we have actually observed count.
|
|
320
|
+
return null;
|
|
321
|
+
} else if (resetAt) bucket = (resetAt - now) <= 12 * 60 * 60 * 1000 ? 'ses' : 'wk';
|
|
307
322
|
else bucket = 'ses';
|
|
308
323
|
return { bucket, utilization, resetAt };
|
|
309
324
|
}
|
|
@@ -406,11 +421,22 @@ export async function fetchProviderUsage(account) {
|
|
|
406
421
|
}
|
|
407
422
|
const now = Date.now();
|
|
408
423
|
let ses = null, wk = null;
|
|
424
|
+
// Two entries can land in the SAME bucket (e.g. a plan reporting both a
|
|
425
|
+
// TOKENS_LIMIT and a CREDIT_LIMIT for its 5h window). Keep the HIGHER utilization:
|
|
426
|
+
// last-wins was silently optimistic — 95% followed by 4% reported 4%, i.e. an
|
|
427
|
+
// account at its cap rendered as nearly empty.
|
|
428
|
+
const keepWorse = (cur, next) => {
|
|
429
|
+
if (!cur) return next;
|
|
430
|
+
if (next.utilization == null) return cur;
|
|
431
|
+
if (cur.utilization == null) return next;
|
|
432
|
+
return next.utilization > cur.utilization ? next : cur;
|
|
433
|
+
};
|
|
409
434
|
for (const l of (Array.isArray(data.data.limits) ? data.data.limits : [])) {
|
|
410
435
|
const c = classifyZaiLimit(l, now);
|
|
411
436
|
if (!c) continue;
|
|
412
|
-
|
|
413
|
-
|
|
437
|
+
const entry = { utilization: c.utilization, resetAt: c.resetAt };
|
|
438
|
+
if (c.bucket === 'ses') ses = keepWorse(ses, entry);
|
|
439
|
+
else if (c.bucket === 'wk') wk = keepWorse(wk, entry);
|
|
414
440
|
}
|
|
415
441
|
return { ses, wk, level: data.data.level || null, source: 'zai' };
|
|
416
442
|
} catch (err) {
|
package/src/prober.js
CHANGED
|
@@ -176,9 +176,18 @@ export class Prober {
|
|
|
176
176
|
await this.am.ensureTokenFresh(account.index);
|
|
177
177
|
let usage = await this._withTimeout(this.probeFn(account.credential));
|
|
178
178
|
if (usage?.status === 401) {
|
|
179
|
-
// Token rejected — force a refresh and retry once.
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
// Token rejected — force a refresh and retry once. NOT for a DISABLED account:
|
|
180
|
+
// `force` deliberately overrides the no-rotate guard in ensureTokenFresh (it
|
|
181
|
+
// exists for user-initiated re-auth), so forcing here would rotate the very
|
|
182
|
+
// single-use token that guard protects. Worse, it is GUARANTEED to fire for a
|
|
183
|
+
// disabled account: blocking the proactive refresh means the token always
|
|
184
|
+
// expires, which always 401s — moving the rotation from the controlled
|
|
185
|
+
// pre-expiry path into this error path. Accept the 401 and record it; the
|
|
186
|
+
// account refreshes normally the moment it is re-enabled.
|
|
187
|
+
if (account.enabled !== false) {
|
|
188
|
+
await this.am.ensureTokenFresh(account.index, true);
|
|
189
|
+
usage = await this._withTimeout(this.probeFn(account.credential));
|
|
190
|
+
}
|
|
182
191
|
}
|
|
183
192
|
if (usage == null) { // timed out
|
|
184
193
|
this.am.recordProbeError?.(account.index, 'probe timed out', null);
|
package/src/reload-protocol.js
CHANGED
|
@@ -126,3 +126,11 @@ export async function runReloadBaton({
|
|
|
126
126
|
log('reload: cutover complete; new worker is primary');
|
|
127
127
|
return RELOAD_SWAPPED;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
// Sent to the NEW primary worker once the OLD worker has actually exited. Node calls
|
|
131
|
+
// libuv's uv_tty_reset_mode() on process exit, which restores the termios the dying
|
|
132
|
+
// process saved when IT first enabled raw mode — clobbering the live worker's terminal
|
|
133
|
+
// back to canonical (cooked) mode. `process.stdin.isRaw` still reads true, so nothing
|
|
134
|
+
// in-process can detect it; the TUI keeps rendering while the kernel line-buffers, so
|
|
135
|
+
// every keystroke (r / q / p) is swallowed. Measured on a real pty 2026-08-07.
|
|
136
|
+
export const MSG_TTY_REASSERT = 'tty-reassert';
|
package/src/tui.js
CHANGED
|
@@ -599,7 +599,7 @@ export class TUI {
|
|
|
599
599
|
|
|
600
600
|
_keyProviders(k) {
|
|
601
601
|
if (k === 'a') {
|
|
602
|
-
this._providerAddStep('
|
|
602
|
+
this._providerAddStep('type');
|
|
603
603
|
} else if (k === 'd') {
|
|
604
604
|
this._startProviderSelection('delete');
|
|
605
605
|
} else if (k === 't') {
|
|
@@ -609,35 +609,22 @@ export class TUI {
|
|
|
609
609
|
}
|
|
610
610
|
}
|
|
611
611
|
|
|
612
|
-
// Multi-step input for adding a provider. Steps:
|
|
613
|
-
|
|
614
|
-
|
|
612
|
+
// Multi-step input for adding a provider. Steps: type → secret/key → name.
|
|
613
|
+
// Name LAST so it can be pre-filled from the secret (RESTRICTED_AL_MAXPOOL_ZAI →
|
|
614
|
+
// "glm al") instead of asking the user to invent one before they've said which
|
|
615
|
+
// account it is.
|
|
616
|
+
_providerAddStep(step, prev = {}) {
|
|
617
|
+
if (step === 'type') {
|
|
615
618
|
this.mode = 'input';
|
|
616
|
-
this.inputPrompt = 'Provider
|
|
617
|
-
this.inputBuf = '';
|
|
618
|
-
this.inputSensitive = false;
|
|
619
|
-
this.inputCb = value => {
|
|
620
|
-
const name = String(value || '').trim();
|
|
621
|
-
if (!name) { this.mode = 'providers'; return; }
|
|
622
|
-
if (this.am.accounts.some(a => a.name === name)) {
|
|
623
|
-
this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
|
|
624
|
-
}
|
|
625
|
-
this._providerAddStep('type', { name });
|
|
626
|
-
};
|
|
627
|
-
} else if (step === 'type') {
|
|
628
|
-
this.mode = 'input';
|
|
629
|
-
this.inputPrompt = `Type for ${prev.name} (zai or kimi)`;
|
|
619
|
+
this.inputPrompt = 'Provider type (zai or kimi)';
|
|
630
620
|
this.inputBuf = 'zai';
|
|
631
621
|
this.inputSensitive = false;
|
|
632
622
|
this.inputCb = value => {
|
|
633
623
|
const provider = String(value || '').trim().toLowerCase();
|
|
634
624
|
if (provider !== 'zai' && provider !== 'kimi') { this._addLog('Type must be zai or kimi'); this.mode = 'providers'; return; }
|
|
635
|
-
|
|
636
|
-
const suggested = provider === 'kimi' ? 'kimi' : 'glm';
|
|
637
|
-
this._providerAddStep('secret', { ...prev, provider, suggestedName: suggested });
|
|
625
|
+
this._providerAddStep('secret', { ...prev, provider });
|
|
638
626
|
};
|
|
639
627
|
} else if (step === 'secret') {
|
|
640
|
-
const suggested = TUI.deriveProviderName(prev.provider, '');
|
|
641
628
|
this.mode = 'input';
|
|
642
629
|
this.inputPrompt = `${prev.name}: GCP secret name OR paste API key directly`;
|
|
643
630
|
this.inputBuf = '';
|
|
@@ -648,19 +635,29 @@ export class TUI {
|
|
|
648
635
|
// Heuristic: a GCP secret name is uppercase/dashes/underscores and short.
|
|
649
636
|
// An API key is long and contains dots/mixed-case/alphanumeric.
|
|
650
637
|
const looksLikeSecretName = /^[A-Z][A-Z0-9_-]{2,60}$/.test(input) && !input.includes('.');
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
638
|
+
const keyed = looksLikeSecretName ? { secretName: input } : { apiKey: input };
|
|
639
|
+
// The name step is LAST so it can offer a default derived from the secret —
|
|
640
|
+
// `RESTRICTED_AL_MAXPOOL_ZAI` → `glm al`. Pre-filled and editable: press Enter
|
|
641
|
+
// to accept, or type over it.
|
|
642
|
+
this._providerAddStep('name', { ...prev, ...keyed });
|
|
643
|
+
};
|
|
644
|
+
} else if (step === 'name') {
|
|
645
|
+
this.mode = 'input';
|
|
646
|
+
this.inputPrompt = 'Name for this provider (Enter to accept)';
|
|
647
|
+
this.inputBuf = TUI.deriveProviderName(prev.provider, prev.secretName || '');
|
|
648
|
+
this.inputSensitive = false;
|
|
649
|
+
this.inputCb = async value => {
|
|
650
|
+
const name = String(value || '').trim() || TUI.deriveProviderName(prev.provider, prev.secretName || '');
|
|
651
|
+
if (this.am.accounts.some(a => a.name === name)) {
|
|
652
|
+
this._addLog(`Account "${name}" already exists`); this.mode = 'providers'; return;
|
|
656
653
|
}
|
|
654
|
+
await this._doAddProvider({ ...prev, name });
|
|
657
655
|
};
|
|
658
656
|
}
|
|
659
657
|
}
|
|
660
658
|
|
|
661
659
|
async _doAddProvider({ name, provider, secretName, apiKey }) {
|
|
662
660
|
this.mode = 'providers';
|
|
663
|
-
const isDirect = !secretName && apiKey;
|
|
664
661
|
if (secretName) this._addLog(`Resolving secret "${secretName}" from GCP…`);
|
|
665
662
|
else this._addLog(`Adding "${name}" with direct API key…`);
|
|
666
663
|
this.render();
|
|
@@ -709,7 +706,7 @@ export class TUI {
|
|
|
709
706
|
this.mode = 'select';
|
|
710
707
|
}
|
|
711
708
|
|
|
712
|
-
_renderProviders(buf,
|
|
709
|
+
_renderProviders(buf, _width) {
|
|
713
710
|
const providers = this.am.accounts.filter(a => a.type === 'provider');
|
|
714
711
|
buf.push(`${bold('Providers (GLM / Kimi)')} ${dim('— managed via GCP Secret Manager')}`);
|
|
715
712
|
buf.push('');
|