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 CHANGED
@@ -115,28 +115,30 @@ codex-proxy config --cooldown 30 # cooldown minutes
115
115
 
116
116
  ## Using with opencode
117
117
 
118
- Add the proxy as your OpenAI provider base URL in `~/.config/opencode/opencode.json`:
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
- Then set your model as usual — the proxy forwards whatever model the client requests:
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
- - **Streaming** — full SSE streaming support for chat completions
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}${url.pathname}${url.search}`;
95
- log("cyan", `→ ${req.method} ${url.pathname} via ${entry.name}`);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-rotating-proxy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenAI API proxy that rotates between multiple accounts when rate limits hit",
5
5
  "type": "module",
6
6
  "bin": {