maxpool 1.0.0
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/LICENSE +22 -0
- package/README.md +314 -0
- package/package.json +41 -0
- package/src/account-config.js +30 -0
- package/src/account-manager.js +1729 -0
- package/src/config.js +162 -0
- package/src/index.js +1007 -0
- package/src/oauth.js +391 -0
- package/src/prober.js +82 -0
- package/src/restart-controller.js +58 -0
- package/src/server.js +1425 -0
- package/src/tui.js +958 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, chmod, unlink } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function getConfigPath() {
|
|
8
|
+
if (process.env.MAXPOOL_CONFIG) return process.env.MAXPOOL_CONFIG;
|
|
9
|
+
if (process.env.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG; // legacy env
|
|
10
|
+
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
11
|
+
const current = join(configDir, 'maxpool.json');
|
|
12
|
+
if (existsSync(current)) return current;
|
|
13
|
+
// Seamless upgrade from the project's former name: keep using an existing
|
|
14
|
+
// teamclaude.json in place rather than starting empty.
|
|
15
|
+
const legacy = join(configDir, 'teamclaude.json');
|
|
16
|
+
if (existsSync(legacy)) return legacy;
|
|
17
|
+
return current;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Path to the runtime state file (a sibling of the config). Holds volatile data
|
|
22
|
+
* learned at runtime — quota utilization observed passively from traffic — kept
|
|
23
|
+
* out of the hand-editable config so config stays clean and isn't rewritten on
|
|
24
|
+
* every state save.
|
|
25
|
+
*/
|
|
26
|
+
export function getStatePath() {
|
|
27
|
+
const cfg = getConfigPath();
|
|
28
|
+
return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createDefaultConfig() {
|
|
32
|
+
return {
|
|
33
|
+
proxy: {
|
|
34
|
+
port: 3456,
|
|
35
|
+
host: '127.0.0.1',
|
|
36
|
+
apiKey: 'mp-' + randomBytes(24).toString('base64url'),
|
|
37
|
+
},
|
|
38
|
+
upstream: 'https://api.anthropic.com',
|
|
39
|
+
switchThreshold: 0.90,
|
|
40
|
+
quotaProbeSeconds: 0, // background quota probe; 0 = off (opt-in)
|
|
41
|
+
routing: {
|
|
42
|
+
mode: 'automatic',
|
|
43
|
+
preferredAccount: null,
|
|
44
|
+
},
|
|
45
|
+
scheduler: {
|
|
46
|
+
mode: 'adaptive-least-loaded',
|
|
47
|
+
safetyMaxActivePerAccount: 50,
|
|
48
|
+
safetyMaxGlobalActive: 150,
|
|
49
|
+
cooldownMs: 30_000,
|
|
50
|
+
maxCooldownMs: 15 * 60_000,
|
|
51
|
+
},
|
|
52
|
+
retry: {
|
|
53
|
+
maxAttemptsPerRequest: 0,
|
|
54
|
+
maxRetryBufferBytes: 10 * 1024 * 1024,
|
|
55
|
+
},
|
|
56
|
+
queue: {
|
|
57
|
+
enabled: true,
|
|
58
|
+
maxWaitMs: 24 * 60 * 60 * 1000,
|
|
59
|
+
autoMaxWaitMs: null,
|
|
60
|
+
capacityMaxWaitMs: 15 * 60 * 1000,
|
|
61
|
+
maxQueuedBodyBytes: 256 * 1024 * 1024,
|
|
62
|
+
weeklyMaxWaitMs: 0,
|
|
63
|
+
pollMs: 1000,
|
|
64
|
+
heartbeatMs: 10_000,
|
|
65
|
+
},
|
|
66
|
+
accounts: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadConfig() {
|
|
71
|
+
const path = getConfigPath();
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await readFile(path, 'utf-8');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err.code === 'ENOENT') return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(raw);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// With atomic temp+rename writes a torn read is impossible, so a parse
|
|
83
|
+
// failure means genuine corruption. Surface it clearly rather than as a
|
|
84
|
+
// cryptic crash, and do NOT return null — that would let a caller overwrite
|
|
85
|
+
// the file with defaults and lose recoverable OAuth credentials.
|
|
86
|
+
throw new Error(`config at ${path} is not valid JSON (corrupt?): ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function loadOrCreateConfig() {
|
|
91
|
+
let config = await loadConfig();
|
|
92
|
+
if (!config) {
|
|
93
|
+
config = createDefaultConfig();
|
|
94
|
+
await saveConfig(config);
|
|
95
|
+
console.log(`Created config at ${getConfigPath()}`);
|
|
96
|
+
}
|
|
97
|
+
return config;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Atomic 0600 write: a crash/power-loss mid-write must never truncate the file
|
|
102
|
+
* (config holds every account's OAuth tokens). Write a sibling temp file, force
|
|
103
|
+
* 0600 (writeFile's mode only applies on create, not when overwriting an
|
|
104
|
+
* existing 0644 file), then rename — atomic within a filesystem on POSIX.
|
|
105
|
+
*/
|
|
106
|
+
async function atomicWrite(path, content) {
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
109
|
+
try {
|
|
110
|
+
await writeFile(tmp, content, { mode: 0o600 });
|
|
111
|
+
await chmod(tmp, 0o600);
|
|
112
|
+
await rename(tmp, path);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await unlink(tmp).catch(() => {});
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function saveConfig(config) {
|
|
120
|
+
await atomicWrite(getConfigPath(), JSON.stringify(config, null, 2) + '\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Load runtime state (regenerable). Returns null if missing or unreadable —
|
|
124
|
+
* never throws, since stale/corrupt learned state must not crash startup. */
|
|
125
|
+
export async function loadState() {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await readFile(getStatePath(), 'utf-8'));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function saveState(state) {
|
|
134
|
+
await atomicWrite(getStatePath(), JSON.stringify(state, null, 2) + '\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let _configWriteChain = Promise.resolve();
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Serialize an in-process read-modify-write of the config so concurrent updates
|
|
141
|
+
* cannot lose each other's writes — e.g. a background OAuth token refresh
|
|
142
|
+
* (fire-and-forget) racing a TUI account add/delete. Each update re-reads the
|
|
143
|
+
* latest config, applies updater(config), then saves atomically.
|
|
144
|
+
*
|
|
145
|
+
* NOTE: this serializes writes within THIS process only. A separate
|
|
146
|
+
* `maxpool import`/`login` process writing concurrently is not coordinated
|
|
147
|
+
* (that would require a lockfile) — but those are short, rare, human-driven.
|
|
148
|
+
*/
|
|
149
|
+
export function atomicConfigUpdate(updater) {
|
|
150
|
+
const run = async () => {
|
|
151
|
+
const config = await loadConfig() || createDefaultConfig();
|
|
152
|
+
await updater(config);
|
|
153
|
+
await saveConfig(config);
|
|
154
|
+
return config;
|
|
155
|
+
};
|
|
156
|
+
// Run after any in-flight update regardless of whether it resolved or
|
|
157
|
+
// rejected, so one failure doesn't poison the chain; the caller still sees
|
|
158
|
+
// this update's real result/error.
|
|
159
|
+
const result = _configWriteChain.then(run, run);
|
|
160
|
+
_configWriteChain = result.then(() => {}, () => {});
|
|
161
|
+
return result;
|
|
162
|
+
}
|