codex-rotating-proxy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/bin/codex-proxy +10 -0
- package/package.json +18 -0
- package/src/cli.ts +307 -0
- package/src/config.ts +113 -0
- package/src/login.ts +261 -0
- package/src/pool.ts +136 -0
- package/src/server.ts +247 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# codex-rotating-proxy
|
|
2
|
+
|
|
3
|
+
OpenAI API proxy that rotates between multiple ChatGPT/OpenAI accounts when rate limits hit.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌──────────┐ ┌───────────────┐ ┌─────────────┐
|
|
7
|
+
│ your │────▶│ codex-proxy │────▶│ OpenAI API │
|
|
8
|
+
│ tool │◀────│ :4000 │◀────│ │
|
|
9
|
+
└──────────┘ └──────┬────────┘ └─────────────┘
|
|
10
|
+
│
|
|
11
|
+
┌────┴────┐
|
|
12
|
+
│ Pool │
|
|
13
|
+
├─────────┤
|
|
14
|
+
│ acc-1 ● │ ← active
|
|
15
|
+
│ acc-2 ○ │ ← standby
|
|
16
|
+
│ acc-3 ◑ │ ← cooldown
|
|
17
|
+
└─────────┘
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why
|
|
21
|
+
|
|
22
|
+
If you have multiple ChatGPT Plus/Pro subscriptions and keep hitting rate limits in your coding tool, this proxy sits between the tool and the API. It sticks with one account until it hits a limit, then automatically rotates to the next.
|
|
23
|
+
|
|
24
|
+
Works with [opencode](https://opencode.ai), [aider](https://aider.chat), [continue](https://continue.dev), or any tool that talks to an OpenAI-compatible API.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g codex-rotating-proxy
|
|
30
|
+
|
|
31
|
+
# Log in with your OpenAI accounts (opens browser)
|
|
32
|
+
codex-proxy login personal
|
|
33
|
+
codex-proxy login work
|
|
34
|
+
|
|
35
|
+
# Start the proxy
|
|
36
|
+
codex-proxy start
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then point your tool to `http://localhost:4000/v1`.
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
Node.js 22.6+
|
|
44
|
+
|
|
45
|
+
## How login works
|
|
46
|
+
|
|
47
|
+
`codex-proxy login` uses the same OAuth flow as the official [Codex CLI](https://github.com/openai/codex) — it opens your browser to OpenAI's login page, you sign in with your ChatGPT account, and the token is captured automatically. No manual key copying needed.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ codex-proxy login personal
|
|
51
|
+
Opening browser to sign in with OpenAI...
|
|
52
|
+
✓ Connected "personal" (you@email.com)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Tokens are automatically refreshed when they expire. If a token gets a 401 mid-session, the proxy refreshes it transparently and retries.
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
### `codex-proxy login [name]`
|
|
60
|
+
|
|
61
|
+
Opens your browser to sign in with OpenAI. Saves the account for rotation.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
codex-proxy login # name is derived from your email
|
|
65
|
+
codex-proxy login work # explicitly name the account
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `codex-proxy logout <name>`
|
|
69
|
+
|
|
70
|
+
Remove an account from the pool.
|
|
71
|
+
|
|
72
|
+
### `codex-proxy accounts`
|
|
73
|
+
|
|
74
|
+
List all connected accounts with masked tokens.
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
personal sk-1••••Qx2Q 2h ago
|
|
78
|
+
work sk-9••••kPz 5m ago
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `codex-proxy start`
|
|
82
|
+
|
|
83
|
+
Start the proxy server. Foreground by default.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
codex-proxy start # foreground (Ctrl+C to stop)
|
|
87
|
+
codex-proxy start -d # background (daemon)
|
|
88
|
+
codex-proxy start -p 5000 # custom port
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `codex-proxy stop`
|
|
92
|
+
|
|
93
|
+
Stop a running daemon.
|
|
94
|
+
|
|
95
|
+
### `codex-proxy status`
|
|
96
|
+
|
|
97
|
+
Show whether the proxy is running and live account health:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
Proxy running (PID 12345)
|
|
101
|
+
|
|
102
|
+
● personal active 42 req 0 err
|
|
103
|
+
◑ work cooldown 38m 18 req 2 err
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `codex-proxy config`
|
|
107
|
+
|
|
108
|
+
View or update settings:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
codex-proxy config # show current
|
|
112
|
+
codex-proxy config --port 5000 # change port
|
|
113
|
+
codex-proxy config --cooldown 30 # cooldown minutes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Using with opencode
|
|
117
|
+
|
|
118
|
+
Add the proxy as your OpenAI provider base URL in `~/.config/opencode/opencode.json`:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"$schema": "https://opencode.ai/config.json",
|
|
123
|
+
"provider": {
|
|
124
|
+
"openai": {
|
|
125
|
+
"options": {
|
|
126
|
+
"baseURL": "http://localhost:4000/v1"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Then set your model as usual — the proxy forwards whatever model the client requests:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"model": "openai/gpt-4o"
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Start both:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
codex-proxy start -d # start proxy in background
|
|
145
|
+
opencode # start coding
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Using with aider
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
export OPENAI_API_BASE=http://localhost:4000/v1
|
|
152
|
+
export OPENAI_API_KEY=unused # proxy handles auth
|
|
153
|
+
aider
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Using with any OpenAI-compatible tool
|
|
157
|
+
|
|
158
|
+
Set the base URL to `http://localhost:4000/v1`. Set the API key to any non-empty value — the proxy replaces it with the real token from your account pool.
|
|
159
|
+
|
|
160
|
+
## How it works
|
|
161
|
+
|
|
162
|
+
- **Sticky routing** — stays on one account until it hits a rate limit, then rotates to the next
|
|
163
|
+
- **Auto-rotation** — detects HTTP 429, 402, and quota-related 403 responses
|
|
164
|
+
- **Token refresh** — OAuth tokens are automatically refreshed on 401; no manual re-login needed
|
|
165
|
+
- **Streaming** — full SSE streaming support for chat completions
|
|
166
|
+
- **Hot reload** — logging in while the proxy is running adds the new account immediately
|
|
167
|
+
- **Zero dependencies** — just Node.js
|
|
168
|
+
|
|
169
|
+
## Data
|
|
170
|
+
|
|
171
|
+
All data is stored in `~/.codex-proxy/`:
|
|
172
|
+
|
|
173
|
+
| File | Contents |
|
|
174
|
+
|------|----------|
|
|
175
|
+
| `accounts.json` | Account tokens and refresh tokens |
|
|
176
|
+
| `settings.json` | Port, upstream URL, cooldown |
|
|
177
|
+
| `proxy.pid` | PID of running daemon |
|
|
178
|
+
| `proxy.log` | Daemon log output |
|
|
179
|
+
|
|
180
|
+
## License
|
|
181
|
+
|
|
182
|
+
MIT
|
package/bin/codex-proxy
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-rotating-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenAI API proxy that rotates between multiple accounts when rate limits hit",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codex-proxy": "bin/codex-proxy"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "node --experimental-strip-types src/cli.ts",
|
|
11
|
+
"postinstall": "chmod +x bin/codex-proxy 2>/dev/null || true"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["openai", "proxy", "rate-limit", "rotation", "chatgpt", "codex"],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.6.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { addAccount, type Account } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
7
|
+
const ISSUER = "https://auth.openai.com";
|
|
8
|
+
const AUTHORIZE_URL = `${ISSUER}/oauth/authorize`;
|
|
9
|
+
const TOKEN_URL = `${ISSUER}/oauth/token`;
|
|
10
|
+
const SCOPES = "openid profile email offline_access";
|
|
11
|
+
const REFRESH_SCOPES = "openid profile email";
|
|
12
|
+
|
|
13
|
+
// ── PKCE ────────────────────────────────────────────────────────
|
|
14
|
+
function generatePKCE(): { verifier: string; challenge: string } {
|
|
15
|
+
const verifier = randomBytes(64).toString("base64url");
|
|
16
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
17
|
+
return { verifier, challenge };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Login flow ──────────────────────────────────────────────────
|
|
21
|
+
export async function loginFlow(accountName?: string): Promise<void> {
|
|
22
|
+
const { verifier, challenge } = generatePKCE();
|
|
23
|
+
const state = randomBytes(32).toString("base64url");
|
|
24
|
+
|
|
25
|
+
return new Promise<void>((resolve, reject) => {
|
|
26
|
+
let port: number = 1455;
|
|
27
|
+
|
|
28
|
+
const server = createServer(async (req, res) => {
|
|
29
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
30
|
+
|
|
31
|
+
if (url.pathname !== "/auth/callback") {
|
|
32
|
+
res.writeHead(404);
|
|
33
|
+
res.end();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const error = url.searchParams.get("error");
|
|
38
|
+
if (error) {
|
|
39
|
+
const desc = url.searchParams.get("error_description") ?? error;
|
|
40
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
41
|
+
res.end(resultPage(false, desc));
|
|
42
|
+
finish(reject, server, new Error(desc));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (url.searchParams.get("state") !== state) {
|
|
47
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
48
|
+
res.end(resultPage(false, "State mismatch — try again."));
|
|
49
|
+
finish(reject, server, new Error("state mismatch"));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const code = url.searchParams.get("code")!;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Step 1: exchange authorization code for tokens
|
|
57
|
+
const tokens = await tokenRequest({
|
|
58
|
+
grant_type: "authorization_code",
|
|
59
|
+
code,
|
|
60
|
+
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
61
|
+
client_id: CLIENT_ID,
|
|
62
|
+
code_verifier: verifier,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Step 2: exchange id_token for an OpenAI API key
|
|
66
|
+
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
67
|
+
|
|
68
|
+
// Parse JWT for display info + account ID
|
|
69
|
+
const claims = parseJwt(tokens.id_token);
|
|
70
|
+
const email = (claims.email as string) ?? "unknown";
|
|
71
|
+
const authClaims = (claims["https://api.openai.com/auth"] ?? {}) as Record<string, string>;
|
|
72
|
+
const accountId = authClaims.chatgpt_account_id;
|
|
73
|
+
const name = accountName || email.split("@")[0];
|
|
74
|
+
|
|
75
|
+
addAccount({
|
|
76
|
+
name,
|
|
77
|
+
token: apiKey,
|
|
78
|
+
refreshToken: tokens.refresh_token,
|
|
79
|
+
accountId,
|
|
80
|
+
addedAt: new Date().toISOString(),
|
|
81
|
+
lastRefresh: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
85
|
+
res.end(resultPage(true, `Connected as "${name}" (${email})`));
|
|
86
|
+
|
|
87
|
+
console.log(`\x1b[32m✓\x1b[0m Connected "${name}" (${email})`);
|
|
88
|
+
finish(resolve, server);
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
91
|
+
res.end(resultPage(false, err.message));
|
|
92
|
+
finish(reject, server, err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Use port 1455 — same as official Codex CLI (registered redirect_uri)
|
|
97
|
+
server.listen(1455, () => {
|
|
98
|
+
port = 1455;
|
|
99
|
+
|
|
100
|
+
const params = new URLSearchParams({
|
|
101
|
+
response_type: "code",
|
|
102
|
+
client_id: CLIENT_ID,
|
|
103
|
+
redirect_uri: `http://localhost:${port}/auth/callback`,
|
|
104
|
+
scope: SCOPES,
|
|
105
|
+
code_challenge: challenge,
|
|
106
|
+
code_challenge_method: "S256",
|
|
107
|
+
state,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const authUrl = `${AUTHORIZE_URL}?${params}`;
|
|
111
|
+
|
|
112
|
+
console.log(" Opening browser to sign in with OpenAI...");
|
|
113
|
+
console.log(
|
|
114
|
+
` If it doesn't open, visit:\n \x1b[36m${authUrl}\x1b[0m\n`
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
openBrowser(authUrl);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
121
|
+
if (err.code === "EADDRINUSE") {
|
|
122
|
+
console.error(
|
|
123
|
+
"\x1b[31mPort 1455 is in use (maybe Codex CLI is running?).\x1b[0m"
|
|
124
|
+
);
|
|
125
|
+
console.error("Close it and try again.");
|
|
126
|
+
}
|
|
127
|
+
reject(err);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Token refresh ───────────────────────────────────────────────
|
|
133
|
+
export async function refreshAccount(
|
|
134
|
+
account: Account
|
|
135
|
+
): Promise<string | null> {
|
|
136
|
+
if (!account.refreshToken) return null;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const tokens = await tokenRequest({
|
|
140
|
+
client_id: CLIENT_ID,
|
|
141
|
+
grant_type: "refresh_token",
|
|
142
|
+
refresh_token: account.refreshToken,
|
|
143
|
+
scope: REFRESH_SCOPES,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const apiKey = await exchangeForApiKey(tokens.id_token);
|
|
147
|
+
|
|
148
|
+
addAccount({
|
|
149
|
+
...account,
|
|
150
|
+
token: apiKey,
|
|
151
|
+
refreshToken: tokens.refresh_token,
|
|
152
|
+
lastRefresh: new Date().toISOString(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return apiKey;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
162
|
+
async function tokenRequest(
|
|
163
|
+
params: Record<string, string>
|
|
164
|
+
): Promise<{ id_token: string; access_token: string; refresh_token: string }> {
|
|
165
|
+
const res = await fetch(TOKEN_URL, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
168
|
+
body: new URLSearchParams(params).toString(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const text = await res.text();
|
|
173
|
+
throw new Error(`Token request failed (${res.status}): ${text}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return res.json() as any;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function exchangeForApiKey(idToken: string): Promise<string> {
|
|
180
|
+
const res = await tokenRequest({
|
|
181
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
182
|
+
requested_token: "openai-api-key",
|
|
183
|
+
subject_token: idToken,
|
|
184
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
|
|
185
|
+
client_id: CLIENT_ID,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return res.access_token;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseJwt(token: string): Record<string, unknown> {
|
|
192
|
+
const payload = token.split(".")[1];
|
|
193
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function openBrowser(url: string): void {
|
|
197
|
+
const cmd =
|
|
198
|
+
process.platform === "darwin"
|
|
199
|
+
? `open "${url}"`
|
|
200
|
+
: process.platform === "win32"
|
|
201
|
+
? `start "" "${url}"`
|
|
202
|
+
: `xdg-open "${url}"`;
|
|
203
|
+
exec(cmd, () => {});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function finish(
|
|
207
|
+
cb: (value?: any) => void,
|
|
208
|
+
server: ReturnType<typeof createServer>,
|
|
209
|
+
err?: Error
|
|
210
|
+
): void {
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
server.close();
|
|
213
|
+
if (err) cb(err);
|
|
214
|
+
else cb();
|
|
215
|
+
}, 500);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Callback result page ────────────────────────────────────────
|
|
219
|
+
function resultPage(success: boolean, message: string): string {
|
|
220
|
+
const icon = success ? "✓" : "✗";
|
|
221
|
+
const color = success ? "#3fb950" : "#f85149";
|
|
222
|
+
const title = success ? "Connected!" : "Something went wrong";
|
|
223
|
+
const sub = success
|
|
224
|
+
? "You can close this tab and return to your terminal."
|
|
225
|
+
: "Check your terminal for details.";
|
|
226
|
+
|
|
227
|
+
return `<!DOCTYPE html>
|
|
228
|
+
<html lang="en">
|
|
229
|
+
<head>
|
|
230
|
+
<meta charset="utf-8">
|
|
231
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
232
|
+
<title>codex-proxy</title>
|
|
233
|
+
<style>
|
|
234
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
235
|
+
body {
|
|
236
|
+
background: #0d1117; color: #c9d1d9;
|
|
237
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
238
|
+
display: flex; justify-content: center; align-items: center;
|
|
239
|
+
min-height: 100vh; padding: 1rem;
|
|
240
|
+
}
|
|
241
|
+
.card {
|
|
242
|
+
background: #161b22; border: 1px solid #30363d;
|
|
243
|
+
border-radius: 12px; padding: 2.5rem;
|
|
244
|
+
max-width: 420px; width: 100%; text-align: center;
|
|
245
|
+
}
|
|
246
|
+
.icon { font-size: 3rem; color: ${color}; margin-bottom: 0.75rem; }
|
|
247
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; color: ${color}; }
|
|
248
|
+
.msg { color: #c9d1d9; margin-bottom: 1rem; font-size: 0.95rem; }
|
|
249
|
+
.sub { color: #8b949e; font-size: 0.85rem; }
|
|
250
|
+
</style>
|
|
251
|
+
</head>
|
|
252
|
+
<body>
|
|
253
|
+
<div class="card">
|
|
254
|
+
<div class="icon">${icon}</div>
|
|
255
|
+
<h1>${title}</h1>
|
|
256
|
+
<p class="msg">${message}</p>
|
|
257
|
+
<p class="sub">${sub}</p>
|
|
258
|
+
</div>
|
|
259
|
+
</body>
|
|
260
|
+
</html>`;
|
|
261
|
+
}
|
package/src/pool.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Account } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
interface AccountState {
|
|
4
|
+
account: Account;
|
|
5
|
+
status: "ready" | "cooldown";
|
|
6
|
+
cooldownUntil: number;
|
|
7
|
+
totalRequests: number;
|
|
8
|
+
errors: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class AccountPool {
|
|
12
|
+
private states: AccountState[];
|
|
13
|
+
private index = 0;
|
|
14
|
+
private cooldownMs: number;
|
|
15
|
+
|
|
16
|
+
constructor(accounts: Account[], cooldownMinutes: number) {
|
|
17
|
+
this.cooldownMs = cooldownMinutes * 60_000;
|
|
18
|
+
this.states = accounts.map((account) => ({
|
|
19
|
+
account,
|
|
20
|
+
status: "ready" as const,
|
|
21
|
+
cooldownUntil: 0,
|
|
22
|
+
totalRequests: 0,
|
|
23
|
+
errors: 0,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get size(): number {
|
|
28
|
+
return this.states.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Sticky — stays on current account until it hits a limit, then rotates. */
|
|
32
|
+
getNext(): { account: Account; name: string } | null {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
|
|
35
|
+
for (const s of this.states) {
|
|
36
|
+
if (s.status === "cooldown" && now >= s.cooldownUntil) {
|
|
37
|
+
s.status = "ready";
|
|
38
|
+
log("green", `↩ ${s.account.name} back from cooldown`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Prefer current (sticky)
|
|
43
|
+
if (this.states[this.index]?.status === "ready") {
|
|
44
|
+
this.states[this.index].totalRequests++;
|
|
45
|
+
return {
|
|
46
|
+
account: this.states[this.index].account,
|
|
47
|
+
name: this.states[this.index].account.name,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find next ready
|
|
52
|
+
for (let i = 1; i < this.states.length; i++) {
|
|
53
|
+
const idx = (this.index + i) % this.states.length;
|
|
54
|
+
if (this.states[idx].status === "ready") {
|
|
55
|
+
this.index = idx;
|
|
56
|
+
this.states[idx].totalRequests++;
|
|
57
|
+
return {
|
|
58
|
+
account: this.states[idx].account,
|
|
59
|
+
name: this.states[idx].account.name,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
markCooldown(name: string): void {
|
|
68
|
+
const state = this.states.find((s) => s.account.name === name);
|
|
69
|
+
if (!state) return;
|
|
70
|
+
state.status = "cooldown";
|
|
71
|
+
state.cooldownUntil = Date.now() + this.cooldownMs;
|
|
72
|
+
state.errors++;
|
|
73
|
+
const idx = this.states.indexOf(state);
|
|
74
|
+
this.index = (idx + 1) % this.states.length;
|
|
75
|
+
log(
|
|
76
|
+
"yellow",
|
|
77
|
+
`⏸ ${name} → cooldown for ${Math.round(this.cooldownMs / 60_000)}m`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
reload(accounts: Account[]): void {
|
|
82
|
+
// Preserve state for existing accounts, add new ones
|
|
83
|
+
const oldMap = new Map(this.states.map((s) => [s.account.name, s]));
|
|
84
|
+
this.states = accounts.map((account) => {
|
|
85
|
+
const existing = oldMap.get(account.name);
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.account = account; // update token
|
|
88
|
+
return existing;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
account,
|
|
92
|
+
status: "ready" as const,
|
|
93
|
+
cooldownUntil: 0,
|
|
94
|
+
totalRequests: 0,
|
|
95
|
+
errors: 0,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
if (this.index >= this.states.length) this.index = 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
updateToken(name: string, newToken: string): void {
|
|
102
|
+
const state = this.states.find((s) => s.account.name === name);
|
|
103
|
+
if (state) state.account.token = newToken;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getStatus(): object[] {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
return this.states.map((s, i) => ({
|
|
109
|
+
name: s.account.name,
|
|
110
|
+
active: i === this.index && s.status === "ready",
|
|
111
|
+
status: s.status,
|
|
112
|
+
cooldownRemaining:
|
|
113
|
+
s.status === "cooldown"
|
|
114
|
+
? Math.max(0, Math.ceil((s.cooldownUntil - now) / 60_000)) + "m"
|
|
115
|
+
: null,
|
|
116
|
+
totalRequests: s.totalRequests,
|
|
117
|
+
errors: s.errors,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Logging ─────────────────────────────────────────────────────
|
|
123
|
+
const C: Record<string, string> = {
|
|
124
|
+
red: "\x1b[31m",
|
|
125
|
+
green: "\x1b[32m",
|
|
126
|
+
yellow: "\x1b[33m",
|
|
127
|
+
cyan: "\x1b[36m",
|
|
128
|
+
dim: "\x1b[2m",
|
|
129
|
+
reset: "\x1b[0m",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function log(color: string, msg: string): void {
|
|
133
|
+
const c = C[color] ?? "";
|
|
134
|
+
const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
135
|
+
console.log(`${C.dim}${ts}${C.reset} ${c}${msg}${C.reset}`);
|
|
136
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } 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, type Account } from "./config.ts";
|
|
5
|
+
import { refreshAccount } from "./login.ts";
|
|
6
|
+
import { AccountPool, log } from "./pool.ts";
|
|
7
|
+
|
|
8
|
+
const ROTATE_ON = new Set([429, 402]);
|
|
9
|
+
const STRIP_REQ = new Set([
|
|
10
|
+
"host", "authorization", "connection", "content-length",
|
|
11
|
+
"user-agent", "originator",
|
|
12
|
+
]);
|
|
13
|
+
const STRIP_RES = new Set(["transfer-encoding", "connection"]);
|
|
14
|
+
|
|
15
|
+
// ── Codex-style User-Agent ──────────────────────────────────────
|
|
16
|
+
// Map TERM_PROGRAM values to Codex CLI terminal tokens
|
|
17
|
+
const TERMINAL_MAP: Record<string, string> = {
|
|
18
|
+
"iterm.app": "iterm2",
|
|
19
|
+
"iterm": "iterm2",
|
|
20
|
+
"apple_terminal": "apple-terminal",
|
|
21
|
+
"terminal": "apple-terminal",
|
|
22
|
+
"warpterminal": "warp",
|
|
23
|
+
"wezterm": "wezterm",
|
|
24
|
+
"vscode": "vscode",
|
|
25
|
+
"ghostty": "ghostty",
|
|
26
|
+
"alacritty": "alacritty",
|
|
27
|
+
"kitty": "kitty",
|
|
28
|
+
"konsole": "konsole",
|
|
29
|
+
"gnome-terminal": "gnome-terminal",
|
|
30
|
+
"windows terminal": "windows-terminal",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function buildUserAgent(): string {
|
|
34
|
+
let os = osType();
|
|
35
|
+
let ver = release();
|
|
36
|
+
if (os === "Darwin") {
|
|
37
|
+
os = "Mac OS";
|
|
38
|
+
try { ver = execSync("sw_vers -productVersion", { encoding: "utf-8" }).trim(); } 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
|
+
|
|
45
|
+
const CODEX_USER_AGENT = buildUserAgent();
|
|
46
|
+
|
|
47
|
+
function codexHeaders(account: Account): Record<string, string> {
|
|
48
|
+
const h: Record<string, string> = {
|
|
49
|
+
"user-agent": CODEX_USER_AGENT,
|
|
50
|
+
"originator": "codex_cli_rs",
|
|
51
|
+
};
|
|
52
|
+
if (account.accountId) {
|
|
53
|
+
h["chatgpt-account-id"] = account.accountId;
|
|
54
|
+
}
|
|
55
|
+
return h;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startProxy(): void {
|
|
59
|
+
const settings = getSettings();
|
|
60
|
+
const accounts = getAccounts();
|
|
61
|
+
const upstream = settings.upstream.replace(/\/$/, "");
|
|
62
|
+
|
|
63
|
+
if (accounts.length === 0) {
|
|
64
|
+
console.error(
|
|
65
|
+
"\x1b[31mNo accounts configured. Run `codex-proxy login` first.\x1b[0m"
|
|
66
|
+
);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const pool = new AccountPool(accounts, settings.cooldownMinutes);
|
|
71
|
+
|
|
72
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
73
|
+
const url = new URL(req.url ?? "/", `http://localhost:${settings.port}`);
|
|
74
|
+
|
|
75
|
+
// ── Internal endpoints ────────────────────────────────────
|
|
76
|
+
if (url.pathname === "/_status") {
|
|
77
|
+
json(res, 200, { accounts: pool.getStatus() });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (url.pathname === "/_reload") {
|
|
82
|
+
pool.reload(getAccounts());
|
|
83
|
+
log("green", "↻ accounts reloaded");
|
|
84
|
+
json(res, 200, { ok: true, accounts: pool.size });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Buffer body for retries ───────────────────────────────
|
|
89
|
+
const chunks: Buffer[] = [];
|
|
90
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
91
|
+
let body = chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
92
|
+
|
|
93
|
+
// ── Forward headers ───────────────────────────────────────
|
|
94
|
+
const fwdHeaders: Record<string, string> = {};
|
|
95
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
96
|
+
if (v && !STRIP_REQ.has(k.toLowerCase())) {
|
|
97
|
+
fwdHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Try each account ──────────────────────────────────────
|
|
102
|
+
for (let attempt = 0; attempt < pool.size; attempt++) {
|
|
103
|
+
const entry = pool.getNext();
|
|
104
|
+
if (!entry) break;
|
|
105
|
+
|
|
106
|
+
const target = `${upstream}${url.pathname}${url.search}`;
|
|
107
|
+
log("cyan", `→ ${req.method} ${url.pathname} via ${entry.name}`);
|
|
108
|
+
|
|
109
|
+
// Inner loop: try once, and if 401 + refreshable, refresh and retry
|
|
110
|
+
let currentToken = entry.account.token;
|
|
111
|
+
for (let retry = 0; retry < 2; retry++) {
|
|
112
|
+
try {
|
|
113
|
+
const fetchRes = await fetch(target, {
|
|
114
|
+
method: req.method,
|
|
115
|
+
headers: {
|
|
116
|
+
...fwdHeaders,
|
|
117
|
+
...codexHeaders(entry.account),
|
|
118
|
+
authorization: `Bearer ${currentToken}`,
|
|
119
|
+
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
120
|
+
},
|
|
121
|
+
body,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── 401: try token refresh before rotating ────────
|
|
125
|
+
if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
|
|
126
|
+
await fetchRes.text();
|
|
127
|
+
log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
|
|
128
|
+
const newToken = await refreshAccount(entry.account);
|
|
129
|
+
if (newToken) {
|
|
130
|
+
currentToken = newToken;
|
|
131
|
+
entry.account.token = newToken;
|
|
132
|
+
pool.updateToken(entry.name, newToken);
|
|
133
|
+
log("green", `✓ ${entry.name} token refreshed`);
|
|
134
|
+
continue; // retry inner loop
|
|
135
|
+
}
|
|
136
|
+
log("red", `✗ ${entry.name} refresh failed — rotating`);
|
|
137
|
+
pool.markCooldown(entry.name);
|
|
138
|
+
break; // move to next account
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Rate limit / quota → rotate ───────────────────
|
|
142
|
+
if (ROTATE_ON.has(fetchRes.status)) {
|
|
143
|
+
await fetchRes.text();
|
|
144
|
+
log("red", `✗ ${entry.name} hit ${fetchRes.status} — rotating`);
|
|
145
|
+
pool.markCooldown(entry.name);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (fetchRes.status === 403) {
|
|
150
|
+
const text = await fetchRes.text();
|
|
151
|
+
if (/quota|limit|exceeded|rate/i.test(text)) {
|
|
152
|
+
log("red", `✗ ${entry.name} 403 quota — rotating`);
|
|
153
|
+
pool.markCooldown(entry.name);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
log("yellow", `✗ 403 (not quota) — forwarding`);
|
|
157
|
+
forward(res, 403, fetchRes.headers, text);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Stream response back ──────────────────────────
|
|
162
|
+
log("green", `✓ ${fetchRes.status}`);
|
|
163
|
+
const resHeaders: Record<string, string> = {};
|
|
164
|
+
fetchRes.headers.forEach((v, k) => {
|
|
165
|
+
if (!STRIP_RES.has(k.toLowerCase())) resHeaders[k] = v;
|
|
166
|
+
});
|
|
167
|
+
res.writeHead(fetchRes.status, resHeaders);
|
|
168
|
+
|
|
169
|
+
if (fetchRes.body) {
|
|
170
|
+
const reader = (fetchRes.body as ReadableStream<Uint8Array>).getReader();
|
|
171
|
+
try {
|
|
172
|
+
while (true) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
res.write(value);
|
|
176
|
+
}
|
|
177
|
+
} catch {}
|
|
178
|
+
res.end();
|
|
179
|
+
} else {
|
|
180
|
+
res.end();
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
log("red", `✗ ${entry.name} network error: ${err}`);
|
|
185
|
+
pool.markCooldown(entry.name);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
log("red", "✗ all accounts exhausted");
|
|
192
|
+
json(res, 503, {
|
|
193
|
+
error: {
|
|
194
|
+
message: "All accounts exhausted. Check /_status for cooldown times.",
|
|
195
|
+
type: "proxy_error",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── Lifecycle ─────────────────────────────────────────────────
|
|
201
|
+
writePid(process.pid);
|
|
202
|
+
|
|
203
|
+
const shutdown = () => {
|
|
204
|
+
log("yellow", "shutting down...");
|
|
205
|
+
removePid();
|
|
206
|
+
server.close(() => process.exit(0));
|
|
207
|
+
setTimeout(() => process.exit(0), 3000);
|
|
208
|
+
};
|
|
209
|
+
process.on("SIGTERM", shutdown);
|
|
210
|
+
process.on("SIGINT", shutdown);
|
|
211
|
+
|
|
212
|
+
server.listen(settings.port, () => {
|
|
213
|
+
console.log();
|
|
214
|
+
console.log(" \x1b[36mcodex-proxy\x1b[0m");
|
|
215
|
+
console.log(` upstream ${upstream}`);
|
|
216
|
+
console.log(` port ${settings.port}`);
|
|
217
|
+
console.log(` accounts ${accounts.map((a) => a.name).join(", ")}`);
|
|
218
|
+
console.log(` cooldown ${settings.cooldownMinutes}m`);
|
|
219
|
+
console.log();
|
|
220
|
+
log("green", `listening on http://localhost:${settings.port}`);
|
|
221
|
+
console.log();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function json(res: ServerResponse, status: number, data: unknown): void {
|
|
226
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify(data, null, 2));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function forward(
|
|
231
|
+
res: ServerResponse,
|
|
232
|
+
status: number,
|
|
233
|
+
headers: Headers,
|
|
234
|
+
body: string
|
|
235
|
+
): void {
|
|
236
|
+
const h: Record<string, string> = {};
|
|
237
|
+
headers.forEach((v, k) => {
|
|
238
|
+
if (!STRIP_RES.has(k.toLowerCase())) h[k] = v;
|
|
239
|
+
});
|
|
240
|
+
res.writeHead(status, h);
|
|
241
|
+
res.end(body);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Allow running directly for daemon mode
|
|
245
|
+
if (process.env.CODEX_PROXY_DAEMON === "1") {
|
|
246
|
+
startProxy();
|
|
247
|
+
}
|