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 CHANGED
@@ -40,7 +40,7 @@ Then point your tool to `http://localhost:4000/v1`.
40
40
 
41
41
  ## Requirements
42
42
 
43
- Node.js 22.6+
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 = "0.1.0";
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
- const name = positional[0];
158
+ async function cmdLogout() {
159
+ let name = positional[0];
158
160
  if (!name) {
159
- console.log(red("Usage: codex-proxy logout <account-name>"));
160
- return;
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, Math.ceil((s.cooldownUntil - now) / 60_000)) + "m"
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
- const newToken = await refreshAccount(entry.account);
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|exceeded|rate/i.test(text)) {
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
- // Can't parse forward raw
240
- res.writeHead(fetchRes.status, { "content-type": "application/json" });
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",
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"