codex-rotating-proxy 0.1.0 → 0.1.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/dist/cli.js +273 -0
- package/dist/config.js +90 -0
- package/dist/login.js +217 -0
- package/dist/pool.js +112 -0
- package/dist/server.js +223 -0
- package/package.json +20 -5
- package/bin/codex-proxy +0 -10
- package/src/cli.ts +0 -307
- package/src/config.ts +0 -113
- package/src/login.ts +0 -261
- package/src/pool.ts +0 -136
- package/src/server.ts +0 -247
package/dist/server.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { type as osType, release, arch } from "node:os";
|
|
4
|
+
import { getAccounts, getSettings, writePid, removePid } from "./config.js";
|
|
5
|
+
import { refreshAccount } from "./login.js";
|
|
6
|
+
import { AccountPool, log } from "./pool.js";
|
|
7
|
+
const ROTATE_ON = new Set([429, 402]);
|
|
8
|
+
const STRIP_REQ = new Set([
|
|
9
|
+
"host", "authorization", "connection", "content-length",
|
|
10
|
+
"user-agent", "originator",
|
|
11
|
+
]);
|
|
12
|
+
const STRIP_RES = new Set(["transfer-encoding", "connection"]);
|
|
13
|
+
// ── Codex-style User-Agent ──────────────────────────────────────
|
|
14
|
+
// Map TERM_PROGRAM values to Codex CLI terminal tokens
|
|
15
|
+
const TERMINAL_MAP = {
|
|
16
|
+
"iterm.app": "iterm2",
|
|
17
|
+
"iterm": "iterm2",
|
|
18
|
+
"apple_terminal": "apple-terminal",
|
|
19
|
+
"terminal": "apple-terminal",
|
|
20
|
+
"warpterminal": "warp",
|
|
21
|
+
"wezterm": "wezterm",
|
|
22
|
+
"vscode": "vscode",
|
|
23
|
+
"ghostty": "ghostty",
|
|
24
|
+
"alacritty": "alacritty",
|
|
25
|
+
"kitty": "kitty",
|
|
26
|
+
"konsole": "konsole",
|
|
27
|
+
"gnome-terminal": "gnome-terminal",
|
|
28
|
+
"windows terminal": "windows-terminal",
|
|
29
|
+
};
|
|
30
|
+
function buildUserAgent() {
|
|
31
|
+
let os = osType();
|
|
32
|
+
let ver = release();
|
|
33
|
+
if (os === "Darwin") {
|
|
34
|
+
os = "Mac OS";
|
|
35
|
+
try {
|
|
36
|
+
ver = execSync("sw_vers -productVersion", { encoding: "utf-8" }).trim();
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
}
|
|
40
|
+
const raw = (process.env.TERM_PROGRAM ?? "").toLowerCase().replace(/\.app$/i, "");
|
|
41
|
+
const terminal = TERMINAL_MAP[raw] ?? (raw || "unknown-terminal");
|
|
42
|
+
return `codex_cli_rs/0.1.0 (${os} ${ver}; ${arch()}) ${terminal}`;
|
|
43
|
+
}
|
|
44
|
+
const CODEX_USER_AGENT = buildUserAgent();
|
|
45
|
+
function codexHeaders(account) {
|
|
46
|
+
const h = {
|
|
47
|
+
"user-agent": CODEX_USER_AGENT,
|
|
48
|
+
"originator": "codex_cli_rs",
|
|
49
|
+
};
|
|
50
|
+
if (account.accountId) {
|
|
51
|
+
h["chatgpt-account-id"] = account.accountId;
|
|
52
|
+
}
|
|
53
|
+
return h;
|
|
54
|
+
}
|
|
55
|
+
export function startProxy() {
|
|
56
|
+
const settings = getSettings();
|
|
57
|
+
const accounts = getAccounts();
|
|
58
|
+
const upstream = settings.upstream.replace(/\/$/, "");
|
|
59
|
+
if (accounts.length === 0) {
|
|
60
|
+
console.error("\x1b[31mNo accounts configured. Run `codex-proxy login` first.\x1b[0m");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const pool = new AccountPool(accounts, settings.cooldownMinutes);
|
|
64
|
+
const server = createServer(async (req, res) => {
|
|
65
|
+
const url = new URL(req.url ?? "/", `http://localhost:${settings.port}`);
|
|
66
|
+
// ── Internal endpoints ────────────────────────────────────
|
|
67
|
+
if (url.pathname === "/_status") {
|
|
68
|
+
json(res, 200, { accounts: pool.getStatus() });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (url.pathname === "/_reload") {
|
|
72
|
+
pool.reload(getAccounts());
|
|
73
|
+
log("green", "↻ accounts reloaded");
|
|
74
|
+
json(res, 200, { ok: true, accounts: pool.size });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// ── Buffer body for retries ───────────────────────────────
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for await (const chunk of req)
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
let body = chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
82
|
+
// ── Forward headers ───────────────────────────────────────
|
|
83
|
+
const fwdHeaders = {};
|
|
84
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
85
|
+
if (v && !STRIP_REQ.has(k.toLowerCase())) {
|
|
86
|
+
fwdHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── Try each account ──────────────────────────────────────
|
|
90
|
+
for (let attempt = 0; attempt < pool.size; attempt++) {
|
|
91
|
+
const entry = pool.getNext();
|
|
92
|
+
if (!entry)
|
|
93
|
+
break;
|
|
94
|
+
const target = `${upstream}${url.pathname}${url.search}`;
|
|
95
|
+
log("cyan", `→ ${req.method} ${url.pathname} via ${entry.name}`);
|
|
96
|
+
// Inner loop: try once, and if 401 + refreshable, refresh and retry
|
|
97
|
+
let currentToken = entry.account.token;
|
|
98
|
+
for (let retry = 0; retry < 2; retry++) {
|
|
99
|
+
try {
|
|
100
|
+
const fetchRes = await fetch(target, {
|
|
101
|
+
method: req.method,
|
|
102
|
+
headers: {
|
|
103
|
+
...fwdHeaders,
|
|
104
|
+
...codexHeaders(entry.account),
|
|
105
|
+
authorization: `Bearer ${currentToken}`,
|
|
106
|
+
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
107
|
+
},
|
|
108
|
+
body,
|
|
109
|
+
});
|
|
110
|
+
// ── 401: try token refresh before rotating ────────
|
|
111
|
+
if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
|
|
112
|
+
await fetchRes.text();
|
|
113
|
+
log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
|
|
114
|
+
const newToken = await refreshAccount(entry.account);
|
|
115
|
+
if (newToken) {
|
|
116
|
+
currentToken = newToken;
|
|
117
|
+
entry.account.token = newToken;
|
|
118
|
+
pool.updateToken(entry.name, newToken);
|
|
119
|
+
log("green", `✓ ${entry.name} token refreshed`);
|
|
120
|
+
continue; // retry inner loop
|
|
121
|
+
}
|
|
122
|
+
log("red", `✗ ${entry.name} refresh failed — rotating`);
|
|
123
|
+
pool.markCooldown(entry.name);
|
|
124
|
+
break; // move to next account
|
|
125
|
+
}
|
|
126
|
+
// ── Rate limit / quota → rotate ───────────────────
|
|
127
|
+
if (ROTATE_ON.has(fetchRes.status)) {
|
|
128
|
+
await fetchRes.text();
|
|
129
|
+
log("red", `✗ ${entry.name} hit ${fetchRes.status} — rotating`);
|
|
130
|
+
pool.markCooldown(entry.name);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
if (fetchRes.status === 403) {
|
|
134
|
+
const text = await fetchRes.text();
|
|
135
|
+
if (/quota|limit|exceeded|rate/i.test(text)) {
|
|
136
|
+
log("red", `✗ ${entry.name} 403 quota — rotating`);
|
|
137
|
+
pool.markCooldown(entry.name);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
log("yellow", `✗ 403 (not quota) — forwarding`);
|
|
141
|
+
forward(res, 403, fetchRes.headers, text);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// ── Stream response back ──────────────────────────
|
|
145
|
+
log("green", `✓ ${fetchRes.status}`);
|
|
146
|
+
const resHeaders = {};
|
|
147
|
+
fetchRes.headers.forEach((v, k) => {
|
|
148
|
+
if (!STRIP_RES.has(k.toLowerCase()))
|
|
149
|
+
resHeaders[k] = v;
|
|
150
|
+
});
|
|
151
|
+
res.writeHead(fetchRes.status, resHeaders);
|
|
152
|
+
if (fetchRes.body) {
|
|
153
|
+
const reader = fetchRes.body.getReader();
|
|
154
|
+
try {
|
|
155
|
+
while (true) {
|
|
156
|
+
const { done, value } = await reader.read();
|
|
157
|
+
if (done)
|
|
158
|
+
break;
|
|
159
|
+
res.write(value);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch { }
|
|
163
|
+
res.end();
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
res.end();
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
log("red", `✗ ${entry.name} network error: ${err}`);
|
|
172
|
+
pool.markCooldown(entry.name);
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
log("red", "✗ all accounts exhausted");
|
|
178
|
+
json(res, 503, {
|
|
179
|
+
error: {
|
|
180
|
+
message: "All accounts exhausted. Check /_status for cooldown times.",
|
|
181
|
+
type: "proxy_error",
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// ── Lifecycle ─────────────────────────────────────────────────
|
|
186
|
+
writePid(process.pid);
|
|
187
|
+
const shutdown = () => {
|
|
188
|
+
log("yellow", "shutting down...");
|
|
189
|
+
removePid();
|
|
190
|
+
server.close(() => process.exit(0));
|
|
191
|
+
setTimeout(() => process.exit(0), 3000);
|
|
192
|
+
};
|
|
193
|
+
process.on("SIGTERM", shutdown);
|
|
194
|
+
process.on("SIGINT", shutdown);
|
|
195
|
+
server.listen(settings.port, () => {
|
|
196
|
+
console.log();
|
|
197
|
+
console.log(" \x1b[36mcodex-proxy\x1b[0m");
|
|
198
|
+
console.log(` upstream ${upstream}`);
|
|
199
|
+
console.log(` port ${settings.port}`);
|
|
200
|
+
console.log(` accounts ${accounts.map((a) => a.name).join(", ")}`);
|
|
201
|
+
console.log(` cooldown ${settings.cooldownMinutes}m`);
|
|
202
|
+
console.log();
|
|
203
|
+
log("green", `listening on http://localhost:${settings.port}`);
|
|
204
|
+
console.log();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function json(res, status, data) {
|
|
208
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
209
|
+
res.end(JSON.stringify(data, null, 2));
|
|
210
|
+
}
|
|
211
|
+
function forward(res, status, headers, body) {
|
|
212
|
+
const h = {};
|
|
213
|
+
headers.forEach((v, k) => {
|
|
214
|
+
if (!STRIP_RES.has(k.toLowerCase()))
|
|
215
|
+
h[k] = v;
|
|
216
|
+
});
|
|
217
|
+
res.writeHead(status, h);
|
|
218
|
+
res.end(body);
|
|
219
|
+
}
|
|
220
|
+
// Allow running directly for daemon mode
|
|
221
|
+
if (process.env.CODEX_PROXY_DAEMON === "1") {
|
|
222
|
+
startProxy();
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-rotating-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "OpenAI API proxy that rotates between multiple accounts when rate limits hit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"codex-proxy": "
|
|
7
|
+
"codex-proxy": "dist/cli.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
9
12
|
"scripts": {
|
|
13
|
+
"build": "tsc && node -e \"const f='dist/cli.js';const c=require('fs').readFileSync(f,'utf8');require('fs').writeFileSync(f,'#!/usr/bin/env node\\n'+c)\"",
|
|
10
14
|
"dev": "node --experimental-strip-types src/cli.ts",
|
|
11
|
-
"
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
12
16
|
},
|
|
13
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"openai",
|
|
19
|
+
"proxy",
|
|
20
|
+
"rate-limit",
|
|
21
|
+
"rotation",
|
|
22
|
+
"chatgpt",
|
|
23
|
+
"codex"
|
|
24
|
+
],
|
|
14
25
|
"license": "MIT",
|
|
15
26
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.3.0",
|
|
31
|
+
"typescript": "^5.9.3"
|
|
17
32
|
}
|
|
18
33
|
}
|
package/bin/codex-proxy
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Resolve symlinks (npm link creates one)
|
|
3
|
-
SELF="$0"
|
|
4
|
-
while [ -L "$SELF" ]; do
|
|
5
|
-
DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
|
6
|
-
SELF="$(readlink "$SELF")"
|
|
7
|
-
case "$SELF" in /*) ;; *) SELF="$DIR/$SELF" ;; esac
|
|
8
|
-
done
|
|
9
|
-
SCRIPT_DIR="$(cd "$(dirname "$SELF")" && pwd)"
|
|
10
|
-
exec node --experimental-strip-types "$SCRIPT_DIR/../src/cli.ts" "$@"
|
package/src/cli.ts
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { openSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
6
|
-
getAccounts,
|
|
7
|
-
getSettings,
|
|
8
|
-
updateSettings,
|
|
9
|
-
removeAccount,
|
|
10
|
-
readPid,
|
|
11
|
-
isRunning,
|
|
12
|
-
removePid,
|
|
13
|
-
ensureDataDir,
|
|
14
|
-
LOG_FILE,
|
|
15
|
-
} from "./config.ts";
|
|
16
|
-
import { loginFlow } from "./login.ts";
|
|
17
|
-
import { startProxy } from "./server.ts";
|
|
18
|
-
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const VERSION = "0.1.0";
|
|
21
|
-
|
|
22
|
-
// ── Parse args ──────────────────────────────────────────────────
|
|
23
|
-
const args = process.argv.slice(2);
|
|
24
|
-
const command = args[0];
|
|
25
|
-
const flags = new Set(args.slice(1));
|
|
26
|
-
const getFlag = (short: string, long: string): string | undefined => {
|
|
27
|
-
for (let i = 1; i < args.length; i++) {
|
|
28
|
-
if (args[i] === short || args[i] === long) return args[i + 1];
|
|
29
|
-
}
|
|
30
|
-
return undefined;
|
|
31
|
-
};
|
|
32
|
-
const positional = args.slice(1).filter((a) => !a.startsWith("-"));
|
|
33
|
-
|
|
34
|
-
// ── Colors ──────────────────────────────────────────────────────
|
|
35
|
-
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
|
|
36
|
-
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
37
|
-
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
38
|
-
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
39
|
-
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
40
|
-
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
|
|
41
|
-
|
|
42
|
-
// ── Commands ────────────────────────────────────────────────────
|
|
43
|
-
async function main(): Promise<void> {
|
|
44
|
-
ensureDataDir();
|
|
45
|
-
|
|
46
|
-
switch (command) {
|
|
47
|
-
case "start":
|
|
48
|
-
return cmdStart();
|
|
49
|
-
case "stop":
|
|
50
|
-
return cmdStop();
|
|
51
|
-
case "status":
|
|
52
|
-
return cmdStatus();
|
|
53
|
-
case "login":
|
|
54
|
-
return cmdLogin();
|
|
55
|
-
case "logout":
|
|
56
|
-
case "remove":
|
|
57
|
-
return cmdLogout();
|
|
58
|
-
case "accounts":
|
|
59
|
-
case "list":
|
|
60
|
-
return cmdAccounts();
|
|
61
|
-
case "config":
|
|
62
|
-
return cmdConfig();
|
|
63
|
-
case "-v":
|
|
64
|
-
case "--version":
|
|
65
|
-
console.log(VERSION);
|
|
66
|
-
return;
|
|
67
|
-
case "-h":
|
|
68
|
-
case "--help":
|
|
69
|
-
case "help":
|
|
70
|
-
case undefined:
|
|
71
|
-
return cmdHelp();
|
|
72
|
-
default:
|
|
73
|
-
console.log(red(`Unknown command: ${command}`));
|
|
74
|
-
console.log(`Run ${cyan("codex-proxy --help")} for usage.`);
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ── start ───────────────────────────────────────────────────────
|
|
80
|
-
function cmdStart(): void {
|
|
81
|
-
const pid = readPid();
|
|
82
|
-
if (pid && isRunning(pid)) {
|
|
83
|
-
console.log(yellow(`Proxy already running (PID ${pid}).`));
|
|
84
|
-
console.log(`Run ${cyan("codex-proxy stop")} first, or ${cyan("codex-proxy status")} to check.`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Apply flag overrides
|
|
89
|
-
const port = getFlag("-p", "--port");
|
|
90
|
-
const upstream = getFlag("-u", "--upstream");
|
|
91
|
-
if (port) updateSettings({ port: parseInt(port) });
|
|
92
|
-
if (upstream) updateSettings({ upstream });
|
|
93
|
-
|
|
94
|
-
const isDaemon = flags.has("-d") || flags.has("--daemon");
|
|
95
|
-
|
|
96
|
-
if (isDaemon) {
|
|
97
|
-
const logFd = openSync(LOG_FILE, "a");
|
|
98
|
-
const child = spawn(
|
|
99
|
-
process.execPath,
|
|
100
|
-
["--experimental-strip-types", join(__dirname, "server.ts")],
|
|
101
|
-
{
|
|
102
|
-
detached: true,
|
|
103
|
-
stdio: ["ignore", logFd, logFd],
|
|
104
|
-
env: { ...process.env, CODEX_PROXY_DAEMON: "1" },
|
|
105
|
-
}
|
|
106
|
-
);
|
|
107
|
-
child.unref();
|
|
108
|
-
|
|
109
|
-
const settings = getSettings();
|
|
110
|
-
console.log(green(`✓`) + ` Proxy started in background (PID ${child.pid})`);
|
|
111
|
-
console.log(` ${dim("port")} ${settings.port}`);
|
|
112
|
-
console.log(` ${dim("log")} ${LOG_FILE}`);
|
|
113
|
-
console.log(` ${dim("stop")} codex-proxy stop`);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Foreground
|
|
118
|
-
startProxy();
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── stop ────────────────────────────────────────────────────────
|
|
122
|
-
async function cmdStop(): Promise<void> {
|
|
123
|
-
const pid = readPid();
|
|
124
|
-
if (!pid || !isRunning(pid)) {
|
|
125
|
-
console.log(dim("Proxy is not running."));
|
|
126
|
-
removePid();
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
process.kill(pid, "SIGTERM");
|
|
131
|
-
for (let i = 0; i < 30; i++) {
|
|
132
|
-
if (!isRunning(pid)) break;
|
|
133
|
-
await sleep(100);
|
|
134
|
-
}
|
|
135
|
-
removePid();
|
|
136
|
-
console.log(green("✓") + ` Proxy stopped (PID ${pid})`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── status ──────────────────────────────────────────────────────
|
|
140
|
-
async function cmdStatus(): Promise<void> {
|
|
141
|
-
const pid = readPid();
|
|
142
|
-
const running = pid !== null && isRunning(pid);
|
|
143
|
-
|
|
144
|
-
if (!running) {
|
|
145
|
-
console.log(` ${bold("Proxy")} ${dim("stopped")}`);
|
|
146
|
-
if (pid) removePid();
|
|
147
|
-
} else {
|
|
148
|
-
console.log(` ${bold("Proxy")} ${green("running")} ${dim(`(PID ${pid})`)}`);
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
const settings = getSettings();
|
|
152
|
-
const res = await fetch(`http://localhost:${settings.port}/_status`);
|
|
153
|
-
const data = (await res.json()) as { accounts: any[] };
|
|
154
|
-
console.log();
|
|
155
|
-
printAccountTable(data.accounts);
|
|
156
|
-
} catch {
|
|
157
|
-
console.log(dim(" Could not reach proxy for live status."));
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (!running) {
|
|
162
|
-
const accounts = getAccounts();
|
|
163
|
-
if (accounts.length === 0) {
|
|
164
|
-
console.log(
|
|
165
|
-
`\n No accounts. Run ${cyan("codex-proxy login")} to add one.`
|
|
166
|
-
);
|
|
167
|
-
} else {
|
|
168
|
-
console.log(`\n ${bold("Accounts")} ${accounts.length} configured`);
|
|
169
|
-
for (const a of accounts) {
|
|
170
|
-
console.log(` ${a.name} ${dim(maskToken(a.token))}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── login ───────────────────────────────────────────────────────
|
|
177
|
-
function cmdLogin(): Promise<void> {
|
|
178
|
-
return loginFlow(positional[0]);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── logout ──────────────────────────────────────────────────────
|
|
182
|
-
function cmdLogout(): void {
|
|
183
|
-
const name = positional[0];
|
|
184
|
-
if (!name) {
|
|
185
|
-
console.log(red("Usage: codex-proxy logout <account-name>"));
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
if (removeAccount(name)) {
|
|
189
|
-
console.log(green("✓") + ` Removed "${name}"`);
|
|
190
|
-
} else {
|
|
191
|
-
console.log(red(`Account "${name}" not found.`));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// ── accounts ────────────────────────────────────────────────────
|
|
196
|
-
function cmdAccounts(): void {
|
|
197
|
-
const accounts = getAccounts();
|
|
198
|
-
if (accounts.length === 0) {
|
|
199
|
-
console.log(dim("No accounts. Run codex-proxy login to add one."));
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
console.log();
|
|
203
|
-
for (const a of accounts) {
|
|
204
|
-
const ago = timeAgo(new Date(a.addedAt));
|
|
205
|
-
console.log(` ${bold(a.name.padEnd(16))} ${dim(maskToken(a.token))} ${dim(ago)}`);
|
|
206
|
-
}
|
|
207
|
-
console.log();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── config ──────────────────────────────────────────────────────
|
|
211
|
-
function cmdConfig(): void {
|
|
212
|
-
const s = getSettings();
|
|
213
|
-
console.log();
|
|
214
|
-
console.log(` ${dim("port")} ${s.port}`);
|
|
215
|
-
console.log(` ${dim("upstream")} ${s.upstream}`);
|
|
216
|
-
console.log(` ${dim("cooldown")} ${s.cooldownMinutes}m`);
|
|
217
|
-
console.log();
|
|
218
|
-
console.log(
|
|
219
|
-
dim(` Edit: codex-proxy config --port 5000`)
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
// Handle inline config changes
|
|
223
|
-
const port = getFlag("-p", "--port") ?? getFlag("--port", "--port");
|
|
224
|
-
const upstream = getFlag("-u", "--upstream") ?? getFlag("--upstream", "--upstream");
|
|
225
|
-
const cooldown = getFlag("-c", "--cooldown") ?? getFlag("--cooldown", "--cooldown");
|
|
226
|
-
|
|
227
|
-
const changes: Partial<{ port: number; upstream: string; cooldownMinutes: number }> = {};
|
|
228
|
-
if (port) changes.port = parseInt(port);
|
|
229
|
-
if (upstream) changes.upstream = upstream;
|
|
230
|
-
if (cooldown) changes.cooldownMinutes = parseInt(cooldown);
|
|
231
|
-
|
|
232
|
-
if (Object.keys(changes).length > 0) {
|
|
233
|
-
updateSettings(changes);
|
|
234
|
-
console.log(green("\n ✓ Updated"));
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ── help ────────────────────────────────────────────────────────
|
|
239
|
-
function cmdHelp(): void {
|
|
240
|
-
console.log(`
|
|
241
|
-
${bold("codex-proxy")} ${dim(`v${VERSION}`)} — OpenAI API proxy with account rotation
|
|
242
|
-
|
|
243
|
-
${bold("Usage")}
|
|
244
|
-
codex-proxy <command> [options]
|
|
245
|
-
|
|
246
|
-
${bold("Commands")}
|
|
247
|
-
${cyan("login")} [name] Add an account via browser
|
|
248
|
-
${cyan("logout")} <name> Remove an account
|
|
249
|
-
${cyan("accounts")} List all connected accounts
|
|
250
|
-
${cyan("start")} Start the proxy server
|
|
251
|
-
${cyan("stop")} Stop the proxy server
|
|
252
|
-
${cyan("status")} Show proxy and account status
|
|
253
|
-
${cyan("config")} Show or update configuration
|
|
254
|
-
|
|
255
|
-
${bold("Options")} ${dim("(for start)")}
|
|
256
|
-
-p, --port <n> Port number ${dim("(default: 4000)")}
|
|
257
|
-
-u, --upstream <url> Upstream API URL ${dim("(default: https://api.openai.com)")}
|
|
258
|
-
-d, --daemon Run in background
|
|
259
|
-
|
|
260
|
-
${bold("Quick start")}
|
|
261
|
-
${dim("$")} codex-proxy login
|
|
262
|
-
${dim("$")} codex-proxy login work
|
|
263
|
-
${dim("$")} codex-proxy start
|
|
264
|
-
|
|
265
|
-
${bold("Configure your tool")}
|
|
266
|
-
Point your OpenAI-compatible tool to ${cyan("http://localhost:4000/v1")}
|
|
267
|
-
`);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── Helpers ─────────────────────────────────────────────────────
|
|
271
|
-
function maskToken(token: string): string {
|
|
272
|
-
if (token.length <= 8) return "••••••••";
|
|
273
|
-
return token.slice(0, 4) + "••••" + token.slice(-4);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function timeAgo(date: Date): string {
|
|
277
|
-
const sec = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
278
|
-
if (sec < 60) return "just now";
|
|
279
|
-
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
|
|
280
|
-
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
|
|
281
|
-
return `${Math.floor(sec / 86400)}d ago`;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function printAccountTable(accounts: any[]): void {
|
|
285
|
-
for (const a of accounts) {
|
|
286
|
-
const indicator = a.status === "ready" ? green("●") : yellow("◑");
|
|
287
|
-
const status =
|
|
288
|
-
a.status === "ready"
|
|
289
|
-
? a.active
|
|
290
|
-
? green("active")
|
|
291
|
-
: dim("standby")
|
|
292
|
-
: yellow(`cooldown ${a.cooldownRemaining}`);
|
|
293
|
-
const stats = dim(
|
|
294
|
-
`${String(a.totalRequests).padStart(4)} req ${String(a.errors).padStart(2)} err`
|
|
295
|
-
);
|
|
296
|
-
console.log(` ${indicator} ${bold(a.name.padEnd(16))} ${status.padEnd(30)} ${stats}`);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function sleep(ms: number): Promise<void> {
|
|
301
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
main().catch((err) => {
|
|
305
|
-
console.error(red(err.message));
|
|
306
|
-
process.exit(1);
|
|
307
|
-
});
|
package/src/config.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
|
|
5
|
-
// ── Paths ───────────────────────────────────────────────────────
|
|
6
|
-
export const DATA_DIR = join(homedir(), ".codex-proxy");
|
|
7
|
-
const ACCOUNTS_FILE = join(DATA_DIR, "accounts.json");
|
|
8
|
-
const SETTINGS_FILE = join(DATA_DIR, "settings.json");
|
|
9
|
-
export const PID_FILE = join(DATA_DIR, "proxy.pid");
|
|
10
|
-
export const LOG_FILE = join(DATA_DIR, "proxy.log");
|
|
11
|
-
|
|
12
|
-
// ── Types ───────────────────────────────────────────────────────
|
|
13
|
-
export interface Account {
|
|
14
|
-
name: string;
|
|
15
|
-
token: string;
|
|
16
|
-
refreshToken?: string;
|
|
17
|
-
accountId?: string;
|
|
18
|
-
addedAt: string;
|
|
19
|
-
lastRefresh?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface Settings {
|
|
23
|
-
port: number;
|
|
24
|
-
upstream: string;
|
|
25
|
-
cooldownMinutes: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const DEFAULTS: Settings = {
|
|
29
|
-
port: 4000,
|
|
30
|
-
upstream: "https://api.openai.com",
|
|
31
|
-
cooldownMinutes: 60,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// ── Helpers ─────────────────────────────────────────────────────
|
|
35
|
-
export function ensureDataDir(): void {
|
|
36
|
-
mkdirSync(DATA_DIR, { recursive: true });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function readJson<T>(path: string, fallback: T): T {
|
|
40
|
-
try {
|
|
41
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
42
|
-
} catch {
|
|
43
|
-
return fallback;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function writeJson(path: string, data: unknown): void {
|
|
48
|
-
ensureDataDir();
|
|
49
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ── Accounts ────────────────────────────────────────────────────
|
|
53
|
-
export function getAccounts(): Account[] {
|
|
54
|
-
return readJson<{ accounts: Account[] }>(ACCOUNTS_FILE, { accounts: [] })
|
|
55
|
-
.accounts;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function addAccount(account: Account): void {
|
|
59
|
-
const accounts = getAccounts();
|
|
60
|
-
const idx = accounts.findIndex((a) => a.name === account.name);
|
|
61
|
-
if (idx >= 0) accounts[idx] = account;
|
|
62
|
-
else accounts.push(account);
|
|
63
|
-
writeJson(ACCOUNTS_FILE, { accounts });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function removeAccount(name: string): boolean {
|
|
67
|
-
const accounts = getAccounts();
|
|
68
|
-
const filtered = accounts.filter((a) => a.name !== name);
|
|
69
|
-
if (filtered.length === accounts.length) return false;
|
|
70
|
-
writeJson(ACCOUNTS_FILE, { accounts: filtered });
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── Settings ────────────────────────────────────────────────────
|
|
75
|
-
export function getSettings(): Settings {
|
|
76
|
-
return {
|
|
77
|
-
...DEFAULTS,
|
|
78
|
-
...readJson<Partial<Settings>>(SETTINGS_FILE, {}),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function updateSettings(partial: Partial<Settings>): void {
|
|
83
|
-
writeJson(SETTINGS_FILE, { ...getSettings(), ...partial });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── PID ─────────────────────────────────────────────────────────
|
|
87
|
-
export function readPid(): number | null {
|
|
88
|
-
try {
|
|
89
|
-
return parseInt(readFileSync(PID_FILE, "utf-8").trim());
|
|
90
|
-
} catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function writePid(pid: number): void {
|
|
96
|
-
ensureDataDir();
|
|
97
|
-
writeFileSync(PID_FILE, String(pid));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function removePid(): void {
|
|
101
|
-
try {
|
|
102
|
-
unlinkSync(PID_FILE);
|
|
103
|
-
} catch {}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function isRunning(pid: number): boolean {
|
|
107
|
-
try {
|
|
108
|
-
process.kill(pid, 0);
|
|
109
|
-
return true;
|
|
110
|
-
} catch {
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
}
|