codex-rotating-proxy 0.1.2 → 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
@@ -51,17 +51,18 @@ export async function loginFlow(accountName) {
51
51
  client_id: CLIENT_ID,
52
52
  code_verifier: verifier,
53
53
  });
54
- // Step 2: exchange id_token for an OpenAI API key
55
- const apiKey = await exchangeForApiKey(tokens.id_token);
54
+ // Use access_token directly (JWT bearer) — NOT an sk-proj-* API key.
55
+ // Codex models like gpt-5.3-codex require the JWT access token,
56
+ // not an exchanged API key.
56
57
  // Parse JWT for display info + account ID
57
58
  const claims = parseJwt(tokens.id_token);
58
59
  const email = claims.email ?? "unknown";
59
60
  const authClaims = (claims["https://api.openai.com/auth"] ?? {});
60
61
  const accountId = authClaims.chatgpt_account_id;
61
- const name = accountName || email.split("@")[0];
62
+ const name = accountName || (email.includes("@") ? email.split("@")[0] : email) || "account";
62
63
  addAccount({
63
64
  name,
64
- token: apiKey,
65
+ token: tokens.access_token,
65
66
  refreshToken: tokens.refresh_token,
66
67
  accountId,
67
68
  addedAt: new Date().toISOString(),
@@ -115,14 +116,13 @@ export async function refreshAccount(account) {
115
116
  refresh_token: account.refreshToken,
116
117
  scope: REFRESH_SCOPES,
117
118
  });
118
- const apiKey = await exchangeForApiKey(tokens.id_token);
119
119
  addAccount({
120
120
  ...account,
121
- token: apiKey,
121
+ token: tokens.access_token,
122
122
  refreshToken: tokens.refresh_token,
123
123
  lastRefresh: new Date().toISOString(),
124
124
  });
125
- return apiKey;
125
+ return tokens.access_token;
126
126
  }
