@carjms/codexswitch 0.4.0 → 0.6.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 +4 -1
- package/README.md +4 -1
- package/package.json +1 -1
- package/src/cli.js +251 -53
- package/src/proxy.js +220 -0
- package/src/runner.js +26 -4
package/README.ko.md
CHANGED
|
@@ -205,7 +205,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
205
205
|
| `cxs add-key <이름> [키]` | OpenAI API 키 계정 등록 (키 생략 시 `$OPENAI_API_KEY` 사용) |
|
|
206
206
|
| `cxs list` | 계정 목록: 활성 표시, 이메일, 플랜, 우선순위, 한도 상태, 사용량 % |
|
|
207
207
|
| `cxs usage [이름]` | 계정별 사용량 대시보드: 5시간/주간 게이지 바, 리셋 카운트다운, 다음 로테이션 계정 (별칭: `status`) |
|
|
208
|
-
| `cxs
|
|
208
|
+
| `cxs chat` | **대화형 입력창 (Claude Code 스타일)**: 매 턴이 로테이션을 거치고 같은 codex 세션을 이어가므로, 계정이 바뀌어도 대화가 유지됨. 내부 명령: `/usage /use /next /model /new /quit` |
|
|
209
|
+
| `cxs watch` | 실시간 인터랙티브 대시보드 — 5초마다 갱신; 키: `↑/↓` 선택, `s` 전환, `e` 활성/비활성, `p` 프로브, `q` 종료 |
|
|
209
210
|
| `cxs probe [이름]` | 계정마다 최소 요청 1회를 보내 사용량 게이지 워밍업 (토큰 소량 소모) |
|
|
210
211
|
| `cxs log [개수]` | 최근 활동 기록: 계정 전환, 한도 도달, 로테이션, 프로브 |
|
|
211
212
|
| `cxs use <이름>` | 활성 계정 전환 |
|
|
@@ -222,6 +223,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
|
|
|
222
223
|
| `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
|
|
223
224
|
| `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
|
|
224
225
|
| `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
|
|
226
|
+
| `cxs server [--port N]` | **실험 기능** 로컬 프록시(teamclaude 방식): 요청마다 인증을 교체하고, 429는 다음 계정으로 재시도, 응답 헤더에서 사용량을 실시간 수집 |
|
|
227
|
+
| `cxs run --proxy [인자]` | codex를 로컬 프록시 경유로 실행 — 대화형 세션 안에서도 요청 단위 로테이션 |
|
|
225
228
|
| `cxs remove <이름>` | 계정 삭제 |
|
|
226
229
|
| `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
|
|
227
230
|
| `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
|
package/README.md
CHANGED
|
@@ -207,7 +207,8 @@ cxs list # per-account 5h/week usage columns
|
|
|
207
207
|
| `cxs add-key <name> [key]` | Register an OpenAI API-key account (falls back to `$OPENAI_API_KEY`) |
|
|
208
208
|
| `cxs list` | List accounts: active marker, email, plan, priority, limit status, usage % |
|
|
209
209
|
| `cxs usage [name]` | Per-account usage dashboard: 5h/weekly gauge bars, reset countdowns, next rotation pick (alias: `status`) |
|
|
210
|
-
| `cxs
|
|
210
|
+
| `cxs chat` | **Interactive prompt loop (Claude Code-style)**: each turn runs through rotation and resumes the same codex session, so the conversation survives account switches. Slash commands inside: `/usage /use /next /model /new /quit` |
|
|
211
|
+
| `cxs watch` | Live interactive dashboard — refreshes every 5s; keys: `↑/↓` select, `s` switch, `e` enable/disable, `p` probe, `q` quit |
|
|
211
212
|
| `cxs probe [name]` | Warm up the usage gauges with one minimal request per account (costs a few tokens) |
|
|
212
213
|
| `cxs log [count]` | Recent activity: account switches, limits hit, rotations, probes |
|
|
213
214
|
| `cxs use <name>` | Switch the active account |
|
|
@@ -224,6 +225,8 @@ cxs list # per-account 5h/week usage columns
|
|
|
224
225
|
| `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
|
|
225
226
|
| `cxs completion <bash\|zsh>` | Print a shell completion script |
|
|
226
227
|
| `cxs <anything else>` | Forwarded to codex under the managed account — `cxs resume`, `cxs goal ...`, `cxs apply` all work like their codex counterparts |
|
|
228
|
+
| `cxs server [--port N]` | **EXPERIMENTAL** local proxy (teamclaude-style): swaps credentials per request, retries 429s on the next account, and feeds usage gauges live from response headers |
|
|
229
|
+
| `cxs run --proxy [args]` | Run codex routed through the local proxy — per-request rotation even inside interactive sessions |
|
|
227
230
|
| `cxs remove <name>` | Delete an account |
|
|
228
231
|
| `cxs rename <old> <new>` | Rename an account |
|
|
229
232
|
| `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -34,6 +34,10 @@ Accounts
|
|
|
34
34
|
clear-limit <name> forget a recorded rate-limit for an account
|
|
35
35
|
|
|
36
36
|
Running codex
|
|
37
|
+
chat interactive prompt loop (Claude Code-style): each turn
|
|
38
|
+
runs through rotation and resumes the same session,
|
|
39
|
+
so the conversation survives account switches
|
|
40
|
+
(/usage /use /next /model /new /quit inside)
|
|
37
41
|
run [name] [args...] run codex as <name> (or active account) in an isolated
|
|
38
42
|
per-account CODEX_HOME; config/sessions are shared
|
|
39
43
|
exec [args...] run "codex exec ..." and auto-rotate through accounts in
|
|
@@ -41,6 +45,9 @@ Running codex
|
|
|
41
45
|
the same session (--skip-git-repo-check added automatically)
|
|
42
46
|
-a, --account <n> start exec with a specific account
|
|
43
47
|
--no-resume restart the prompt instead of resuming on rotation
|
|
48
|
+
server [--port N] EXPERIMENTAL local proxy: per-request account
|
|
49
|
+
rotation with live usage from response headers
|
|
50
|
+
run --proxy [args...] run codex routed through the local proxy
|
|
44
51
|
|
|
45
52
|
Settings
|
|
46
53
|
model [name|default] show/set the default model injected into run/exec
|
|
@@ -360,47 +367,54 @@ function cmdModel(args) {
|
|
|
360
367
|
out(`default model set to "${meta.model}" (applied to run/exec)`);
|
|
361
368
|
}
|
|
362
369
|
|
|
363
|
-
//
|
|
364
|
-
// reset countdowns
|
|
365
|
-
function
|
|
366
|
-
const meta = store.loadMeta();
|
|
367
|
-
let accounts = store.listAccounts();
|
|
368
|
-
if (args[0]) {
|
|
369
|
-
accounts = accounts.filter((a) => a.name === args[0]);
|
|
370
|
-
if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
|
|
371
|
-
}
|
|
372
|
-
if (accounts.length === 0) {
|
|
373
|
-
out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
370
|
+
// Render the per-account usage view (gauge bars for the 5h / weekly windows
|
|
371
|
+
// with reset countdowns). `cursor` marks the selected row in watch mode.
|
|
372
|
+
function usageLines(accounts, meta, { cursor = -1 } = {}) {
|
|
376
373
|
const now = Date.now();
|
|
374
|
+
const lines = [];
|
|
377
375
|
const bar = (pct, threshold) => {
|
|
378
376
|
const width = 20;
|
|
379
377
|
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
|
|
380
378
|
const paint = pct >= threshold ? ui.red : pct >= 70 ? ui.yellow : ui.green;
|
|
381
379
|
return `[${paint('█'.repeat(filled))}${ui.dim('░'.repeat(width - filled))}]`;
|
|
382
380
|
};
|
|
383
|
-
const
|
|
381
|
+
const gauge = (label, win, threshold) => {
|
|
384
382
|
if (!win || typeof win.pct !== 'number') return ` ${label} ${ui.dim('no data')}`;
|
|
385
383
|
const pct = Math.round(win.pct);
|
|
386
384
|
const reset = win.resetAt && win.resetAt > now ? ` resets in ${fmtRemaining(win.resetAt)}` : '';
|
|
387
385
|
const overMark = win.pct >= threshold ? ` ${ui.red(`≥ threshold ${threshold}%`)}` : '';
|
|
388
386
|
return ` ${label} ${bar(win.pct, threshold)} ${String(pct).padStart(3)}%${ui.dim(reset)}${overMark}`;
|
|
389
387
|
};
|
|
390
|
-
|
|
388
|
+
accounts.forEach((a, i) => {
|
|
389
|
+
const sel = i === cursor ? ui.cyan('▶') : ' ';
|
|
391
390
|
const mark = a.active ? ui.green('●') : ' ';
|
|
392
391
|
const state = a.disabled ? ui.red(' [disabled]') : a.limitedUntil && a.limitedUntil > now ? ui.yellow(` [limited ${fmtRemaining(a.limitedUntil)}]`) : '';
|
|
393
|
-
|
|
392
|
+
lines.push(`${cursor >= 0 ? sel : ''}${mark} ${ui.bold(a.name)}${ui.dim(` (${a.plan || a.email || '-'})`)}${state}`);
|
|
394
393
|
if (!a.usage) {
|
|
395
|
-
|
|
394
|
+
lines.push(ui.dim(' no usage data yet — codex records it during runs (try "codexswitch run" once)'));
|
|
396
395
|
} else {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (a.usage.at)
|
|
396
|
+
lines.push(gauge('5h ', a.usage.p5h, meta.threshold5h));
|
|
397
|
+
lines.push(gauge('weekly', a.usage.weekly, meta.thresholdWeekly));
|
|
398
|
+
if (a.usage.at) lines.push(ui.dim(` measured ${fmtDate(a.usage.at)}`));
|
|
400
399
|
}
|
|
401
|
-
}
|
|
400
|
+
});
|
|
402
401
|
const next = store.pickAccount();
|
|
403
|
-
|
|
402
|
+
lines.push(ui.dim(`\nrotation would pick: ${next ? next.name : '(none usable)'} · threshold 5h ${meta.threshold5h}% / weekly ${meta.thresholdWeekly}%`));
|
|
403
|
+
return lines;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function cmdUsage(args) {
|
|
407
|
+
const meta = store.loadMeta();
|
|
408
|
+
let accounts = store.listAccounts();
|
|
409
|
+
if (args[0]) {
|
|
410
|
+
accounts = accounts.filter((a) => a.name === args[0]);
|
|
411
|
+
if (accounts.length === 0) throw new Error(`no such account: ${args[0]}`);
|
|
412
|
+
}
|
|
413
|
+
if (accounts.length === 0) {
|
|
414
|
+
out('no accounts yet — add one with "codexswitch login" or "codexswitch import"');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
for (const l of usageLines(accounts, meta)) out(l);
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
// Warm up quota measurements: send a minimal request per account so the
|
|
@@ -417,32 +431,38 @@ async function cmdProbe(args) {
|
|
|
417
431
|
out(ui.info(ui.dim(`probing ${accounts.length} account(s) with a minimal request...`)));
|
|
418
432
|
let failures = 0;
|
|
419
433
|
for (const a of accounts) {
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
['exec', ...buildExecArgs(['Reply with exactly: ok'], meta)],
|
|
424
|
-
{ capture: true, silent: true }
|
|
425
|
-
);
|
|
426
|
-
const usage = runner.recordUsage(a.name, res.profile, startTs);
|
|
427
|
-
if (res.code !== 0) {
|
|
428
|
-
failures++;
|
|
429
|
-
const why = runner.looksAuthFailed(res.output)
|
|
430
|
-
? `login revoked — fix with "codexswitch login ${a.name}"`
|
|
431
|
-
: runner.looksRateLimited(res.output, meta.limitPatterns)
|
|
432
|
-
? 'rate limited'
|
|
433
|
-
: `exit ${res.code}`;
|
|
434
|
-
out(ui.fail(`${a.name}: ${why}`));
|
|
435
|
-
store.logEvent('probe', `${a.name} failed: ${why}`);
|
|
436
|
-
} else if (usage && usage.p5h) {
|
|
437
|
-
out(ui.ok(`${a.name}: 5h ${Math.round(usage.p5h.pct)}%${usage.weekly ? ` / weekly ${Math.round(usage.weekly.pct)}%` : ''}`));
|
|
438
|
-
store.logEvent('probe', `${a.name} measured`);
|
|
439
|
-
} else {
|
|
440
|
-
out(ui.warn(`${a.name}: reachable, but codex reported no usage data`));
|
|
441
|
-
}
|
|
434
|
+
const line = await probeOne(a, meta);
|
|
435
|
+
out(line.text);
|
|
436
|
+
if (!line.ok) failures++;
|
|
442
437
|
}
|
|
443
438
|
return failures > 0 ? 1 : 0;
|
|
444
439
|
}
|
|
445
440
|
|
|
441
|
+
// Probe a single account; returns a printable result line.
|
|
442
|
+
async function probeOne(a, meta) {
|
|
443
|
+
const startTs = Date.now();
|
|
444
|
+
const res = await runner.runCodex(
|
|
445
|
+
a.name,
|
|
446
|
+
['exec', ...buildExecArgs(['Reply with exactly: ok'], meta)],
|
|
447
|
+
{ capture: true, silent: true }
|
|
448
|
+
);
|
|
449
|
+
const usage = runner.recordUsage(a.name, res.profile, startTs);
|
|
450
|
+
if (res.code !== 0) {
|
|
451
|
+
const why = runner.looksAuthFailed(res.output)
|
|
452
|
+
? `login revoked — fix with "codexswitch login ${a.name}"`
|
|
453
|
+
: runner.looksRateLimited(res.output, meta.limitPatterns)
|
|
454
|
+
? 'rate limited'
|
|
455
|
+
: `exit ${res.code}`;
|
|
456
|
+
store.logEvent('probe', `${a.name} failed: ${why}`);
|
|
457
|
+
return { ok: false, text: ui.fail(`${a.name}: ${why}`) };
|
|
458
|
+
}
|
|
459
|
+
store.logEvent('probe', `${a.name} measured`);
|
|
460
|
+
if (usage && usage.p5h) {
|
|
461
|
+
return { ok: true, text: ui.ok(`${a.name}: 5h ${Math.round(usage.p5h.pct)}%${usage.weekly ? ` / weekly ${Math.round(usage.weekly.pct)}%` : ''}`) };
|
|
462
|
+
}
|
|
463
|
+
return { ok: true, text: ui.warn(`${a.name}: reachable, but codex reported no usage data`) };
|
|
464
|
+
}
|
|
465
|
+
|
|
446
466
|
function cmdLog(args) {
|
|
447
467
|
const count = args[0] ? parseInt(args[0], 10) : 20;
|
|
448
468
|
if (Number.isNaN(count) || count < 1) throw new Error('usage: codexswitch log [count]');
|
|
@@ -458,35 +478,81 @@ function cmdLog(args) {
|
|
|
458
478
|
}
|
|
459
479
|
}
|
|
460
480
|
|
|
461
|
-
// Live dashboard:
|
|
481
|
+
// Live interactive dashboard: usage gauges + recent activity, refreshed
|
|
482
|
+
// every 5s. Keyboard: up/down select, s switch, e enable/disable, p probe,
|
|
483
|
+
// r refresh, q quit.
|
|
462
484
|
async function cmdWatch() {
|
|
463
485
|
if (!process.stdout.isTTY) {
|
|
464
486
|
cmdUsage([]);
|
|
465
487
|
return 0;
|
|
466
488
|
}
|
|
489
|
+
let cursor = 0;
|
|
490
|
+
let message = '';
|
|
491
|
+
let busy = false;
|
|
467
492
|
const render = () => {
|
|
493
|
+
const meta = store.loadMeta();
|
|
494
|
+
const accounts = store.listAccounts();
|
|
495
|
+
if (cursor >= accounts.length) cursor = Math.max(0, accounts.length - 1);
|
|
468
496
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
469
|
-
out(`${ui.bold('codexswitch watch')}${ui.dim(` ${new Date().toLocaleTimeString()}
|
|
470
|
-
|
|
471
|
-
|
|
497
|
+
out(`${ui.bold('codexswitch watch')}${ui.dim(` ${new Date().toLocaleTimeString()} \u00b7 refreshes every 5s`)}\n`);
|
|
498
|
+
if (accounts.length === 0) {
|
|
499
|
+
out('no accounts yet \u2014 add one with "codexswitch login"');
|
|
500
|
+
} else {
|
|
501
|
+
for (const l of usageLines(accounts, meta, { cursor })) out(l);
|
|
502
|
+
}
|
|
503
|
+
const events = store.readEvents(4);
|
|
472
504
|
if (events.length > 0) {
|
|
473
505
|
out(ui.dim('\nrecent activity:'));
|
|
474
506
|
for (const e of events) out(ui.dim(` ${e.at.replace('T', ' ').slice(11, 19)} ${e.type}: ${e.message}`));
|
|
475
507
|
}
|
|
508
|
+
out(`\n${ui.dim('keys:')} ${ui.cyan('\u2191/\u2193')} select ${ui.cyan('s')} switch ${ui.cyan('e')} enable/disable ${ui.cyan('p')} probe ${ui.cyan('r')} refresh ${ui.cyan('q')} quit`);
|
|
509
|
+
if (message) out(message);
|
|
476
510
|
};
|
|
477
511
|
render();
|
|
478
|
-
const timer = setInterval(
|
|
512
|
+
const timer = setInterval(() => {
|
|
513
|
+
if (!busy) render();
|
|
514
|
+
}, 5000);
|
|
479
515
|
await new Promise((resolve) => {
|
|
480
516
|
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
481
517
|
process.stdin.resume();
|
|
482
|
-
process.stdin.on('data', (b) => {
|
|
518
|
+
process.stdin.on('data', async (b) => {
|
|
519
|
+
if (busy) return;
|
|
483
520
|
const s = b.toString();
|
|
521
|
+
const accounts = store.listAccounts();
|
|
522
|
+
const selected = accounts[cursor];
|
|
484
523
|
if (s === 'q' || s === '\u0003') {
|
|
485
524
|
clearInterval(timer);
|
|
486
525
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
487
526
|
process.stdin.pause();
|
|
488
527
|
resolve();
|
|
528
|
+
return;
|
|
489
529
|
}
|
|
530
|
+
if (s === '\x1b[A' || s === 'k') cursor = Math.max(0, cursor - 1);
|
|
531
|
+
else if (s === '\x1b[B' || s === 'j') cursor = Math.min(accounts.length - 1, cursor + 1);
|
|
532
|
+
else if (s === 'r') message = '';
|
|
533
|
+
else if (s === 's' && selected) {
|
|
534
|
+
try {
|
|
535
|
+
cmdUse([selected.name]);
|
|
536
|
+
message = ui.ok(`switched to "${selected.name}"`);
|
|
537
|
+
} catch (e) {
|
|
538
|
+
message = ui.fail(e.message);
|
|
539
|
+
}
|
|
540
|
+
} else if (s === 'e' && selected) {
|
|
541
|
+
setFlag(selected.name, { disabled: !selected.disabled });
|
|
542
|
+
message = selected.disabled ? ui.ok(`enabled "${selected.name}"`) : ui.warn(`disabled "${selected.name}"`);
|
|
543
|
+
} else if (s === 'p' && selected) {
|
|
544
|
+
busy = true;
|
|
545
|
+
message = ui.dim(`probing "${selected.name}"...`);
|
|
546
|
+
render();
|
|
547
|
+
try {
|
|
548
|
+
const result = await probeOne(selected, store.loadMeta());
|
|
549
|
+
message = result.text;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
message = ui.fail(e.message);
|
|
552
|
+
}
|
|
553
|
+
busy = false;
|
|
554
|
+
}
|
|
555
|
+
render();
|
|
490
556
|
});
|
|
491
557
|
});
|
|
492
558
|
return 0;
|
|
@@ -575,7 +641,7 @@ function cmdNames() {
|
|
|
575
641
|
}
|
|
576
642
|
|
|
577
643
|
const COMMANDS =
|
|
578
|
-
'login import add-key list usage watch probe log use current next run exec order model remove rename ' +
|
|
644
|
+
'login import add-key list usage watch probe log chat server use current next run exec order model remove rename ' +
|
|
579
645
|
'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
|
|
580
646
|
|
|
581
647
|
function cmdCompletion(args) {
|
|
@@ -600,6 +666,18 @@ complete -F _codexswitch codexswitch cxs`;
|
|
|
600
666
|
// non-git folders, and the configured default model — unless the user
|
|
601
667
|
// already passed their own flags.
|
|
602
668
|
function buildExecArgs(rest, meta) {
|
|
669
|
+
// "exec resume [--last|<id>] ..." — the subcommand and its target must
|
|
670
|
+
// stay in front; flags are injected after them.
|
|
671
|
+
if (rest[0] === 'resume') {
|
|
672
|
+
const head = ['resume'];
|
|
673
|
+
let i = 1;
|
|
674
|
+
if (rest[i] === '--last' || (rest[i] && /^[0-9a-f][0-9a-f-]{7,}$/i.test(rest[i]))) head.push(rest[i++]);
|
|
675
|
+
return [...head, ...injectExecFlags(rest.slice(i), meta)];
|
|
676
|
+
}
|
|
677
|
+
return injectExecFlags(rest, meta);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function injectExecFlags(rest, meta) {
|
|
603
681
|
const args = [...rest];
|
|
604
682
|
if (!args.includes('--skip-git-repo-check')) args.unshift('--skip-git-repo-check');
|
|
605
683
|
const hasModel = args.some((a) => a === '-m' || a === '--model' || a.startsWith('--model='));
|
|
@@ -607,8 +685,49 @@ function buildExecArgs(rest, meta) {
|
|
|
607
685
|
return args;
|
|
608
686
|
}
|
|
609
687
|
|
|
688
|
+
// EXPERIMENTAL: foreground proxy server for per-request rotation.
|
|
689
|
+
async function cmdServer(args) {
|
|
690
|
+
const proxy = require('./proxy.js');
|
|
691
|
+
let port = proxy.DEFAULT_PORT;
|
|
692
|
+
for (let i = 0; i < args.length; i++) {
|
|
693
|
+
if (args[i] === '--port' || args[i] === '-p') port = parseInt(args[++i], 10);
|
|
694
|
+
}
|
|
695
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) throw new Error('invalid port');
|
|
696
|
+
if (store.listAccounts().length === 0) throw new Error('no accounts — add one with "codexswitch login"');
|
|
697
|
+
const log = (type, msg) => {
|
|
698
|
+
const paint = { rotate: ui.warn, error: ui.fail, ws: ui.info, req: (s) => ui.dim(` ${s}`) }[type] || ui.info;
|
|
699
|
+
out(paint(msg));
|
|
700
|
+
};
|
|
701
|
+
const server = await proxy.startServer({ port, log });
|
|
702
|
+
const stateFile = path.join(store.paths().home, 'proxy.json');
|
|
703
|
+
writeJSONAtomic(stateFile, { port, pid: process.pid, startedAt: Date.now() });
|
|
704
|
+
out(`${ui.bold('codexswitch proxy')} ${ui.yellow('(experimental)')} listening on ${ui.cyan(`http://127.0.0.1:${port}`)}`);
|
|
705
|
+
out(ui.dim('per-request account rotation · 429 retries on the next account · live usage from response headers'));
|
|
706
|
+
out(ui.dim(`use it with: codexswitch run --proxy (Ctrl-C to stop)\n`));
|
|
707
|
+
await new Promise((resolve) => {
|
|
708
|
+
const stop = () => {
|
|
709
|
+
server.close();
|
|
710
|
+
fs.rmSync(stateFile, { force: true });
|
|
711
|
+
resolve();
|
|
712
|
+
};
|
|
713
|
+
process.on('SIGINT', stop);
|
|
714
|
+
process.on('SIGTERM', stop);
|
|
715
|
+
});
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
610
719
|
async function cmdRun(args) {
|
|
611
720
|
store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
|
|
721
|
+
let proxyPort = null;
|
|
722
|
+
if (args.includes('--proxy')) {
|
|
723
|
+
const proxy = require('./proxy.js');
|
|
724
|
+
args = args.filter((a) => a !== '--proxy');
|
|
725
|
+
const state = readJSONSafe(path.join(store.paths().home, 'proxy.json'));
|
|
726
|
+
proxyPort = (state && state.port) || proxy.DEFAULT_PORT;
|
|
727
|
+
if (!(await proxy.ping(proxyPort))) {
|
|
728
|
+
throw new Error(`no proxy on port ${proxyPort} — start it first with "codexswitch server"`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
612
731
|
let name = null;
|
|
613
732
|
let rest = args;
|
|
614
733
|
if (args[0] && store.accountExists(args[0])) {
|
|
@@ -628,9 +747,9 @@ async function cmdRun(args) {
|
|
|
628
747
|
} else if (meta.model && rest.length === 0) {
|
|
629
748
|
rest = ['-m', meta.model];
|
|
630
749
|
}
|
|
631
|
-
out(ui.info(`running codex as ${ui.bold(`"${name}"`)}`));
|
|
750
|
+
out(ui.info(`running codex as ${ui.bold(`"${name}"`)}${proxyPort ? ui.dim(` via proxy :${proxyPort}`) : ''}`));
|
|
632
751
|
const startTs = Date.now();
|
|
633
|
-
const res = await runner.runCodex(name, rest);
|
|
752
|
+
const res = await runner.runCodex(name, rest, { proxyPort });
|
|
634
753
|
runner.recordUsage(name, res.profile, startTs);
|
|
635
754
|
return res.code;
|
|
636
755
|
}
|
|
@@ -716,6 +835,79 @@ async function cmdExec(args) {
|
|
|
716
835
|
return 2;
|
|
717
836
|
}
|
|
718
837
|
|
|
838
|
+
// Interactive chat: a Claude Code-style prompt loop on top of exec.
|
|
839
|
+
// Every turn goes through the rotation engine, and turns 2+ resume the same
|
|
840
|
+
// codex session — so the conversation continues even when the account under
|
|
841
|
+
// it changes between (or during) turns.
|
|
842
|
+
async function cmdChat() {
|
|
843
|
+
const readline = require('readline');
|
|
844
|
+
runner.assertCodexAvailable();
|
|
845
|
+
if (store.listAccounts().length === 0) {
|
|
846
|
+
throw new Error('no accounts — add one with "codexswitch login"');
|
|
847
|
+
}
|
|
848
|
+
const meta = store.loadMeta();
|
|
849
|
+
out(`${ui.bold('codexswitch chat')} ${ui.dim(`· model ${meta.model || '(codex default)'} · rotation on limits`)}`);
|
|
850
|
+
out(ui.dim('type a prompt, or /help for commands (/quit to exit)\n'));
|
|
851
|
+
|
|
852
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
853
|
+
rl.setPrompt(ui.enabled ? `${ui.cyan('cxs')} ${ui.dim('›')} ` : 'cxs › ');
|
|
854
|
+
// Buffer lines ourselves: input can arrive while a turn is still running
|
|
855
|
+
// (type-ahead, piped input) and must not be dropped or hit a closed rl.
|
|
856
|
+
const pending = [];
|
|
857
|
+
const waiters = [];
|
|
858
|
+
let closed = false;
|
|
859
|
+
rl.on('line', (l) => {
|
|
860
|
+
const w = waiters.shift();
|
|
861
|
+
if (w) w(l);
|
|
862
|
+
else pending.push(l);
|
|
863
|
+
});
|
|
864
|
+
rl.on('close', () => {
|
|
865
|
+
closed = true;
|
|
866
|
+
for (const w of waiters.splice(0)) w(null);
|
|
867
|
+
});
|
|
868
|
+
const ask = () => {
|
|
869
|
+
if (pending.length > 0) return Promise.resolve(pending.shift());
|
|
870
|
+
if (closed) return Promise.resolve(null);
|
|
871
|
+
rl.prompt();
|
|
872
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
let inSession = false;
|
|
876
|
+
for (;;) {
|
|
877
|
+
const raw = await ask();
|
|
878
|
+
if (raw == null) break; // EOF (Ctrl-D or piped input ended)
|
|
879
|
+
const line = raw.trim();
|
|
880
|
+
if (!line) continue;
|
|
881
|
+
|
|
882
|
+
if (line[0] === '/') {
|
|
883
|
+
const [cmd, ...rest] = line.slice(1).split(/\s+/);
|
|
884
|
+
try {
|
|
885
|
+
if (cmd === 'quit' || cmd === 'exit' || cmd === 'q') break;
|
|
886
|
+
else if (cmd === 'help')
|
|
887
|
+
out(ui.dim('/usage /list /use <name> /next /model [m] /new (fresh session) /quit'));
|
|
888
|
+
else if (cmd === 'usage') cmdUsage([]);
|
|
889
|
+
else if (cmd === 'list') cmdList();
|
|
890
|
+
else if (cmd === 'use') cmdUse(rest);
|
|
891
|
+
else if (cmd === 'next') cmdNext();
|
|
892
|
+
else if (cmd === 'model') cmdModel(rest);
|
|
893
|
+
else if (cmd === 'new') {
|
|
894
|
+
inSession = false;
|
|
895
|
+
out(ui.ok('starting a fresh session on the next prompt'));
|
|
896
|
+
} else out(ui.warn(`unknown command /${cmd} — try /help`));
|
|
897
|
+
} catch (e) {
|
|
898
|
+
out(ui.fail(e.message));
|
|
899
|
+
}
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const code = await cmdExec(inSession ? ['resume', '--last', line] : [line]);
|
|
904
|
+
if (code === 0) inSession = true;
|
|
905
|
+
else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
|
|
906
|
+
}
|
|
907
|
+
rl.close();
|
|
908
|
+
return 0;
|
|
909
|
+
}
|
|
910
|
+
|
|
719
911
|
function warnIfOverThreshold(name) {
|
|
720
912
|
const meta = store.loadMeta();
|
|
721
913
|
const account = store.listAccounts().find((a) => a.name === name);
|
|
@@ -817,6 +1009,12 @@ async function main(argv) {
|
|
|
817
1009
|
return cmdRun(args);
|
|
818
1010
|
case 'exec':
|
|
819
1011
|
return cmdExec(args);
|
|
1012
|
+
case 'chat':
|
|
1013
|
+
case 'repl':
|
|
1014
|
+
return cmdChat();
|
|
1015
|
+
case 'server':
|
|
1016
|
+
case 'proxy':
|
|
1017
|
+
return cmdServer(args);
|
|
820
1018
|
default:
|
|
821
1019
|
// Forward everything else to codex under the managed account, so
|
|
822
1020
|
// codexswitch is a drop-in replacement: "cxs goal", "cxs resume", ...
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// EXPERIMENTAL local proxy (teamclaude-style): codex is pointed at
|
|
4
|
+
// http://127.0.0.1:<port>/backend-api via chatgpt_base_url, and every
|
|
5
|
+
// request gets its Authorization / chatgpt-account-id swapped to the
|
|
6
|
+
// account chosen by the rotation rules — so even interactive codex
|
|
7
|
+
// sessions rotate per request. 429 responses retry on the next account.
|
|
8
|
+
// Rate-limit response headers (x-codex-*) feed the usage gauges live.
|
|
9
|
+
|
|
10
|
+
const http = require('http');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const net = require('net');
|
|
13
|
+
const tls = require('tls');
|
|
14
|
+
const { URL } = require('url');
|
|
15
|
+
const store = require('./store.js');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PORT = 8437;
|
|
18
|
+
const MAX_BODY = 32 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function upstreamBase() {
|
|
21
|
+
return new URL(process.env.CODEX_SWITCH_UPSTREAM || 'https://chatgpt.com');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pickAuth(exclude = []) {
|
|
25
|
+
const acct = store.pickAccount(exclude);
|
|
26
|
+
if (!acct) return null;
|
|
27
|
+
const auth = store.readAccountAuth(acct.name);
|
|
28
|
+
const tokens = auth.tokens || {};
|
|
29
|
+
return {
|
|
30
|
+
name: acct.name,
|
|
31
|
+
accountId: tokens.account_id || null,
|
|
32
|
+
token: tokens.access_token || auth.OPENAI_API_KEY || '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Parse x-codex-(primary|secondary)-* response headers into a usage
|
|
37
|
+
// snapshot compatible with the session-file scanner.
|
|
38
|
+
function usageFromHeaders(headers) {
|
|
39
|
+
const wins = { primary: {}, secondary: {} };
|
|
40
|
+
let found = false;
|
|
41
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
42
|
+
const m = /^x-codex-(primary|secondary)-(.+)$/i.exec(key);
|
|
43
|
+
if (!m) continue;
|
|
44
|
+
found = true;
|
|
45
|
+
wins[m[1].toLowerCase()][m[2].toLowerCase()] = Number(value);
|
|
46
|
+
}
|
|
47
|
+
if (!found) return null;
|
|
48
|
+
const at = Date.now();
|
|
49
|
+
const win = (w) => {
|
|
50
|
+
const pct = w['used-percent'] ?? w['used_percent'];
|
|
51
|
+
if (typeof pct !== 'number' || Number.isNaN(pct)) return null;
|
|
52
|
+
const resets = w['resets-in-seconds'] ?? w['reset-after-seconds'] ?? w['resets_in_seconds'];
|
|
53
|
+
return {
|
|
54
|
+
pct,
|
|
55
|
+
resetAt: typeof resets === 'number' && !Number.isNaN(resets) ? at + resets * 1000 : null,
|
|
56
|
+
windowMinutes: w['window-minutes'] ?? null,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const p5h = win(wins.primary);
|
|
60
|
+
const weekly = win(wins.secondary);
|
|
61
|
+
if (!p5h && !weekly) return null;
|
|
62
|
+
return { p5h, weekly, at };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function swapHeaders(headers, acct, host) {
|
|
66
|
+
const h = {};
|
|
67
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
68
|
+
const lk = k.toLowerCase();
|
|
69
|
+
if (lk === 'host' || lk === 'content-length' || lk === 'connection') continue;
|
|
70
|
+
h[k] = v;
|
|
71
|
+
}
|
|
72
|
+
h.host = host;
|
|
73
|
+
h.authorization = `Bearer ${acct.token}`;
|
|
74
|
+
if (acct.accountId) h['chatgpt-account-id'] = acct.accountId;
|
|
75
|
+
return h;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readBody(req) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let size = 0;
|
|
82
|
+
req.on('data', (c) => {
|
|
83
|
+
size += c.length;
|
|
84
|
+
if (size > MAX_BODY) {
|
|
85
|
+
reject(new Error('request body too large'));
|
|
86
|
+
req.destroy();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
chunks.push(c);
|
|
90
|
+
});
|
|
91
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
92
|
+
req.on('error', reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function forwardOnce(req, body, acct) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const base = upstreamBase();
|
|
99
|
+
const mod = base.protocol === 'https:' ? https : http;
|
|
100
|
+
const up = mod.request(
|
|
101
|
+
{
|
|
102
|
+
hostname: base.hostname,
|
|
103
|
+
port: base.port || (base.protocol === 'https:' ? 443 : 80),
|
|
104
|
+
path: req.url,
|
|
105
|
+
method: req.method,
|
|
106
|
+
headers: { ...swapHeaders(req.headers, acct, base.host), 'content-length': body.length },
|
|
107
|
+
},
|
|
108
|
+
(upRes) => resolve(upRes)
|
|
109
|
+
);
|
|
110
|
+
up.on('error', reject);
|
|
111
|
+
up.end(body);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function startServer({ port = DEFAULT_PORT, log = () => {} } = {}) {
|
|
116
|
+
const server = http.createServer(async (req, res) => {
|
|
117
|
+
if (req.url === '/__codexswitch') {
|
|
118
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
119
|
+
res.end(JSON.stringify({ ok: true, accounts: store.listAccounts().length }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let body;
|
|
123
|
+
try {
|
|
124
|
+
body = await readBody(req);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
res.writeHead(413).end(e.message);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const meta = store.loadMeta();
|
|
130
|
+
const total = store.listAccounts().length;
|
|
131
|
+
const tried = [];
|
|
132
|
+
for (let attempt = 0; attempt < Math.max(1, total); attempt++) {
|
|
133
|
+
const acct = pickAuth(tried);
|
|
134
|
+
if (!acct) {
|
|
135
|
+
res.writeHead(503, { 'content-type': 'application/json' });
|
|
136
|
+
res.end(JSON.stringify({ error: 'codexswitch: no usable account' }));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
tried.push(acct.name);
|
|
140
|
+
let upRes;
|
|
141
|
+
try {
|
|
142
|
+
upRes = await forwardOnce(req, body, acct);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
log('error', `${acct.name}: upstream ${e.message}`);
|
|
145
|
+
res.writeHead(502).end(`codexswitch: upstream error: ${e.message}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const usage = usageFromHeaders(upRes.headers);
|
|
149
|
+
if (usage) store.saveUsage(acct.name, usage);
|
|
150
|
+
if (upRes.statusCode === 429 && tried.length < total) {
|
|
151
|
+
store.markLimited(acct.name, Date.now() + meta.cooldownMinutes * 60000);
|
|
152
|
+
store.logEvent('limit', `proxy: "${acct.name}" got 429 — rotating`);
|
|
153
|
+
log('rotate', `${acct.name} hit 429 -> trying next account`);
|
|
154
|
+
upRes.resume(); // discard
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
log('req', `${acct.name} ${req.method} ${req.url} -> ${upRes.statusCode}`);
|
|
158
|
+
res.writeHead(upRes.statusCode, upRes.headers);
|
|
159
|
+
upRes.pipe(res);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
res.writeHead(503).end('codexswitch: all accounts exhausted');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// WebSocket passthrough: swap auth on the handshake, then pipe raw bytes.
|
|
166
|
+
server.on('upgrade', (req, socket, head) => {
|
|
167
|
+
const acct = pickAuth();
|
|
168
|
+
if (!acct) {
|
|
169
|
+
socket.end('HTTP/1.1 503 Service Unavailable\r\n\r\n');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const base = upstreamBase();
|
|
173
|
+
const isTls = base.protocol === 'https:';
|
|
174
|
+
const upPort = base.port || (isTls ? 443 : 80);
|
|
175
|
+
const upstream = isTls
|
|
176
|
+
? tls.connect({ host: base.hostname, port: upPort, servername: base.hostname })
|
|
177
|
+
: net.connect({ host: base.hostname, port: upPort });
|
|
178
|
+
const onReady = () => {
|
|
179
|
+
const headers = swapHeaders(req.headers, acct, base.host);
|
|
180
|
+
headers.connection = 'Upgrade';
|
|
181
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
182
|
+
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`);
|
|
183
|
+
upstream.write(lines.join('\r\n') + '\r\n\r\n');
|
|
184
|
+
if (head && head.length) upstream.write(head);
|
|
185
|
+
upstream.pipe(socket);
|
|
186
|
+
socket.pipe(upstream);
|
|
187
|
+
log('ws', `${acct.name} ${req.url}`);
|
|
188
|
+
store.logEvent('exec', `proxy ws opened as "${acct.name}"`);
|
|
189
|
+
};
|
|
190
|
+
upstream.on(isTls ? 'secureConnect' : 'connect', onReady);
|
|
191
|
+
const drop = () => {
|
|
192
|
+
socket.destroy();
|
|
193
|
+
upstream.destroy();
|
|
194
|
+
};
|
|
195
|
+
upstream.on('error', drop);
|
|
196
|
+
socket.on('error', drop);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
server.once('error', reject);
|
|
201
|
+
server.listen(port, '127.0.0.1', () => resolve(server));
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Is a codexswitch proxy answering on this port?
|
|
206
|
+
function ping(port) {
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/__codexswitch', timeout: 1500 }, (res) => {
|
|
209
|
+
res.resume();
|
|
210
|
+
resolve(res.statusCode === 200);
|
|
211
|
+
});
|
|
212
|
+
req.on('error', () => resolve(false));
|
|
213
|
+
req.on('timeout', () => {
|
|
214
|
+
req.destroy();
|
|
215
|
+
resolve(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { startServer, ping, DEFAULT_PORT, usageFromHeaders };
|
package/src/runner.js
CHANGED
|
@@ -60,9 +60,9 @@ function assertCodexAvailable() {
|
|
|
60
60
|
// history are shared — except auth.json (per-account, copied from the store)
|
|
61
61
|
// and sqlite databases (excluded: two codex processes writing the same
|
|
62
62
|
// sqlite file through symlinks risks corruption; each profile keeps its own).
|
|
63
|
-
function buildProfile(name) {
|
|
63
|
+
function buildProfile(name, dirName = name) {
|
|
64
64
|
const p = store.paths();
|
|
65
|
-
const profile = path.join(p.profilesDir,
|
|
65
|
+
const profile = path.join(p.profilesDir, dirName);
|
|
66
66
|
ensureDir(profile);
|
|
67
67
|
|
|
68
68
|
// Sessions must exist in the real CODEX_HOME before linking, so that every
|
|
@@ -109,6 +109,26 @@ function buildProfile(name) {
|
|
|
109
109
|
return profile;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Proxy-mode profile: like a normal overlay, but config.toml is a patched
|
|
113
|
+
// copy pointing chatgpt_base_url at the local codexswitch proxy so every
|
|
114
|
+
// codex request flows through it (and rotates per request).
|
|
115
|
+
function buildProxyProfile(name, port) {
|
|
116
|
+
const p = store.paths();
|
|
117
|
+
const profile = buildProfile(name, `${name}.proxy`);
|
|
118
|
+
const cfgDest = path.join(profile, 'config.toml');
|
|
119
|
+
let cfg = '';
|
|
120
|
+
try {
|
|
121
|
+
cfg = fs.readFileSync(path.join(p.codexHome, 'config.toml'), 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
/* no config yet */
|
|
124
|
+
}
|
|
125
|
+
cfg = cfg.replace(/^\s*chatgpt_base_url\s*=.*$/gm, '').trimEnd();
|
|
126
|
+
cfg += `\n\n# managed by codexswitch proxy mode\nchatgpt_base_url = "http://127.0.0.1:${port}/backend-api/"\n`;
|
|
127
|
+
fs.rmSync(cfgDest, { force: true });
|
|
128
|
+
fs.writeFileSync(cfgDest, cfg);
|
|
129
|
+
return profile;
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
function shareable(entry) {
|
|
113
133
|
if (entry === 'auth.json') return false;
|
|
114
134
|
if (/\.sqlite(-wal|-shm|-journal)?$/.test(entry)) return false;
|
|
@@ -165,9 +185,10 @@ function refreshCopy(target, dest) {
|
|
|
165
185
|
// capture=false: interactive, stdio inherited. capture=true: stream output
|
|
166
186
|
// through while also collecting it so the caller can detect limit errors.
|
|
167
187
|
// silent=true (with capture): collect without echoing (used by probe).
|
|
168
|
-
|
|
188
|
+
// proxyPort: route the run through the local codexswitch proxy.
|
|
189
|
+
function runCodex(name, args, { capture = false, silent = false, proxyPort = null } = {}) {
|
|
169
190
|
assertCodexAvailable();
|
|
170
|
-
const profile = buildProfile(name);
|
|
191
|
+
const profile = proxyPort ? buildProxyProfile(name, proxyPort) : buildProfile(name);
|
|
171
192
|
const env = { ...process.env, CODEX_HOME: profile };
|
|
172
193
|
|
|
173
194
|
return new Promise((resolve) => {
|
|
@@ -321,6 +342,7 @@ module.exports = {
|
|
|
321
342
|
spawnCodexSync,
|
|
322
343
|
assertCodexAvailable,
|
|
323
344
|
buildProfile,
|
|
345
|
+
buildProxyProfile,
|
|
324
346
|
runCodex,
|
|
325
347
|
looksRateLimited,
|
|
326
348
|
looksAuthFailed,
|