codex-rotating-proxy 0.1.1 → 0.1.2
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 +13 -10
- package/dist/server.js +83 -3
- package/dist/translate.js +248 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,28 +115,30 @@ codex-proxy config --cooldown 30 # cooldown minutes
|
|
|
115
115
|
|
|
116
116
|
## Using with opencode
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
The built-in `openai` provider ignores `baseURL` overrides for Codex models. Instead, register the proxy as a custom provider using `@ai-sdk/openai-compatible` in `~/.config/opencode/opencode.json`:
|
|
119
119
|
|
|
120
120
|
```json
|
|
121
121
|
{
|
|
122
122
|
"$schema": "https://opencode.ai/config.json",
|
|
123
|
+
"model": "rotating-openai/gpt-5.3-codex",
|
|
123
124
|
"provider": {
|
|
124
|
-
"openai": {
|
|
125
|
+
"rotating-openai": {
|
|
126
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
127
|
+
"name": "Rotating OpenAI",
|
|
125
128
|
"options": {
|
|
126
129
|
"baseURL": "http://localhost:4000/v1"
|
|
130
|
+
},
|
|
131
|
+
"models": {
|
|
132
|
+
"gpt-5.3-codex": {
|
|
133
|
+
"name": "GPT-5.3 Codex"
|
|
134
|
+
}
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
```
|
|
132
140
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
```json
|
|
136
|
-
{
|
|
137
|
-
"model": "openai/gpt-4o"
|
|
138
|
-
}
|
|
139
|
-
```
|
|
141
|
+
You can add any OpenAI model to the `models` map — the proxy forwards whatever model the client requests.
|
|
140
142
|
|
|
141
143
|
Start both:
|
|
142
144
|
|
|
@@ -162,7 +164,8 @@ Set the base URL to `http://localhost:4000/v1`. Set the API key to any non-empty
|
|
|
162
164
|
- **Sticky routing** — stays on one account until it hits a rate limit, then rotates to the next
|
|
163
165
|
- **Auto-rotation** — detects HTTP 429, 402, and quota-related 403 responses
|
|
164
166
|
- **Token refresh** — OAuth tokens are automatically refreshed on 401; no manual re-login needed
|
|
165
|
-
- **
|
|
167
|
+
- **Chat Completions compatibility** — automatically translates `/v1/chat/completions` requests to the Responses API, so tools that only speak Chat Completions work with Codex models
|
|
168
|
+
- **Streaming** — full SSE streaming support for both chat completions and responses API
|
|
166
169
|
- **Hot reload** — logging in while the proxy is running adds the new account immediately
|
|
167
170
|
- **Zero dependencies** — just Node.js
|
|
168
171
|
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { type as osType, release, arch } from "node:os";
|
|
|
4
4
|
import { getAccounts, getSettings, writePid, removePid } from "./config.js";
|
|
5
5
|
import { refreshAccount } from "./login.js";
|
|
6
6
|
import { AccountPool, log } from "./pool.js";
|
|
7
|
+
import { chatToResponsesRequest, responsesToChatResponse, createStreamTranslator } from "./translate.js";
|
|
7
8
|
const ROTATE_ON = new Set([429, 402]);
|
|
8
9
|
const STRIP_REQ = new Set([
|
|
9
10
|
"host", "authorization", "connection", "content-length",
|
|
@@ -79,6 +80,25 @@ export function startProxy() {
|
|
|
79
80
|
for await (const chunk of req)
|
|
80
81
|
chunks.push(chunk);
|
|
81
82
|
let body = chunks.length > 0 ? Buffer.concat(chunks) : null;
|
|
83
|
+
// ── Detect chat completions → responses translation ─────
|
|
84
|
+
const isChatCompletions = url.pathname === "/v1/chat/completions" && req.method === "POST";
|
|
85
|
+
let targetPath = url.pathname;
|
|
86
|
+
let parsedBody = null;
|
|
87
|
+
let isStreaming = false;
|
|
88
|
+
if (isChatCompletions && body) {
|
|
89
|
+
try {
|
|
90
|
+
parsedBody = JSON.parse(body.toString("utf-8"));
|
|
91
|
+
isStreaming = !!parsedBody.stream;
|
|
92
|
+
const translated = chatToResponsesRequest(parsedBody);
|
|
93
|
+
body = Buffer.from(JSON.stringify(translated));
|
|
94
|
+
targetPath = "/v1/responses";
|
|
95
|
+
log("cyan", `↔ translating chat/completions → responses`);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
log("red", `✗ failed to parse/translate body: ${err}`);
|
|
99
|
+
// fall through with original body
|
|
100
|
+
}
|
|
101
|
+
}
|
|
82
102
|
// ── Forward headers ───────────────────────────────────────
|
|
83
103
|
const fwdHeaders = {};
|
|
84
104
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
@@ -91,8 +111,8 @@ export function startProxy() {
|
|
|
91
111
|
const entry = pool.getNext();
|
|
92
112
|
if (!entry)
|
|
93
113
|
break;
|
|
94
|
-
const target = `${upstream}${
|
|
95
|
-
log("cyan", `→ ${req.method} ${
|
|
114
|
+
const target = `${upstream}${targetPath}${url.search}`;
|
|
115
|
+
log("cyan", `→ ${req.method} ${targetPath} via ${entry.name}`);
|
|
96
116
|
// Inner loop: try once, and if 401 + refreshable, refresh and retry
|
|
97
117
|
let currentToken = entry.account.token;
|
|
98
118
|
for (let retry = 0; retry < 2; retry++) {
|
|
@@ -103,6 +123,7 @@ export function startProxy() {
|
|
|
103
123
|
...fwdHeaders,
|
|
104
124
|
...codexHeaders(entry.account),
|
|
105
125
|
authorization: `Bearer ${currentToken}`,
|
|
126
|
+
"accept-encoding": "identity",
|
|
106
127
|
...(body ? { "content-length": String(body.byteLength) } : {}),
|
|
107
128
|
},
|
|
108
129
|
body,
|
|
@@ -141,8 +162,67 @@ export function startProxy() {
|
|
|
141
162
|
forward(res, 403, fetchRes.headers, text);
|
|
142
163
|
return;
|
|
143
164
|
}
|
|
144
|
-
// ── Stream response back ──────────────────────────
|
|
145
165
|
log("green", `✓ ${fetchRes.status}`);
|
|
166
|
+
// ── Translate response if chat completions ─────────
|
|
167
|
+
if (isChatCompletions && parsedBody) {
|
|
168
|
+
if (isStreaming) {
|
|
169
|
+
// Streaming: translate Responses SSE → Chat Completions SSE
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"content-type": "text/event-stream",
|
|
172
|
+
"cache-control": "no-cache",
|
|
173
|
+
"connection": "keep-alive",
|
|
174
|
+
});
|
|
175
|
+
const translator = createStreamTranslator(parsedBody.model);
|
|
176
|
+
const reader = fetchRes.body.getReader();
|
|
177
|
+
const decoder = new TextDecoder();
|
|
178
|
+
let buffer = "";
|
|
179
|
+
try {
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done)
|
|
183
|
+
break;
|
|
184
|
+
buffer += decoder.decode(value, { stream: true });
|
|
185
|
+
const lines = buffer.split("\n");
|
|
186
|
+
buffer = lines.pop() ?? "";
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
const trimmed = line.trim();
|
|
189
|
+
if (!trimmed)
|
|
190
|
+
continue;
|
|
191
|
+
const translated = translator.feed(trimmed);
|
|
192
|
+
for (const out of translated)
|
|
193
|
+
res.write(out);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Process remaining buffer
|
|
197
|
+
if (buffer.trim()) {
|
|
198
|
+
const translated = translator.feed(buffer.trim());
|
|
199
|
+
for (const out of translated)
|
|
200
|
+
res.write(out);
|
|
201
|
+
}
|
|
202
|
+
const flushed = translator.flush();
|
|
203
|
+
for (const out of flushed)
|
|
204
|
+
res.write(out);
|
|
205
|
+
}
|
|
206
|
+
catch { }
|
|
207
|
+
res.end();
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Non-streaming: buffer full response and translate
|
|
211
|
+
const text = await fetchRes.text();
|
|
212
|
+
try {
|
|
213
|
+
const respBody = JSON.parse(text);
|
|
214
|
+
const translated = responsesToChatResponse(respBody, parsedBody.model);
|
|
215
|
+
json(res, 200, translated);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Can't parse — forward raw
|
|
219
|
+
res.writeHead(fetchRes.status, { "content-type": "application/json" });
|
|
220
|
+
res.end(text);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// ── Pass-through (non-translated) ─────────────────
|
|
146
226
|
const resHeaders = {};
|
|
147
227
|
fetchRes.headers.forEach((v, k) => {
|
|
148
228
|
if (!STRIP_RES.has(k.toLowerCase()))
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// ── Chat Completions ↔ Responses API translation layer ─────────────
|
|
2
|
+
// ── Request: Chat Completions → Responses ──────────────────────────
|
|
3
|
+
export function chatToResponsesRequest(body) {
|
|
4
|
+
const out = { model: body.model };
|
|
5
|
+
// Extract system message → instructions
|
|
6
|
+
const messages = body.messages ?? [];
|
|
7
|
+
const systemMsgs = messages.filter((m) => m.role === "system");
|
|
8
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
9
|
+
if (systemMsgs.length > 0) {
|
|
10
|
+
out.instructions = systemMsgs
|
|
11
|
+
.map((m) => typeof m.content === "string" ? m.content : JSON.stringify(m.content))
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
14
|
+
// Convert messages → input
|
|
15
|
+
out.input = [];
|
|
16
|
+
for (const msg of nonSystem) {
|
|
17
|
+
if (msg.role === "user") {
|
|
18
|
+
out.input.push({ role: "user", content: convertInputContent(msg.content) });
|
|
19
|
+
}
|
|
20
|
+
else if (msg.role === "assistant") {
|
|
21
|
+
// Text part as a message item
|
|
22
|
+
if (msg.content) {
|
|
23
|
+
out.input.push({
|
|
24
|
+
type: "message",
|
|
25
|
+
role: "assistant",
|
|
26
|
+
status: "completed",
|
|
27
|
+
content: [{ type: "output_text", text: msg.content, annotations: [] }],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// Tool calls as separate function_call items
|
|
31
|
+
if (msg.tool_calls) {
|
|
32
|
+
for (const tc of msg.tool_calls) {
|
|
33
|
+
out.input.push({
|
|
34
|
+
type: "function_call",
|
|
35
|
+
call_id: tc.id,
|
|
36
|
+
name: tc.function.name,
|
|
37
|
+
arguments: tc.function.arguments,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else if (msg.role === "tool") {
|
|
43
|
+
out.input.push({
|
|
44
|
+
type: "function_call_output",
|
|
45
|
+
call_id: msg.tool_call_id,
|
|
46
|
+
output: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Simple field mappings
|
|
51
|
+
if (body.stream !== undefined)
|
|
52
|
+
out.stream = body.stream;
|
|
53
|
+
if (body.temperature !== undefined)
|
|
54
|
+
out.temperature = body.temperature;
|
|
55
|
+
if (body.top_p !== undefined)
|
|
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;
|
|
61
|
+
if (body.stop !== undefined)
|
|
62
|
+
out.stop = body.stop;
|
|
63
|
+
if (body.frequency_penalty !== undefined)
|
|
64
|
+
out.frequency_penalty = body.frequency_penalty;
|
|
65
|
+
if (body.presence_penalty !== undefined)
|
|
66
|
+
out.presence_penalty = body.presence_penalty;
|
|
67
|
+
if (body.user !== undefined)
|
|
68
|
+
out.user = body.user;
|
|
69
|
+
if (body.parallel_tool_calls !== undefined)
|
|
70
|
+
out.parallel_tool_calls = body.parallel_tool_calls;
|
|
71
|
+
if (body.store !== undefined)
|
|
72
|
+
out.store = body.store;
|
|
73
|
+
if (body.metadata !== undefined)
|
|
74
|
+
out.metadata = body.metadata;
|
|
75
|
+
// reasoning_effort
|
|
76
|
+
if (body.reasoning_effort !== undefined) {
|
|
77
|
+
out.reasoning = { effort: body.reasoning_effort };
|
|
78
|
+
}
|
|
79
|
+
// response_format → text.format
|
|
80
|
+
if (body.response_format) {
|
|
81
|
+
out.text = { format: body.response_format };
|
|
82
|
+
}
|
|
83
|
+
// tools: unwrap function wrapper
|
|
84
|
+
if (body.tools) {
|
|
85
|
+
out.tools = body.tools.map((t) => {
|
|
86
|
+
if (t.type === "function" && t.function) {
|
|
87
|
+
return { type: "function", ...t.function };
|
|
88
|
+
}
|
|
89
|
+
return t;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// tool_choice: translate object form
|
|
93
|
+
if (body.tool_choice !== undefined) {
|
|
94
|
+
if (typeof body.tool_choice === "object" && body.tool_choice.function) {
|
|
95
|
+
out.tool_choice = { type: "function", name: body.tool_choice.function.name };
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
out.tool_choice = body.tool_choice;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function convertInputContent(content) {
|
|
104
|
+
if (typeof content === "string")
|
|
105
|
+
return content;
|
|
106
|
+
if (!Array.isArray(content))
|
|
107
|
+
return content;
|
|
108
|
+
return content.map((part) => {
|
|
109
|
+
if (part.type === "text")
|
|
110
|
+
return { type: "input_text", text: part.text };
|
|
111
|
+
if (part.type === "image_url")
|
|
112
|
+
return { type: "input_image", image_url: part.image_url.url ?? part.image_url };
|
|
113
|
+
return part;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// ── Response: Responses → Chat Completions (non-streaming) ─────────
|
|
117
|
+
export function responsesToChatResponse(resp, model) {
|
|
118
|
+
const output = resp.output ?? [];
|
|
119
|
+
let textContent = "";
|
|
120
|
+
const toolCalls = [];
|
|
121
|
+
for (const item of output) {
|
|
122
|
+
if (item.type === "message" && item.content) {
|
|
123
|
+
for (const part of item.content) {
|
|
124
|
+
if (part.type === "output_text")
|
|
125
|
+
textContent += part.text;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (item.type === "function_call") {
|
|
129
|
+
toolCalls.push({
|
|
130
|
+
id: item.call_id,
|
|
131
|
+
type: "function",
|
|
132
|
+
function: { name: item.name, arguments: item.arguments },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const finishReason = toolCalls.length > 0 ? "tool_calls" :
|
|
137
|
+
resp.status === "completed" ? "stop" :
|
|
138
|
+
resp.status === "incomplete" ? "length" : "stop";
|
|
139
|
+
const message = { role: "assistant", content: textContent || null };
|
|
140
|
+
if (toolCalls.length > 0)
|
|
141
|
+
message.tool_calls = toolCalls;
|
|
142
|
+
return {
|
|
143
|
+
id: resp.id?.replace(/^resp_/, "chatcmpl-") ?? "chatcmpl-proxy",
|
|
144
|
+
object: "chat.completion",
|
|
145
|
+
created: Math.floor(resp.created_at ?? Date.now() / 1000),
|
|
146
|
+
model: resp.model ?? model,
|
|
147
|
+
choices: [{ index: 0, message, finish_reason: finishReason, logprobs: null }],
|
|
148
|
+
usage: resp.usage ? {
|
|
149
|
+
prompt_tokens: resp.usage.input_tokens ?? 0,
|
|
150
|
+
completion_tokens: resp.usage.output_tokens ?? 0,
|
|
151
|
+
total_tokens: resp.usage.total_tokens ?? 0,
|
|
152
|
+
} : undefined,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function createStreamTranslator(model) {
|
|
156
|
+
const id = `chatcmpl-${Date.now()}`;
|
|
157
|
+
let sentRole = false;
|
|
158
|
+
let toolCallIndex = -1;
|
|
159
|
+
const toolCallIds = new Map(); // item_id → index
|
|
160
|
+
function chunk(delta, finishReason = null) {
|
|
161
|
+
return `data: ${JSON.stringify({
|
|
162
|
+
id,
|
|
163
|
+
object: "chat.completion.chunk",
|
|
164
|
+
created: Math.floor(Date.now() / 1000),
|
|
165
|
+
model,
|
|
166
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
167
|
+
})}\n\n`;
|
|
168
|
+
}
|
|
169
|
+
function usageChunk(usage) {
|
|
170
|
+
return `data: ${JSON.stringify({
|
|
171
|
+
id,
|
|
172
|
+
object: "chat.completion.chunk",
|
|
173
|
+
created: Math.floor(Date.now() / 1000),
|
|
174
|
+
model,
|
|
175
|
+
choices: [],
|
|
176
|
+
usage: {
|
|
177
|
+
prompt_tokens: usage.input_tokens ?? 0,
|
|
178
|
+
completion_tokens: usage.output_tokens ?? 0,
|
|
179
|
+
total_tokens: usage.total_tokens ?? 0,
|
|
180
|
+
},
|
|
181
|
+
})}\n\n`;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
feed(line) {
|
|
185
|
+
if (!line.startsWith("data: "))
|
|
186
|
+
return [];
|
|
187
|
+
const jsonStr = line.slice(6).trim();
|
|
188
|
+
if (!jsonStr || jsonStr === "[DONE]")
|
|
189
|
+
return [];
|
|
190
|
+
let event;
|
|
191
|
+
try {
|
|
192
|
+
event = JSON.parse(jsonStr);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const results = [];
|
|
198
|
+
const type = event.type;
|
|
199
|
+
if (type === "response.output_item.added") {
|
|
200
|
+
// Role announcement on first message
|
|
201
|
+
if (event.item?.type === "message" && !sentRole) {
|
|
202
|
+
sentRole = true;
|
|
203
|
+
results.push(chunk({ role: "assistant", content: "" }));
|
|
204
|
+
}
|
|
205
|
+
// Function call start
|
|
206
|
+
if (event.item?.type === "function_call") {
|
|
207
|
+
toolCallIndex++;
|
|
208
|
+
toolCallIds.set(event.item.id, toolCallIndex);
|
|
209
|
+
results.push(chunk({
|
|
210
|
+
tool_calls: [{
|
|
211
|
+
index: toolCallIndex,
|
|
212
|
+
id: event.item.call_id,
|
|
213
|
+
type: "function",
|
|
214
|
+
function: { name: event.item.name, arguments: "" },
|
|
215
|
+
}],
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
else if (type === "response.output_text.delta") {
|
|
220
|
+
if (!sentRole) {
|
|
221
|
+
sentRole = true;
|
|
222
|
+
results.push(chunk({ role: "assistant", content: "" }));
|
|
223
|
+
}
|
|
224
|
+
results.push(chunk({ content: event.delta }));
|
|
225
|
+
}
|
|
226
|
+
else if (type === "response.function_call_arguments.delta") {
|
|
227
|
+
const idx = toolCallIds.get(event.item_id) ?? 0;
|
|
228
|
+
results.push(chunk({
|
|
229
|
+
tool_calls: [{ index: idx, function: { arguments: event.delta } }],
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
else if (type === "response.completed") {
|
|
233
|
+
const resp = event.response;
|
|
234
|
+
const hasFnCalls = (resp?.output ?? []).some((o) => o.type === "function_call");
|
|
235
|
+
const finishReason = hasFnCalls ? "tool_calls" :
|
|
236
|
+
resp?.status === "incomplete" ? "length" : "stop";
|
|
237
|
+
results.push(chunk({}, finishReason));
|
|
238
|
+
if (resp?.usage)
|
|
239
|
+
results.push(usageChunk(resp.usage));
|
|
240
|
+
results.push("data: [DONE]\n\n");
|
|
241
|
+
}
|
|
242
|
+
return results;
|
|
243
|
+
},
|
|
244
|
+
flush() {
|
|
245
|
+
return [];
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|