@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 +2 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/cli.js +62 -5
- package/src/proxy.js +220 -0
- package/src/runner.js +26 -4
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
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
|
-
|
|
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,
|
|
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,
|