127
127
  catch {
128
128
  return null;
@@ -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,
@@ -83,6 +86,7 @@ export function startProxy() {
83
86
  // ── Detect chat completions → responses translation ─────
84
87
  const isChatCompletions = url.pathname === "/v1/chat/completions" && req.method === "POST";
85
88
  let targetPath = url.pathname;
89
+ let targetBase = upstream;
86
90
  let parsedBody = null;
87
91
  let isStreaming = false;
88
92
  if (isChatCompletions && body) {
@@ -90,9 +94,12 @@ export function startProxy() {
90
94
  parsedBody = JSON.parse(body.toString("utf-8"));
91
95
  isStreaming = !!parsedBody.stream;
92
96
  const translated = chatToResponsesRequest(parsedBody);
97
+ log("cyan", `↔ translating chat/completions → responses (stream=${isStreaming})`);
98
+ log("cyan", ` request: ${JSON.stringify(translated).slice(0, 200)}`);
93
99
  body = Buffer.from(JSON.stringify(translated));
94
- targetPath = "/v1/responses";
95
- log("cyan", `↔ translating chat/completions → responses`);
100
+ // Codex models (gpt-5.x-codex) use ChatGPT backend, not api.openai.com
101
+ targetBase = "https://chatgpt.com/backend-api";
102
+ targetPath = "/codex/responses";
96
103
  }
97
104
  catch (err) {
98
105
  log("red", `✗ failed to parse/translate body: ${err}`);
@@ -111,7 +118,7 @@ export function startProxy() {
111
118
  const entry = pool.getNext();
112
119
  if (!entry)
113
120
  break;
114
- const target = `${upstream}${targetPath}${url.search}`;
121
+ const target = `${targetBase}${targetPath}${url.search}`;
115
122
  log("cyan", `→ ${req.method} ${targetPath} via ${entry.name}`);
116
123
  // Inner loop: try once, and if 401 + refreshable, refresh and retry
117
124
  let currentToken = entry.account.token;
@@ -127,12 +134,19 @@ export function startProxy() {
127
134
  ...(body ? { "content-length": String(body.byteLength) } : {}),
128
135
  },
129
136
  body,
137
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
130
138
  });
131
139
  // ── 401: try token refresh before rotating ────────
132
140
  if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
133
141
  await fetchRes.text();
134
142
  log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
135
- 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;
136
150
  if (newToken) {
137
151
  currentToken = newToken;
138
152
  entry.account.token = newToken;
@@ -153,7 +167,7 @@ export function startProxy() {
153
167
  }
154
168
  if (fetchRes.status === 403) {
155
169
  const text = await fetchRes.text();
156
- if (/quota|limit|exceeded|rate/i.test(text)) {
170
+ if (/rate.limit|quota.exceeded|usage.limit|too.many.requests|capacity/i.test(text)) {
157
171
  log("red", `✗ ${entry.name} 403 quota — rotating`);
158
172
  pool.markCooldown(entry.name);
159
173
  break;
@@ -167,6 +181,10 @@ export function startProxy() {
167
181
  if (isChatCompletions && parsedBody) {
168
182
  if (isStreaming) {
169
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
+ }
170
188
  res.writeHead(200, {
171
189
  "content-type": "text/event-stream",
172
190
  "cache-control": "no-cache",
@@ -176,12 +194,16 @@ export function startProxy() {
176
194
  const reader = fetchRes.body.getReader();
177
195
  const decoder = new TextDecoder();
178
196
  let buffer = "";
197
+ let emittedCount = 0;
179
198
  try {
180
199
  while (true) {
181
200
  const { done, value } = await reader.read();
182
201
  if (done)
183
202
  break;
184
- buffer += decoder.decode(value, { stream: true });
203
+ const raw = decoder.decode(value, { stream: true });
204
+ if (emittedCount === 0)
205
+ log("cyan", ` upstream first chunk: ${raw.slice(0, 300).replace(/\n/g, "\\n")}`);
206
+ buffer += raw;
185
207
  const lines = buffer.split("\n");
186
208
  buffer = lines.pop() ?? "";
187
209
  for (const line of lines) {
@@ -189,35 +211,51 @@ export function startProxy() {
189
211
  if (!trimmed)
190
212
  continue;
191
213
  const translated = translator.feed(trimmed);
192
- for (const out of translated)
214
+ for (const out of translated) {
215
+ if (emittedCount < 3)
216
+ log("cyan", ` emit[${emittedCount}]: ${out.slice(0, 200).replace(/\n/g, "\\n")}`);
193
217
  res.write(out);
218
+ emittedCount++;
219
+ }
194
220
  }
195
221
  }
196
222
  // Process remaining buffer
197
223
  if (buffer.trim()) {
198
224
  const translated = translator.feed(buffer.trim());
199
- for (const out of translated)
225
+ for (const out of translated) {
200
226
  res.write(out);
227
+ emittedCount++;
228
+ }
201
229
  }
202
230
  const flushed = translator.flush();
203
- for (const out of flushed)
231
+ for (const out of flushed) {
204
232
  res.write(out);
233
+ emittedCount++;
234
+ }
205
235
  }
206
- catch { }
236
+ catch (err) {
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 { }
242
+ }
243
+ log("cyan", ` stream done, emitted ${emittedCount} chunks`);
207
244
  res.end();
208
245
  }
209
246
  else {
210
247
  // Non-streaming: buffer full response and translate
211
248
  const text = await fetchRes.text();
249
+ log("cyan", ` upstream response: ${text.slice(0, 300)}`);
212
250
  try {
213
251
  const respBody = JSON.parse(text);
214
252
  const translated = responsesToChatResponse(respBody, parsedBody.model);
253
+ log("cyan", ` translated: ${JSON.stringify(translated).slice(0, 300)}`);
215
254
  json(res, 200, translated);
216
255
  }
217
256
  catch {
218
- // Can't parse forward raw
219
- res.writeHead(fetchRes.status, { "content-type": "application/json" });
220
- 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" } });
221
259
  }
222
260
  }
223
261
  return;
@@ -239,7 +277,9 @@ export function startProxy() {
239
277
  res.write(value);
240
278
  }
241
279
  }
242
- catch { }
280
+ catch (err) {
281
+ log("red", ` pass-through stream error: ${err}`);
282
+ }
243
283
  res.end();
244
284
  }
245
285
  else {
package/dist/translate.js CHANGED
@@ -54,10 +54,7 @@ export function chatToResponsesRequest(body) {
54
54
  out.temperature = body.temperature;
55
55
  if (body.top_p !== undefined)
56
56
  out.top_p = body.top_p;
57
- if (body.max_completion_tokens !== undefined)
58
- out.max_output_tokens = body.max_completion_tokens;
59
- else if (body.max_tokens !== undefined)
60
- out.max_output_tokens = body.max_tokens;
57
+ // Note: ChatGPT backend doesn't support max_output_tokens, skip it
61
58
  if (body.stop !== undefined)
62
59
  out.stop = body.stop;
63
60
  if (body.frequency_penalty !== undefined)
@@ -68,8 +65,7 @@ export function chatToResponsesRequest(body) {
68
65
  out.user = body.user;
69
66
  if (body.parallel_tool_calls !== undefined)
70
67
  out.parallel_tool_calls = body.parallel_tool_calls;
71
- if (body.store !== undefined)
72
- out.store = body.store;
68
+ out.store = false; // ChatGPT backend requires store=false
73
69
  if (body.metadata !== undefined)
74
70
  out.metadata = body.metadata;
75
71
  // reasoning_effort
@@ -155,6 +151,7 @@ export function responsesToChatResponse(resp, model) {
155
151
  export function createStreamTranslator(model) {
156
152
  const id = `chatcmpl-${Date.now()}`;
157
153
  let sentRole = false;
154
+ let sentDone = false;
158
155
  let toolCallIndex = -1;
159
156
  const toolCallIds = new Map(); // item_id → index
160
157
  function chunk(delta, finishReason = null) {
@@ -239,9 +236,40 @@ export function createStreamTranslator(model) {
239
236
  results.push(usageChunk(resp.usage));
240
237
  results.push("data: [DONE]\n\n");
241
238
  }
239
+ else if (type === "error") {
240
+ // Forward API errors as Chat Completions error format
241
+ const err = event.error ?? {};
242
+ results.push(`data: ${JSON.stringify({
243
+ error: {
244
+ message: err.message ?? "Unknown error",
245
+ type: err.type ?? "api_error",
246
+ code: err.code ?? null,
247
+ },
248
+ })}\n\n`);
249
+ results.push("data: [DONE]\n\n");
250
+ }
251
+ else if (type === "response.failed") {
252
+ const resp = event.response;
253
+ const err = resp?.error ?? {};
254
+ if (!sentDone) {
255
+ results.push(`data: ${JSON.stringify({
256
+ error: {
257
+ message: err.message ?? "Response failed",
258
+ type: "api_error",
259
+ code: err.code ?? null,
260
+ },
261
+ })}\n\n`);
262
+ results.push("data: [DONE]\n\n");
263
+ }
264
+ }
265
+ if (results.some(r => r.includes("[DONE]")))
266
+ sentDone = true;
242
267
  return results;
243
268
  },
244
269
  flush() {
270
+ // If stream ended without a proper termination, send [DONE]
271
+ if (!sentDone)
272
+ return ["data: [DONE]\n\n"];
245
273
  return [];
246
274
  },
247
275
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-rotating-proxy",
3
- "version": "0.1.2",
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"