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 +8 -1
- package/dist/cli.js +27 -7
- package/dist/config.js +21 -4
- package/dist/login.js +7 -17
- package/dist/pool.js +7 -1
- package/dist/server.js +54 -14
- package/dist/translate.js +34 -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
|
@@ -51,17 +51,18 @@ export async function loginFlow(accountName) {
|
|
|
51
51
|
client_id: CLIENT_ID,
|
|
52
52
|
code_verifier: verifier,
|
|
53
53
|
});
|
|
54
|
-
//
|
|
55
|
-
|
|
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:
|
|
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:
|
|
121
|
+
token: tokens.access_token,
|
|
122
122
|
refreshToken: tokens.refresh_token,
|
|
123
123
|
lastRefresh: new Date().toISOString(),
|
|
124
124
|
});
|
|
125
|
-
return
|
|
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,
|
|
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
|
-
|
|
95
|
-
|
|
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 = `${
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
res
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|