@carjms/codexswitch 0.1.0 → 0.3.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/README.ko.md +300 -0
- package/README.md +157 -129
- package/package.json +1 -1
- package/src/cli.js +297 -24
- package/src/runner.js +102 -5
- package/src/store.js +71 -9
package/src/cli.js
CHANGED
|
@@ -12,27 +12,40 @@ const HELP = `codexswitch — multi-account manager for the OpenAI Codex CLI
|
|
|
12
12
|
Accounts
|
|
13
13
|
login [name] log in to a new account (isolated "codex login") and store it
|
|
14
14
|
import [name] import the account currently in ~/.codex/auth.json
|
|
15
|
+
add-key <name> [key] register an OpenAI API-key account (or read $OPENAI_API_KEY)
|
|
15
16
|
list list stored accounts (alias: accounts, status)
|
|
16
17
|
use <name> make <name> the active account in ~/.codex
|
|
17
18
|
current show the active account
|
|
18
|
-
next
|
|
19
|
+
next switch to the next account in rotation order (wraps around)
|
|
19
20
|
remove <name> delete a stored account
|
|
20
21
|
rename <old> <new> rename a stored account
|
|
21
22
|
enable <name> re-enable a disabled account
|
|
22
23
|
disable <name> temporarily exclude an account from rotation
|
|
23
|
-
|
|
24
|
+
order [name...] show or set the rotation order in one command
|
|
25
|
+
priority <name> <n> set one account's priority (lower = preferred, default 0)
|
|
24
26
|
clear-limit <name> forget a recorded rate-limit for an account
|
|
25
27
|
|
|
26
28
|
Running codex
|
|
27
29
|
run [name] [args...] run codex as <name> (or active account) in an isolated
|
|
28
30
|
per-account CODEX_HOME; config/sessions are shared
|
|
29
|
-
exec [args...] run "codex exec ..." and auto-rotate
|
|
30
|
-
|
|
31
|
+
exec [args...] run "codex exec ..." and auto-rotate through accounts in
|
|
32
|
+
rotation order on usage limits; the next account resumes
|
|
33
|
+
the same session (--skip-git-repo-check added automatically)
|
|
31
34
|
-a, --account <n> start exec with a specific account
|
|
35
|
+
--no-resume restart the prompt instead of resuming on rotation
|
|
36
|
+
|
|
37
|
+
Settings
|
|
38
|
+
model [name|default] show/set the default model injected into run/exec
|
|
39
|
+
threshold [5h%] [wk%] rotate early when recorded usage reaches these percents
|
|
40
|
+
of the 5-hour / weekly window (default 95, one value = both)
|
|
41
|
+
cooldown [minutes] show/set rate-limit cooldown (default 60)
|
|
42
|
+
patterns [add|remove] extra regex patterns treated as rate-limit errors
|
|
32
43
|
|
|
33
44
|
Maintenance
|
|
34
45
|
sync save tokens refreshed by codex back into the store
|
|
35
|
-
|
|
46
|
+
export <file> back up all accounts + settings (contains tokens!)
|
|
47
|
+
restore <file> restore accounts from a backup file
|
|
48
|
+
completion <bash|zsh> print a shell completion script
|
|
36
49
|
help show this help
|
|
37
50
|
|
|
38
51
|
Environment
|
|
@@ -119,16 +132,31 @@ function cmdList() {
|
|
|
119
132
|
return;
|
|
120
133
|
}
|
|
121
134
|
const now = Date.now();
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
a
|
|
126
|
-
a.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
const meta = store.loadMeta();
|
|
136
|
+
const pct = (win) => (win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '-');
|
|
137
|
+
const rows = accounts.map((a) => {
|
|
138
|
+
const over = store.overThreshold(a, meta, now);
|
|
139
|
+
const status = a.disabled
|
|
140
|
+
? 'disabled'
|
|
141
|
+
: a.limitedUntil && a.limitedUntil > now
|
|
142
|
+
? `limited ${fmtRemaining(a.limitedUntil)}`
|
|
143
|
+
: over
|
|
144
|
+
? `over-${over}`
|
|
145
|
+
: 'ok';
|
|
146
|
+
return [
|
|
147
|
+
a.active ? '*' : '',
|
|
148
|
+
a.name,
|
|
149
|
+
a.email,
|
|
150
|
+
a.plan,
|
|
151
|
+
a.priority,
|
|
152
|
+
status,
|
|
153
|
+
pct(a.usage && a.usage.p5h),
|
|
154
|
+
pct(a.usage && a.usage.weekly),
|
|
155
|
+
fmtDate(a.lastRefresh),
|
|
156
|
+
];
|
|
157
|
+
});
|
|
158
|
+
out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
|
|
159
|
+
out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
function cmdUse(args) {
|
|
@@ -164,12 +192,32 @@ function cmdCurrent() {
|
|
|
164
192
|
}
|
|
165
193
|
|
|
166
194
|
function cmdNext() {
|
|
167
|
-
const
|
|
168
|
-
const next = store.pickAccount(meta.active ? [meta.active] : []);
|
|
195
|
+
const next = store.nextAccount();
|
|
169
196
|
if (!next) throw new Error('no other usable account available');
|
|
170
197
|
cmdUse([next.name]);
|
|
171
198
|
}
|
|
172
199
|
|
|
200
|
+
// Register an API-key account (Console/platform billing instead of a
|
|
201
|
+
// ChatGPT plan). The key is stored only inside the account's auth.json.
|
|
202
|
+
function cmdAddKey(args) {
|
|
203
|
+
const name = requireArg(args, 0, 'account name');
|
|
204
|
+
const key = args[1] || process.env.OPENAI_API_KEY;
|
|
205
|
+
if (!key) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'missing API key: pass it as the second argument or set OPENAI_API_KEY in the environment'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (!/^sk-/.test(key)) throw new Error('that does not look like an OpenAI API key (should start with "sk-")');
|
|
211
|
+
storeAccount(name, {
|
|
212
|
+
auth_mode: 'apikey',
|
|
213
|
+
OPENAI_API_KEY: key,
|
|
214
|
+
tokens: null,
|
|
215
|
+
last_refresh: null,
|
|
216
|
+
});
|
|
217
|
+
const meta = store.loadMeta();
|
|
218
|
+
if (!meta.active) cmdUse([name]);
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
function cmdRemove(args) {
|
|
174
222
|
const name = requireArg(args, 0, 'account name');
|
|
175
223
|
if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
|
|
@@ -230,6 +278,169 @@ function cmdSync() {
|
|
|
230
278
|
out(updated ? `synced refreshed tokens into "${updated}"` : 'nothing to sync');
|
|
231
279
|
}
|
|
232
280
|
|
|
281
|
+
// Set the rotation order in one go: listed accounts get priority 0..k-1,
|
|
282
|
+
// unlisted accounts follow after in their current order.
|
|
283
|
+
function cmdOrder(args) {
|
|
284
|
+
const accounts = store.listAccounts();
|
|
285
|
+
if (args.length === 0) {
|
|
286
|
+
if (accounts.length === 0) {
|
|
287
|
+
out('no accounts yet');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
accounts.forEach((a, i) => out(`${i + 1}. ${a.name}${a.active ? ' (active)' : ''}`));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
for (const name of args) {
|
|
294
|
+
if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
|
|
295
|
+
}
|
|
296
|
+
const dupe = args.find((n, i) => args.indexOf(n) !== i);
|
|
297
|
+
if (dupe) throw new Error(`account listed twice: ${dupe}`);
|
|
298
|
+
const meta = store.loadMeta();
|
|
299
|
+
let prio = 0;
|
|
300
|
+
for (const name of args) {
|
|
301
|
+
meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
|
|
302
|
+
}
|
|
303
|
+
for (const a of accounts) {
|
|
304
|
+
if (args.includes(a.name)) continue;
|
|
305
|
+
meta.accounts[a.name] = { ...(meta.accounts[a.name] || {}), priority: prio++ };
|
|
306
|
+
}
|
|
307
|
+
store.saveMeta(meta);
|
|
308
|
+
out('rotation order set:');
|
|
309
|
+
store.listAccounts().forEach((a, i) => out(`${i + 1}. ${a.name}`));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cmdModel(args) {
|
|
313
|
+
const meta = store.loadMeta();
|
|
314
|
+
if (args[0] == null) {
|
|
315
|
+
out(`model: ${meta.model || '(codex default)'}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (args[0] === 'default' || args[0] === 'clear') {
|
|
319
|
+
delete meta.model;
|
|
320
|
+
store.saveMeta(meta);
|
|
321
|
+
out('model reset to codex default');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
meta.model = args[0];
|
|
325
|
+
store.saveMeta(meta);
|
|
326
|
+
out(`default model set to "${meta.model}" (applied to run/exec)`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Rotate-early thresholds: how full the 5h / weekly window may get before
|
|
330
|
+
// the account is skipped in rotation.
|
|
331
|
+
function cmdThreshold(args) {
|
|
332
|
+
const meta = store.loadMeta();
|
|
333
|
+
if (args.length === 0) {
|
|
334
|
+
out(`threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
|
|
335
|
+
out('usage: codexswitch threshold <5h%> [weekly%] (one value sets both)');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const parse = (s) => {
|
|
339
|
+
const n = parseInt(s, 10);
|
|
340
|
+
if (Number.isNaN(n) || n < 1 || n > 100) throw new Error(`threshold must be 1-100, got "${s}"`);
|
|
341
|
+
return n;
|
|
342
|
+
};
|
|
343
|
+
meta.threshold5h = parse(args[0]);
|
|
344
|
+
meta.thresholdWeekly = args[1] != null ? parse(args[1]) : meta.threshold5h;
|
|
345
|
+
store.saveMeta(meta);
|
|
346
|
+
out(`threshold set: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function cmdPatterns(args) {
|
|
350
|
+
const meta = store.loadMeta();
|
|
351
|
+
const [sub, ...rest] = args;
|
|
352
|
+
if (!sub) {
|
|
353
|
+
out('built-in: usage limit / rate limit / too many requests / quota exceeded / 429');
|
|
354
|
+
if (meta.limitPatterns.length === 0) out('custom: (none)');
|
|
355
|
+
else meta.limitPatterns.forEach((p, i) => out(`custom ${i + 1}: ${p}`));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (sub === 'add') {
|
|
359
|
+
const pattern = rest.join(' ');
|
|
360
|
+
if (!pattern) throw new Error('usage: codexswitch patterns add <regex>');
|
|
361
|
+
new RegExp(pattern, 'i'); // validate — throws on bad regex
|
|
362
|
+
meta.limitPatterns.push(pattern);
|
|
363
|
+
store.saveMeta(meta);
|
|
364
|
+
out(`added pattern: ${pattern}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (sub === 'remove') {
|
|
368
|
+
const key = rest.join(' ');
|
|
369
|
+
const idx = /^\d+$/.test(key) ? parseInt(key, 10) - 1 : meta.limitPatterns.indexOf(key);
|
|
370
|
+
if (idx < 0 || idx >= meta.limitPatterns.length) throw new Error(`no such pattern: ${key}`);
|
|
371
|
+
const [removed] = meta.limitPatterns.splice(idx, 1);
|
|
372
|
+
store.saveMeta(meta);
|
|
373
|
+
out(`removed pattern: ${removed}`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw new Error('usage: codexswitch patterns [add <regex> | remove <n|regex>]');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function cmdExport(args) {
|
|
380
|
+
const file = requireArg(args, 0, 'output file path');
|
|
381
|
+
const accounts = {};
|
|
382
|
+
for (const a of store.listAccounts()) accounts[a.name] = store.readAccountAuth(a.name);
|
|
383
|
+
if (Object.keys(accounts).length === 0) throw new Error('no accounts to export');
|
|
384
|
+
writeJSONAtomic(path.resolve(file), { version: 1, exportedAt: new Date().toISOString(), meta: store.loadMeta(), accounts }, 0o600);
|
|
385
|
+
out(`exported ${Object.keys(accounts).length} account(s) to ${file}`);
|
|
386
|
+
out('WARNING: this file contains login tokens — treat it like a password.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function cmdRestore(args) {
|
|
390
|
+
const file = requireArg(args, 0, 'backup file path');
|
|
391
|
+
const data = readJSONSafe(path.resolve(file));
|
|
392
|
+
if (!data || data.version !== 1 || !data.accounts) throw new Error('not a codexswitch backup file');
|
|
393
|
+
store.ensureDirs();
|
|
394
|
+
const meta = store.loadMeta();
|
|
395
|
+
let n = 0;
|
|
396
|
+
for (const [name, auth] of Object.entries(data.accounts)) {
|
|
397
|
+
store.writeAccountAuth(name, auth);
|
|
398
|
+
const backup = (data.meta && data.meta.accounts && data.meta.accounts[name]) || {};
|
|
399
|
+
meta.accounts[name] = { ...backup, ...(meta.accounts[name] || {}) };
|
|
400
|
+
n++;
|
|
401
|
+
}
|
|
402
|
+
if (!meta.active && data.meta && data.meta.active) meta.active = data.meta.active;
|
|
403
|
+
store.saveMeta(meta);
|
|
404
|
+
out(`restored ${n} account(s) from ${file}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function cmdNames() {
|
|
408
|
+
for (const a of store.listAccounts()) out(a.name);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const COMMANDS =
|
|
412
|
+
'login import add-key list use current next run exec order model remove rename ' +
|
|
413
|
+
'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
|
|
414
|
+
|
|
415
|
+
function cmdCompletion(args) {
|
|
416
|
+
const shell = args[0];
|
|
417
|
+
if (shell !== 'bash' && shell !== 'zsh') {
|
|
418
|
+
throw new Error('usage: codexswitch completion <bash|zsh> (append the output to your shell rc file)');
|
|
419
|
+
}
|
|
420
|
+
const script = `# codexswitch completion (${shell})
|
|
421
|
+
${shell === 'zsh' ? 'autoload -Uz bashcompinit && bashcompinit\n' : ''}_codexswitch() {
|
|
422
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
423
|
+
if [ "\$COMP_CWORD" -eq 1 ]; then
|
|
424
|
+
COMPREPLY=( \$(compgen -W "${COMMANDS}" -- "\$cur") )
|
|
425
|
+
else
|
|
426
|
+
COMPREPLY=( \$(compgen -W "\$(codexswitch names 2>/dev/null)" -- "\$cur") )
|
|
427
|
+
fi
|
|
428
|
+
}
|
|
429
|
+
complete -F _codexswitch codexswitch cxs`;
|
|
430
|
+
out(script);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Final argv for "codex exec": inject --skip-git-repo-check so it works in
|
|
434
|
+
// non-git folders, and the configured default model — unless the user
|
|
435
|
+
// already passed their own flags.
|
|
436
|
+
function buildExecArgs(rest, meta) {
|
|
437
|
+
const args = [...rest];
|
|
438
|
+
if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
|
|
439
|
+
const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
|
|
440
|
+
if (meta.model && !hasModel) args.unshift('-m', meta.model);
|
|
441
|
+
return args;
|
|
442
|
+
}
|
|
443
|
+
|
|
233
444
|
async function cmdRun(args) {
|
|
234
445
|
let name = null;
|
|
235
446
|
let rest = args;
|
|
@@ -244,28 +455,43 @@ async function cmdRun(args) {
|
|
|
244
455
|
name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
|
|
245
456
|
}
|
|
246
457
|
if (rest[0] === '--') rest = rest.slice(1);
|
|
458
|
+
const meta = store.loadMeta();
|
|
459
|
+
if (rest[0] === 'exec') {
|
|
460
|
+
rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
|
|
461
|
+
} else if (meta.model && rest.length === 0) {
|
|
462
|
+
rest = ['-m', meta.model];
|
|
463
|
+
}
|
|
247
464
|
out(`[codexswitch] running codex as "${name}"`);
|
|
465
|
+
const startTs = Date.now();
|
|
248
466
|
const res = await runner.runCodex(name, rest);
|
|
467
|
+
runner.recordUsage(name, res.profile, startTs);
|
|
249
468
|
return res.code;
|
|
250
469
|
}
|
|
251
470
|
|
|
471
|
+
const RESUME_PROMPT = 'Continue the previous task from exactly where it left off.';
|
|
472
|
+
|
|
252
473
|
async function cmdExec(args) {
|
|
253
474
|
let explicit = null;
|
|
475
|
+
let allowResume = true;
|
|
254
476
|
const rest = [];
|
|
255
477
|
for (let i = 0; i < args.length; i++) {
|
|
256
478
|
if (args[i] === '-a' || args[i] === '--account') {
|
|
257
479
|
explicit = requireArg(args, ++i, 'account name after --account');
|
|
480
|
+
} else if (args[i] === '--no-resume') {
|
|
481
|
+
allowResume = false;
|
|
258
482
|
} else {
|
|
259
483
|
rest.push(args[i]);
|
|
260
484
|
}
|
|
261
485
|
}
|
|
262
486
|
if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
|
|
487
|
+
if (rest[0] === 'resume') allowResume = false; // user drives resume themselves
|
|
263
488
|
|
|
264
489
|
const meta = store.loadMeta();
|
|
265
490
|
const total = store.listAccounts().length;
|
|
266
491
|
if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
|
|
267
492
|
|
|
268
493
|
const tried = [];
|
|
494
|
+
let useResume = false;
|
|
269
495
|
for (let attempt = 0; attempt < total; attempt++) {
|
|
270
496
|
let name;
|
|
271
497
|
if (explicit && attempt === 0) {
|
|
@@ -276,24 +502,52 @@ async function cmdExec(args) {
|
|
|
276
502
|
name = picked.name;
|
|
277
503
|
}
|
|
278
504
|
tried.push(name);
|
|
279
|
-
console.error(
|
|
280
|
-
|
|
505
|
+
console.error(
|
|
506
|
+
`[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : ''}`
|
|
507
|
+
);
|
|
508
|
+
// On rotation, continue the same session with the next account instead
|
|
509
|
+
// of restarting the whole prompt — the session files are shared.
|
|
510
|
+
const codexArgs = useResume
|
|
511
|
+
? ['exec', 'resume', '--last', ...buildExecArgs([], meta), RESUME_PROMPT]
|
|
512
|
+
: ['exec', ...buildExecArgs(rest, meta)];
|
|
513
|
+
const startTs = Date.now();
|
|
514
|
+
const res = await runner.runCodex(name, codexArgs, { capture: true });
|
|
515
|
+
runner.recordUsage(name, res.profile, startTs);
|
|
281
516
|
if (res.code === 0) {
|
|
282
517
|
store.clearLimited(name);
|
|
518
|
+
warnIfOverThreshold(name);
|
|
283
519
|
return 0;
|
|
284
520
|
}
|
|
285
|
-
if (runner.looksRateLimited(res.output)) {
|
|
521
|
+
if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
|
|
286
522
|
const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
|
|
287
523
|
store.markLimited(name, until);
|
|
288
|
-
|
|
524
|
+
if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
|
|
525
|
+
useResume = true;
|
|
526
|
+
}
|
|
527
|
+
console.error(
|
|
528
|
+
`[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`
|
|
529
|
+
);
|
|
289
530
|
continue;
|
|
290
531
|
}
|
|
291
532
|
return res.code; // real failure, don't burn other accounts on it
|
|
292
533
|
}
|
|
293
|
-
console.error('[codexswitch] all accounts are rate-limited or disabled');
|
|
534
|
+
console.error('[codexswitch] all accounts are rate-limited, over threshold, or disabled');
|
|
294
535
|
return 2;
|
|
295
536
|
}
|
|
296
537
|
|
|
538
|
+
function warnIfOverThreshold(name) {
|
|
539
|
+
const meta = store.loadMeta();
|
|
540
|
+
const account = store.listAccounts().find((a) => a.name === name);
|
|
541
|
+
if (!account) return;
|
|
542
|
+
const blocked = store.overThreshold(account, meta);
|
|
543
|
+
if (blocked) {
|
|
544
|
+
const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
|
|
545
|
+
console.error(
|
|
546
|
+
`[codexswitch] "${name}" is at ${Math.round(pct)}% of its ${blocked} limit (threshold ${blocked === '5h' ? meta.threshold5h : meta.thresholdWeekly}%) — the next exec will rotate to another account`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
297
551
|
async function main(argv) {
|
|
298
552
|
const [cmd, ...args] = argv;
|
|
299
553
|
switch (cmd) {
|
|
@@ -333,10 +587,29 @@ async function main(argv) {
|
|
|
333
587
|
return 0;
|
|
334
588
|
case 'priority':
|
|
335
589
|
return cmdPriority(args), 0;
|
|
590
|
+
case 'order':
|
|
591
|
+
return cmdOrder(args), 0;
|
|
592
|
+
case 'model':
|
|
593
|
+
return cmdModel(args), 0;
|
|
594
|
+
case 'add-key':
|
|
595
|
+
case 'apikey':
|
|
596
|
+
return cmdAddKey(args), 0;
|
|
597
|
+
case 'threshold':
|
|
598
|
+
return cmdThreshold(args), 0;
|
|
599
|
+
case 'patterns':
|
|
600
|
+
return cmdPatterns(args), 0;
|
|
601
|
+
case 'export':
|
|
602
|
+
return cmdExport(args), 0;
|
|
603
|
+
case 'restore':
|
|
604
|
+
return cmdRestore(args), 0;
|
|
605
|
+
case 'names':
|
|
606
|
+
return cmdNames(), 0;
|
|
607
|
+
case 'completion':
|
|
608
|
+
return cmdCompletion(args), 0;
|
|
336
609
|
case 'clear-limit': {
|
|
337
610
|
const name = requireArg(args, 0, 'account name');
|
|
338
|
-
store.clearLimited(name);
|
|
339
|
-
out(`cleared rate-limit
|
|
611
|
+
store.clearLimited(name, { includeUsage: true });
|
|
612
|
+
out(`cleared rate-limit and usage records for "${name}"`);
|
|
340
613
|
return 0;
|
|
341
614
|
}
|
|
342
615
|
case 'cooldown':
|
package/src/runner.js
CHANGED
|
@@ -65,6 +65,10 @@ function buildProfile(name) {
|
|
|
65
65
|
const profile = path.join(p.profilesDir, name);
|
|
66
66
|
ensureDir(profile);
|
|
67
67
|
|
|
68
|
+
// Sessions must exist in the real CODEX_HOME before linking, so that every
|
|
69
|
+
// profile shares them — resume-on-rotation and usage tracking depend on it.
|
|
70
|
+
ensureDir(path.join(p.codexHome, 'sessions'), 0o755);
|
|
71
|
+
|
|
68
72
|
let entries = [];
|
|
69
73
|
try {
|
|
70
74
|
entries = fs.readdirSync(p.codexHome);
|
|
@@ -184,7 +188,7 @@ function runCodex(name, args, { capture = false } = {}) {
|
|
|
184
188
|
collect(child.stdout, process.stdout);
|
|
185
189
|
collect(child.stderr, process.stderr);
|
|
186
190
|
}
|
|
187
|
-
child.on('error', (err) => resolve({ code: 1, output: out, error: err }));
|
|
191
|
+
child.on('error', (err) => resolve({ code: 1, output: out, error: err, profile }));
|
|
188
192
|
child.on('close', (code) => {
|
|
189
193
|
// codex may have refreshed the token while running — persist it.
|
|
190
194
|
try {
|
|
@@ -192,16 +196,106 @@ function runCodex(name, args, { capture = false } = {}) {
|
|
|
192
196
|
} catch {
|
|
193
197
|
/* best effort */
|
|
194
198
|
}
|
|
195
|
-
resolve({ code: code == null ? 1 : code, output: out });
|
|
199
|
+
resolve({ code: code == null ? 1 : code, output: out, profile });
|
|
196
200
|
});
|
|
197
201
|
});
|
|
198
202
|
}
|
|
199
203
|
|
|
204
|
+
// ---- usage tracking ---------------------------------------------------
|
|
205
|
+
// Codex writes token_count events into session rollout files; when the
|
|
206
|
+
// backend sends x-codex-* headers they include rate_limits with the 5h
|
|
207
|
+
// (primary) and weekly (secondary) used_percent and reset times. Some
|
|
208
|
+
// exec-mode versions report null there, so all parsing is best-effort.
|
|
209
|
+
|
|
210
|
+
function listRolloutsSince(sessionsDir, sinceMs) {
|
|
211
|
+
const found = [];
|
|
212
|
+
const walk = (dir, depth) => {
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
216
|
+
} catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const e of entries) {
|
|
220
|
+
const full = path.join(dir, e.name);
|
|
221
|
+
if (e.isDirectory() && depth < 4) walk(full, depth + 1);
|
|
222
|
+
else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) {
|
|
223
|
+
try {
|
|
224
|
+
const st = fs.statSync(full);
|
|
225
|
+
if (st.mtimeMs >= sinceMs) found.push({ full, mtime: st.mtimeMs });
|
|
226
|
+
} catch {
|
|
227
|
+
/* ignore */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
walk(sessionsDir, 0);
|
|
233
|
+
return found.sort((a, b) => a.mtime - b.mtime);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function windowUsage(win, eventTs) {
|
|
237
|
+
if (!win || typeof win.used_percent !== 'number') return null;
|
|
238
|
+
const usage = { pct: win.used_percent, resetAt: null, windowMinutes: win.window_minutes || null };
|
|
239
|
+
if (typeof win.resets_in_seconds === 'number') usage.resetAt = eventTs + win.resets_in_seconds * 1000;
|
|
240
|
+
else if (typeof win.resets_at === 'number') usage.resetAt = win.resets_at * 1000;
|
|
241
|
+
return usage;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Scan rollouts touched since `sinceMs` for the newest non-null rate_limits
|
|
245
|
+
// snapshot. Returns { p5h, weekly, at } or null when codex reported nothing.
|
|
246
|
+
function scanUsage(sessionsDir, sinceMs) {
|
|
247
|
+
let result = null;
|
|
248
|
+
for (const f of listRolloutsSince(sessionsDir, sinceMs)) {
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = fs.readFileSync(f.full, 'utf8');
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
for (const line of text.split('\n')) {
|
|
256
|
+
if (!line.includes('rate_limits')) continue;
|
|
257
|
+
try {
|
|
258
|
+
const obj = JSON.parse(line);
|
|
259
|
+
const rl = (obj.payload && obj.payload.rate_limits) || obj.rate_limits;
|
|
260
|
+
if (!rl) continue;
|
|
261
|
+
const eventTs = obj.timestamp ? Date.parse(obj.timestamp) || Date.now() : Date.now();
|
|
262
|
+
const p5h = windowUsage(rl.primary, eventTs);
|
|
263
|
+
const weekly = windowUsage(rl.secondary, eventTs);
|
|
264
|
+
if (p5h || weekly) result = { p5h, weekly, at: eventTs };
|
|
265
|
+
} catch {
|
|
266
|
+
/* malformed line */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// After running codex for an account, persist the freshest usage snapshot.
|
|
274
|
+
function recordUsage(name, profile, sinceMs) {
|
|
275
|
+
const usage = scanUsage(path.join(profile, 'sessions'), sinceMs);
|
|
276
|
+
if (usage) store.saveUsage(name, usage);
|
|
277
|
+
return usage;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Was a session rollout created/updated during this run? (drives whether a
|
|
281
|
+
// rotated retry can "exec resume" instead of restarting from scratch)
|
|
282
|
+
function sessionTouchedSince(profile, sinceMs) {
|
|
283
|
+
return listRolloutsSince(path.join(profile, 'sessions'), sinceMs).length > 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
200
286
|
const LIMIT_RE =
|
|
201
|
-
/usage limit|rate limit|too many requests|quota (?:exceeded|reached)|\b429\b|hit your (?:usage|weekly|5h) limit/i;
|
|
287
|
+
/usage[ _]limit|rate[ _]limit|too many requests|quota (?:exceeded|reached)|\b429\b|hit your (?:usage|weekly|5h) limit/i;
|
|
202
288
|
|
|
203
|
-
function looksRateLimited(output) {
|
|
204
|
-
|
|
289
|
+
function looksRateLimited(output, extraPatterns = []) {
|
|
290
|
+
if (LIMIT_RE.test(output || '')) return true;
|
|
291
|
+
for (const p of extraPatterns) {
|
|
292
|
+
try {
|
|
293
|
+
if (new RegExp(p, 'i').test(output || '')) return true;
|
|
294
|
+
} catch {
|
|
295
|
+
/* invalid user pattern — ignore */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
205
299
|
}
|
|
206
300
|
|
|
207
301
|
// Try to extract "try again in 2 hours 30 minutes" style hints; fall back
|
|
@@ -220,4 +314,7 @@ module.exports = {
|
|
|
220
314
|
runCodex,
|
|
221
315
|
looksRateLimited,
|
|
222
316
|
limitCooldownMs,
|
|
317
|
+
scanUsage,
|
|
318
|
+
recordUsage,
|
|
319
|
+
sessionTouchedSince,
|
|
223
320
|
};
|
package/src/store.js
CHANGED
|
@@ -30,6 +30,9 @@ function loadMeta() {
|
|
|
30
30
|
if (!meta.accounts) meta.accounts = {};
|
|
31
31
|
if (!('active' in meta)) meta.active = null;
|
|
32
32
|
if (!meta.cooldownMinutes) meta.cooldownMinutes = 60;
|
|
33
|
+
if (!meta.threshold5h) meta.threshold5h = 95;
|
|
34
|
+
if (!meta.thresholdWeekly) meta.thresholdWeekly = 95;
|
|
35
|
+
if (!Array.isArray(meta.limitPatterns)) meta.limitPatterns = [];
|
|
33
36
|
return meta;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -83,6 +86,7 @@ function listAccounts() {
|
|
|
83
86
|
priority: m.priority ?? 0,
|
|
84
87
|
disabled: !!m.disabled,
|
|
85
88
|
limitedUntil: m.limitedUntil || null,
|
|
89
|
+
usage: m.usage || null,
|
|
86
90
|
active: meta.active === name,
|
|
87
91
|
};
|
|
88
92
|
})
|
|
@@ -122,24 +126,78 @@ function markLimited(name, untilTs) {
|
|
|
122
126
|
saveMeta(meta);
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
function clearLimited(name) {
|
|
129
|
+
function clearLimited(name, { includeUsage = false } = {}) {
|
|
126
130
|
const meta = loadMeta();
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
const m = meta.accounts[name];
|
|
132
|
+
if (m && (m.limitedUntil || (includeUsage && m.usage))) {
|
|
133
|
+
delete m.limitedUntil;
|
|
134
|
+
if (includeUsage) delete m.usage;
|
|
129
135
|
saveMeta(meta);
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
function saveUsage(name, usage) {
|
|
140
|
+
const meta = loadMeta();
|
|
141
|
+
if (!meta.accounts[name]) meta.accounts[name] = {};
|
|
142
|
+
meta.accounts[name].usage = usage;
|
|
143
|
+
saveMeta(meta);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An account is "over threshold" when its last recorded 5h/weekly usage
|
|
147
|
+
// exceeds the configured percentage and the window has not reset yet.
|
|
148
|
+
// Returns the blocking window ('5h' | 'weekly') or null.
|
|
149
|
+
function overThreshold(account, meta, now = Date.now()) {
|
|
150
|
+
const u = account.usage;
|
|
151
|
+
if (!u) return null;
|
|
152
|
+
const checks = [
|
|
153
|
+
['5h', u.p5h, meta.threshold5h],
|
|
154
|
+
['weekly', u.weekly, meta.thresholdWeekly],
|
|
155
|
+
];
|
|
156
|
+
for (const [label, win, threshold] of checks) {
|
|
157
|
+
if (!win || typeof win.pct !== 'number') continue;
|
|
158
|
+
if (win.pct < threshold) continue;
|
|
159
|
+
// Trust the snapshot only while its window can still be in effect.
|
|
160
|
+
if (win.resetAt) {
|
|
161
|
+
if (win.resetAt > now) return label;
|
|
162
|
+
} else if (win.windowMinutes && u.at + win.windowMinutes * 60000 > now) {
|
|
163
|
+
return label;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isUsable(account, meta, now = Date.now()) {
|
|
170
|
+
if (account.disabled) return false;
|
|
171
|
+
if (account.limitedUntil && account.limitedUntil > now) return false;
|
|
172
|
+
if (overThreshold(account, meta, now)) return false;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
133
176
|
// Pick the best usable account: enabled, not currently rate-limited,
|
|
134
|
-
// lowest priority number first
|
|
177
|
+
// lowest priority number first (the order set by "order"/"priority").
|
|
178
|
+
// The active account only wins ties within the same priority, so an
|
|
179
|
+
// explicit order is always respected. `exclude` skips accounts already tried.
|
|
135
180
|
function pickAccount(exclude = []) {
|
|
136
181
|
const now = Date.now();
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
);
|
|
182
|
+
const meta = loadMeta();
|
|
183
|
+
const usable = listAccounts().filter((a) => !exclude.includes(a.name) && isUsable(a, meta, now));
|
|
140
184
|
if (usable.length === 0) return null;
|
|
141
|
-
const
|
|
142
|
-
return active ||
|
|
185
|
+
const group = usable.filter((a) => a.priority === usable[0].priority);
|
|
186
|
+
return group.find((a) => a.active) || group[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Next usable account after the active one, following priority order and
|
|
190
|
+
// wrapping around — so repeated "next" cycles through the configured order.
|
|
191
|
+
function nextAccount() {
|
|
192
|
+
const all = listAccounts();
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const meta = loadMeta();
|
|
195
|
+
const start = all.findIndex((a) => a.active);
|
|
196
|
+
for (let i = 1; i <= all.length; i++) {
|
|
197
|
+
const cand = all[(start + i) % all.length];
|
|
198
|
+
if (!cand.active && isUsable(cand, meta, now)) return cand;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
143
201
|
}
|
|
144
202
|
|
|
145
203
|
module.exports = {
|
|
@@ -155,7 +213,11 @@ module.exports = {
|
|
|
155
213
|
syncBackFrom,
|
|
156
214
|
markLimited,
|
|
157
215
|
clearLimited,
|
|
216
|
+
saveUsage,
|
|
217
|
+
overThreshold,
|
|
218
|
+
isUsable,
|
|
158
219
|
pickAccount,
|
|
220
|
+
nextAccount,
|
|
159
221
|
ensureDirs() {
|
|
160
222
|
const p = paths();
|
|
161
223
|
ensureDir(p.home);
|