codex-rotating-proxy 0.1.0 → 0.1.2
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.md +13 -10
- 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 +303 -0
- package/dist/translate.js +248 -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/README.md
CHANGED
|
@@ -115,28 +115,30 @@ codex-proxy config --cooldown 30 # cooldown minutes
|
|
|
115
115
|
|
|
116
116
|
## Using with opencode
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
The built-in `openai` provider ignores `baseURL` overrides for Codex models. Instead, register the proxy as a custom provider using `@ai-sdk/openai-compatible` in `~/.config/opencode/opencode.json`:
|
|
119
119
|
|
|
120
120
|
```json
|
|
121
121
|
{
|
|
122
122
|
"$schema": "https://opencode.ai/config.json",
|
|
123
|
+
"model": "rotating-openai/gpt-5.3-codex",
|
|
123
124
|
"provider": {
|
|
124
|
-
"openai": {
|
|
125
|
+
"rotating-openai": {
|
|
126
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
127
|
+
"name": "Rotating OpenAI",
|
|
125
128
|
"options": {
|
|
126
129
|
"baseURL": "http://localhost:4000/v1"
|
|
130
|
+
},
|
|
131
|
+
"models": {
|
|
132
|
+
"gpt-5.3-codex": {
|
|
133
|
+
"name": "GPT-5.3 Codex"
|
|
134
|
+
}
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
```
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
```json
|
|
136
|
-
{
|
|
137
|
-
"model": "openai/gpt-4o"
|
|
138
|
-
}
|
|
139
|
-
```
|
|
141
|
+
You can add any OpenAI model to the `models` map — the proxy forwards whatever model the client requests.
|
|
140
142
|
|
|
141
143
|
Start both:
|
|
142
144
|
|
|
@@ -162,7 +164,8 @@ Set the base URL to `http://localhost:4000/v1`. Set the API key to any non-empty
|
|
|
162
164
|
- **Sticky routing** — stays on one account until it hits a rate limit, then rotates to the next
|
|
163
165
|
- **Auto-rotation** — detects HTTP 429, 402, and quota-related 403 responses
|
|
164
166
|
- **Token refresh** — OAuth tokens are automatically refreshed on 401; no manual re-login needed
|
|
165
|
-
- **
|
|
167
|
+
- **Chat Completions compatibility** — automatically translates `/v1/chat/completions` requests to the Responses API, so tools that only speak Chat Completions work with Codex models
|
|
168
|
+
- **Streaming** — full SSE streaming support for both chat completions and responses API
|
|
166
169
|
- **Hot reload** — logging in while the proxy is running adds the new account immediately
|
|
167
170
|
- **Zero dependencies** — just Node.js
|
|
168
171
|
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { openSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getAccounts, getSettings, updateSettings, removeAccount, readPid, isRunning, removePid, ensureDataDir, LOG_FILE, } from "./config.js";
|
|
7
|
+
import { loginFlow } from "./login.js";
|
|
8
|
+
import { startProxy } from "./server.js";
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const VERSION = "0.1.0";
|
|
11
|
+
// ── Parse args ──────────────────────────────────────────────────
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
const command = args[0];
|
|
14
|
+
const flags = new Set(args.slice(1));
|
|
15
|
+
const getFlag = (short, long) => {
|
|
16
|
+
for (let i = 1; i < args.length; i++) {
|
|
17
|
+
if (args[i] === short || args[i] === long)
|
|
18
|
+
return args[i + 1];
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
};
|
|
22
|
+
const positional = args.slice(1).filter((a) => !a.startsWith("-"));
|
|
23
|
+
// ── Colors ──────────────────────────────────────────────────────
|
|
24
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
25
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
26
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
27
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
28
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
29
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
30
|
+
// ── Commands ────────────────────────────────────────────────────
|
|
31
|
+
async function main() {
|
|
32
|
+
ensureDataDir();
|
|
33
|
+
switch (command) {
|
|
34
|
+
case "start":
|
|
35
|
+
return cmdStart();
|
|
36
|
+
case "stop":
|
|
37
|
+
return cmdStop();
|
|
38
|
+
case "status":
|
|
39
|
+
return cmdStatus();
|
|
40
|
+
case "login":
|
|
41
|
+
return cmdLogin();
|
|
42
|
+
case "logout":
|
|
43
|
+
case "remove":
|
|
44
|
+
return cmdLogout();
|
|
45
|
+
case "accounts":
|
|
46
|
+
case "list":
|
|
47
|
+
return cmdAccounts();
|
|
48
|
+
case "config":
|
|
49
|
+
return cmdConfig();
|
|
50
|
+
case "-v":
|
|
51
|
+
case "--version":
|
|
52
|
+
console.log(VERSION);
|
|
53
|
+
return;
|
|
54
|
+
case "-h":
|
|
55
|
+
case "--help":
|
|
56
|
+
case "help":
|
|
57
|
+
case undefined:
|
|
58
|
+
return cmdHelp();
|
|
59
|
+
default:
|
|
60
|
+
console.log(red(`Unknown command: ${command}`));
|
|
61
|
+
console.log(`Run ${cyan("codex-proxy --help")} for usage.`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ── start ───────────────────────────────────────────────────────
|
|
66
|
+
function cmdStart() {
|
|
67
|
+
const pid = readPid();
|
|
68
|
+
if (pid && isRunning(pid)) {
|
|
69
|
+
console.log(yellow(`Proxy already running (PID ${pid}).`));
|
|
70
|
+
console.log(`Run ${cyan("codex-proxy stop")} first, or ${cyan("codex-proxy status")} to check.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Apply flag overrides
|
|
74
|
+
const port = getFlag("-p", "--port");
|
|
75
|
+
const upstream = getFlag("-u", "--upstream");
|
|
76
|
+
if (port)
|
|
77
|
+
updateSettings({ port: parseInt(port) });
|
|
78
|
+
if (upstream)
|
|
79
|
+
updateSettings({ upstream });
|
|
80
|
+
const isDaemon = flags.has("-d") || flags.has("--daemon");
|
|
81
|
+
if (isDaemon) {
|
|
82
|
+
const logFd = openSync(LOG_FILE, "a");
|
|
83
|
+
const child = spawn(process.execPath, [join(__dirname, "server.js")], {
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: ["ignore", logFd, logFd],
|
|
86
|
+
env: { ...process.env, CODEX_PROXY_DAEMON: "1" },
|
|
87
|
+
});
|
|
88
|
+
child.unref();
|
|
89
|
+
const settings = getSettings();
|
|
90
|
+
console.log(green(`✓`) + ` Proxy started in background (PID ${child.pid})`);
|
|
91
|
+
console.log(` ${dim("port")} ${settings.port}`);
|
|
92
|
+
console.log(` ${dim("log")} ${LOG_FILE}`);
|
|
93
|
+
console.log(` ${dim("stop")} codex-proxy stop`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Foreground
|
|
97
|
+
startProxy();
|
|
98
|
+
}
|
|
99
|
+
// ── stop ────────────────────────────────────────────────────────
|
|
100
|
+
async function cmdStop() {
|
|
101
|
+
const pid = readPid();
|
|
102
|
+
if (!pid || !isRunning(pid)) {
|
|
103
|
+
console.log(dim("Proxy is not running."));
|
|
104
|
+
removePid();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
process.kill(pid, "SIGTERM");
|
|
108
|
+
for (let i = 0; i < 30; i++) {
|
|
109
|
+
if (!isRunning(pid))
|
|
110
|
+
break;
|
|
111
|
+
await sleep(100);
|
|
112
|
+
}
|
|
113
|
+
removePid();
|
|
114
|
+
console.log(green("✓") + ` Proxy stopped (PID ${pid})`);
|
|
115
|
+
}
|
|
116
|
+
// ── status ──────────────────────────────────────────────────────
|
|
117
|
+
async function cmdStatus() {
|
|
118
|
+
const pid = readPid();
|
|
119
|
+
const running = pid !== null && isRunning(pid);
|
|
120
|
+
if (!running) {
|
|
121
|
+
console.log(` ${bold("Proxy")} ${dim("stopped")}`);
|
|
122
|
+
if (pid)
|
|
123
|
+
removePid();
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
console.log(` ${bold("Proxy")} ${green("running")} ${dim(`(PID ${pid})`)}`);
|
|
127
|
+
try {
|
|
128
|
+
const settings = getSettings();
|
|
129
|
+
const res = await fetch(`http://localhost:${settings.port}/_status`);
|
|
130
|
+
const data = (await res.json());
|
|
131
|
+
console.log();
|
|
132
|
+
printAccountTable(data.accounts);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
console.log(dim(" Could not reach proxy for live status."));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!running) {
|
|
139
|
+
const accounts = getAccounts();
|
|
140
|
+
if (accounts.length === 0) {
|
|
141
|
+
console.log(`\n No accounts. Run ${cyan("codex-proxy login")} to add one.`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.log(`\n ${bold("Accounts")} ${accounts.length} configured`);
|
|
145
|
+
for (const a of accounts) {
|
|
146
|
+
console.log(` ${a.name} ${dim(maskToken(a.token))}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ── login ───────────────────────────────────────────────────────
|
|
152
|
+
function cmdLogin() {
|
|
153
|
+
return loginFlow(positional[0]);
|
|
154
|
+
}
|
|
155
|
+
// ── logout ──────────────────────────────────────────────────────
|
|
156
|
+
function cmdLogout() {
|
|
157
|
+
const name = positional[0];
|
|
158
|
+
if (!name) {
|
|
159
|
+
console.log(red("Usage: codex-proxy logout <account-name>"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (removeAccount(name)) {
|
|
163
|
+
console.log(green("✓") + ` Removed "${name}"`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
console.log(red(`Account "${name}" not found.`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ── accounts ────────────────────────────────────────────────────
|
|
170
|
+
function cmdAccounts() {
|
|
171
|
+
const accounts = getAccounts();
|
|
172
|
+
if (accounts.length === 0) {
|
|
173
|
+
console.log(dim("No accounts. Run codex-proxy login to add one."));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
console.log();
|
|
177
|
+
for (const a of accounts) {
|
|
178
|
+
const ago = timeAgo(new Date(a.addedAt));
|
|
179
|
+
console.log(` ${bold(a.name.padEnd(16))} ${dim(maskToken(a.token))} ${dim(ago)}`);
|
|
180
|
+
}
|
|
181
|
+
console.log();
|
|
182
|
+
}
|
|
183
|
+
// ── config ──────────────────────────────────────────────────────
|
|
184
|
+
function cmdConfig() {
|
|
185
|
+
const s = getSettings();
|
|
186
|
+
console.log();
|
|
187
|
+
console.log(` ${dim("port")} ${s.port}`);
|
|
188
|
+
console.log(` ${dim("upstream")} ${s.upstream}`);
|
|
189
|
+
console.log(` ${dim("cooldown")} ${s.cooldownMinutes}m`);
|
|
190
|
+
console.log();
|
|
191
|
+
console.log(dim(` Edit: codex-proxy config --port 5000`));
|
|
192
|
+
// Handle inline config changes
|
|
193
|
+
const port = getFlag("-p", "--port") ?? getFlag("--port", "--port");
|
|
194
|
+
const upstream = getFlag("-u", "--upstream") ?? getFlag("--upstream", "--upstream");
|
|
195
|
+
const cooldown = getFlag("-c", "--cooldown") ?? getFlag("--cooldown", "--cooldown");
|
|
196
|
+
const changes = {};
|
|
197
|
+
if (port)
|
|
198
|
+
changes.port = parseInt(port);
|
|
199
|
+
if (upstream)
|
|
200
|
+
changes.upstream = upstream;
|
|
201
|
+
if (cooldown)
|
|
202
|
+
changes.cooldownMinutes = parseInt(cooldown);
|
|
203
|
+
if (Object.keys(changes).length > 0) {
|
|
204
|
+
updateSettings(changes);
|
|
205
|
+
console.log(green("\n ✓ Updated"));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ── help ────────────────────────────────────────────────────────
|
|
209
|
+
function cmdHelp() {
|
|
210
|
+
console.log(`
|
|
211
|
+
${bold("codex-proxy")} ${dim(`v${VERSION}`)} — OpenAI API proxy with account rotation
|
|
212
|
+
|
|
213
|
+
${bold("Usage")}
|
|
214
|
+
codex-proxy <command> [options]
|
|
215
|
+
|
|
216
|
+
${bold("Commands")}
|
|
217
|
+
${cyan("login")} [name] Add an account via browser
|
|
218
|
+
${cyan("logout")} <name> Remove an account
|
|
219
|
+
${cyan("accounts")} List all connected accounts
|
|
220
|
+
${cyan("start")} Start the proxy server
|
|
221
|
+
${cyan("stop")} Stop the proxy server
|
|
222
|
+
${cyan("status")} Show proxy and account status
|
|
223
|
+
${cyan("config")} Show or update configuration
|
|
224
|
+
|
|
225
|
+
${bold("Options")} ${dim("(for start)")}
|
|
226
|
+
-p, --port <n> Port number ${dim("(default: 4000)")}
|
|
227
|
+
-u, --upstream <url> Upstream API URL ${dim("(default: https://api.openai.com)")}
|
|
228
|
+
-d, --daemon Run in background
|
|
229
|
+
|
|
230
|
+
${bold("Quick start")}
|
|
231
|
+
${dim("$")} codex-proxy login
|
|
232
|
+
${dim("$")} codex-proxy login work
|
|
233
|
+
${dim("$")} codex-proxy start
|
|
234
|
+
|
|
235
|
+
${bold("Configure your tool")}
|
|
236
|
+
Point your OpenAI-compatible tool to ${cyan("http://localhost:4000/v1")}
|
|
237
|
+
`);
|
|
238
|
+
}
|
|
239
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
240
|
+
function maskToken(token) {
|
|
241
|
+
if (token.length <= 8)
|
|
242
|
+
return "••••••••";
|
|
243
|
+
return token.slice(0, 4) + "••••" + token.slice(-4);
|
|
244
|
+
}
|
|
245
|
+
function timeAgo(date) {
|
|
246
|
+
const sec = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
247
|
+
if (sec < 60)
|
|
248
|
+
return "just now";
|
|
249
|
+
if (sec < 3600)
|
|
250
|
+
return `${Math.floor(sec / 60)}m ago`;
|
|
251
|
+
if (sec < 86400)
|
|
252
|
+
return `${Math.floor(sec / 3600)}h ago`;
|
|
253
|
+
return `${Math.floor(sec / 86400)}d ago`;
|
|
254
|
+
}
|
|
255
|
+
function printAccountTable(accounts) {
|
|
256
|
+
for (const a of accounts) {
|
|
257
|
+
const indicator = a.status === "ready" ? green("●") : yellow("◑");
|
|
258
|
+
const status = a.status === "ready"
|
|
259
|
+
? a.active
|
|
260
|
+
? green("active")
|
|
261
|
+
: dim("standby")
|
|
262
|
+
: yellow(`cooldown ${a.cooldownRemaining}`);
|
|
263
|
+
const stats = dim(`${String(a.totalRequests).padStart(4)} req ${String(a.errors).padStart(2)} err`);
|
|
264
|
+
console.log(` ${indicator} ${bold(a.name.padEnd(16))} ${status.padEnd(30)} ${stats}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function sleep(ms) {
|
|
268
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
269
|
+
}
|
|
270
|
+
main().catch((err) => {
|
|
271
|
+
console.error(red(err.message));
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
// ── Paths ───────────────────────────────────────────────────────
|
|
5
|
+
export const DATA_DIR = join(homedir(), ".codex-proxy");
|
|
6
|
+
const ACCOUNTS_FILE = join(DATA_DIR, "accounts.json");
|
|
7
|
+
const SETTINGS_FILE = join(DATA_DIR, "settings.json");
|
|
8
|
+
export const PID_FILE = join(DATA_DIR, "proxy.pid");
|
|
9
|
+
export const LOG_FILE = join(DATA_DIR, "proxy.log");
|
|
10
|
+
const DEFAULTS = {
|
|
11
|
+
port: 4000,
|
|
12
|
+
upstream: "https://api.openai.com",
|
|
13
|
+
cooldownMinutes: 60,
|
|
14
|
+
};
|
|
15
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
16
|
+
export function ensureDataDir() {
|
|
17
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
function readJson(path, fallback) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeJson(path, data) {
|
|
28
|
+
ensureDataDir();
|
|
29
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
30
|
+
}
|
|
31
|
+
// ── Accounts ────────────────────────────────────────────────────
|
|
32
|
+
export function getAccounts() {
|
|
33
|
+
return readJson(ACCOUNTS_FILE, { accounts: [] })
|
|
34
|
+
.accounts;
|
|
35
|
+
}
|
|
36
|
+
export function addAccount(account) {
|
|
37
|
+
const accounts = getAccounts();
|
|
38
|
+
const idx = accounts.findIndex((a) => a.name === account.name);
|
|
39
|
+
if (idx >= 0)
|
|
40
|
+
accounts[idx] = account;
|
|
41
|
+
else
|
|
42
|
+
accounts.push(account);
|
|
43
|
+
writeJson(ACCOUNTS_FILE, { accounts });
|
|
44
|
+
}
|
|
45
|
+
export function removeAccount(name) {
|
|
46
|
+
const accounts = getAccounts();
|
|
47
|
+
const filtered = accounts.filter((a) => a.name !== name);
|
|
48
|
+
if (filtered.length === accounts.length)
|
|
49
|
+
return false;
|
|
50
|
+
writeJson(ACCOUNTS_FILE, { accounts: filtered });
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
// ── Settings ────────────────────────────────────────────────────
|
|
54
|
+
export function getSettings() {
|
|
55
|
+
return {
|
|
56
|
+
...DEFAULTS,
|
|
57
|
+
...readJson(SETTINGS_FILE, {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function updateSettings(partial) {
|
|
61
|
+
writeJson(SETTINGS_FILE, { ...getSettings(), ...partial });
|
|
62
|
+
}
|
|
63
|
+
// ── PID ─────────────────────────────────────────────────────────
|
|
64
|
+
export function readPid() {
|
|
65
|
+
try {
|
|
66
|
+
return parseInt(readFileSync(PID_FILE, "utf-8").trim());
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function writePid(pid) {
|
|
73
|
+
ensureDataDir();
|
|
74
|
+
writeFileSync(PID_FILE, String(pid));
|
|
75
|
+
}
|
|
76
|
+
export function removePid() {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(PID_FILE);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
export function isRunning(pid) {
|
|
83
|
+
try {
|
|
84
|
+
process.kill(pid, 0);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { addAccount } from "./config.js";
|
|
5
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
|
+
const ISSUER = "https://auth.openai.com";
|
|
7
|
+
const AUTHORIZE_URL = `${ISSUER}/oauth/authorize`;
|
|
8
|
+
const TOKEN_URL = `${ISSUER}/oauth/token`;
|
|
9
|
+
const SCOPES = "openid profile email offline_access";
|
|
10
|
+
const REFRESH_SCOPES = "openid profile email";
|
|
11
|
+
// ── PKCE ────────────────────────────────────────────────────────
|
|
12
|
+
function generatePKCE() {
|
|
13
|
+
const verifier = randomBytes(64).toString("base64url");
|
|
14
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
15
|
+
return { verifier, challenge };
|
|
16
|
+
}
|
|
17
|
+
// ── Login flow ──────────────────────────────────────────────────
|
|
18
|
+
export async function loginFlow(accountName) {
|
|
19
|
+
const { verifier, challenge } = generatePKCE();
|
|
20
|
+
const state = randomBytes(32).toString("base64url");
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let port = 1455;
|
|
23
|
+
const server = createServer(async (req, res) => {
|
|
24
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
25
|
+
if (url.pathname !== "/auth/callback") {
|
|
26
|
+
res.writeHead(404);
|
|
27
|
+
res.end();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const error = url.searchParams.get("error");
|
|
31
|
+
if (error) {
|
|
32
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
33
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
34
|
+
res.end(resultPage(false, desc));
|
|
35
|
+
finish(reject, server, new Error(desc));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (url.searchParams.get("state") !== state) {
|
|
39
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
40
|
+
res.end(resultPage(false, "State mismatch — try again."));
|
|
41
|
+
finish(reject, server, new Error("state mismatch"));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const code = url.searchParams.get("code");
|
|
45
|
+
try {
|
|
46
|
+
// Step 1: exchange authorization code for tokens
|
|
47
|
+
const tokens = await tokenRequest({
|
|
48
|
+
grant_type: "authorization_code",
|
|
49
|
+
code,
|
|
50
|
+
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
51
|
+
client_id: CLIENT_ID,
|
|
52
|
+
code_verifier: verifier,
|
|
53
|
+
});
|
|
54
|
+
// Step 2: exchange id_token for an OpenAI API key
|
|
55
|
+
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
56
|
+
// Parse JWT for display info + account ID
|
|
57
|
+
const claims = parseJwt(tokens.id_token);
|
|
58
|
+
const email = claims.email ?? "unknown";
|
|
59
|
+
const authClaims = (claims["https://api.openai.com/auth"] ?? {});
|
|
60
|
+
const accountId = authClaims.chatgpt_account_id;
|
|
61
|
+
const name = accountName || email.split("@")[0];
|
|
62
|
+
addAccount({
|
|
63
|
+
name,
|
|
64
|
+
token: apiKey,
|
|
65
|
+
refreshToken: tokens.refresh_token,
|
|
66
|
+
accountId,
|
|
67
|
+
addedAt: new Date().toISOString(),
|
|
68
|
+
lastRefresh: new Date().toISOString(),
|
|
69
|
+
});
|
|
70
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
71
|
+
res.end(resultPage(true, `Connected as "${name}" (${email})`));
|
|
72
|
+
console.log(`\x1b[32m✓\x1b[0m Connected "${name}" (${email})`);
|
|
73
|
+
finish(resolve, server);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
77
|
+
res.end(resultPage(false, err.message));
|
|
78
|
+
finish(reject, server, err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
// Use port 1455 — same as official Codex CLI (registered redirect_uri)
|
|
82
|
+
server.listen(1455, () => {
|
|
83
|
+
port = 1455;
|
|
84
|
+
const params = new URLSearchParams({
|
|
85
|
+
response_type: "code",
|
|
86
|
+
client_id: CLIENT_ID,
|
|
87
|
+
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
88
|
+
scope: SCOPES,
|
|
89
|
+
code_challenge: challenge,
|
|
90
|
+
code_challenge_method: "S256",
|
|
91
|
+
state,
|
|
92
|
+
});
|
|
93
|
+
const authUrl = `${AUTHORIZE_URL}?${params}`;
|
|
94
|
+
console.log(" Opening browser to sign in with OpenAI...");
|
|
95
|
+
console.log(` If it doesn't open, visit:\n \x1b[36m${authUrl}\x1b[0m\n`);
|
|
96
|
+
openBrowser(authUrl);
|
|
97
|
+
});
|
|
98
|
+
server.on("error", (err) => {
|
|
99
|
+
if (err.code === "EADDRINUSE") {
|
|
100
|
+
console.error("\x1b[31mPort 1455 is in use (maybe Codex CLI is running?).\x1b[0m");
|
|
101
|
+
console.error("Close it and try again.");
|
|
102
|
+
}
|
|
103
|
+
reject(err);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
// ── Token refresh ───────────────────────────────────────────────
|
|
108
|
+
export async function refreshAccount(account) {
|
|
109
|
+
if (!account.refreshToken)
|
|
110
|
+
return null;
|
|
111
|
+
try {
|
|
112
|
+
const tokens = await tokenRequest({
|
|
113
|
+
client_id: CLIENT_ID,
|
|
114
|
+
grant_type: "refresh_token",
|
|
115
|
+
refresh_token: account.refreshToken,
|
|
116
|
+
scope: REFRESH_SCOPES,
|
|
117
|
+
});
|
|
118
|
+
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
119
|
+
addAccount({
|
|
120
|
+
...account,
|
|
121
|
+
token: apiKey,
|
|
122
|
+
refreshToken: tokens.refresh_token,
|
|
123
|
+
lastRefresh: new Date().toISOString(),
|
|
124
|
+
});
|
|
125
|
+
return apiKey;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
132
|
+
async function tokenRequest(params) {
|
|
133
|
+
const res = await fetch(TOKEN_URL, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
136
|
+
body: new URLSearchParams(params).toString(),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
const text = await res.text();
|
|
140
|
+
throw new Error(`Token request failed (${res.status}): ${text}`);
|
|
141
|
+
}
|
|
142
|
+
return res.json();
|
|
143
|
+
}
|
|
144
|
+
async function exchangeForApiKey(idToken) {
|
|
145
|
+
const res = await tokenRequest({
|
|
146
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
147
|
+
requested_token: "openai-api-key",
|
|
148
|
+
subject_token: idToken,
|
|
149
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
|
|
150
|
+
client_id: CLIENT_ID,
|
|
151
|
+
});
|
|
152
|
+
return res.access_token;
|
|
153
|
+
}
|
|
154
|
+
function parseJwt(token) {
|
|
155
|
+
const payload = token.split(".")[1];
|
|
156
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
157
|
+
}
|
|
158
|
+
function openBrowser(url) {
|
|
159
|
+
const cmd = process.platform === "darwin"
|
|
160
|
+
? `open "${url}"`
|
|
161
|
+
: process.platform === "win32"
|
|
162
|
+
? `start "" "${url}"`
|
|
163
|
+
: `xdg-open "${url}"`;
|
|
164
|
+
exec(cmd, () => { });
|
|
165
|
+
}
|
|
166
|
+
function finish(cb, server, err) {
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
server.close();
|
|
169
|
+
if (err)
|
|
170
|
+
cb(err);
|
|
171
|
+
else
|
|
172
|
+
cb();
|
|
173
|
+
}, 500);
|
|
174
|
+
}
|
|
175
|
+
// ── Callback result page ────────────────────────────────────────
|
|
176
|
+
function resultPage(success, message) {
|
|
177
|
+
const icon = success ? "✓" : "✗";
|
|
178
|
+
const color = success ? "#3fb950" : "#f85149";
|
|
179
|
+
const title = success ? "Connected!" : "Something went wrong";
|
|
180
|
+
const sub = success
|
|
181
|
+
? "You can close this tab and return to your terminal."
|
|
182
|
+
: "Check your terminal for details.";
|
|
183
|
+
return `<!DOCTYPE html>
|
|
184
|
+
<html lang="en">
|
|
185
|
+
<head>
|
|
186
|
+
<meta charset="utf-8">
|
|
187
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
188
|
+
<title>codex-proxy</title>
|
|
189
|
+
<style>
|
|
190
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
191
|
+
body {
|
|
192
|
+
background: #0d1117; color: #c9d1d9;
|
|
193
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
194
|
+
display: flex; justify-content: center; align-items: center;
|
|
195
|
+
min-height: 100vh; padding: 1rem;
|
|
196
|
+
}
|
|
197
|
+
.card {
|
|
198
|
+
background: #161b22; border: 1px solid #30363d;
|
|
199
|
+
border-radius: 12px; padding: 2.5rem;
|
|
200
|
+
max-width: 420px; width: 100%; text-align: center;
|
|
201
|
+
}
|
|
202
|
+
.icon { font-size: 3rem; color: ${color}; margin-bottom: 0.75rem; }
|
|
203
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; color: ${color}; }
|
|
204
|
+
.msg { color: #c9d1d9; margin-bottom: 1rem; font-size: 0.95rem; }
|
|
205
|
+
.sub { color: #8b949e; font-size: 0.85rem; }
|
|
206
|
+
</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<div class="card">
|
|
210
|
+
<div class="icon">${icon}</div>
|
|
211
|
+
<h1>${title}</h1>
|
|
212
|
+
<p class="msg">${message}</p>
|
|
213
|
+
<p class="sub">${sub}</p>
|
|
214
|
+
</div>
|
|
215
|
+
</body>
|
|
216
|
+
</html>`;
|
|
217
|
+
}
|