@carjms/codexswitch 0.5.0 → 0.6.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 CHANGED
@@ -223,6 +223,8 @@ cxs list # 계정별 5h/week 사용량 % 확인
223
223
  | `cxs restore <파일>` | 백업 파일에서 계정 복원 (다른 PC 이전용) |
224
224
  | `cxs completion <bash\|zsh>` | 셸 자동완성 스크립트 출력 |
225
225
  | `cxs <그 외 명령>` | codex로 그대로 전달 — `cxs resume`, `cxs goal ...`, `cxs apply` 등 codex의 모든 명령을 관리 계정으로 바로 사용 가능 |
226
+ | `cxs server [--port N]` | **실험 기능** 로컬 프록시(teamclaude 방식): 요청마다 인증을 교체하고, 429는 다음 계정으로 재시도, 응답 헤더에서 사용량을 실시간 수집 |
227
+ | `cxs run --proxy [인자]` | codex를 로컬 프록시 경유로 실행 — 대화형 세션 안에서도 요청 단위 로테이션 |
226
228
  | `cxs remove <이름>` | 계정 삭제 |
227
229
  | `cxs rename <옛이름> <새이름>` | 계정 이름 변경 |
228
230
  | `cxs disable / enable <이름>` | 로테이션에서 임시 제외 / 복귀 |
package/README.md CHANGED
@@ -225,6 +225,8 @@ cxs list # per-account 5h/week usage columns
225
225
  | `cxs restore <file>` | Restore accounts from a backup (for moving machines) |
226
226
  | `cxs completion <bash\|zsh>` | Print a shell completion script |
227
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 |
228
230
  | `cxs remove <name>` | Delete an account |
229
231
  | `cxs rename <old> <new>` | Rename an account |
230
232
  | `cxs disable / enable <name>` | Temporarily exclude from / restore to rotation |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carjms/codexswitch",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Multi-account manager for OpenAI Codex CLI with quota-based rotation (inspired by KarpelesLab/teamclaude)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -45,6 +45,9 @@ Running codex
45
45
  the same session (--skip-git-repo-check added automatically)
46
46
  -a, --account <n> start exec with a specific account
47
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
48
51
 
49
52
  Settings
50
53
  model [name|default] show/set the default model injected into run/exec
@@ -638,7 +641,7 @@ function cmdNames() {
638
641
  }
639
642
 
640
643
  const COMMANDS =
641
- 'login import add-key list usage watch probe log chat 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 ' +
642
645
  'enable disable priority clear-limit cooldown threshold patterns export restore sync completion help';
643
646
 
644
647
  function cmdCompletion(args) {
@@ -682,8 +685,49 @@ function injectExecFlags(rest, meta) {
682
685
  return args;
683
686
  }
684
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
+
685
719
  async function cmdRun(args) {
686
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
+ }
687
731
  let name = null;
688
732
  let rest = args;
689
733
  if (args[0] && store.accountExists(args[0])) {
@@ -703,9 +747,9 @@ async function cmdRun(args) {
703
747
  } else if (meta.model && rest.length === 0) {
704
748
  rest = ['-m', meta.model];
705
749
  }
706
- 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}`) : ''}`));
707
751
  const startTs = Date.now();
708
- const res = await runner.runCodex(name, rest);
752
+ const res = await runner.runCodex(name, rest, { proxyPort });
709
753
  runner.recordUsage(name, res.profile, startTs);
710
754
  return res.code;
711
755
  }
@@ -728,6 +772,14 @@ async function cmdExec(args) {
728
772
  if (explicit && !store.accountExists(explicit)) throw new Error(`no such account: ${explicit}`);
729
773
  if (rest[0] === 'resume') allowResume = false; // user drives resume themselves
730
774
 
775
+ // A prompt like "- item one ..." would be parsed as an option by codex;
776
+ // if the last argument starts with "-" but contains whitespace it can only
777
+ // be a prompt, so protect it with a "--" separator.
778
+ if (!rest.includes('--')) {
779
+ const last = rest[rest.length - 1];
780
+ if (last && /^-/.test(last) && /\s/.test(last)) rest.splice(rest.length - 1, 0, '--');
781
+ }
782
+
731
783
  store.syncBack(); // pick up tokens refreshed by plain codex before overlaying
732
784
  const meta = store.loadMeta();
733
785
  const total = store.listAccounts().length;
@@ -751,7 +803,7 @@ async function cmdExec(args) {
751
803
  // On rotation, continue the same session with the next account instead
752
804
  // of restarting the whole prompt — the session files are shared.
753
805
  const codexArgs = useResume
754
- ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), RESUME_PROMPT]
806
+ ? ['exec', 'resume', '--last', ...buildExecArgs([], meta), '--', RESUME_PROMPT]
755
807
  : ['exec', ...buildExecArgs(rest, meta)];
756
808
  const startTs = Date.now();
757
809
  const res = await runner.runCodex(name, codexArgs, { capture: true });
@@ -856,7 +908,9 @@ async function cmdChat() {
856
908
  continue;
857
909
  }
858
910
 
859
- const code = await cmdExec(inSession ? ['resume', '--last', line] : [line]);
911
+ // "--" marks the prompt as positional so lines starting with "-" are
912
+ // never parsed as codex options.
913
+ const code = await cmdExec(inSession ? ['resume', '--last', '--', line] : ['--', line]);
860
914
  if (code === 0) inSession = true;
861
915
  else if (code === 2) out(ui.fail('all accounts exhausted — try again later or /use a specific account'));
862
916
  }
@@ -968,6 +1022,9 @@ async function main(argv) {
968
1022
  case 'chat':
969
1023
  case 'repl':
970
1024
  return cmdChat();
1025
+ case 'server':
1026
+ case 'proxy':
1027
+ return cmdServer(args);
971
1028
  default:
972
1029
  // Forward everything else to codex under the managed account, so
973
1030
  // 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, name);
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
- function runCodex(name, args, { capture = false, silent = false } = {}) {
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,