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/index.js
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline';
|
|
5
|
+
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath, loadState, saveState } from './config.js';
|
|
6
|
+
import { AccountManager } from './account-manager.js';
|
|
7
|
+
import { createProxyServer } from './server.js';
|
|
8
|
+
import { Prober } from './prober.js';
|
|
9
|
+
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
|
|
10
|
+
import { TUI } from './tui.js';
|
|
11
|
+
import { RestartController } from './restart-controller.js';
|
|
12
|
+
import { resolveAccounts } from './account-config.js';
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const command = args[0];
|
|
16
|
+
const SERVER_RESTART_EXIT_CODE = 75;
|
|
17
|
+
const SERVER_WORKER_ENV = 'MAXPOOL_SERVER_WORKER';
|
|
18
|
+
|
|
19
|
+
switch (command) {
|
|
20
|
+
case 'server':
|
|
21
|
+
await serverCommand();
|
|
22
|
+
break;
|
|
23
|
+
case 'run':
|
|
24
|
+
await runCommand();
|
|
25
|
+
break;
|
|
26
|
+
case 'import':
|
|
27
|
+
await importCommand();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
break;
|
|
30
|
+
case 'login':
|
|
31
|
+
await loginCommand();
|
|
32
|
+
process.exit(0);
|
|
33
|
+
break;
|
|
34
|
+
case 'env':
|
|
35
|
+
await envCommand();
|
|
36
|
+
process.exit(0);
|
|
37
|
+
break;
|
|
38
|
+
case 'status':
|
|
39
|
+
await statusCommand();
|
|
40
|
+
process.exit(0);
|
|
41
|
+
break;
|
|
42
|
+
case 'accounts':
|
|
43
|
+
await accountsCommand();
|
|
44
|
+
process.exit(0);
|
|
45
|
+
break;
|
|
46
|
+
case 'remove':
|
|
47
|
+
await removeCommand();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
break;
|
|
50
|
+
case 'api':
|
|
51
|
+
await apiCommand();
|
|
52
|
+
process.exit(0);
|
|
53
|
+
break;
|
|
54
|
+
case 'help':
|
|
55
|
+
case '--help':
|
|
56
|
+
case '-h':
|
|
57
|
+
showHelp();
|
|
58
|
+
break;
|
|
59
|
+
default:
|
|
60
|
+
// No command or unknown command → start server
|
|
61
|
+
if (command && !command.startsWith('-')) {
|
|
62
|
+
console.error(`Unknown command: ${command}\n`);
|
|
63
|
+
showHelp();
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
await serverCommand();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── server ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
async function serverCommand() {
|
|
73
|
+
if (
|
|
74
|
+
process.env[SERVER_WORKER_ENV] === '1' ||
|
|
75
|
+
!process.stdout.isTTY ||
|
|
76
|
+
!process.stdin.isTTY
|
|
77
|
+
) {
|
|
78
|
+
return serverWorkerCommand();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Keep a stable foreground process attached to the shell. The worker can
|
|
82
|
+
// then request a restart without orphaning its replacement or losing TTY IO.
|
|
83
|
+
const ignoreTerminalSignal = () => {};
|
|
84
|
+
process.on('SIGINT', ignoreTerminalSignal);
|
|
85
|
+
process.on('SIGTERM', ignoreTerminalSignal);
|
|
86
|
+
try {
|
|
87
|
+
while (true) {
|
|
88
|
+
const result = await runServerWorker();
|
|
89
|
+
if (result.code === SERVER_RESTART_EXIT_CODE) continue;
|
|
90
|
+
if (result.signal) process.exitCode = 1;
|
|
91
|
+
else process.exitCode = result.code ?? 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
process.off('SIGINT', ignoreTerminalSignal);
|
|
96
|
+
process.off('SIGTERM', ignoreTerminalSignal);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runServerWorker() {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
103
|
+
cwd: process.cwd(),
|
|
104
|
+
env: { ...process.env, [SERVER_WORKER_ENV]: '1' },
|
|
105
|
+
stdio: 'inherit',
|
|
106
|
+
});
|
|
107
|
+
child.once('error', reject);
|
|
108
|
+
child.once('exit', (code, signal) => resolve({ code, signal }));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function serverWorkerCommand() {
|
|
113
|
+
const config = await loadOrCreateConfig();
|
|
114
|
+
|
|
115
|
+
// --log-to <dir>
|
|
116
|
+
const logTo = argValue('--log-to');
|
|
117
|
+
if (logTo) config.logDir = logTo;
|
|
118
|
+
|
|
119
|
+
if (config.accounts.length === 0) {
|
|
120
|
+
console.error('No accounts configured.\n');
|
|
121
|
+
console.error('Add an account first:');
|
|
122
|
+
console.error(' maxpool import Import from Claude Code');
|
|
123
|
+
console.error(' maxpool login OAuth login via browser');
|
|
124
|
+
console.error(' maxpool login --api Add an API key');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const accounts = await resolveAccounts(config);
|
|
129
|
+
if (accounts.length === 0) {
|
|
130
|
+
console.error('No valid accounts after initialization');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const threshold = config.switchThreshold || 0.90;
|
|
135
|
+
const accountManager = new AccountManager(accounts, threshold, config.scheduler || {});
|
|
136
|
+
accountManager.setRoutingMode(
|
|
137
|
+
config.routing?.mode,
|
|
138
|
+
config.routing?.preferredAccount,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Restore quota observed in a previous run so a restart doesn't lose routing
|
|
142
|
+
// accuracy and re-probe from scratch. Stale windows clear on first use.
|
|
143
|
+
const savedState = await loadState();
|
|
144
|
+
if (savedState?.quota) accountManager.restoreQuotaState(savedState.quota);
|
|
145
|
+
const persistQuotaState = () =>
|
|
146
|
+
saveState({ quota: accountManager.exportQuotaState() }).catch(() => {});
|
|
147
|
+
// Persist quota every minute; unref so it never keeps the process alive.
|
|
148
|
+
const quotaSaveInterval = setInterval(persistQuotaState, 60_000);
|
|
149
|
+
quotaSaveInterval.unref?.();
|
|
150
|
+
|
|
151
|
+
// Opt-in background quota probe (config.quotaProbeSeconds, default 0 = off).
|
|
152
|
+
const prober = new Prober(accountManager, { intervalMs: (config.quotaProbeSeconds || 0) * 1000 });
|
|
153
|
+
prober.start();
|
|
154
|
+
|
|
155
|
+
// Persist refreshed tokens back to config (re-read from disk to avoid clobbering
|
|
156
|
+
// accounts added externally, e.g. by `maxpool import` while server is running)
|
|
157
|
+
accountManager.onTokenRefresh((idx, newTokens) => {
|
|
158
|
+
const account = accountManager.accounts[idx];
|
|
159
|
+
if (!account) return;
|
|
160
|
+
// Keep config.accounts in sync so TUI saveConfig doesn't clobber fresh tokens
|
|
161
|
+
const memIdx = findConfigAccount(config, account);
|
|
162
|
+
if (memIdx >= 0) {
|
|
163
|
+
config.accounts[memIdx].accessToken = newTokens.accessToken;
|
|
164
|
+
config.accounts[memIdx].refreshToken = newTokens.refreshToken;
|
|
165
|
+
config.accounts[memIdx].expiresAt = newTokens.expiresAt;
|
|
166
|
+
}
|
|
167
|
+
atomicConfigUpdate(diskConfig => {
|
|
168
|
+
// Pick up any new accounts from disk so index matching stays correct
|
|
169
|
+
// (only add, don't refresh credentials — we're about to write the authoritative tokens)
|
|
170
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
171
|
+
const known = (diskAcct.accountUuid && config.accounts.some(a => a.accountUuid === diskAcct.accountUuid))
|
|
172
|
+
|| config.accounts.some(a => a.name === diskAcct.name);
|
|
173
|
+
if (!known) {
|
|
174
|
+
config.accounts.push(diskAcct);
|
|
175
|
+
accountManager.addAccount(diskAcct);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Match by UUID first, then by name — index may have shifted
|
|
179
|
+
const cfgIdx = findConfigAccount(diskConfig, account);
|
|
180
|
+
if (cfgIdx >= 0) {
|
|
181
|
+
diskConfig.accounts[cfgIdx].accessToken = newTokens.accessToken;
|
|
182
|
+
diskConfig.accounts[cfgIdx].refreshToken = newTokens.refreshToken;
|
|
183
|
+
diskConfig.accounts[cfgIdx].expiresAt = newTokens.expiresAt;
|
|
184
|
+
}
|
|
185
|
+
}).catch(err => console.error(`[Maxpool] Failed to save refreshed token: ${err.message}`));
|
|
186
|
+
});
|
|
187
|
+
const port = config.proxy.port;
|
|
188
|
+
const host = config.proxy.host || '127.0.0.1';
|
|
189
|
+
const useTUI = process.stdout.isTTY && process.stdin.isTTY;
|
|
190
|
+
|
|
191
|
+
let tui = null;
|
|
192
|
+
let server = null;
|
|
193
|
+
let syncTimer = null;
|
|
194
|
+
let draining = false;
|
|
195
|
+
let restartController = null;
|
|
196
|
+
const drainTimeoutMs = Math.max(1000, Number(config.shutdown?.drainTimeoutMs) || 10 * 60_000);
|
|
197
|
+
const hooks = {
|
|
198
|
+
onRequestStart: (id, info) => {
|
|
199
|
+
const accepted = restartController.requestStarted(id);
|
|
200
|
+
if (accepted) tui?.onRequestStart(id, info);
|
|
201
|
+
return accepted;
|
|
202
|
+
},
|
|
203
|
+
onRequestRouted: (id, info) => {
|
|
204
|
+
restartController.requestRouted(id, info.account);
|
|
205
|
+
tui?.onRequestRouted(id, info);
|
|
206
|
+
},
|
|
207
|
+
onRequestEnd: (id, info) => {
|
|
208
|
+
restartController.requestEnded(id);
|
|
209
|
+
tui?.onRequestEnd(id, info);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const restartWorkerNow = () => {
|
|
214
|
+
if (draining) return;
|
|
215
|
+
draining = true;
|
|
216
|
+
if (syncTimer) clearInterval(syncTimer);
|
|
217
|
+
clearInterval(quotaSaveInterval);
|
|
218
|
+
persistQuotaState(); // flush learned quota so the restart restores it
|
|
219
|
+
prober.stop();
|
|
220
|
+
if (tui?.running) tui.stop();
|
|
221
|
+
console.log('\n[Maxpool] Restarting server now; queued requests will reconnect automatically.');
|
|
222
|
+
server.closeAllConnections?.();
|
|
223
|
+
process.exit(SERVER_RESTART_EXIT_CODE);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
restartController = new RestartController({
|
|
227
|
+
pauseAdmission: () => accountManager.setAdmissionPaused(true),
|
|
228
|
+
restartNow: restartWorkerNow,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const shutdownGracefully = (reason, options = {}) => {
|
|
232
|
+
if (draining) {
|
|
233
|
+
console.error(`\n[Maxpool] Force exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
draining = true;
|
|
238
|
+
if (syncTimer) clearInterval(syncTimer);
|
|
239
|
+
clearInterval(quotaSaveInterval);
|
|
240
|
+
persistQuotaState(); // best-effort final flush of learned quota
|
|
241
|
+
prober.stop();
|
|
242
|
+
if (tui?.running) tui.stop();
|
|
243
|
+
|
|
244
|
+
console.log(`\n[Maxpool] Draining shutdown (${reason}).`);
|
|
245
|
+
console.log(`[Maxpool] Stopped accepting new requests; waiting for ${restartController.activeRequests.size} active request(s). Press Ctrl-C again to force.`);
|
|
246
|
+
|
|
247
|
+
let done = false;
|
|
248
|
+
let reportTimer = null;
|
|
249
|
+
let timeoutTimer = null;
|
|
250
|
+
const finish = code => {
|
|
251
|
+
if (done) return;
|
|
252
|
+
done = true;
|
|
253
|
+
if (reportTimer) clearInterval(reportTimer);
|
|
254
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
255
|
+
if (options.restart && code === 0) {
|
|
256
|
+
console.log('[Maxpool] Restarting server...');
|
|
257
|
+
process.exit(SERVER_RESTART_EXIT_CODE);
|
|
258
|
+
}
|
|
259
|
+
process.exit(code);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
reportTimer = setInterval(() => {
|
|
263
|
+
console.log(`[Maxpool] Still draining ${restartController.activeRequests.size} active request(s)...`);
|
|
264
|
+
}, 5000);
|
|
265
|
+
reportTimer.unref();
|
|
266
|
+
|
|
267
|
+
timeoutTimer = setTimeout(() => {
|
|
268
|
+
console.error(`[Maxpool] Drain timeout after ${Math.ceil(drainTimeoutMs / 1000)}s; exiting with ${restartController.activeRequests.size} active request(s) still open.`);
|
|
269
|
+
finish(1);
|
|
270
|
+
}, drainTimeoutMs);
|
|
271
|
+
timeoutTimer.unref();
|
|
272
|
+
|
|
273
|
+
server.close(err => {
|
|
274
|
+
if (err) {
|
|
275
|
+
console.error(`[Maxpool] Shutdown error: ${err.message}`);
|
|
276
|
+
finish(1);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
console.log('[Maxpool] Shutdown complete.');
|
|
280
|
+
finish(0);
|
|
281
|
+
});
|
|
282
|
+
server.closeIdleConnections?.();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
if (useTUI) {
|
|
286
|
+
tui = new TUI({
|
|
287
|
+
accountManager, config,
|
|
288
|
+
saveConfig: () => atomicConfigUpdate(async diskConfig => {
|
|
289
|
+
diskConfig.routing = {
|
|
290
|
+
mode: config.routing?.mode || 'automatic',
|
|
291
|
+
preferredAccount: config.routing?.preferredAccount || null,
|
|
292
|
+
};
|
|
293
|
+
// Write in-memory accounts as the authoritative state, preserving
|
|
294
|
+
// extra disk-only fields (e.g. importFrom) where the account still exists.
|
|
295
|
+
// Use live tokens from AccountManager (not the stale config.accounts copy).
|
|
296
|
+
diskConfig.accounts = config.accounts.map(a => {
|
|
297
|
+
const am = accountManager.accounts.find(candidate =>
|
|
298
|
+
(a.accountUuid && candidate.accountUuid === a.accountUuid) || candidate.name === a.name
|
|
299
|
+
);
|
|
300
|
+
const live = am ? {
|
|
301
|
+
...a,
|
|
302
|
+
accessToken: am.credential,
|
|
303
|
+
refreshToken: am.refreshToken,
|
|
304
|
+
expiresAt: am.expiresAt,
|
|
305
|
+
} : a;
|
|
306
|
+
const diskAcct = diskConfig.accounts.find(
|
|
307
|
+
d => (a.accountUuid && d.accountUuid === a.accountUuid) || d.name === a.name
|
|
308
|
+
);
|
|
309
|
+
return diskAcct ? { ...diskAcct, ...live } : live;
|
|
310
|
+
});
|
|
311
|
+
}),
|
|
312
|
+
syncAccounts: async () => {
|
|
313
|
+
const diskConfig = await loadConfig();
|
|
314
|
+
if (!diskConfig) return 0;
|
|
315
|
+
return syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
316
|
+
},
|
|
317
|
+
onQuit: () => {
|
|
318
|
+
shutdownGracefully('quit');
|
|
319
|
+
},
|
|
320
|
+
onRestart: () => {
|
|
321
|
+
restartController.requestRestart();
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
server = createProxyServer(accountManager, config, hooks);
|
|
327
|
+
let syncInFlight = false;
|
|
328
|
+
const syncIntervalMs = config.sync?.accountsIntervalMs ?? 15_000;
|
|
329
|
+
syncTimer = setInterval(async () => {
|
|
330
|
+
if (syncInFlight) return;
|
|
331
|
+
syncInFlight = true;
|
|
332
|
+
try {
|
|
333
|
+
const diskConfig = await loadConfig();
|
|
334
|
+
if (!diskConfig) return;
|
|
335
|
+
const added = await syncAccountsFromDisk(diskConfig, config, accountManager);
|
|
336
|
+
if (added && tui) tui._addLog(`Auto-loaded ${added} account(s) from config`);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(`[Maxpool] Account auto-sync failed: ${err.message}`);
|
|
339
|
+
} finally {
|
|
340
|
+
syncInFlight = false;
|
|
341
|
+
}
|
|
342
|
+
}, syncIntervalMs);
|
|
343
|
+
syncTimer.unref();
|
|
344
|
+
const onListenError = err => handleServerListenError(err, host, port);
|
|
345
|
+
server.once('error', onListenError);
|
|
346
|
+
|
|
347
|
+
server.listen(port, host, () => {
|
|
348
|
+
server.removeListener('error', onListenError);
|
|
349
|
+
server.on('error', err => console.error(`[Maxpool] Server error: ${err.message}`));
|
|
350
|
+
if (tui) {
|
|
351
|
+
if (tui.start()) {
|
|
352
|
+
console.log(`Listening on ${host}:${port} with ${accounts.length} account(s)`);
|
|
353
|
+
} else {
|
|
354
|
+
tui = null;
|
|
355
|
+
logPlainServerStart({ host, port, accounts, threshold, config });
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
logPlainServerStart({ host, port, accounts, threshold, config });
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
process.on('SIGINT', () => shutdownGracefully('SIGINT'));
|
|
363
|
+
process.on('SIGTERM', () => shutdownGracefully('SIGTERM'));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function logPlainServerStart({ host, port, accounts, threshold, config }) {
|
|
367
|
+
const sep = '='.repeat(60);
|
|
368
|
+
console.log('');
|
|
369
|
+
console.log(sep);
|
|
370
|
+
console.log(' Maxpool Proxy');
|
|
371
|
+
console.log(sep);
|
|
372
|
+
console.log(` Listen: ${host}:${port}`);
|
|
373
|
+
console.log(` Accounts: ${accounts.length}`);
|
|
374
|
+
console.log(` Threshold: ${(threshold * 100).toFixed(0)}%`);
|
|
375
|
+
console.log(` Scheduler: adaptive least-loaded`);
|
|
376
|
+
console.log(` Upstream: ${config.upstream || 'https://api.anthropic.com'}`);
|
|
377
|
+
console.log('');
|
|
378
|
+
accounts.forEach((a, i) => {
|
|
379
|
+
console.log(` [${i + 1}] ${a.name} (${a.type})`);
|
|
380
|
+
});
|
|
381
|
+
console.log('');
|
|
382
|
+
console.log(' Run Claude through proxy: maxpool run');
|
|
383
|
+
console.log(' Show env vars: maxpool env');
|
|
384
|
+
console.log(sep);
|
|
385
|
+
console.log('');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── import ──────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
async function importCommand() {
|
|
391
|
+
const config = await loadOrCreateConfig();
|
|
392
|
+
|
|
393
|
+
let name = argValue('--name');
|
|
394
|
+
const jsonStr = argValue('--json');
|
|
395
|
+
|
|
396
|
+
let creds;
|
|
397
|
+
if (jsonStr) {
|
|
398
|
+
// Accept raw JSON: --json '{"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":...}}'
|
|
399
|
+
// or flat: --json '{"accessToken":"...","refreshToken":"...","expiresAt":...}'
|
|
400
|
+
try {
|
|
401
|
+
const raw = JSON.parse(jsonStr);
|
|
402
|
+
const data = raw.claudeAiOauth || raw;
|
|
403
|
+
if (!data.accessToken) {
|
|
404
|
+
console.error('JSON must contain "accessToken" (directly or under "claudeAiOauth")');
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
creds = {
|
|
408
|
+
accessToken: data.accessToken,
|
|
409
|
+
refreshToken: data.refreshToken,
|
|
410
|
+
expiresAt: data.expiresAt,
|
|
411
|
+
};
|
|
412
|
+
} catch (err) {
|
|
413
|
+
console.error(`Failed to parse --json: ${err.message}`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
const fromPath = argValue('--from') || '~/.claude/.credentials.json';
|
|
418
|
+
try {
|
|
419
|
+
creds = await importCredentials(fromPath);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.error(`Failed to import from ${fromPath}: ${err.message}`);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await upsertOAuthAccount(config, name, creds, 'import');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── login ───────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
async function loginCommand() {
|
|
432
|
+
if (args.includes('--api')) {
|
|
433
|
+
await loginApiCommand();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (args.includes('--oauth')) {
|
|
437
|
+
await loginOAuthCommand();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Default to OAuth if not a TTY
|
|
442
|
+
if (!process.stdout.isTTY) {
|
|
443
|
+
await loginOAuthCommand();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Interactive menu
|
|
448
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
449
|
+
console.log('Select login method:\n');
|
|
450
|
+
console.log(' 1. Claude subscription (Pro, Max, Team, Enterprise)');
|
|
451
|
+
console.log(' 2. Anthropic API key (Console API billing)');
|
|
452
|
+
console.log('');
|
|
453
|
+
const choice = await new Promise(resolve => rl.question('Choice [1]: ', resolve));
|
|
454
|
+
rl.close();
|
|
455
|
+
|
|
456
|
+
switch (choice.trim() || '1') {
|
|
457
|
+
case '1': await loginOAuthCommand(); break;
|
|
458
|
+
case '2': await loginApiCommand(); break;
|
|
459
|
+
default:
|
|
460
|
+
console.error(`Invalid choice: ${choice.trim()}`);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function loginApiCommand() {
|
|
466
|
+
const config = await loadOrCreateConfig();
|
|
467
|
+
let name = argValue('--name');
|
|
468
|
+
|
|
469
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
470
|
+
const apiKey = await new Promise(resolve => rl.question('Anthropic API key: ', resolve));
|
|
471
|
+
rl.close();
|
|
472
|
+
|
|
473
|
+
if (!apiKey.trim()) {
|
|
474
|
+
console.error('No API key provided');
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!name) {
|
|
479
|
+
const n = config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
|
|
480
|
+
name = `api-${n}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
config.accounts.push({ name, type: 'apikey', apiKey: apiKey.trim() });
|
|
484
|
+
await saveConfig(config);
|
|
485
|
+
console.log(`Added API key account "${name}"`);
|
|
486
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function loginOAuthCommand() {
|
|
490
|
+
const config = await loadOrCreateConfig();
|
|
491
|
+
let name = argValue('--name');
|
|
492
|
+
|
|
493
|
+
console.log('Starting OAuth login...');
|
|
494
|
+
let creds;
|
|
495
|
+
try {
|
|
496
|
+
creds = await loginOAuth();
|
|
497
|
+
} catch (err) {
|
|
498
|
+
console.error(`OAuth login failed: ${err.message}`);
|
|
499
|
+
console.error('');
|
|
500
|
+
console.error('Alternatives:');
|
|
501
|
+
console.error(' maxpool import Import from existing Claude Code credentials');
|
|
502
|
+
console.error(' maxpool login --api Add an API key instead');
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await upsertOAuthAccount(config, name, creds, 'login');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── env ─────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
async function envCommand() {
|
|
512
|
+
const config = await loadOrCreateConfig();
|
|
513
|
+
console.log(`export ANTHROPIC_BASE_URL=http://${config.proxy.host || '127.0.0.1'}:${config.proxy.port}`);
|
|
514
|
+
if (args.includes('--with-key')) {
|
|
515
|
+
console.log(`export ANTHROPIC_API_KEY=${config.proxy.apiKey}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── run ─────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
async function runCommand() {
|
|
522
|
+
const config = await loadOrCreateConfig();
|
|
523
|
+
|
|
524
|
+
// Everything after 'run' (skip -- separator if present)
|
|
525
|
+
const claudeArgs = args.slice(1);
|
|
526
|
+
if (claudeArgs[0] === '--') claudeArgs.shift();
|
|
527
|
+
|
|
528
|
+
// Only set ANTHROPIC_BASE_URL — Claude Code keeps its own OAuth token
|
|
529
|
+
// which the proxy accepts from localhost. Not setting ANTHROPIC_API_KEY
|
|
530
|
+
// lets Claude Code stay in subscription mode (full model access).
|
|
531
|
+
// Use spawnSync so the Node process blocks entirely — behaves like execvp.
|
|
532
|
+
const result = spawnSync('claude', claudeArgs, {
|
|
533
|
+
stdio: 'inherit',
|
|
534
|
+
env: {
|
|
535
|
+
...process.env,
|
|
536
|
+
ANTHROPIC_BASE_URL: `http://${config.proxy.host || '127.0.0.1'}:${config.proxy.port}`,
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (result.error) {
|
|
541
|
+
if (result.error.code === 'ENOENT') {
|
|
542
|
+
console.error('Claude Code not found in PATH. Install it first.');
|
|
543
|
+
} else {
|
|
544
|
+
console.error(`Failed to start claude: ${result.error.message}`);
|
|
545
|
+
}
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
process.exit(result.status ?? 1);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── status ──────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
async function statusCommand() {
|
|
555
|
+
const config = await loadOrCreateConfig();
|
|
556
|
+
const url = `http://${config.proxy.host || '127.0.0.1'}:${config.proxy.port}/maxpool/status`;
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
const res = await fetch(url, { headers: { 'x-api-key': config.proxy.apiKey } });
|
|
560
|
+
const data = await res.json();
|
|
561
|
+
|
|
562
|
+
const routing = data.routing?.mode === 'preferred'
|
|
563
|
+
? `prefer ${data.routing.preferredAccount} with automatic failover`
|
|
564
|
+
: 'automatic load balancing';
|
|
565
|
+
console.log(`Routing: ${routing}`);
|
|
566
|
+
console.log(`Last selected: ${data.currentAccount}`);
|
|
567
|
+
console.log(`Switch at: ${(data.switchThreshold * 100).toFixed(0)}% usage\n`);
|
|
568
|
+
if (data.upstreamThrottle?.active || data.upstreamThrottle?.queued) {
|
|
569
|
+
const state = data.upstreamThrottle.active
|
|
570
|
+
? data.upstreamThrottle.probeInFlight
|
|
571
|
+
? 'probing recovery'
|
|
572
|
+
: `retry at ${data.upstreamThrottle.until}`
|
|
573
|
+
: 'recovering';
|
|
574
|
+
const queued = data.upstreamThrottle.queued || 0;
|
|
575
|
+
const oldest = queued ? `, oldest ${formatDurationMs(data.upstreamThrottle.oldestQueuedMs)}` : '';
|
|
576
|
+
console.log(`Anthropic: temporarily throttled (${state}, queued ${queued}${oldest})\n`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const acct of data.accounts) {
|
|
580
|
+
const q = acct.quota;
|
|
581
|
+
const current = acct.name === data.currentAccount ? ' *' : '';
|
|
582
|
+
|
|
583
|
+
console.log(` ${acct.name} (${acct.type})${current}`);
|
|
584
|
+
console.log(` Status: ${acct.enabled === false ? 'disabled' : acct.status}`);
|
|
585
|
+
console.log(` Load: current ${acct.load?.current?.inFlight || 0}/${acct.load?.current?.activeWeight || 0} weight, 15m ${formatLoadWindow(acct.load?.last15m)}, 1h ${formatLoadWindow(acct.load?.last1h)}`);
|
|
586
|
+
|
|
587
|
+
if (acct.type === 'provider') {
|
|
588
|
+
const last = acct.lastStatus ? `${acct.lastStatus} in ${formatDurationMs(acct.lastResponseMs)}` : '-';
|
|
589
|
+
console.log(` Active: ${acct.inFlight} OK: ${acct.completedRequests} Failed: ${acct.failedRequests}`);
|
|
590
|
+
console.log(` Last: ${last}`);
|
|
591
|
+
if (q.genericLimit != null && q.genericRemaining != null) {
|
|
592
|
+
const used = q.genericLimit - q.genericRemaining;
|
|
593
|
+
const reset = q.genericReset ? ` Reset: ${new Date(q.genericReset).toISOString()}` : '';
|
|
594
|
+
console.log(` Limit: ${used}/${q.genericLimit} used${reset}`);
|
|
595
|
+
}
|
|
596
|
+
} else if (q.unified5h != null || q.unified7d != null) {
|
|
597
|
+
const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
|
|
598
|
+
const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
|
|
599
|
+
console.log(` Session: ${ses} used Weekly: ${wk} used`);
|
|
600
|
+
} else {
|
|
601
|
+
const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
|
|
602
|
+
const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
|
|
603
|
+
console.log(` Tokens: ${tok} used Requests: ${req} used`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
console.log(` Total: ${acct.usage.totalInputTokens + acct.usage.totalOutputTokens} tokens, ${acct.usage.totalRequests} requests`);
|
|
607
|
+
if (acct.rateLimitedUntil) console.log(` Throttled until: ${acct.rateLimitedUntil}`);
|
|
608
|
+
console.log('');
|
|
609
|
+
}
|
|
610
|
+
} catch {
|
|
611
|
+
console.error(`Cannot connect to proxy at ${config.proxy.host || '127.0.0.1'}:${config.proxy.port}`);
|
|
612
|
+
console.error('Is the server running? Start with: maxpool server');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── accounts ────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
async function accountsCommand() {
|
|
620
|
+
const config = await loadOrCreateConfig();
|
|
621
|
+
const verbose = args.includes('-v') || args.includes('--verbose');
|
|
622
|
+
|
|
623
|
+
if (config.accounts.length === 0) {
|
|
624
|
+
console.log('No accounts configured.');
|
|
625
|
+
console.log('Add one with: maxpool import, maxpool login, or maxpool login --api');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Refresh expired tokens before fetching profiles
|
|
630
|
+
let configDirty = false;
|
|
631
|
+
await Promise.all(config.accounts.map(async (a) => {
|
|
632
|
+
if (a.type !== 'oauth' || !a.refreshToken) return;
|
|
633
|
+
if (!isTokenExpiringSoon(a.expiresAt)) return;
|
|
634
|
+
try {
|
|
635
|
+
const newTokens = await refreshAccessToken(a.refreshToken);
|
|
636
|
+
a.accessToken = newTokens.accessToken;
|
|
637
|
+
a.refreshToken = newTokens.refreshToken;
|
|
638
|
+
a.expiresAt = newTokens.expiresAt;
|
|
639
|
+
configDirty = true;
|
|
640
|
+
} catch (err) {
|
|
641
|
+
// refresh failed — fetchProfile will report the specific error
|
|
642
|
+
}
|
|
643
|
+
}));
|
|
644
|
+
if (configDirty) await saveConfig(config);
|
|
645
|
+
|
|
646
|
+
// Fetch profiles in parallel for all OAuth accounts
|
|
647
|
+
const profiles = await Promise.all(
|
|
648
|
+
config.accounts.map(a =>
|
|
649
|
+
a.type === 'oauth' && a.accessToken ? fetchProfile(a.accessToken) : null
|
|
650
|
+
)
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Deduplicate by accountUuid — keep the last (most recently added) entry
|
|
654
|
+
const seen = new Map();
|
|
655
|
+
let removed = 0;
|
|
656
|
+
for (let i = config.accounts.length - 1; i >= 0; i--) {
|
|
657
|
+
const a = config.accounts[i];
|
|
658
|
+
const uuid = profiles[i]?.accountUuid || a.accountUuid;
|
|
659
|
+
if (uuid) {
|
|
660
|
+
if (seen.has(uuid)) {
|
|
661
|
+
config.accounts.splice(i, 1);
|
|
662
|
+
profiles.splice(i, 1);
|
|
663
|
+
removed++;
|
|
664
|
+
} else {
|
|
665
|
+
seen.set(uuid, i);
|
|
666
|
+
// Update stored UUID and name from profile
|
|
667
|
+
if (profiles[i] && !profiles[i].error) {
|
|
668
|
+
a.accountUuid = profiles[i].accountUuid;
|
|
669
|
+
if (profiles[i].email) a.name = profiles[i].email;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (removed > 0) {
|
|
675
|
+
await saveConfig(config);
|
|
676
|
+
console.log(`Removed ${removed} duplicate account(s)\n`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const [i, a] of config.accounts.entries()) {
|
|
680
|
+
const p = profiles[i];
|
|
681
|
+
|
|
682
|
+
if (a.type === 'apikey') {
|
|
683
|
+
console.log(` [${i + 1}] ${a.name} (apikey) ${a.apiKey?.slice(0, 15)}...`);
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// OAuth account
|
|
688
|
+
const hasProfile = p && !p.error;
|
|
689
|
+
const tier = hasProfile ? (p.hasClaudeMax ? 'Max' : p.hasClaudePro ? 'Pro' : 'subscription') : null;
|
|
690
|
+
const status = hasProfile ? `Claude ${tier}` : `unknown (${p?.error || 'no token'})`;
|
|
691
|
+
const src = a.source ? `, ${a.source}` : '';
|
|
692
|
+
console.log(` [${i + 1}] ${a.name} (${status}${src})`);
|
|
693
|
+
if (hasProfile && p.email && p.email !== a.name) console.log(` Email: ${p.email}`);
|
|
694
|
+
if (hasProfile && p.orgName) console.log(` Org: ${p.orgName}`);
|
|
695
|
+
if (verbose && a.expiresAt) {
|
|
696
|
+
const remaining = a.expiresAt - Date.now();
|
|
697
|
+
if (remaining <= 0) {
|
|
698
|
+
console.log(` Token: expired`);
|
|
699
|
+
} else {
|
|
700
|
+
const mins = Math.floor(remaining / 60000);
|
|
701
|
+
const hrs = Math.floor(mins / 60);
|
|
702
|
+
const expiry = hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
703
|
+
console.log(` Token: expires in ${expiry}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── api ─────────────────────────────────────────────────────
|
|
710
|
+
|
|
711
|
+
async function apiCommand() {
|
|
712
|
+
const config = await loadOrCreateConfig();
|
|
713
|
+
const path = args[1];
|
|
714
|
+
|
|
715
|
+
if (!path) {
|
|
716
|
+
console.error('Usage: maxpool api <path> [--account NAME] [--method POST] [--data JSON]');
|
|
717
|
+
console.error('Example: maxpool api /api/oauth/claude_cli/roles');
|
|
718
|
+
process.exit(1);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Find account to use
|
|
722
|
+
const accountName = argValue('--account');
|
|
723
|
+
const method = (argValue('--method') || 'GET').toUpperCase();
|
|
724
|
+
const data = argValue('--data');
|
|
725
|
+
|
|
726
|
+
const accounts = await resolveAccounts(config);
|
|
727
|
+
let account;
|
|
728
|
+
if (accountName) {
|
|
729
|
+
account = accounts.find(a => a.name === accountName);
|
|
730
|
+
if (!account) { console.error(`Account "${accountName}" not found`); process.exit(1); }
|
|
731
|
+
} else {
|
|
732
|
+
account = accounts.find(a => a.type === 'oauth') || accounts[0];
|
|
733
|
+
if (!account) { console.error('No accounts configured'); process.exit(1); }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const credential = account.accessToken || account.apiKey;
|
|
737
|
+
const isOAuth = account.type === 'oauth';
|
|
738
|
+
const upstream = config.upstream || 'https://api.anthropic.com';
|
|
739
|
+
const url = path.startsWith('http') ? path : `${upstream}${path}`;
|
|
740
|
+
|
|
741
|
+
const headers = isOAuth
|
|
742
|
+
? { 'Authorization': `Bearer ${credential}` }
|
|
743
|
+
: { 'x-api-key': credential };
|
|
744
|
+
|
|
745
|
+
const fetchOpts = { method, headers };
|
|
746
|
+
if (data) {
|
|
747
|
+
headers['Content-Type'] = 'application/json';
|
|
748
|
+
fetchOpts.body = data;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const res = await fetch(url, fetchOpts);
|
|
752
|
+
|
|
753
|
+
// Print response headers to stderr
|
|
754
|
+
console.error(`${res.status} ${res.statusText}`);
|
|
755
|
+
for (const [k, v] of res.headers.entries()) {
|
|
756
|
+
console.error(` ${k}: ${v}`);
|
|
757
|
+
}
|
|
758
|
+
console.error('');
|
|
759
|
+
|
|
760
|
+
// Print body to stdout
|
|
761
|
+
const body = await res.text();
|
|
762
|
+
try {
|
|
763
|
+
console.log(JSON.stringify(JSON.parse(body), null, 2));
|
|
764
|
+
} catch {
|
|
765
|
+
console.log(body);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ── remove ──────────────────────────────────────────────────
|
|
770
|
+
|
|
771
|
+
async function removeCommand() {
|
|
772
|
+
const config = await loadOrCreateConfig();
|
|
773
|
+
const name = args[1];
|
|
774
|
+
|
|
775
|
+
if (!name) {
|
|
776
|
+
console.error('Usage: maxpool remove <account-name>');
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const idx = config.accounts.findIndex(a => a.name === name);
|
|
781
|
+
if (idx < 0) {
|
|
782
|
+
console.error(`Account "${name}" not found`);
|
|
783
|
+
process.exit(1);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
config.accounts.splice(idx, 1);
|
|
787
|
+
await saveConfig(config);
|
|
788
|
+
console.log(`Removed account "${name}"`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ── help ────────────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
function showHelp() {
|
|
794
|
+
console.log(`Maxpool - Multi-account Claude proxy
|
|
795
|
+
|
|
796
|
+
Usage: maxpool [command] [options]
|
|
797
|
+
|
|
798
|
+
Commands:
|
|
799
|
+
server Start the proxy server (default)
|
|
800
|
+
import Import credentials from Claude Code
|
|
801
|
+
login OAuth login via browser
|
|
802
|
+
login --api Add an API key account
|
|
803
|
+
env [--with-key] Print env vars to use with Claude
|
|
804
|
+
run [-- args...] Run Claude Code through the proxy
|
|
805
|
+
status Show proxy & account status (live)
|
|
806
|
+
accounts List configured accounts
|
|
807
|
+
remove <name> Remove an account
|
|
808
|
+
api <path> Call an API endpoint with account credentials
|
|
809
|
+
help Show this help
|
|
810
|
+
|
|
811
|
+
Options:
|
|
812
|
+
--name NAME Set account name (import/login)
|
|
813
|
+
--from PATH Credentials path (import, default: ~/.claude/.credentials.json)
|
|
814
|
+
--json JSON Import from inline JSON (import), e.g.:
|
|
815
|
+
--json '{"accessToken":"...","refreshToken":"...","expiresAt":1234}'
|
|
816
|
+
--log-to DIR Log full requests/responses to DIR (server, one file per request)
|
|
817
|
+
--with-key Include proxy API key in maxpool env output
|
|
818
|
+
|
|
819
|
+
Config: ${getConfigPath()}
|
|
820
|
+
`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ── shared account upsert ────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
async function upsertOAuthAccount(config, name, creds, source = 'unknown') {
|
|
826
|
+
// Fetch profile to auto-name and deduplicate by account UUID
|
|
827
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
828
|
+
const profileOk = profile && !profile.error;
|
|
829
|
+
|
|
830
|
+
if (!profileOk) {
|
|
831
|
+
console.error(`Warning: could not fetch account profile — ${profile?.error || 'no token'}`);
|
|
832
|
+
}
|
|
833
|
+
if (!name && profile?.email) {
|
|
834
|
+
name = profile.email;
|
|
835
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
836
|
+
if (tier) console.log(`Detected Claude ${tier} account: ${profile.email}`);
|
|
837
|
+
}
|
|
838
|
+
if (!name) {
|
|
839
|
+
const n = config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
|
|
840
|
+
name = `account-${n}`;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const account = {
|
|
844
|
+
name,
|
|
845
|
+
type: 'oauth',
|
|
846
|
+
source,
|
|
847
|
+
accountUuid: profile?.accountUuid || null,
|
|
848
|
+
accessToken: creds.accessToken,
|
|
849
|
+
refreshToken: creds.refreshToken,
|
|
850
|
+
expiresAt: creds.expiresAt,
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Deduplicate: match by UUID first, then by name
|
|
854
|
+
let idx = profile?.accountUuid
|
|
855
|
+
? config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
|
|
856
|
+
: -1;
|
|
857
|
+
if (idx < 0) idx = config.accounts.findIndex(a => a.name === name);
|
|
858
|
+
|
|
859
|
+
if (idx >= 0) {
|
|
860
|
+
config.accounts[idx] = account;
|
|
861
|
+
console.log(`Updated account "${name}"`);
|
|
862
|
+
} else {
|
|
863
|
+
config.accounts.push(account);
|
|
864
|
+
console.log(`Added account "${name}"`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
await saveConfig(config);
|
|
868
|
+
console.log(`Saved to ${getConfigPath()}`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ── config sync helpers ─────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Find a config account entry matching an in-memory account (by UUID, then name).
|
|
875
|
+
*/
|
|
876
|
+
function findConfigAccount(diskConfig, account) {
|
|
877
|
+
if (account.accountUuid) {
|
|
878
|
+
const idx = diskConfig.accounts.findIndex(a => a.accountUuid === account.accountUuid);
|
|
879
|
+
if (idx >= 0) return idx;
|
|
880
|
+
}
|
|
881
|
+
return diskConfig.accounts.findIndex(a => a.name === account.name);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Sync accounts from disk config: add new accounts and refresh credentials
|
|
886
|
+
* for existing ones (handles re-imported OAuth tokens, rotated API keys, etc.).
|
|
887
|
+
* Returns the number of new accounts added.
|
|
888
|
+
*/
|
|
889
|
+
async function syncAccountsFromDisk(diskConfig, memConfig, accountManager) {
|
|
890
|
+
let added = 0;
|
|
891
|
+
for (const diskAcct of diskConfig.accounts) {
|
|
892
|
+
const matchByUuid = diskAcct.accountUuid
|
|
893
|
+
? memConfig.accounts.findIndex(a => a.accountUuid === diskAcct.accountUuid)
|
|
894
|
+
: -1;
|
|
895
|
+
const matchByName = memConfig.accounts.findIndex(a => a.name === diskAcct.name);
|
|
896
|
+
const memIdx = (matchByUuid >= 0 ? matchByUuid : null) ?? (matchByName >= 0 ? matchByName : -1);
|
|
897
|
+
|
|
898
|
+
if (memIdx < 0) {
|
|
899
|
+
// New account discovered on disk — add to running server
|
|
900
|
+
memConfig.accounts.push(diskAcct);
|
|
901
|
+
accountManager.addAccount(diskAcct);
|
|
902
|
+
added++;
|
|
903
|
+
console.log(`[Maxpool] Picked up new account "${diskAcct.name}" from config`);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Existing account — resolve fresh credentials from disk
|
|
908
|
+
let freshCred = null;
|
|
909
|
+
if (diskAcct.type === 'oauth' && diskAcct.importFrom) {
|
|
910
|
+
try {
|
|
911
|
+
const creds = await importCredentials(diskAcct.importFrom);
|
|
912
|
+
freshCred = { accessToken: creds.accessToken, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt };
|
|
913
|
+
} catch (err) {
|
|
914
|
+
console.error(`[Maxpool] Re-import failed for "${diskAcct.name}": ${err.message}`);
|
|
915
|
+
}
|
|
916
|
+
} else if (diskAcct.type === 'oauth' && diskAcct.accessToken) {
|
|
917
|
+
freshCred = { accessToken: diskAcct.accessToken, refreshToken: diskAcct.refreshToken, expiresAt: diskAcct.expiresAt };
|
|
918
|
+
} else if (diskAcct.type === 'apikey' && diskAcct.apiKey) {
|
|
919
|
+
freshCred = { apiKey: diskAcct.apiKey };
|
|
920
|
+
} else if (diskAcct.type === 'provider' && (diskAcct.authToken || diskAcct.apiKey)) {
|
|
921
|
+
freshCred = { authToken: diskAcct.authToken || diskAcct.apiKey };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (!freshCred) continue;
|
|
925
|
+
|
|
926
|
+
// Find the corresponding AccountManager entry and update credentials
|
|
927
|
+
const mgr = accountManager.accounts.find(a =>
|
|
928
|
+
(diskAcct.accountUuid && a.accountUuid === diskAcct.accountUuid) || a.name === diskAcct.name
|
|
929
|
+
);
|
|
930
|
+
if (!mgr) continue;
|
|
931
|
+
const enabled = diskAcct.enabled !== false;
|
|
932
|
+
if (mgr.enabled !== enabled) {
|
|
933
|
+
accountManager.setAccountEnabled(mgr.index, enabled);
|
|
934
|
+
console.log(`[Maxpool] ${enabled ? 'Enabled' : 'Disabled'} account "${mgr.name}" from config`);
|
|
935
|
+
}
|
|
936
|
+
memConfig.accounts[memIdx] = { ...memConfig.accounts[memIdx], ...diskAcct };
|
|
937
|
+
|
|
938
|
+
if (freshCred.accessToken) {
|
|
939
|
+
const changed = mgr.credential !== freshCred.accessToken ||
|
|
940
|
+
mgr.refreshToken !== freshCred.refreshToken;
|
|
941
|
+
// Don't overwrite in-memory credentials with staler ones from disk
|
|
942
|
+
// (e.g. after a TUI import updated the AM before saveConfig wrote to disk)
|
|
943
|
+
const diskIsStaler = freshCred.expiresAt && mgr.expiresAt &&
|
|
944
|
+
freshCred.expiresAt < mgr.expiresAt;
|
|
945
|
+
if (changed && !diskIsStaler) {
|
|
946
|
+
accountManager.updateAccountTokens(mgr.index, freshCred);
|
|
947
|
+
console.log(`[Maxpool] Refreshed credentials for "${mgr.name}"`);
|
|
948
|
+
}
|
|
949
|
+
} else if (freshCred.apiKey && mgr.credential !== freshCred.apiKey) {
|
|
950
|
+
mgr.credential = freshCred.apiKey;
|
|
951
|
+
if (mgr.status === 'error') mgr.status = 'active';
|
|
952
|
+
console.log(`[Maxpool] Updated API key for "${mgr.name}"`);
|
|
953
|
+
} else if (freshCred.authToken && mgr.credential !== freshCred.authToken) {
|
|
954
|
+
mgr.credential = freshCred.authToken;
|
|
955
|
+
if (mgr.status === 'error') mgr.status = 'active';
|
|
956
|
+
console.log(`[Maxpool] Updated provider token for "${mgr.name}"`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
memConfig.routing = {
|
|
960
|
+
mode: diskConfig.routing?.mode || 'automatic',
|
|
961
|
+
preferredAccount: diskConfig.routing?.preferredAccount || null,
|
|
962
|
+
};
|
|
963
|
+
const preferredApplied = accountManager.setRoutingMode(
|
|
964
|
+
memConfig.routing.mode,
|
|
965
|
+
memConfig.routing.preferredAccount,
|
|
966
|
+
);
|
|
967
|
+
if (memConfig.routing.mode === 'preferred' && !preferredApplied) {
|
|
968
|
+
memConfig.routing = { mode: 'automatic', preferredAccount: null };
|
|
969
|
+
}
|
|
970
|
+
return added;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ── helpers ─────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
function argValue(flag) {
|
|
976
|
+
const i = args.indexOf(flag);
|
|
977
|
+
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function handleServerListenError(err, host, port) {
|
|
981
|
+
if (err.code === 'EADDRINUSE') {
|
|
982
|
+
console.error(`[Maxpool] ${host}:${port} is already in use.`);
|
|
983
|
+
console.error('Another Maxpool proxy may already be running.');
|
|
984
|
+
console.error('Check the existing server with: maxpool status');
|
|
985
|
+
console.error(`Find the listener with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
|
|
986
|
+
} else if (err.code === 'EACCES') {
|
|
987
|
+
console.error(`[Maxpool] Permission denied while listening on ${host}:${port}.`);
|
|
988
|
+
console.error('Choose a non-privileged port in the Maxpool config.');
|
|
989
|
+
} else {
|
|
990
|
+
console.error(`[Maxpool] Failed to listen on ${host}:${port}: ${err.message}`);
|
|
991
|
+
}
|
|
992
|
+
process.exit(1);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function formatDurationMs(ms) {
|
|
996
|
+
if (ms == null || Number.isNaN(ms)) return '-';
|
|
997
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
998
|
+
const sec = ms / 1000;
|
|
999
|
+
if (sec < 60) return `${sec.toFixed(1)}s`;
|
|
1000
|
+
return `${Math.floor(sec / 60)}m${String(Math.round(sec % 60)).padStart(2, '0')}s`;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function formatLoadWindow(load = {}) {
|
|
1004
|
+
const avg = load.avgMs != null ? ` avg ${formatDurationMs(load.avgMs)}` : '';
|
|
1005
|
+
const failed = load.failed ? `, ${load.failed} failed` : '';
|
|
1006
|
+
return `${load.requests || 0} req${avg}${failed}`;
|
|
1007
|
+
}
|