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/tui.js
ADDED
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import { importCredentials, fetchProfile } from './oauth.js';
|
|
2
|
+
|
|
3
|
+
// ── ANSI helpers ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'.split('');
|
|
6
|
+
const ESC = '\x1b[';
|
|
7
|
+
const RESET = `${ESC}0m`;
|
|
8
|
+
const BOLD = `${ESC}1m`;
|
|
9
|
+
const DIM = `${ESC}2m`;
|
|
10
|
+
|
|
11
|
+
const bold = s => `${BOLD}${s}${RESET}`;
|
|
12
|
+
const dim = s => `${DIM}${s}${RESET}`;
|
|
13
|
+
const fg = (c, s) => `${ESC}${c}m${s}${RESET}`;
|
|
14
|
+
const green = s => fg(32, s);
|
|
15
|
+
const yellow = s => fg(33, s);
|
|
16
|
+
const red = s => fg(31, s);
|
|
17
|
+
const cyan = s => fg(36, s);
|
|
18
|
+
const gray = s => fg(90, s);
|
|
19
|
+
|
|
20
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
21
|
+
const strip = s => s.replace(ANSI_RE, '');
|
|
22
|
+
const vw = s => strip(s).length;
|
|
23
|
+
|
|
24
|
+
function rpad(s, w) {
|
|
25
|
+
const gap = w - vw(s);
|
|
26
|
+
return gap > 0 ? s + ' '.repeat(gap) : s;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Truncate a string with ANSI codes to exactly w visible characters, then reset. */
|
|
30
|
+
function truncate(s, w) {
|
|
31
|
+
let visible = 0;
|
|
32
|
+
let out = '';
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < s.length && visible < w) {
|
|
35
|
+
if (s[i] === '\x1b') {
|
|
36
|
+
const end = s.indexOf('m', i);
|
|
37
|
+
if (end >= 0) { out += s.slice(i, end + 1); i = end + 1; continue; }
|
|
38
|
+
}
|
|
39
|
+
out += s[i];
|
|
40
|
+
visible++;
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
return out + RESET;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fit a line to exactly w columns: truncate if too long, pad if too short. */
|
|
47
|
+
function fitLine(s, w) {
|
|
48
|
+
const v = vw(s);
|
|
49
|
+
if (v > w) return truncate(s, w);
|
|
50
|
+
if (v < w) return s + ' '.repeat(w - v);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatReset(resetTs) {
|
|
55
|
+
if (!resetTs) return '';
|
|
56
|
+
const ms = resetTs - Date.now();
|
|
57
|
+
if (ms <= 0) return '';
|
|
58
|
+
const mins = Math.ceil(ms / 60000);
|
|
59
|
+
if (mins < 60) return `${mins}m`;
|
|
60
|
+
const hrs = Math.floor(mins / 60);
|
|
61
|
+
const rm = mins % 60;
|
|
62
|
+
if (hrs < 24) return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`;
|
|
63
|
+
const days = Math.floor(hrs / 24);
|
|
64
|
+
const rh = hrs % 24;
|
|
65
|
+
return rh > 0 ? `${days}d${rh}h` : `${days}d`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quotaLabel(ratio, resetTs, width) {
|
|
69
|
+
const rst = formatReset(resetTs);
|
|
70
|
+
if (ratio == null || isNaN(ratio)) return (rst || '-').slice(0, width);
|
|
71
|
+
const pct = `${Math.max(0, Math.min(100, ratio * 100)).toFixed(0)}%`;
|
|
72
|
+
const full = rst ? `${pct} ${rst}` : pct;
|
|
73
|
+
if (full.length <= width) return full;
|
|
74
|
+
return (rst || pct).slice(0, width);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatMs(ms) {
|
|
78
|
+
if (ms == null || isNaN(ms)) return '-';
|
|
79
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
80
|
+
const sec = ms / 1000;
|
|
81
|
+
if (sec < 60) return `${sec.toFixed(1)}s`;
|
|
82
|
+
const min = Math.floor(sec / 60);
|
|
83
|
+
const rem = Math.round(sec % 60);
|
|
84
|
+
return `${min}m${String(rem).padStart(2, '0')}s`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function statusColor(status) {
|
|
88
|
+
if (status == null) return '-';
|
|
89
|
+
if (status >= 200 && status < 300) return green(String(status));
|
|
90
|
+
if (status === 429) return yellow(String(status));
|
|
91
|
+
if (status >= 500) return red(String(status));
|
|
92
|
+
return String(status);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function loadText(load) {
|
|
96
|
+
const cur = load?.current || {};
|
|
97
|
+
const m15 = load?.last15m || {};
|
|
98
|
+
const h1 = load?.last1h || {};
|
|
99
|
+
const current = `${cur.inFlight || 0}/${cur.activeWeight || 0}`;
|
|
100
|
+
const recent = `${m15.requests || 0}r`;
|
|
101
|
+
const recentAvg = m15.avgMs != null ? ` ${formatMs(m15.avgMs)}` : '';
|
|
102
|
+
const hour = `${h1.requests || 0}r`;
|
|
103
|
+
const fails = (m15.failed || 0) > 0 ? ` ${red(`${m15.failed}f`)}` : '';
|
|
104
|
+
return `Load ${current} 15m ${recent}${recentAvg}${fails} 1h ${hour}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function weeklyPolicyText(am, account) {
|
|
108
|
+
if (!am?._weeklyState || !account || account.type === 'provider') return '';
|
|
109
|
+
const state = am._weeklyState(account);
|
|
110
|
+
if (!state || state === 'unknown' || state === 'normal') return '';
|
|
111
|
+
const rawState = am._weeklyRawState?.(account) || state;
|
|
112
|
+
const used = Number(account.quota?.unified7d);
|
|
113
|
+
const pct = Number.isFinite(used)
|
|
114
|
+
? ` ${Math.max(0, Math.min(100, used * 100)).toFixed(0)}%`
|
|
115
|
+
: '';
|
|
116
|
+
const paceOnly = state !== rawState && rawState !== 'exhausted';
|
|
117
|
+
const label = paceOnly ? `Pace ${state}` : `Wk ${state}`;
|
|
118
|
+
const text = paceOnly ? label : `${label}${pct}`;
|
|
119
|
+
if (state === 'critical' || state === 'exhausted') return state !== rawState ? yellow(text) : red(text);
|
|
120
|
+
if (state === 'reserve') return yellow(text);
|
|
121
|
+
return cyan(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render a progress bar using background colors with text overlaid.
|
|
126
|
+
* The label (e.g. "Ses 2h30m" or "45%") is drawn on top of the bar.
|
|
127
|
+
*/
|
|
128
|
+
function bar(ratio, w = 10, resetTs) {
|
|
129
|
+
if (ratio == null || isNaN(ratio)) {
|
|
130
|
+
// No data — dim background, show label or dash
|
|
131
|
+
const label = quotaLabel(ratio, resetTs, w);
|
|
132
|
+
const text = label.slice(0, w);
|
|
133
|
+
const pad = w - text.length;
|
|
134
|
+
const lp = Math.floor(pad / 2);
|
|
135
|
+
const rp = pad - lp;
|
|
136
|
+
return `${ESC}100m${' '.repeat(lp)}${text}${' '.repeat(rp)}${RESET}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ratio = Math.max(0, Math.min(1, ratio));
|
|
140
|
+
const f = Math.round(ratio * w);
|
|
141
|
+
// Background colors: 42=green, 43=yellow, 41=red; 100=bright black (gray) for empty
|
|
142
|
+
const bg = ratio < 0.7 ? 42 : ratio < 0.9 ? 43 : 41;
|
|
143
|
+
|
|
144
|
+
// Build the label to overlay: show both usage and reset when it fits.
|
|
145
|
+
const label = quotaLabel(ratio, resetTs, w);
|
|
146
|
+
const text = label.slice(0, w);
|
|
147
|
+
const pad = w - text.length;
|
|
148
|
+
const lp = Math.floor(pad / 2);
|
|
149
|
+
const rp = pad - lp;
|
|
150
|
+
const chars = (' '.repeat(lp) + text + ' '.repeat(rp));
|
|
151
|
+
|
|
152
|
+
// Split chars into filled (colored bg) and empty (gray bg) portions
|
|
153
|
+
const filled = chars.slice(0, f);
|
|
154
|
+
const empty = chars.slice(f);
|
|
155
|
+
|
|
156
|
+
let out = '';
|
|
157
|
+
if (filled) out += `${ESC}${bg};97m${filled}`;
|
|
158
|
+
if (empty) out += `${ESC}100;37m${empty}`;
|
|
159
|
+
out += RESET;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const __tuiTest = { formatReset, quotaLabel, bar, strip };
|
|
164
|
+
|
|
165
|
+
function timestamp() {
|
|
166
|
+
return new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── TUI class ────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export class TUI {
|
|
172
|
+
constructor({ accountManager, config, saveConfig, syncAccounts, onQuit, onRestart }) {
|
|
173
|
+
this.am = accountManager;
|
|
174
|
+
this.config = config;
|
|
175
|
+
this.saveConfig = saveConfig;
|
|
176
|
+
this.syncAccounts = syncAccounts;
|
|
177
|
+
this.onQuit = onQuit;
|
|
178
|
+
this.onRestart = onRestart;
|
|
179
|
+
|
|
180
|
+
this.log = []; // completed activity entries
|
|
181
|
+
this.active = new Map(); // in-flight requests
|
|
182
|
+
this.mode = 'normal'; // normal | accounts | routing | select | input | confirm
|
|
183
|
+
this.selAction = null; // prefer | toggle | delete
|
|
184
|
+
this.selIdx = 0;
|
|
185
|
+
this.inputPrompt = '';
|
|
186
|
+
this.inputBuf = '';
|
|
187
|
+
this.inputCb = null;
|
|
188
|
+
this.inputSensitive = false;
|
|
189
|
+
this.confirmTitle = '';
|
|
190
|
+
this.confirmDetail = '';
|
|
191
|
+
this.confirmCb = null;
|
|
192
|
+
this.frame = 0;
|
|
193
|
+
this.running = false;
|
|
194
|
+
this.timer = null;
|
|
195
|
+
this._origLog = null;
|
|
196
|
+
this._origErr = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── lifecycle ──────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
start() {
|
|
202
|
+
try {
|
|
203
|
+
process.stdin.setRawMode(true);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
process.stderr.write(`[Maxpool] TUI unavailable (${err.code || err.message}); continuing with plain logs.\n`);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.running = true;
|
|
210
|
+
process.stdout.write(`${ESC}?1049h${ESC}?25l`);
|
|
211
|
+
process.stdin.resume();
|
|
212
|
+
process.stdin.setEncoding('utf8');
|
|
213
|
+
this._dataHandler = d => this._onData(d);
|
|
214
|
+
this._resizeHandler = () => this.render();
|
|
215
|
+
process.stdin.on('data', this._dataHandler);
|
|
216
|
+
process.stdout.on('resize', this._resizeHandler);
|
|
217
|
+
|
|
218
|
+
// Redirect console to activity log
|
|
219
|
+
this._origLog = console.log;
|
|
220
|
+
this._origErr = console.error;
|
|
221
|
+
console.log = (...a) => this._addLog(a.join(' '));
|
|
222
|
+
console.error = (...a) => this._addLog(a.join(' '));
|
|
223
|
+
|
|
224
|
+
this.render();
|
|
225
|
+
this.timer = setInterval(() => {
|
|
226
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
227
|
+
this.render();
|
|
228
|
+
}, 500);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
stop() {
|
|
233
|
+
this.running = false;
|
|
234
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
235
|
+
if (this._origLog) { console.log = this._origLog; console.error = this._origErr; }
|
|
236
|
+
process.stdin.removeListener('data', this._dataHandler);
|
|
237
|
+
process.stdout.removeListener('resize', this._resizeHandler);
|
|
238
|
+
process.stdout.write(`${ESC}?25h${ESC}?1049l`);
|
|
239
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
240
|
+
process.stdin.pause();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── server hooks ───────────────────────────────────
|
|
244
|
+
|
|
245
|
+
onRequestStart(id, info) {
|
|
246
|
+
this.active.set(id, { ...info, t: timestamp(), started: Date.now(), account: null });
|
|
247
|
+
this.render();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
onRequestRouted(id, info) {
|
|
251
|
+
const r = this.active.get(id);
|
|
252
|
+
if (r) r.account = info.account;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onRequestEnd(id, info) {
|
|
256
|
+
const r = this.active.get(id);
|
|
257
|
+
this.active.delete(id);
|
|
258
|
+
const dur = r ? ((Date.now() - r.started) / 1000).toFixed(1) : '?';
|
|
259
|
+
const acct = info.account || r?.account || '?';
|
|
260
|
+
this._addLog(`${info.method} ${info.path} → ${acct} (${info.status}, ${dur}s)`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_addLog(msg) {
|
|
264
|
+
msg = msg.replace(/^\[Maxpool\]\s*/, '');
|
|
265
|
+
this.log.unshift({ t: timestamp(), msg });
|
|
266
|
+
if (this.log.length > 200) this.log.length = 200;
|
|
267
|
+
if (this.running) this.render();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── input handling ─────────────────────────────────
|
|
271
|
+
|
|
272
|
+
_onData(d) {
|
|
273
|
+
if (d === '\x1b[A') return this._key('up');
|
|
274
|
+
if (d === '\x1b[B') return this._key('down');
|
|
275
|
+
if (d === '\x1b') return this._key('esc');
|
|
276
|
+
if (d === '\x03') return this._key('ctrl-c');
|
|
277
|
+
if (d === '\x7f' || d === '\x08') return this._key('bs');
|
|
278
|
+
|
|
279
|
+
// Input mode accepts typed AND pasted text. A terminal delivers a paste as
|
|
280
|
+
// one multi-char chunk — often wrapped in bracketed-paste markers and/or
|
|
281
|
+
// ending in a newline. Sanitize the chunk instead of all-or-nothing
|
|
282
|
+
// rejecting it (which silently dropped pasted API keys): strip the paste
|
|
283
|
+
// markers, take the text up to the first newline, drop control chars, append
|
|
284
|
+
// it, and treat an embedded newline as Enter (submit).
|
|
285
|
+
if (this.mode === 'input') {
|
|
286
|
+
// Prepend any partial escape held from the previous chunk so a bracketed-
|
|
287
|
+
// paste marker split across two stdin reads (e.g. "\x1b[20" then "0~key")
|
|
288
|
+
// is recognized rather than appended as literal text.
|
|
289
|
+
let chunk = (this._pendingPaste || '') + d;
|
|
290
|
+
this._pendingPaste = '';
|
|
291
|
+
chunk = chunk.replace(/\x1b\[20[01]~/g, '');
|
|
292
|
+
// Hold back a trailing fragment that could be the start of a paste marker.
|
|
293
|
+
const partial = chunk.match(/\x1b(?:\[(?:2(?:0[01]?)?)?)?$/);
|
|
294
|
+
if (partial) {
|
|
295
|
+
this._pendingPaste = partial[0];
|
|
296
|
+
chunk = chunk.slice(0, chunk.length - partial[0].length);
|
|
297
|
+
}
|
|
298
|
+
const nlIdx = chunk.search(/[\r\n]/);
|
|
299
|
+
const typed = (nlIdx === -1 ? chunk : chunk.slice(0, nlIdx))
|
|
300
|
+
.split('')
|
|
301
|
+
.filter(c => c >= ' ' && c !== '\x7f')
|
|
302
|
+
.join('');
|
|
303
|
+
if (typed) { this.inputBuf += typed; this.render(); }
|
|
304
|
+
if (nlIdx !== -1) { this._pendingPaste = ''; return this._key('enter'); }
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (d === '\r' || d === '\n') return this._key('enter');
|
|
309
|
+
if (d.length === 1 && d >= ' ') return this._key(d);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_key(k) {
|
|
313
|
+
if (k === 'ctrl-c') { this.stop(); this.onQuit?.(); return; }
|
|
314
|
+
|
|
315
|
+
switch (this.mode) {
|
|
316
|
+
case 'normal': this._keyNormal(k); break;
|
|
317
|
+
case 'accounts': this._keyAccounts(k); break;
|
|
318
|
+
case 'routing': this._keyRouting(k); break;
|
|
319
|
+
case 'select': this._keySelect(k); break;
|
|
320
|
+
case 'input': this._keyInput(k); break;
|
|
321
|
+
case 'confirm': this._keyConfirm(k); break;
|
|
322
|
+
}
|
|
323
|
+
this.render();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_keyNormal(k) {
|
|
327
|
+
if (k === 'q') {
|
|
328
|
+
this._confirm(
|
|
329
|
+
'Stop Maxpool?',
|
|
330
|
+
'New requests will stop; active requests will drain before the server exits.',
|
|
331
|
+
() => { this.stop(); this.onQuit?.(); },
|
|
332
|
+
);
|
|
333
|
+
} else if (k === 'r') {
|
|
334
|
+
this._confirm(
|
|
335
|
+
'Restart Maxpool?',
|
|
336
|
+
'Pause new requests, drain active work, then start the updated server.',
|
|
337
|
+
() => { this.stop(); this.onRestart?.(); },
|
|
338
|
+
);
|
|
339
|
+
} else if (k === 'a') {
|
|
340
|
+
this.mode = 'accounts';
|
|
341
|
+
} else if (k === 'm') {
|
|
342
|
+
this.mode = 'routing';
|
|
343
|
+
} else if (k === 's') {
|
|
344
|
+
this._confirm(
|
|
345
|
+
'Sync accounts now?',
|
|
346
|
+
'Reload account credentials and newly added accounts from the config file.',
|
|
347
|
+
() => this._doSync(),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_keyAccounts(k) {
|
|
353
|
+
if (k === 'i') {
|
|
354
|
+
this._confirm(
|
|
355
|
+
'Import current Claude login?',
|
|
356
|
+
'Add or update the account currently logged into Claude Code.',
|
|
357
|
+
() => this._doImport(),
|
|
358
|
+
);
|
|
359
|
+
} else if (k === 'k') {
|
|
360
|
+
this.mode = 'input';
|
|
361
|
+
this.inputPrompt = 'Anthropic API key';
|
|
362
|
+
this.inputBuf = '';
|
|
363
|
+
this.inputSensitive = true;
|
|
364
|
+
this.inputCb = value => {
|
|
365
|
+
if (!value) return;
|
|
366
|
+
this._confirm(
|
|
367
|
+
'Add this API key?',
|
|
368
|
+
'Store it in Maxpool config as a new Anthropic API account.',
|
|
369
|
+
() => this._doAddKey(value),
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
} else if (k === 't' && this.am.accounts.length > 0) {
|
|
373
|
+
this._startSelection('toggle');
|
|
374
|
+
} else if (k === 'd' && this.am.accounts.length > 0) {
|
|
375
|
+
this._startSelection('delete');
|
|
376
|
+
} else if (k === 'esc' || k === 'q') {
|
|
377
|
+
this.mode = 'normal';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_keyRouting(k) {
|
|
382
|
+
if (k === 'a') {
|
|
383
|
+
this._confirm(
|
|
384
|
+
'Use automatic routing?',
|
|
385
|
+
'Spread new requests across healthy accounts using load, quota, and recent errors.',
|
|
386
|
+
() => this._setAutomaticRouting(),
|
|
387
|
+
);
|
|
388
|
+
} else if (k === 'p' && this.am.accounts.some(account => account.type !== 'provider')) {
|
|
389
|
+
this._startSelection('prefer');
|
|
390
|
+
} else if (k === 'esc' || k === 'q') {
|
|
391
|
+
this.mode = 'normal';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
_keySelect(k) {
|
|
396
|
+
const selectable = this._selectableIndexes(this.selAction);
|
|
397
|
+
const position = Math.max(0, selectable.indexOf(this.selIdx));
|
|
398
|
+
if (k === 'up' || k === 'k') this.selIdx = selectable[Math.max(0, position - 1)] ?? this.selIdx;
|
|
399
|
+
else if (k === 'down' || k === 'j') this.selIdx = selectable[Math.min(selectable.length - 1, position + 1)] ?? this.selIdx;
|
|
400
|
+
else if (k === 'enter') {
|
|
401
|
+
const account = this.am.accounts[this.selIdx];
|
|
402
|
+
if (!account) {
|
|
403
|
+
this.mode = 'normal';
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (this.selAction === 'prefer') {
|
|
407
|
+
if (account.type === 'provider') {
|
|
408
|
+
this._addLog('Manual preference is available only for Claude accounts');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (!account.enabled) {
|
|
412
|
+
this._addLog(`Enable "${account.name}" before selecting it as the manual preference`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
this._confirm(
|
|
416
|
+
`Use manual preference for "${account.name}"?`,
|
|
417
|
+
'Move idle sessions on their next request; fail over and return automatically.',
|
|
418
|
+
() => this._setPreferredRouting(account.name),
|
|
419
|
+
);
|
|
420
|
+
} else if (this.selAction === 'toggle') {
|
|
421
|
+
const enable = !account.enabled;
|
|
422
|
+
this._confirm(
|
|
423
|
+
`${enable ? 'Enable' : 'Disable'} "${account.name}"?`,
|
|
424
|
+
enable
|
|
425
|
+
? 'Allow this account to receive new requests again.'
|
|
426
|
+
: 'Stop assigning new requests to it. Active requests will continue.',
|
|
427
|
+
() => this._doToggle(this.selIdx, enable),
|
|
428
|
+
);
|
|
429
|
+
} else if (this.selAction === 'delete') {
|
|
430
|
+
this._confirm(
|
|
431
|
+
`Delete "${account.name}"?`,
|
|
432
|
+
'Permanently remove it from Maxpool config. Deletion is blocked while it has active requests.',
|
|
433
|
+
() => this._doDelete(this.selIdx),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (k === 'esc' || k === 'q') { this.mode = 'normal'; }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_startSelection(action) {
|
|
441
|
+
const selectable = this._selectableIndexes(action);
|
|
442
|
+
if (!selectable.length) {
|
|
443
|
+
this._addLog(action === 'prefer'
|
|
444
|
+
? 'No enabled Claude account is available for manual preference'
|
|
445
|
+
: 'No configurable account is available for this action');
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.mode = 'select';
|
|
449
|
+
this.selAction = action;
|
|
450
|
+
this.selIdx = selectable.includes(this.am.currentIndex) ? this.am.currentIndex : selectable[0];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_selectableIndexes(action) {
|
|
454
|
+
return this.am.accounts
|
|
455
|
+
.map((account, index) => ({ account, index }))
|
|
456
|
+
.filter(({ account }) => {
|
|
457
|
+
if (action === 'prefer') return account.type !== 'provider' && account.enabled;
|
|
458
|
+
return this._configAccountIndex(account) >= 0;
|
|
459
|
+
})
|
|
460
|
+
.map(({ index }) => index);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_keyInput(k) {
|
|
464
|
+
if (k === 'enter') {
|
|
465
|
+
const cb = this.inputCb;
|
|
466
|
+
const v = this.inputBuf;
|
|
467
|
+
this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; this.inputSensitive = false; this._pendingPaste = '';
|
|
468
|
+
cb?.(v);
|
|
469
|
+
}
|
|
470
|
+
else if (k === 'esc') {
|
|
471
|
+
this.mode = 'normal'; this.inputCb = null; this.inputBuf = ''; this.inputSensitive = false; this._pendingPaste = '';
|
|
472
|
+
}
|
|
473
|
+
else if (k === 'bs') { this.inputBuf = this.inputBuf.slice(0, -1); }
|
|
474
|
+
else if (k.length === 1) { this.inputBuf += k; }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_keyConfirm(k) {
|
|
478
|
+
if (k === 'y') {
|
|
479
|
+
const cb = this.confirmCb;
|
|
480
|
+
this._clearConfirm();
|
|
481
|
+
Promise.resolve(cb?.()).catch(error => {
|
|
482
|
+
this._addLog(`Action failed: ${error.message}`);
|
|
483
|
+
});
|
|
484
|
+
} else if (k === 'n' || k === 'esc' || k === 'q') {
|
|
485
|
+
this._clearConfirm();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_confirm(title, detail, cb) {
|
|
490
|
+
this.mode = 'confirm';
|
|
491
|
+
this.confirmTitle = title;
|
|
492
|
+
this.confirmDetail = detail;
|
|
493
|
+
this.confirmCb = cb;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_clearConfirm() {
|
|
497
|
+
this.mode = 'normal';
|
|
498
|
+
this.confirmTitle = '';
|
|
499
|
+
this.confirmDetail = '';
|
|
500
|
+
this.confirmCb = null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── account operations ─────────────────────────────
|
|
504
|
+
|
|
505
|
+
async _doSync() {
|
|
506
|
+
try {
|
|
507
|
+
this._addLog('Reloading config...');
|
|
508
|
+
const count = await this.syncAccounts();
|
|
509
|
+
if (count > 0) {
|
|
510
|
+
this._addLog(`Synced ${count} new account(s) from config`);
|
|
511
|
+
} else {
|
|
512
|
+
this._addLog('Config reloaded, credentials refreshed');
|
|
513
|
+
}
|
|
514
|
+
} catch (e) {
|
|
515
|
+
this._addLog(`Sync failed: ${e.message}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async _doImport() {
|
|
520
|
+
try {
|
|
521
|
+
this._addLog('Importing credentials...');
|
|
522
|
+
const creds = await importCredentials('~/.claude/.credentials.json');
|
|
523
|
+
const profile = await fetchProfile(creds.accessToken);
|
|
524
|
+
const profileOk = profile && !profile.error;
|
|
525
|
+
|
|
526
|
+
if (!profileOk) {
|
|
527
|
+
this._addLog(`Warning: could not fetch profile — ${profile?.error || 'no token'}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let name;
|
|
531
|
+
if (profile?.email) {
|
|
532
|
+
name = profile.email;
|
|
533
|
+
const tier = profile.hasClaudeMax ? 'Max' : profile.hasClaudePro ? 'Pro' : null;
|
|
534
|
+
if (tier) this._addLog(`Detected Claude ${tier}: ${name}`);
|
|
535
|
+
} else {
|
|
536
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('account-')).length + 1;
|
|
537
|
+
name = `account-${n}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const entry = {
|
|
541
|
+
name, type: 'oauth', source: 'import',
|
|
542
|
+
accountUuid: profile?.accountUuid || null,
|
|
543
|
+
accessToken: creds.accessToken,
|
|
544
|
+
refreshToken: creds.refreshToken,
|
|
545
|
+
expiresAt: creds.expiresAt,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
// Deduplicate: match by UUID first, then by name
|
|
549
|
+
let idx = profile?.accountUuid
|
|
550
|
+
? this.config.accounts.findIndex(a => a.accountUuid === profile.accountUuid)
|
|
551
|
+
: -1;
|
|
552
|
+
if (idx < 0) idx = this.config.accounts.findIndex(a => a.name === name);
|
|
553
|
+
|
|
554
|
+
if (idx >= 0) {
|
|
555
|
+
const previous = this.config.accounts[idx];
|
|
556
|
+
entry.enabled = this.config.accounts[idx].enabled;
|
|
557
|
+
this.config.accounts[idx] = entry;
|
|
558
|
+
try {
|
|
559
|
+
await this.saveConfig(this.config);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.config.accounts[idx] = previous;
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
// Update the running account manager entry
|
|
565
|
+
const amAcct = this.am.accounts.find(account =>
|
|
566
|
+
(entry.accountUuid && account.accountUuid === entry.accountUuid) || account.name === name
|
|
567
|
+
);
|
|
568
|
+
if (amAcct) {
|
|
569
|
+
amAcct.credential = creds.accessToken;
|
|
570
|
+
amAcct.refreshToken = creds.refreshToken;
|
|
571
|
+
amAcct.expiresAt = creds.expiresAt;
|
|
572
|
+
amAcct.accountUuid = entry.accountUuid;
|
|
573
|
+
amAcct.name = name;
|
|
574
|
+
if (amAcct.status === 'error') amAcct.status = 'active';
|
|
575
|
+
}
|
|
576
|
+
this._addLog(`Updated account "${name}"`);
|
|
577
|
+
} else {
|
|
578
|
+
this.config.accounts.push(entry);
|
|
579
|
+
try {
|
|
580
|
+
await this.saveConfig(this.config);
|
|
581
|
+
} catch (error) {
|
|
582
|
+
this.config.accounts.pop();
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
this.am.addAccount(entry);
|
|
586
|
+
this._addLog(`Imported account "${name}"`);
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {
|
|
589
|
+
this._addLog(`Import failed: ${e.message}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async _doAddKey(apiKey) {
|
|
594
|
+
const key = String(apiKey || '').trim();
|
|
595
|
+
if (!key) { this._addLog('No API key entered'); return; }
|
|
596
|
+
const n = this.config.accounts.filter(a => a.name.startsWith('api-')).length + 1;
|
|
597
|
+
const name = `api-${n}`;
|
|
598
|
+
const entry = { name, type: 'apikey', apiKey: key };
|
|
599
|
+
this.config.accounts.push(entry);
|
|
600
|
+
try {
|
|
601
|
+
await this.saveConfig(this.config);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
this.config.accounts.pop();
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
this.am.addAccount(entry);
|
|
607
|
+
this._addLog(`Added API key account "${name}"`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async _setAutomaticRouting() {
|
|
611
|
+
const previous = this.config.routing;
|
|
612
|
+
this.config.routing = { mode: 'automatic', preferredAccount: null };
|
|
613
|
+
try {
|
|
614
|
+
await this.saveConfig(this.config);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
this.config.routing = previous;
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
this.am.setRoutingMode('automatic');
|
|
620
|
+
this._addLog('Routing set to automatic load balancing');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async _setPreferredRouting(name) {
|
|
624
|
+
const account = this.am.accounts.find(candidate => candidate.name === name);
|
|
625
|
+
if (!account?.enabled || account.type === 'provider') {
|
|
626
|
+
throw new Error(`Claude account "${name}" must be enabled before it can be preferred`);
|
|
627
|
+
}
|
|
628
|
+
const previous = this.config.routing;
|
|
629
|
+
this.config.routing = { mode: 'preferred', preferredAccount: name };
|
|
630
|
+
try {
|
|
631
|
+
await this.saveConfig(this.config);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
this.config.routing = previous;
|
|
634
|
+
throw error;
|
|
635
|
+
}
|
|
636
|
+
this.am.setRoutingMode('preferred', name);
|
|
637
|
+
this._addLog(`Routing now prefers "${name}" with automatic failover`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
_configAccountIndex(account) {
|
|
641
|
+
if (!account) return -1;
|
|
642
|
+
if (account.accountUuid) {
|
|
643
|
+
const byUuid = this.config.accounts.findIndex(candidate => candidate.accountUuid === account.accountUuid);
|
|
644
|
+
if (byUuid >= 0) return byUuid;
|
|
645
|
+
}
|
|
646
|
+
return this.config.accounts.findIndex(candidate => candidate.name === account.name);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async _doToggle(idx, enabled) {
|
|
650
|
+
const account = this.am.accounts[idx];
|
|
651
|
+
if (!account) return;
|
|
652
|
+
const configIndex = this._configAccountIndex(account);
|
|
653
|
+
if (configIndex < 0) {
|
|
654
|
+
this._addLog(`Cannot ${enabled ? 'enable' : 'disable'} runtime provider "${account.name}" here`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const previous = this.config.accounts[configIndex].enabled;
|
|
658
|
+
this.config.accounts[configIndex].enabled = enabled;
|
|
659
|
+
const previousRouting = this.config.routing;
|
|
660
|
+
const resetsRouting = !enabled && this.config.routing?.preferredAccount === account.name;
|
|
661
|
+
if (resetsRouting) {
|
|
662
|
+
this.config.routing = { mode: 'automatic', preferredAccount: null };
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
await this.saveConfig(this.config);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.config.accounts[configIndex].enabled = previous;
|
|
668
|
+
this.config.routing = previousRouting;
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
this.am.setAccountEnabled(idx, enabled);
|
|
672
|
+
this._addLog(`${enabled ? 'Enabled' : 'Disabled'} account "${account.name}"`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async _doDelete(idx) {
|
|
676
|
+
if (idx < 0 || idx >= this.am.accounts.length) return;
|
|
677
|
+
const account = this.am.accounts[idx];
|
|
678
|
+
const name = account.name;
|
|
679
|
+
if (account.inFlight > 0) {
|
|
680
|
+
this._addLog(`Cannot delete "${name}" while ${account.inFlight} request(s) are active; disable it and retry when idle`);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const configIndex = this._configAccountIndex(account);
|
|
684
|
+
if (configIndex < 0) {
|
|
685
|
+
this._addLog(`Cannot permanently delete runtime provider "${name}" from the TUI`);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const wasEnabled = account.enabled;
|
|
689
|
+
this.am.setAccountEnabled(idx, false);
|
|
690
|
+
if (account.inFlight > 0) {
|
|
691
|
+
this.am.setAccountEnabled(idx, wasEnabled);
|
|
692
|
+
this._addLog(`Cannot delete "${name}" because a request started; the account was not changed`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const [removedConfig] = this.config.accounts.splice(configIndex, 1);
|
|
697
|
+
const previousRouting = this.config.routing;
|
|
698
|
+
if (this.config.routing?.preferredAccount === name) {
|
|
699
|
+
this.config.routing = { mode: 'automatic', preferredAccount: null };
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
await this.saveConfig(this.config);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
this.config.accounts.splice(configIndex, 0, removedConfig);
|
|
705
|
+
this.config.routing = previousRouting;
|
|
706
|
+
this.am.setAccountEnabled(idx, wasEnabled);
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
if (!this.am.removeAccount(idx)) {
|
|
710
|
+
this.config.accounts.splice(configIndex, 0, removedConfig);
|
|
711
|
+
this.config.routing = previousRouting;
|
|
712
|
+
this.am.setAccountEnabled(idx, wasEnabled);
|
|
713
|
+
await this.saveConfig(this.config);
|
|
714
|
+
throw new Error(`Account "${name}" became active before deletion completed`);
|
|
715
|
+
}
|
|
716
|
+
if (this.selIdx >= this.am.accounts.length) this.selIdx = Math.max(0, this.am.accounts.length - 1);
|
|
717
|
+
this._addLog(`Deleted account "${name}"`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── rendering ──────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
render() {
|
|
723
|
+
if (!this.running) return;
|
|
724
|
+
// Guard against re-entry: clearing an expired quota logs, and _addLog calls
|
|
725
|
+
// render() again — without this the nested call would render twice.
|
|
726
|
+
if (this._rendering) return;
|
|
727
|
+
this._rendering = true;
|
|
728
|
+
try {
|
|
729
|
+
this._render();
|
|
730
|
+
} finally {
|
|
731
|
+
this._rendering = false;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
_render() {
|
|
736
|
+
// Reset the display the instant a quota window (e.g. 5-hour session) expires,
|
|
737
|
+
// instead of waiting for the next request to clear it.
|
|
738
|
+
this.am.refreshExpiredQuotas();
|
|
739
|
+
const W = process.stdout.columns || 80;
|
|
740
|
+
const H = process.stdout.rows || 24;
|
|
741
|
+
|
|
742
|
+
if (W < 40 || H < 8) {
|
|
743
|
+
process.stdout.write(`${ESC}H${ESC}2JTerminal too small (need 40x8+)\r\n`);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const lines = [];
|
|
748
|
+
|
|
749
|
+
// ── Header
|
|
750
|
+
const left = bold(' Maxpool');
|
|
751
|
+
const port = this.config.proxy?.port || 3456;
|
|
752
|
+
const right = `Port ${port} ${green('▲')} `;
|
|
753
|
+
lines.push(left + ' '.repeat(Math.max(1, W - vw(left) - vw(right))) + right);
|
|
754
|
+
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
755
|
+
const routing = this.am.routingMode === 'preferred'
|
|
756
|
+
? `Manual preference: ${this.am.preferredAccountName} (automatic failover)`
|
|
757
|
+
: 'Automatic load balancing';
|
|
758
|
+
lines.push(` Routing ${cyan(routing)}`);
|
|
759
|
+
const queuedCount = this.am.queueState?.waiting?.length || 0;
|
|
760
|
+
if (this.am._isUpstreamThrottleBlocking?.() || queuedCount) {
|
|
761
|
+
const throttle = this.am.upstreamThrottle;
|
|
762
|
+
const remaining = throttle.until ? Math.max(0, Math.ceil((throttle.until - Date.now()) / 1000)) : 0;
|
|
763
|
+
const state = this.am._isUpstreamThrottleBlocking?.()
|
|
764
|
+
? throttle.probeInFlight ? 'probing recovery' : `retry in ${remaining}s`
|
|
765
|
+
: 'recovering';
|
|
766
|
+
const queued = queuedCount;
|
|
767
|
+
const oldest = queued ? Math.max(0, Date.now() - this.am.queueState.waiting[0].queuedAt) : 0;
|
|
768
|
+
const queueText = queued ? ` queued ${queued} oldest ${formatMs(oldest)}` : '';
|
|
769
|
+
lines.push(` ${yellow(' Anthropic upstream throttled')} ${dim(state + queueText)}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ── Accounts
|
|
773
|
+
if (this.am.accounts.length === 0) {
|
|
774
|
+
lines.push('');
|
|
775
|
+
lines.push(yellow(' No accounts configured. Press [a] to add one.'));
|
|
776
|
+
} else {
|
|
777
|
+
lines.push('');
|
|
778
|
+
const showBoth = W >= 70;
|
|
779
|
+
const bw = showBoth
|
|
780
|
+
? Math.max(5, Math.min(20, Math.floor((W - 56) / 2)))
|
|
781
|
+
: Math.max(5, Math.min(20, W - 45));
|
|
782
|
+
|
|
783
|
+
for (let i = 0; i < this.am.accounts.length; i++) {
|
|
784
|
+
lines.push(this._renderAcct(i, bw, showBoth));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── Activity header
|
|
789
|
+
lines.push('');
|
|
790
|
+
const ac = this.active.size;
|
|
791
|
+
const acTag = ac > 0 ? ` ${cyan(ac + ' active')}` : '';
|
|
792
|
+
const aHdr = ` Activity${acTag} `;
|
|
793
|
+
lines.push(aHdr + dim('─'.repeat(Math.max(1, W - vw(aHdr)))));
|
|
794
|
+
|
|
795
|
+
// Active requests
|
|
796
|
+
const now = Date.now();
|
|
797
|
+
for (const [, r] of this.active) {
|
|
798
|
+
const el = ((now - r.started) / 1000).toFixed(1);
|
|
799
|
+
const sp = cyan(SPINNER[this.frame]);
|
|
800
|
+
const a = r.account ? ` → ${r.account}` : '';
|
|
801
|
+
lines.push(` ${sp} ${gray(r.t)} ${r.method} ${r.path}${a} ${dim(`(${el}s...)`)}`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Completed log
|
|
805
|
+
const footerH = this.mode === 'confirm' ? 3 : 2;
|
|
806
|
+
const space = Math.max(0, H - lines.length - footerH);
|
|
807
|
+
for (let i = 0; i < space && i < this.log.length; i++) {
|
|
808
|
+
lines.push(` ${gray(this.log[i].t)} ${this.log[i].msg}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Pad to fill
|
|
812
|
+
while (lines.length < H - footerH) lines.push('');
|
|
813
|
+
|
|
814
|
+
// ── Footer
|
|
815
|
+
lines.push(' ' + dim('─'.repeat(W - 2)));
|
|
816
|
+
if (this.mode === 'confirm') lines.push(` ${this.confirmDetail}`);
|
|
817
|
+
lines.push(this._renderFooter());
|
|
818
|
+
|
|
819
|
+
// Write buffer
|
|
820
|
+
let buf = `${ESC}H`;
|
|
821
|
+
for (let i = 0; i < H; i++) {
|
|
822
|
+
buf += fitLine(lines[i] || '', W);
|
|
823
|
+
if (i < H - 1) buf += '\r\n';
|
|
824
|
+
}
|
|
825
|
+
// Show cursor only in input mode
|
|
826
|
+
buf += this.mode === 'input' ? `${ESC}?25h` : `${ESC}?25l`;
|
|
827
|
+
process.stdout.write(buf);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
_renderAcct(idx, bw, showBoth) {
|
|
831
|
+
const a = this.am.accounts[idx];
|
|
832
|
+
const isCur = this.am.routingMode === 'preferred' && a.name === this.am.preferredAccountName;
|
|
833
|
+
const isSel = this.mode === 'select' && idx === this.selIdx;
|
|
834
|
+
|
|
835
|
+
// Prefix: selection marker + current marker
|
|
836
|
+
const sel = isSel ? cyan('>') : ' ';
|
|
837
|
+
const cur = isCur ? green('►') : ' ';
|
|
838
|
+
|
|
839
|
+
// Name (bold if selected)
|
|
840
|
+
const rawName = a.name.slice(0, 12).padEnd(12);
|
|
841
|
+
const name = isSel ? bold(rawName) : rawName;
|
|
842
|
+
|
|
843
|
+
// Type
|
|
844
|
+
const type = gray(a.type.padEnd(7));
|
|
845
|
+
|
|
846
|
+
// Status
|
|
847
|
+
let status;
|
|
848
|
+
// A shared upstream throttle holds the whole Claude pool, not any one
|
|
849
|
+
// account. Don't mislabel healthy accounts as per-account "paused": show
|
|
850
|
+
// the one running the recovery probe as "probing" and the rest "waiting"
|
|
851
|
+
// (the pool-wide throttle banner above conveys the cause).
|
|
852
|
+
const upstreamBlocking = a.type !== 'provider' && this.am._isUpstreamThrottleBlocking?.();
|
|
853
|
+
let effectiveStatus = a.enabled === false ? 'disabled' : a.status;
|
|
854
|
+
if (a.enabled !== false && upstreamBlocking && a.status === 'active') {
|
|
855
|
+
effectiveStatus = a.inFlight > 0 ? 'probing' : 'waiting';
|
|
856
|
+
}
|
|
857
|
+
switch (effectiveStatus) {
|
|
858
|
+
case 'active': status = isCur ? green('active') : 'active'; break;
|
|
859
|
+
case 'probing': status = green('probing'); break;
|
|
860
|
+
case 'waiting': status = yellow('waiting'); break;
|
|
861
|
+
case 'paused': status = yellow('paused'); break;
|
|
862
|
+
case 'disabled': status = gray('disabled'); break;
|
|
863
|
+
case 'throttled': status = yellow('throttled'); break;
|
|
864
|
+
case 'exhausted': status = red('exhausted'); break;
|
|
865
|
+
case 'error': status = red('error'); break;
|
|
866
|
+
default: status = a.status || 'ready';
|
|
867
|
+
}
|
|
868
|
+
status = rpad(status, 10);
|
|
869
|
+
|
|
870
|
+
if (a.type === 'provider') {
|
|
871
|
+
return this._renderProviderAcct(sel, cur, name, type, status, a);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
|
|
875
|
+
const q = a.quota;
|
|
876
|
+
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
|
|
877
|
+
|
|
878
|
+
if (q.unified5h != null || q.unified7d != null) {
|
|
879
|
+
r1 = q.unified5h;
|
|
880
|
+
r2 = q.unified7d;
|
|
881
|
+
t1 = q.unified5hReset;
|
|
882
|
+
t2 = q.unified7dReset;
|
|
883
|
+
} else {
|
|
884
|
+
l1 = 'Tok';
|
|
885
|
+
l2 = 'Req';
|
|
886
|
+
r1 = (q.tokensLimit != null && q.tokensRemaining != null)
|
|
887
|
+
? 1 - q.tokensRemaining / q.tokensLimit : null;
|
|
888
|
+
r2 = (q.requestsLimit != null && q.requestsRemaining != null)
|
|
889
|
+
? 1 - q.requestsRemaining / q.requestsLimit : null;
|
|
890
|
+
t1 = q.resetsAt ? new Date(q.resetsAt).getTime() : null;
|
|
891
|
+
t2 = t1;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
|
|
895
|
+
if (showBoth) {
|
|
896
|
+
line += ` ${l2} ${bar(r2, bw, t2)}`;
|
|
897
|
+
}
|
|
898
|
+
const weekly = weeklyPolicyText(this.am, a);
|
|
899
|
+
if (weekly) line += ` ${weekly}`;
|
|
900
|
+
line += ` ${dim(loadText(this._accountLoad(a)))}`;
|
|
901
|
+
return line;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
_renderProviderAcct(sel, cur, name, type, status, a) {
|
|
905
|
+
const completed = a.completedRequests || 0;
|
|
906
|
+
const failed = a.failedRequests || 0;
|
|
907
|
+
const active = a.inFlight || 0;
|
|
908
|
+
const last = a.lastStatus ? `${statusColor(a.lastStatus)} ${formatMs(a.lastResponseMs)}` : '-';
|
|
909
|
+
const q = a.quota || {};
|
|
910
|
+
let limit = '';
|
|
911
|
+
if (q.genericLimit != null && q.genericRemaining != null) {
|
|
912
|
+
const used = q.genericLimit - q.genericRemaining;
|
|
913
|
+
const reset = formatReset(q.genericReset);
|
|
914
|
+
limit = ` Lim ${used}/${q.genericLimit}${reset ? ` ${reset}` : ''}`;
|
|
915
|
+
}
|
|
916
|
+
const err = a.lastError ? ` Err ${String(a.lastError).slice(0, 18)}` : '';
|
|
917
|
+
return ` ${sel}${cur} ${name} ${type} ${status} Act ${String(active).padStart(2)} OK ${String(completed).padStart(3)} Fail ${String(failed).padStart(2)} Last ${last} ${dim(loadText(this._accountLoad(a)))}${limit}${err}`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
_accountLoad(account) {
|
|
921
|
+
if (account.load) return account.load;
|
|
922
|
+
if (!this.am?._loadSummary) return null;
|
|
923
|
+
const now = Date.now();
|
|
924
|
+
return {
|
|
925
|
+
current: {
|
|
926
|
+
inFlight: account.inFlight,
|
|
927
|
+
activeWeight: account.activeWeight,
|
|
928
|
+
},
|
|
929
|
+
last15m: this.am._loadSummary(account, 15 * 60 * 1000, now),
|
|
930
|
+
last1h: this.am._loadSummary(account, 60 * 60 * 1000, now),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
_renderFooter() {
|
|
935
|
+
switch (this.mode) {
|
|
936
|
+
case 'normal':
|
|
937
|
+
return ` ${bold('a')} Accounts ${bold('m')} Routing ${bold('s')} Sync ${bold('r')} Restart ${bold('q')} Stop`;
|
|
938
|
+
case 'accounts':
|
|
939
|
+
return ` ${bold('i')} Import Claude login ${bold('k')} API key ${bold('t')} Enable/disable ${bold('d')} Delete ${bold('Esc')} Back`;
|
|
940
|
+
case 'routing':
|
|
941
|
+
return ` ${bold('a')} Automatic ${bold('p')} Manual preference ${bold('Esc')} Back`;
|
|
942
|
+
case 'select': {
|
|
943
|
+
const act = this.selAction === 'prefer'
|
|
944
|
+
? 'prefer'
|
|
945
|
+
: this.selAction === 'toggle'
|
|
946
|
+
? 'enable/disable'
|
|
947
|
+
: 'delete';
|
|
948
|
+
return ` ${dim('↑↓')} select ${bold('Enter')} ${act} ${bold('Esc')} cancel`;
|
|
949
|
+
}
|
|
950
|
+
case 'input':
|
|
951
|
+
return ` ${this.inputPrompt}: ${this.inputSensitive ? '•'.repeat(this.inputBuf.length) : this.inputBuf}█`;
|
|
952
|
+
case 'confirm':
|
|
953
|
+
return ` ${bold(this.confirmTitle)} ${bold('y')} Yes ${bold('n')} No`;
|
|
954
|
+
default:
|
|
955
|
+
return '';
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|