maxpool 1.5.5 → 1.5.7
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/account-manager.js +9 -5
- package/src/config.js +23 -2
- package/src/index.js +18 -2
- package/src/prober.js +6 -5
- package/src/sleep-guard.js +77 -0
package/package.json
CHANGED
package/src/account-manager.js
CHANGED
|
@@ -1432,11 +1432,15 @@ export class AccountManager {
|
|
|
1432
1432
|
|
|
1433
1433
|
const ramp = this._recoveryRamp(account, now);
|
|
1434
1434
|
const failurePenalty = account.consecutiveFailures * 5;
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1435
|
+
// NO unknown-quota bonus. An account whose quota we cannot see must never be
|
|
1436
|
+
// MORE attractive than a known-healthy one — the old -0.5 nudge (safe only
|
|
1437
|
+
// while the prober quickly resolved "unknown") turned into a relentless pull
|
|
1438
|
+
// toward blind accounts once probing was off, driving an out-of-band-burned
|
|
1439
|
+
// account to exhaustion. Unknown now scores neutral; the prober (on by
|
|
1440
|
+
// default) learns the real number within a cycle. `probing`/requalify still
|
|
1441
|
+
// flags a never-seen account for learning — that path is unchanged.
|
|
1442
|
+
|
|
1443
|
+
return concurrency + capPenalty + paceCost + spread + ramp + failurePenalty;
|
|
1440
1444
|
}
|
|
1441
1445
|
|
|
1442
1446
|
/**
|
package/src/config.js
CHANGED
|
@@ -46,6 +46,10 @@ export function createDefaultConfig() {
|
|
|
46
46
|
apiKey: 'mp-' + randomBytes(24).toString('base64url'),
|
|
47
47
|
},
|
|
48
48
|
upstream: 'https://api.anthropic.com',
|
|
49
|
+
// (macOS) Keep the system awake while requests are in flight so long overnight
|
|
50
|
+
// streams survive Maintenance Sleep; the Mac sleeps normally when idle. Set false
|
|
51
|
+
// to disable. AC power only; the display still sleeps (screen stays dark).
|
|
52
|
+
preventSleep: true,
|
|
49
53
|
// On startup, check npm for a newer maxpool and notify. Set false to disable.
|
|
50
54
|
updateCheck: true,
|
|
51
55
|
// When true, a newer version is installed automatically (npm i -g maxpool@latest)
|
|
@@ -58,7 +62,11 @@ export function createDefaultConfig() {
|
|
|
58
62
|
// Raise toward 0.97 to squeeze more out of accounts before rotating
|
|
59
63
|
// (less margin, slightly higher 429 risk); lower to rotate more eagerly.
|
|
60
64
|
switchThreshold: 0.90,
|
|
61
|
-
|
|
65
|
+
// Background quota probe: poll every account's real 5h/7d utilization from the
|
|
66
|
+
// zero-spend usage endpoint every N seconds. ON by default — without it maxpool
|
|
67
|
+
// is blind to idle / out-of-band-used accounts and the scorer will pile traffic
|
|
68
|
+
// onto exactly the account it cannot see. 0 = off.
|
|
69
|
+
quotaProbeSeconds: 60,
|
|
62
70
|
routing: {
|
|
63
71
|
mode: 'automatic',
|
|
64
72
|
preferredAccount: null,
|
|
@@ -131,8 +139,9 @@ export async function loadConfig() {
|
|
|
131
139
|
if (err.code === 'ENOENT') return null;
|
|
132
140
|
throw err;
|
|
133
141
|
}
|
|
142
|
+
let parsed;
|
|
134
143
|
try {
|
|
135
|
-
|
|
144
|
+
parsed = JSON.parse(raw);
|
|
136
145
|
} catch (err) {
|
|
137
146
|
// With atomic temp+rename writes a torn read is impossible, so a parse
|
|
138
147
|
// failure means genuine corruption. Surface it clearly rather than as a
|
|
@@ -140,6 +149,18 @@ export async function loadConfig() {
|
|
|
140
149
|
// the file with defaults and lose recoverable OAuth credentials.
|
|
141
150
|
throw new Error(`config at ${path} is not valid JSON (corrupt?): ${err.message}`);
|
|
142
151
|
}
|
|
152
|
+
// Backfill top-level keys ABSENT from an older on-disk config with the current
|
|
153
|
+
// defaults, so a changed default (e.g. quotaProbeSeconds flipping on) actually
|
|
154
|
+
// reaches existing installs instead of shipping inert. A key that is present —
|
|
155
|
+
// even falsy, like an explicit `0` — is an intentional user choice and always
|
|
156
|
+
// wins; only genuinely-missing keys inherit the default.
|
|
157
|
+
if (parsed && typeof parsed === 'object') {
|
|
158
|
+
const defaults = createDefaultConfig();
|
|
159
|
+
for (const key of Object.keys(defaults)) {
|
|
160
|
+
if (!(key in parsed)) parsed[key] = defaults[key];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return parsed;
|
|
143
164
|
}
|
|
144
165
|
|
|
145
166
|
export async function loadOrCreateConfig() {
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState, getStatePath, getLogPath, readGeneration, flushConfigWrites, flushStateWrites } from './config.js';
|
|
6
6
|
import { setEventLogPath, installConsoleMirror } from './event-log.js';
|
|
7
|
+
import { SleepGuard } from './sleep-guard.js';
|
|
7
8
|
import { AccountManager } from './account-manager.js';
|
|
8
9
|
import { createProxyServer } from './server.js';
|
|
9
10
|
import { Prober } from './prober.js';
|
|
@@ -469,6 +470,14 @@ async function serverWorkerCommand() {
|
|
|
469
470
|
|
|
470
471
|
const threshold = config.switchThreshold || 0.90;
|
|
471
472
|
const accountManager = new AccountManager(accounts, threshold, config.scheduler || {});
|
|
473
|
+
// (macOS) Keep the system awake ONLY while there is work in flight or queued, so a
|
|
474
|
+
// long overnight streaming request survives Maintenance Sleep; the Mac sleeps
|
|
475
|
+
// normally when idle. Disable via `preventSleep: false` in config.
|
|
476
|
+
const sleepGuard = new SleepGuard({
|
|
477
|
+
enabled: config.preventSleep !== false && process.platform === 'darwin' && process.env.MAXPOOL_DISABLE_SLEEP_GUARD !== '1',
|
|
478
|
+
getWorkPending: () => accountManager.getGlobalInFlight() > 0 || accountManager.queueState.waiting.length > 0,
|
|
479
|
+
log: msg => console.log(`[Maxpool] ${msg}`),
|
|
480
|
+
});
|
|
472
481
|
accountManager.setRoutingMode(
|
|
473
482
|
config.routing?.mode,
|
|
474
483
|
config.routing?.preferredAccount,
|
|
@@ -521,8 +530,13 @@ async function serverWorkerCommand() {
|
|
|
521
530
|
// Persist quota every minute; unref so it never keeps the process alive.
|
|
522
531
|
let quotaSaveInterval = null;
|
|
523
532
|
|
|
524
|
-
//
|
|
525
|
-
|
|
533
|
+
// Background quota probe (config.quotaProbeSeconds, default 60s). Keeps every
|
|
534
|
+
// account's real 5h/7d utilization fresh so the scorer is never blind to an idle
|
|
535
|
+
// or out-of-band-used account. MAXPOOL_DISABLE_QUOTA_PROBE=1 forces it off — used
|
|
536
|
+
// by spawned integration tests so a startup probe can't race their refresh/rotation
|
|
537
|
+
// assertions (mirrors MAXPOOL_DISABLE_SLEEP_GUARD).
|
|
538
|
+
const probeSeconds = process.env.MAXPOOL_DISABLE_QUOTA_PROBE === '1' ? 0 : (config.quotaProbeSeconds || 0);
|
|
539
|
+
const prober = new Prober(accountManager, { intervalMs: probeSeconds * 1000 });
|
|
526
540
|
|
|
527
541
|
// Persist refreshed tokens back to config. Defense-in-depth: the updater reads
|
|
528
542
|
// the on-disk refresh token and SKIPS the rotation if a fresher writer already
|
|
@@ -656,6 +670,7 @@ async function serverWorkerCommand() {
|
|
|
656
670
|
quotaSaveInterval.unref?.();
|
|
657
671
|
}
|
|
658
672
|
prober.start();
|
|
673
|
+
sleepGuard.start(); // (macOS) keep the system awake while serving, so long streams survive Maintenance Sleep
|
|
659
674
|
};
|
|
660
675
|
// Stop scheduling writes and flip the lease. Returns a promise that settles
|
|
661
676
|
// once any in-flight prober cycle has finished (so no probe-driven token
|
|
@@ -666,6 +681,7 @@ async function serverWorkerCommand() {
|
|
|
666
681
|
hasLease = false;
|
|
667
682
|
if (quotaSaveInterval) { clearInterval(quotaSaveInterval); quotaSaveInterval = null; }
|
|
668
683
|
accountManager.setWriterLease(false);
|
|
684
|
+
sleepGuard.stop(); // release the wake — only the active lease-holder keeps the Mac awake
|
|
669
685
|
await prober.stop(); // awaits any in-flight probe cycle (B1)
|
|
670
686
|
};
|
|
671
687
|
|
package/src/prober.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Background quota probe.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// ON BY DEFAULT (config.quotaProbeSeconds, default 60s; 0 = off). Periodically
|
|
4
4
|
// reads each OAuth account's quota from the zero-spend /api/oauth/usage endpoint
|
|
5
|
-
// so idle accounts' utilization/reset stay fresh without waiting
|
|
6
|
-
// to — and without consuming any message quota.
|
|
7
|
-
//
|
|
5
|
+
// so idle / out-of-band-used accounts' utilization/reset stay fresh without waiting
|
|
6
|
+
// to be rotated to — and without consuming any message quota. Without it the scorer
|
|
7
|
+
// is blind to an account it isn't actively routing to and will pile traffic onto it.
|
|
8
|
+
// This is the one sanctioned active-upstream feature; the proxy is otherwise passive.
|
|
8
9
|
|
|
9
10
|
import { fetchUsage } from './oauth.js';
|
|
10
11
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Conditional sleep guard (macOS): while maxpool has work in flight, prevent the
|
|
2
|
+
// SYSTEM from sleeping so long-lived streaming requests survive overnight
|
|
3
|
+
// Maintenance Sleep (which powers down the network stack and kills the
|
|
4
|
+
// connection → "terminated"). When idle, it releases so the Mac sleeps normally.
|
|
5
|
+
//
|
|
6
|
+
// Uses the built-in `caffeinate -s` (prevent SYSTEM sleep only — the DISPLAY still
|
|
7
|
+
// sleeps, so the screen stays dark) with `-w <pid>` so caffeinate auto-exits if the
|
|
8
|
+
// worker dies (never a zombie keeping the Mac awake forever). `-s` is AC-power only
|
|
9
|
+
// by design. Complete no-op off macOS or when disabled (e.g. the test suite).
|
|
10
|
+
|
|
11
|
+
import { spawn as realSpawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const POLL_MS = 5_000; // re-check the in-flight signal (idle-sleep timers are minutes)
|
|
14
|
+
const GRACE_MS = 60_000; // stay awake this long after the last request, to bridge gaps
|
|
15
|
+
|
|
16
|
+
export class SleepGuard {
|
|
17
|
+
constructor({ getWorkPending, log = () => {}, spawn = realSpawn, enabled, pollMs = POLL_MS, graceMs = GRACE_MS } = {}) {
|
|
18
|
+
this.getWorkPending = getWorkPending; // () => boolean: true while there's active/queued work
|
|
19
|
+
this.log = log;
|
|
20
|
+
this._spawn = spawn;
|
|
21
|
+
this.enabled = enabled ?? (process.platform === 'darwin' && process.env.MAXPOOL_DISABLE_SLEEP_GUARD !== '1');
|
|
22
|
+
this.pollMs = pollMs;
|
|
23
|
+
this.graceMs = graceMs;
|
|
24
|
+
this.child = null;
|
|
25
|
+
this.pollTimer = null;
|
|
26
|
+
this.graceTimer = null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start() {
|
|
30
|
+
if (!this.enabled || this.pollTimer) return;
|
|
31
|
+
this.pollTimer = setInterval(() => this._tick(), this.pollMs);
|
|
32
|
+
this.pollTimer.unref?.();
|
|
33
|
+
this._tick(); // engage immediately if there's already work
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_tick() {
|
|
37
|
+
let pending = false;
|
|
38
|
+
try { pending = Boolean(this.getWorkPending?.()); } catch { pending = false; }
|
|
39
|
+
if (pending) {
|
|
40
|
+
if (this.graceTimer) { clearTimeout(this.graceTimer); this.graceTimer = null; }
|
|
41
|
+
this._ensureAwake();
|
|
42
|
+
} else if (this.child && !this.graceTimer) {
|
|
43
|
+
// Idle: hold the wake for a grace period, then release (bridges brief gaps
|
|
44
|
+
// between back-to-back requests so we never drop it mid-conversation).
|
|
45
|
+
this.graceTimer = setTimeout(() => { this.graceTimer = null; this._allowSleep(); }, this.graceMs);
|
|
46
|
+
this.graceTimer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_ensureAwake() {
|
|
51
|
+
if (!this.enabled || this.child) return;
|
|
52
|
+
try {
|
|
53
|
+
const c = this._spawn('/usr/bin/caffeinate', ['-s', '-w', String(process.pid)], { stdio: 'ignore' });
|
|
54
|
+
this.child = c;
|
|
55
|
+
// Only clear the ref if it's still THIS child (a stale exit can't null a newer one).
|
|
56
|
+
// caffeinate missing/unspawnable → disable so we don't re-spawn every poll forever.
|
|
57
|
+
c.once('error', () => { if (this.child === c) this.child = null; this.enabled = false; });
|
|
58
|
+
c.once('exit', () => { if (this.child === c) this.child = null; });
|
|
59
|
+
// Honest: -s keeps the SYSTEM awake ON AC ONLY (a no-op on battery); the display
|
|
60
|
+
// still sleeps so the screen stays dark.
|
|
61
|
+
this.log('Keeping the system awake while busy (caffeinate -s; AC power only, display still sleeps)');
|
|
62
|
+
} catch { this.child = null; this.enabled = false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_allowSleep() {
|
|
66
|
+
if (!this.child) return;
|
|
67
|
+
try { this.child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
68
|
+
this.child = null;
|
|
69
|
+
this.log('Idle — allowing normal system sleep again');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stop() {
|
|
73
|
+
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
|
|
74
|
+
if (this.graceTimer) { clearTimeout(this.graceTimer); this.graceTimer = null; }
|
|
75
|
+
this._allowSleep();
|
|
76
|
+
}
|
|
77
|
+
}
|