@carjms/codexswitch 0.1.0 → 0.3.1
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 +315 -25
- package/src/runner.js +112 -5
- package/src/store.js +78 -11
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
|
|
@@ -67,6 +80,9 @@ function storeAccount(name, auth) {
|
|
|
67
80
|
store.writeAccountAuth(name, auth);
|
|
68
81
|
const meta = store.loadMeta();
|
|
69
82
|
if (!meta.accounts[name]) meta.accounts[name] = { priority: 0, addedAt: Date.now() };
|
|
83
|
+
// fresh credentials fix whatever got the account disabled or limited
|
|
84
|
+
delete meta.accounts[name].disabled;
|
|
85
|
+
delete meta.accounts[name].limitedUntil;
|
|
70
86
|
store.saveMeta(meta);
|
|
71
87
|
out(`${existed ? 'updated' : 'added'} account "${name}"${info.email ? ` (${info.email}, ${info.plan || 'unknown plan'})` : ''}`);
|
|
72
88
|
return name;
|
|
@@ -119,16 +135,31 @@ function cmdList() {
|
|
|
119
135
|
return;
|
|
120
136
|
}
|
|
121
137
|
const now = Date.now();
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
a
|
|
126
|
-
a.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
138
|
+
const meta = store.loadMeta();
|
|
139
|
+
const pct = (win) => (win && typeof win.pct === 'number' ? `${Math.round(win.pct)}%` : '-');
|
|
140
|
+
const rows = accounts.map((a) => {
|
|
141
|
+
const over = store.overThreshold(a, meta, now);
|
|
142
|
+
const status = a.disabled
|
|
143
|
+
? 'disabled'
|
|
144
|
+
: a.limitedUntil && a.limitedUntil > now
|
|
145
|
+
? `limited ${fmtRemaining(a.limitedUntil)}`
|
|
146
|
+
: over
|
|
147
|
+
? `over-${over}`
|
|
148
|
+
: 'ok';
|
|
149
|
+
return [
|
|
150
|
+
a.active ? '*' : '',
|
|
151
|
+
a.name,
|
|
152
|
+
a.email,
|
|
153
|
+
a.plan,
|
|
154
|
+
a.priority,
|
|
155
|
+
status,
|
|
156
|
+
pct(a.usage && a.usage.p5h),
|
|
157
|
+
pct(a.usage && a.usage.weekly),
|
|
158
|
+
fmtDate(a.lastRefresh),
|
|
159
|
+
];
|
|
160
|
+
});
|
|
161
|
+
out(table(rows, ['', 'name', 'email', 'plan', 'prio', 'status', '5h', 'week', 'token refreshed']));
|
|
162
|
+
out(`(rotate threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}% — change with "codexswitch threshold")`);
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
function cmdUse(args) {
|
|
@@ -156,7 +187,10 @@ function cmdCurrent() {
|
|
|
156
187
|
return;
|
|
157
188
|
}
|
|
158
189
|
const info = authInfo(live);
|
|
159
|
-
const
|
|
190
|
+
const accounts = store.listAccounts();
|
|
191
|
+
const match =
|
|
192
|
+
(info.email && accounts.find((a) => a.email === info.email)) ||
|
|
193
|
+
accounts.find((a) => a.accountId === info.accountId);
|
|
160
194
|
const name = match ? match.name : '(not stored — run "codexswitch import")';
|
|
161
195
|
const note = meta.active && match && meta.active !== match.name ? ` (meta says "${meta.active}" — out of sync)` : '';
|
|
162
196
|
out(`active: ${name}${note}`);
|
|
@@ -164,12 +198,32 @@ function cmdCurrent() {
|
|
|
164
198
|
}
|
|
165
199
|
|
|
166
200
|
function cmdNext() {
|
|
167
|
-
const
|
|
168
|
-
const next = store.pickAccount(meta.active ? [meta.active] : []);
|
|
201
|
+
const next = store.nextAccount();
|
|
169
202
|
if (!next) throw new Error('no other usable account available');
|
|
170
203
|
cmdUse([next.name]);
|
|
171
204
|
}
|
|
172
205
|
|
|
206
|
+
// Register an API-key account (Console/platform billing instead of a
|
|
207
|
+
// ChatGPT plan). The key is stored only inside the account's auth.json.
|
|
208
|
+
function cmdAddKey(args) {
|
|
209
|
+
const name = requireArg(args, 0, 'account name');
|
|
210
|
+
const key = args[1] || process.env.OPENAI_API_KEY;
|
|
211
|
+
if (!key) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
'missing API key: pass it as the second argument or set OPENAI_API_KEY in the environment'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!/^sk-/.test(key)) throw new Error('that does not look like an OpenAI API key (should start with "sk-")');
|
|
217
|
+
storeAccount(name, {
|
|
218
|
+
auth_mode: 'apikey',
|
|
219
|
+
OPENAI_API_KEY: key,
|
|
220
|
+
tokens: null,
|
|
221
|
+
last_refresh: null,
|
|
222
|
+
});
|
|
223
|
+
const meta = store.loadMeta();
|
|
224
|
+
if (!meta.active) cmdUse([name]);
|
|
225
|
+
}
|
|
226
|
+
|
|
173
227
|
function cmdRemove(args) {
|
|
174
228
|
const name = requireArg(args, 0, 'account name');
|
|
175
229
|
if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
|
|
@@ -230,7 +284,171 @@ function cmdSync() {
|
|
|
230
284
|
out(updated ? `synced refreshed tokens into "${updated}"` : 'nothing to sync');
|
|
231
285
|
}
|
|
232
286
|
|
|
287
|
+
// Set the rotation order in one go: listed accounts get priority 0..k-1,
|
|
288
|
+
// unlisted accounts follow after in their current order.
|
|
289
|
+
function cmdOrder(args) {
|
|
290
|
+
const accounts = store.listAccounts();
|
|
291
|
+
if (args.length === 0) {
|
|
292
|
+
if (accounts.length === 0) {
|
|
293
|
+
out('no accounts yet');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
accounts.forEach((a, i) => out(`${i + 1}. ${a.name}${a.active ? ' (active)' : ''}`));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
for (const name of args) {
|
|
300
|
+
if (!store.accountExists(name)) throw new Error(`no such account: ${name}`);
|
|
301
|
+
}
|
|
302
|
+
const dupe = args.find((n, i) => args.indexOf(n) !== i);
|
|
303
|
+
if (dupe) throw new Error(`account listed twice: ${dupe}`);
|
|
304
|
+
const meta = store.loadMeta();
|
|
305
|
+
let prio = 0;
|
|
306
|
+
for (const name of args) {
|
|
307
|
+
meta.accounts[name] = { ...(meta.accounts[name] || {}), priority: prio++ };
|
|
308
|
+
}
|
|
309
|
+
for (const a of accounts) {
|
|
310
|
+
if (args.includes(a.name)) continue;
|
|
311
|
+
meta.accounts[a.name] = { ...(meta.accounts[a.name] || {}), priority: prio++ };
|
|
312
|
+
}
|
|
313
|
+
store.saveMeta(meta);
|
|
314
|
+
out('rotation order set:');
|
|
315
|
+
store.listAccounts().forEach((a, i) => out(`${i + 1}. ${a.name}`));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function cmdModel(args) {
|
|
319
|
+
const meta = store.loadMeta();
|
|
320
|
+
if (args[0] == null) {
|
|
321
|
+
out(`model: ${meta.model || '(codex default)'}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (args[0] === 'default' || args[0] === 'clear') {
|
|
325
|
+
delete meta.model;
|
|
326
|
+
store.saveMeta(meta);
|
|
327
|
+
out('model reset to codex default');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
meta.model = args[0];
|
|
331
|
+
store.saveMeta(meta);
|
|
332
|
+
out(`default model set to "${meta.model}" (applied to run/exec)`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Rotate-early thresholds: how full the 5h / weekly window may get before
|
|
336
|
+
// the account is skipped in rotation.
|
|
337
|
+
function cmdThreshold(args) {
|
|
338
|
+
const meta = store.loadMeta();
|
|
339
|
+
if (args.length === 0) {
|
|
340
|
+
out(`threshold: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
|
|
341
|
+
out('usage: codexswitch threshold <5h%> [weekly%] (one value sets both)');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const parse = (s) => {
|
|
345
|
+
const n = parseInt(s, 10);
|
|
346
|
+
if (Number.isNaN(n) || n < 1 || n > 100) throw new Error(`threshold must be 1-100, got "${s}"`);
|
|
347
|
+
return n;
|
|
348
|
+
};
|
|
349
|
+
meta.threshold5h = parse(args[0]);
|
|
350
|
+
meta.thresholdWeekly = args[1] != null ? parse(args[1]) : meta.threshold5h;
|
|
351
|
+
store.saveMeta(meta);
|
|
352
|
+
out(`threshold set: 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function cmdPatterns(args) {
|
|
356
|
+
const meta = store.loadMeta();
|
|
357
|
+
const [sub, ...rest] = args;
|
|
358
|
+
if (!sub) {
|
|
359
|
+
out('built-in: usage limit / rate limit / too many requests / quota exceeded / 429');
|
|
360
|
+
if (meta.limitPatterns.length === 0) out('custom: (none)');
|
|
361
|
+
else meta.limitPatterns.forEach((p, i) => out(`custom ${i + 1}: ${p}`));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (sub === 'add') {
|
|
365
|
+
const pattern = rest.join(' ');
|
|
366
|
+
if (!pattern) throw new Error('usage: codexswitch patterns add <regex>');
|
|
367
|
+
new RegExp(pattern, 'i'); // validate — throws on bad regex
|
|
368
|
+
meta.limitPatterns.push(pattern);
|
|
369
|
+
store.saveMeta(meta);
|
|
370
|
+
out(`added pattern: ${pattern}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (sub === 'remove') {
|
|
374
|
+
const key = rest.join(' ');
|
|
375
|
+
const idx = /^\d+$/.test(key) ? parseInt(key, 10) - 1 : meta.limitPatterns.indexOf(key);
|
|
376
|
+
if (idx < 0 || idx >= meta.limitPatterns.length) throw new Error(`no such pattern: ${key}`);
|
|
377
|
+
const [removed] = meta.limitPatterns.splice(idx, 1);
|
|
378
|
+
store.saveMeta(meta);
|
|
379
|
+
out(`removed pattern: ${removed}`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw new Error('usage: codexswitch patterns [add <regex> | remove <n|regex>]');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function cmdExport(args) {
|
|
386
|
+
const file = requireArg(args, 0, 'output file path');
|
|
387
|
+
const accounts = {};
|
|
388
|
+
for (const a of store.listAccounts()) accounts[a.name] = store.readAccountAuth(a.name);
|
|
389
|
+
if (Object.keys(accounts).length === 0) throw new Error('no accounts to export');
|
|
390
|
+
writeJSONAtomic(path.resolve(file), { version: 1, exportedAt: new Date().toISOString(), meta: store.loadMeta(), accounts }, 0o600);
|
|
391
|
+
out(`exported ${Object.keys(accounts).length} account(s) to ${file}`);
|
|
392
|
+
out('WARNING: this file contains login tokens — treat it like a password.');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function cmdRestore(args) {
|
|
396
|
+
const file = requireArg(args, 0, 'backup file path');
|
|
397
|
+
const data = readJSONSafe(path.resolve(file));
|
|
398
|
+
if (!data || data.version !== 1 || !data.accounts) throw new Error('not a codexswitch backup file');
|
|
399
|
+
store.ensureDirs();
|
|
400
|
+
const meta = store.loadMeta();
|
|
401
|
+
let n = 0;
|
|
402
|
+
for (const [name, auth] of Object.entries(data.accounts)) {
|
|
403
|
+
store.writeAccountAuth(name, auth);
|
|
404
|
+
const backup = (data.meta && data.meta.accounts && data.meta.accounts[name]) || {};
|
|
405
|
+
meta.accounts[name] = { ...backup, ...(meta.accounts[name] || {}) };
|
|
406
|
+
n++;
|
|
407
|
+
}
|
|
408
|
+
if (!meta.active && data.meta && data.meta.active) meta.active = data.meta.active;
|
|
409
|
+
store.saveMeta(meta);
|
|
410
|
+
out(`restored ${n} account(s) from ${file}`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function cmdNames() {
|
|
414
|
+
for (const a of store.listAccounts()) out(a.name);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const COMMANDS =
|
|
418
|
+
'login import add-key list use current next run exec order model remove rename ' +
|
|
419
|
+
'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
|
|
420
|
+
|
|
421
|
+
function cmdCompletion(args) {
|
|
422
|
+
const shell = args[0];
|
|
423
|
+
if (shell !== 'bash' && shell !== 'zsh') {
|
|
424
|
+
throw new Error('usage: codexswitch completion <bash|zsh> (append the output to your shell rc file)');
|
|
425
|
+
}
|
|
426
|
+
const script = `# codexswitch completion (${shell})
|
|
427
|
+
${shell === 'zsh' ? 'autoload -Uz bashcompinit && bashcompinit\n' : ''}_codexswitch() {
|
|
428
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
429
|
+
if [ "\$COMP_CWORD" -eq 1 ]; then
|
|
430
|
+
COMPREPLY=( \$(compgen -W "${COMMANDS}" -- "\$cur") )
|
|
431
|
+
else
|
|
432
|
+
COMPREPLY=( \$(compgen -W "\$(codexswitch names 2>/dev/null)" -- "\$cur") )
|
|
433
|
+
fi
|
|
434
|
+
}
|
|
435
|
+
complete -F _codexswitch codexswitch cxs`;
|
|
436
|
+
out(script);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Final argv for "codex exec": inject --skip-git-repo-check so it works in
|
|
440
|
+
// non-git folders, and the configured default model — unless the user
|
|
441
|
+
// already passed their own flags.
|
|
442
|
+
function buildExecArgs(rest, meta) {
|
|
443
|
+
const args = [...rest];
|
|
444
|
+
if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
|
|
445
|
+
const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
|
|
446
|
+
if (meta.model && !hasModel) args.unshift('-m', meta.model);
|
|
447
|
+
return args;
|
|
448
|
+
}
|
|
449
|
+
|
|
233
450
|
async function cmdRun(args) {
|
|
451
|
+
store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
|
|
234
452
|
let name = null;
|
|
235
453
|
let rest = args;
|
|
236
454
|
if (args[0] && store.accountExists(args[0])) {
|
|
@@ -244,28 +462,44 @@ async function cmdRun(args) {
|
|
|
244
462
|
name = meta.active && store.accountExists(meta.active) ? meta.active : picked.name;
|
|
245
463
|
}
|
|
246
464
|
if (rest[0] === '--') rest = rest.slice(1);
|
|
465
|
+
const meta = store.loadMeta();
|
|
466
|
+
if (rest[0] === 'exec') {
|
|
467
|
+
rest = ['exec', ...buildExecArgs(rest.slice(1), meta)];
|
|
468
|
+
} else if (meta.model && rest.length === 0) {
|
|
469
|
+
rest = ['-m', meta.model];
|
|
470
|
+
}
|
|
247
471
|
out(`[codexswitch] running codex as "${name}"`);
|
|
472
|
+
const startTs = Date.now();
|
|
248
473
|
const res = await runner.runCodex(name, rest);
|
|
474
|
+
runner.recordUsage(name, res.profile, startTs);
|
|
249
475
|
return res.code;
|
|
250
476
|
}
|
|
251
477
|
|
|
478
|
+
const RESUME_PROMPT = 'Continue the previous task from exactly where it left off.';
|
|
479
|
+
|
|
252
480
|
async function cmdExec(args) {
|
|
253
481
|
let explicit = null;
|
|
482
|
+
let allowResume = true;
|
|
254
483
|
const rest = [];
|
|
255
484
|
for (let i = 0; i < args.length; i++) {
|
|
256
485
|
if (args[i] === '-a' || args[i] === '--account') {
|
|
257
486
|
explicit = requireArg(args, ++i, 'account name after --account');
|
|
487
|
+
} else if (args[i] === '--no-resume') {
|
|
488
|
+
allowResume = false;
|
|
258
489
|
} else {
|
|
259
490
|
rest.push(args[i]);
|
|
260
491
|
}
|
|
261
492
|
}
|
|
262
493
|
if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
|
|
494
|
+
if (rest[0] === 'resume') allowResume = false; // user drives resume themselves
|
|
263
495
|
|
|
496
|
+
store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
|
|
264
497
|
const meta = store.loadMeta();
|
|
265
498
|
const total = store.listAccounts().length;
|
|
266
499
|
if (total === 0) throw new Error('no accounts — add one with "codexswitch login"');
|
|
267
500
|
|
|
268
501
|
const tried = [];
|
|
502
|
+
let useResume = false;
|
|
269
503
|
for (let attempt = 0; attempt < total; attempt++) {
|
|
270
504
|
let name;
|
|
271
505
|
if (explicit && attempt === 0) {
|
|
@@ -276,24 +510,61 @@ async function cmdExec(args) {
|
|
|
276
510
|
name = picked.name;
|
|
277
511
|
}
|
|
278
512
|
tried.push(name);
|
|
279
|
-
console.error(
|
|
280
|
-
|
|
513
|
+
console.error(
|
|
514
|
+
`[codexswitch] exec as "${name}"${attempt > 0 ? ` (attempt ${attempt + 1}${useResume ? ', resuming session' : ''})` : ''}`
|
|
515
|
+
);
|
|
516
|
+
// On rotation, continue the same session with the next account instead
|
|
517
|
+
// of restarting the whole prompt — the session files are shared.
|
|
518
|
+
const codexArgs = useResume
|
|
519
|
+
? ['exec', 'resume', '--last', ...buildExecArgs([], meta), RESUME_PROMPT]
|
|
520
|
+
: ['exec', ...buildExecArgs(rest, meta)];
|
|
521
|
+
const startTs = Date.now();
|
|
522
|
+
const res = await runner.runCodex(name, codexArgs, { capture: true });
|
|
523
|
+
runner.recordUsage(name, res.profile, startTs);
|
|
281
524
|
if (res.code === 0) {
|
|
282
525
|
store.clearLimited(name);
|
|
526
|
+
warnIfOverThreshold(name);
|
|
283
527
|
return 0;
|
|
284
528
|
}
|
|
285
|
-
if (runner.looksRateLimited(res.output)) {
|
|
529
|
+
if (runner.looksRateLimited(res.output, meta.limitPatterns)) {
|
|
286
530
|
const until = Date.now() + runner.limitCooldownMs(res.output, meta.cooldownMinutes);
|
|
287
531
|
store.markLimited(name, until);
|
|
288
|
-
|
|
532
|
+
if (allowResume && !useResume && runner.sessionTouchedSince(res.profile, startTs)) {
|
|
533
|
+
useResume = true;
|
|
534
|
+
}
|
|
535
|
+
console.error(
|
|
536
|
+
`[codexswitch] "${name}" hit a usage/rate limit (paused until ${fmtDate(until)}) — rotating${useResume ? ' and resuming the session' : ''}`
|
|
537
|
+
);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (runner.looksAuthFailed(res.output)) {
|
|
541
|
+
// A revoked token won't heal by itself — take the account out of
|
|
542
|
+
// rotation and keep the task going on the next one.
|
|
543
|
+
setFlag(name, { disabled: true });
|
|
544
|
+
console.error(
|
|
545
|
+
`[codexswitch] "${name}" has a revoked/invalid login — disabled. Fix it with: codexswitch login ${name}`
|
|
546
|
+
);
|
|
289
547
|
continue;
|
|
290
548
|
}
|
|
291
549
|
return res.code; // real failure, don't burn other accounts on it
|
|
292
550
|
}
|
|
293
|
-
console.error('[codexswitch] all accounts are rate-limited or disabled');
|
|
551
|
+
console.error('[codexswitch] all accounts are rate-limited, over threshold, or disabled');
|
|
294
552
|
return 2;
|
|
295
553
|
}
|
|
296
554
|
|
|
555
|
+
function warnIfOverThreshold(name) {
|
|
556
|
+
const meta = store.loadMeta();
|
|
557
|
+
const account = store.listAccounts().find((a) => a.name === name);
|
|
558
|
+
if (!account) return;
|
|
559
|
+
const blocked = store.overThreshold(account, meta);
|
|
560
|
+
if (blocked) {
|
|
561
|
+
const pct = blocked === '5h' ? account.usage.p5h.pct : account.usage.weekly.pct;
|
|
562
|
+
console.error(
|
|
563
|
+
`[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`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
297
568
|
async function main(argv) {
|
|
298
569
|
const [cmd, ...args] = argv;
|
|
299
570
|
switch (cmd) {
|
|
@@ -333,10 +604,29 @@ async function main(argv) {
|
|
|
333
604
|
return 0;
|
|
334
605
|
case 'priority':
|
|
335
606
|
return cmdPriority(args), 0;
|
|
607
|
+
case 'order':
|
|
608
|
+
return cmdOrder(args), 0;
|
|
609
|
+
case 'model':
|
|
610
|
+
return cmdModel(args), 0;
|
|
611
|
+
case 'add-key':
|
|
612
|
+
case 'apikey':
|
|
613
|
+
return cmdAddKey(args), 0;
|
|
614
|
+
case 'threshold':
|
|
615
|
+
return cmdThreshold(args), 0;
|
|
616
|
+
case 'patterns':
|
|
617
|
+
return cmdPatterns(args), 0;
|
|
618
|
+
case 'export':
|
|
619
|
+
return cmdExport(args), 0;
|
|
620
|
+
case 'restore':
|
|
621
|
+
return cmdRestore(args), 0;
|
|
622
|
+
case 'names':
|
|
623
|
+
return cmdNames(), 0;
|
|
624
|
+
case 'completion':
|
|
625
|
+
return cmdCompletion(args), 0;
|
|
336
626
|
case 'clear-limit': {
|
|
337
627
|
const name = requireArg(args, 0, 'account name');
|
|
338
|
-
store.clearLimited(name);
|
|
339
|
-
out(`cleared rate-limit
|
|
628
|
+
store.clearLimited(name, { includeUsage: true });
|
|
629
|
+
out(`cleared rate-limit and usage records for "${name}"`);
|
|
340
630
|
return 0;
|
|
341
631
|
}
|
|
342
632
|
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,115 @@ 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;
|
|
288
|
+
|
|
289
|
+
// Credential failures (revoked/invalidated tokens) — re-login required, so
|
|
290
|
+
// rotation should skip this account and tell the user how to fix it.
|
|
291
|
+
const AUTH_RE =
|
|
292
|
+
/refresh[ _]token[ _](?:was )?(?:revoked|invalidated)|token_invalidated|please log ?out and sign in again|please try signing in again|401 unauthorized/i;
|
|
293
|
+
|
|
294
|
+
function looksAuthFailed(output) {
|
|
295
|
+
return AUTH_RE.test(output || '');
|
|
296
|
+
}
|
|
202
297
|
|
|
203
|
-
function looksRateLimited(output) {
|
|
204
|
-
|
|
298
|
+
function looksRateLimited(output, extraPatterns = []) {
|
|
299
|
+
if (LIMIT_RE.test(output || '')) return true;
|
|
300
|
+
for (const p of extraPatterns) {
|
|
301
|
+
try {
|
|
302
|
+
if (new RegExp(p, 'i').test(output || '')) return true;
|
|
303
|
+
} catch {
|
|
304
|
+
/* invalid user pattern — ignore */
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
205
308
|
}
|
|
206
309
|
|
|
207
310
|
// Try to extract "try again in 2 hours 30 minutes" style hints; fall back
|
|
@@ -219,5 +322,9 @@ module.exports = {
|
|
|
219
322
|
buildProfile,
|
|
220
323
|
runCodex,
|
|
221
324
|
looksRateLimited,
|
|
325
|
+
looksAuthFailed,
|
|
222
326
|
limitCooldownMs,
|
|
327
|
+
scanUsage,
|
|
328
|
+
recordUsage,
|
|
329
|
+
sessionTouchedSince,
|
|
223
330
|
};
|