codex-rotating-proxy 0.1.3 → 0.1.4
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 +8 -1
- package/dist/cli.js +27 -7
- package/dist/config.js +21 -4
- package/dist/login.js +1 -11
- package/dist/pool.js +7 -1
- package/dist/server.js +25 -6
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Then point your tool to `http://localhost:4000/v1`.
|
|
|
40
40
|
|
|
41
41
|
## Requirements
|
|
42
42
|
|
|
43
|
-
Node.js
|
|
43
|
+
Node.js >= 18
|
|
44
44
|
|
|
45
45
|
## How login works
|
|
46
46
|
|
|
@@ -140,6 +140,13 @@ The built-in `openai` provider ignores `baseURL` overrides for Codex models. Ins
|
|
|
140
140
|
|
|
141
141
|
You can add any OpenAI model to the `models` map — the proxy forwards whatever model the client requests.
|
|
142
142
|
|
|
143
|
+
After adding the config, start opencode and connect the provider:
|
|
144
|
+
|
|
145
|
+
1. Run `/connect` in opencode
|
|
146
|
+
2. Search for your custom provider name (e.g. "Rotating OpenAI")
|
|
147
|
+
3. Enter any dummy API key (e.g. `unused`) — the proxy handles auth
|
|
148
|
+
4. Select the model you configured (e.g. `gpt-5.3-codex`)
|
|
149
|
+
|
|
143
150
|
Start both:
|
|
144
151
|
|
|
145
152
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { openSync } from "node:fs";
|
|
3
|
+
import { openSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { getAccounts, getSettings, updateSettings, removeAccount, readPid, isRunning, removePid, ensureDataDir, LOG_FILE, } from "./config.js";
|
|
7
|
+
import { getAccounts, getSettings, updateSettings, removeAccount, readPid, isRunning, removePid, ensureDataDir, rotateLogIfNeeded, LOG_FILE, } from "./config.js";
|
|
7
8
|
import { loginFlow } from "./login.js";
|
|
8
9
|
import { startProxy } from "./server.js";
|
|
9
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
-
const VERSION = "
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
|
|
11
12
|
// ── Parse args ──────────────────────────────────────────────────
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
const command = args[0];
|
|
@@ -79,6 +80,7 @@ function cmdStart() {
|
|
|
79
80
|
updateSettings({ upstream });
|
|
80
81
|
const isDaemon = flags.has("-d") || flags.has("--daemon");
|
|
81
82
|
if (isDaemon) {
|
|
83
|
+
rotateLogIfNeeded();
|
|
82
84
|
const logFd = openSync(LOG_FILE, "a");
|
|
83
85
|
const child = spawn(process.execPath, [join(__dirname, "server.js")], {
|
|
84
86
|
detached: true,
|
|
@@ -153,11 +155,29 @@ function cmdLogin() {
|
|
|
153
155
|
return loginFlow(positional[0]);
|
|
154
156
|
}
|
|
155
157
|
// ── logout ──────────────────────────────────────────────────────
|
|
156
|
-
function cmdLogout() {
|
|
157
|
-
|
|
158
|
+
async function cmdLogout() {
|
|
159
|
+
let name = positional[0];
|
|
158
160
|
if (!name) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
const accounts = getAccounts();
|
|
162
|
+
if (accounts.length === 0) {
|
|
163
|
+
console.log(dim("No accounts to remove."));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log(`\n ${bold("Select an account to remove:")}\n`);
|
|
167
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
168
|
+
console.log(` ${cyan(String(i + 1))} ${accounts[i].name} ${dim(maskToken(accounts[i].token))}`);
|
|
169
|
+
}
|
|
170
|
+
console.log();
|
|
171
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
172
|
+
const answer = await new Promise((resolve) => {
|
|
173
|
+
rl.question(" Enter number: ", (a) => { rl.close(); resolve(a.trim()); });
|
|
174
|
+
});
|
|
175
|
+
const idx = parseInt(answer) - 1;
|
|
176
|
+
if (isNaN(idx) || idx < 0 || idx >= accounts.length) {
|
|
177
|
+
console.log(red("Invalid selection."));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
name = accounts[idx].name;
|
|
161
181
|
}
|
|
162
182
|
if (removeAccount(name)) {
|
|
163
183
|
console.log(green("✓") + ` Removed "${name}"`);
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync, statSync, chmodSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
// ── Paths ───────────────────────────────────────────────────────
|
|
@@ -24,9 +24,11 @@ function readJson(path, fallback) {
|
|
|
24
24
|
return fallback;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
function writeJson(path, data) {
|
|
27
|
+
function writeJson(path, data, sensitive = false) {
|
|
28
28
|
ensureDataDir();
|
|
29
29
|
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
30
|
+
if (sensitive)
|
|
31
|
+
chmodSync(path, 0o600);
|
|
30
32
|
}
|
|
31
33
|
// ── Accounts ────────────────────────────────────────────────────
|
|
32
34
|
export function getAccounts() {
|
|
@@ -40,14 +42,14 @@ export function addAccount(account) {
|
|
|
40
42
|
accounts[idx] = account;
|
|
41
43
|
else
|
|
42
44
|
accounts.push(account);
|
|
43
|
-
writeJson(ACCOUNTS_FILE, { accounts });
|
|
45
|
+
writeJson(ACCOUNTS_FILE, { accounts }, true);
|
|
44
46
|
}
|
|
45
47
|
export function removeAccount(name) {
|
|
46
48
|
const accounts = getAccounts();
|
|
47
49
|
const filtered = accounts.filter((a) => a.name !== name);
|
|
48
50
|
if (filtered.length === accounts.length)
|
|
49
51
|
return false;
|
|
50
|
-
writeJson(ACCOUNTS_FILE, { accounts: filtered });
|
|
52
|
+
writeJson(ACCOUNTS_FILE, { accounts: filtered }, true);
|
|
51
53
|
return true;
|
|
52
54
|
}
|
|
53
55
|
// ── Settings ────────────────────────────────────────────────────
|
|
@@ -88,3 +90,18 @@ export function isRunning(pid) {
|
|
|
88
90
|
return false;
|
|
89
91
|
}
|
|
90
92
|
}
|
|
93
|
+
const MAX_LOG_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
94
|
+
export function rotateLogIfNeeded() {
|
|
95
|
+
try {
|
|
96
|
+
const stats = statSync(LOG_FILE);
|
|
97
|
+
if (stats.size > MAX_LOG_BYTES) {
|
|
98
|
+
const rotated = LOG_FILE + ".1";
|
|
99
|
+
try {
|
|
100
|
+
unlinkSync(rotated);
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
renameSync(LOG_FILE, rotated);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
}
|
package/dist/login.js
CHANGED
|
@@ -59,7 +59,7 @@ export async function loginFlow(accountName) {
|
|
|
59
59
|
const email = claims.email ?? "unknown";
|
|
60
60
|
const authClaims = (claims["https://api.openai.com/auth"] ?? {});
|
|
61
61
|
const accountId = authClaims.chatgpt_account_id;
|
|
62
|
-
const name = accountName || email.split("@")[0];
|
|
62
|
+
const name = accountName || (email.includes("@") ? email.split("@")[0] : email) || "account";
|
|
63
63
|
addAccount({
|
|
64
64
|
name,
|
|
65
65
|
token: tokens.access_token,
|
|
@@ -141,16 +141,6 @@ async function tokenRequest(params) {
|
|
|
141
141
|
}
|
|
142
142
|
return res.json();
|
|
143
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
144
|
function parseJwt(token) {
|
|
155
145
|
const payload = token.split(".")[1];
|
|
156
146
|
return JSON.parse(Buffer.from(payload, "base64url").toString());
|
package/dist/pool.js
CHANGED
|
@@ -89,13 +89,19 @@ export class AccountPool {
|
|
|
89
89
|
active: i === this.index && s.status === "ready",
|
|
90
90
|
status: s.status,
|
|
91
91
|
cooldownRemaining: s.status === "cooldown"
|
|
92
|
-
? Math.max(0,
|
|
92
|
+
? formatCooldown(Math.max(0, s.cooldownUntil - now))
|
|
93
93
|
: null,
|
|
94
94
|
totalRequests: s.totalRequests,
|
|
95
95
|
errors: s.errors,
|
|
96
96
|
}));
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
function formatCooldown(ms) {
|
|
100
|
+
const sec = Math.ceil(ms / 1000);
|
|
101
|
+
if (sec < 60)
|
|
102
|
+
return `${sec}s`;
|
|
103
|
+
return `${Math.ceil(sec / 60)}m`;
|
|
104
|
+
}
|
|
99
105
|
// ── Logging ─────────────────────────────────────────────────────
|
|
100
106
|
const C = {
|
|
101
107
|
red: "\x1b[31m",
|
package/dist/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { refreshAccount } from "./login.js";
|
|
|
6
6
|
import { AccountPool, log } from "./pool.js";
|
|
7
7
|
import { chatToResponsesRequest, responsesToChatResponse, createStreamTranslator } from "./translate.js";
|
|
8
8
|
const ROTATE_ON = new Set([429, 402]);
|
|
9
|
+
const FETCH_TIMEOUT_MS = 120_000; // 2 minute timeout for upstream requests
|
|
9
10
|
const STRIP_REQ = new Set([
|
|
10
11
|
"host", "authorization", "connection", "content-length",
|
|
11
12
|
"user-agent", "originator",
|
|
@@ -43,6 +44,8 @@ function buildUserAgent() {
|
|
|
43
44
|
return `codex_cli_rs/0.1.0 (${os} ${ver}; ${arch()}) ${terminal}`;
|
|
44
45
|
}
|
|
45
46
|
const CODEX_USER_AGENT = buildUserAgent();
|
|
47
|
+
// Dedup concurrent token refreshes per account
|
|
48
|
+
const refreshInFlight = new Map();
|
|
46
49
|
function codexHeaders(account) {
|
|
47
50
|
const h = {
|
|
48
51
|
"user-agent": CODEX_USER_AGENT,
|
|
@@ -131,12 +134,19 @@ export function startProxy() {
|
|
|
131
134
|
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
132
135
|
},
|
|
133
136
|
body,
|
|
137
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
134
138
|
});
|
|
135
139
|
// ── 401: try token refresh before rotating ────────
|
|
136
140
|
if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
|
|
137
141
|
await fetchRes.text();
|
|
138
142
|
log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
|
|
139
|
-
|
|
143
|
+
let refreshPromise = refreshInFlight.get(entry.name);
|
|
144
|
+
if (!refreshPromise) {
|
|
145
|
+
refreshPromise = refreshAccount(entry.account);
|
|
146
|
+
refreshInFlight.set(entry.name, refreshPromise);
|
|
147
|
+
refreshPromise.finally(() => refreshInFlight.delete(entry.name));
|
|
148
|
+
}
|
|
149
|
+
const newToken = await refreshPromise;
|
|
140
150
|
if (newToken) {
|
|
141
151
|
currentToken = newToken;
|
|
142
152
|
entry.account.token = newToken;
|
|
@@ -157,7 +167,7 @@ export function startProxy() {
|
|
|
157
167
|
}
|
|
158
168
|
if (fetchRes.status === 403) {
|
|
159
169
|
const text = await fetchRes.text();
|
|
160
|
-
if (/quota|limit|
|
|
170
|
+
if (/rate.limit|quota.exceeded|usage.limit|too.many.requests|capacity/i.test(text)) {
|
|
161
171
|
log("red", `✗ ${entry.name} 403 quota — rotating`);
|
|
162
172
|
pool.markCooldown(entry.name);
|
|
163
173
|
break;
|
|
@@ -171,6 +181,10 @@ export function startProxy() {
|
|
|
171
181
|
if (isChatCompletions && parsedBody) {
|
|
172
182
|
if (isStreaming) {
|
|
173
183
|
// Streaming: translate Responses SSE → Chat Completions SSE
|
|
184
|
+
if (!fetchRes.body) {
|
|
185
|
+
json(res, 502, { error: { message: "No response body from upstream", type: "proxy_error" } });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
174
188
|
res.writeHead(200, {
|
|
175
189
|
"content-type": "text/event-stream",
|
|
176
190
|
"cache-control": "no-cache",
|
|
@@ -221,6 +235,10 @@ export function startProxy() {
|
|
|
221
235
|
}
|
|
222
236
|
catch (err) {
|
|
223
237
|
log("red", ` stream error: ${err}`);
|
|
238
|
+
try {
|
|
239
|
+
res.write(`data: ${JSON.stringify({ error: { message: "Stream interrupted", type: "proxy_error" } })}\n\n`);
|
|
240
|
+
}
|
|
241
|
+
catch { }
|
|
224
242
|
}
|
|
225
243
|
log("cyan", ` stream done, emitted ${emittedCount} chunks`);
|
|
226
244
|
res.end();
|
|
@@ -236,9 +254,8 @@ export function startProxy() {
|
|
|
236
254
|
json(res, 200, translated);
|
|
237
255
|
}
|
|
238
256
|
catch {
|
|
239
|
-
|
|
240
|
-
res
|
|
241
|
-
res.end(text);
|
|
257
|
+
log("red", ` failed to parse upstream response as JSON`);
|
|
258
|
+
json(res, 502, { error: { message: "Invalid JSON response from upstream", type: "proxy_error" } });
|
|
242
259
|
}
|
|
243
260
|
}
|
|
244
261
|
return;
|
|
@@ -260,7 +277,9 @@ export function startProxy() {
|
|
|
260
277
|
res.write(value);
|
|
261
278
|
}
|
|
262
279
|
}
|
|
263
|
-
catch {
|
|
280
|
+
catch (err) {
|
|
281
|
+
log("red", ` pass-through stream error: ${err}`);
|
|
282
|
+
}
|
|
264
283
|
res.end();
|
|
265
284
|
}
|
|
266
285
|
else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-rotating-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "OpenAI API proxy that rotates between multiple accounts when rate limits hit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"chatgpt",
|
|
23
23
|
"codex"
|
|
24
24
|
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/uvlad23/codex-rotating-proxy.git"
|
|
28
|
+
},
|
|
25
29
|
"license": "MIT",
|
|
26
30
|
"engines": {
|
|
27
31
|
"node": ">=18.0.0"
|