codex-rotating-proxy 0.1.0 → 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/dist/pool.js ADDED
@@ -0,0 +1,112 @@
1
+ export class AccountPool {
2
+ states;
3
+ index = 0;
4
+ cooldownMs;
5
+ constructor(accounts, cooldownMinutes) {
6
+ this.cooldownMs = cooldownMinutes * 60_000;
7
+ this.states = accounts.map((account) => ({
8
+ account,
9
+ status: "ready",
10
+ cooldownUntil: 0,
11
+ totalRequests: 0,
12
+ errors: 0,
13
+ }));
14
+ }
15
+ get size() {
16
+ return this.states.length;
17
+ }
18
+ /** Sticky — stays on current account until it hits a limit, then rotates. */
19
+ getNext() {
20
+ const now = Date.now();
21
+ for (const s of this.states) {
22
+ if (s.status === "cooldown" && now >= s.cooldownUntil) {
23
+ s.status = "ready";
24
+ log("green", `↩ ${s.account.name} back from cooldown`);
25
+ }
26
+ }
27
+ // Prefer current (sticky)
28
+ if (this.states[this.index]?.status === "ready") {
29
+ this.states[this.index].totalRequests++;
30
+ return {
31
+ account: this.states[this.index].account,
32
+ name: this.states[this.index].account.name,
33
+ };
34
+ }
35
+ // Find next ready
36
+ for (let i = 1; i < this.states.length; i++) {
37
+ const idx = (this.index + i) % this.states.length;
38
+ if (this.states[idx].status === "ready") {
39
+ this.index = idx;
40
+ this.states[idx].totalRequests++;
41
+ return {
42
+ account: this.states[idx].account,
43
+ name: this.states[idx].account.name,
44
+ };
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+ markCooldown(name) {
50
+ const state = this.states.find((s) => s.account.name === name);
51
+ if (!state)
52
+ return;
53
+ state.status = "cooldown";
54
+ state.cooldownUntil = Date.now() + this.cooldownMs;
55
+ state.errors++;
56
+ const idx = this.states.indexOf(state);
57
+ this.index = (idx + 1) % this.states.length;
58
+ log("yellow", `⏸ ${name} → cooldown for ${Math.round(this.cooldownMs / 60_000)}m`);
59
+ }
60
+ reload(accounts) {
61
+ // Preserve state for existing accounts, add new ones
62
+ const oldMap = new Map(this.states.map((s) => [s.account.name, s]));
63
+ this.states = accounts.map((account) => {
64
+ const existing = oldMap.get(account.name);
65
+ if (existing) {
66
+ existing.account = account; // update token
67
+ return existing;
68
+ }
69
+ return {
70
+ account,
71
+ status: "ready",
72
+ cooldownUntil: 0,
73
+ totalRequests: 0,
74
+ errors: 0,
75
+ };
76
+ });
77
+ if (this.index >= this.states.length)
78
+ this.index = 0;
79
+ }
80
+ updateToken(name, newToken) {
81
+ const state = this.states.find((s) => s.account.name === name);
82
+ if (state)
83
+ state.account.token = newToken;
84
+ }
85
+ getStatus() {
86
+ const now = Date.now();
87
+ return this.states.map((s, i) => ({
88
+ name: s.account.name,
89
+ active: i === this.index && s.status === "ready",
90
+ status: s.status,
91
+ cooldownRemaining: s.status === "cooldown"
92
+ ? Math.max(0, Math.ceil((s.cooldownUntil - now) / 60_000)) + "m"
93
+ : null,
94
+ totalRequests: s.totalRequests,
95
+ errors: s.errors,
96
+ }));
97
+ }
98
+ }
99
+ // ── Logging ─────────────────────────────────────────────────────
100
+ const C = {
101
+ red: "\x1b[31m",
102
+ green: "\x1b[32m",
103
+ yellow: "\x1b[33m",
104
+ cyan: "\x1b[36m",
105
+ dim: "\x1b[2m",
106
+ reset: "\x1b[0m",
107
+ };
108
+ export function log(color, msg) {
109
+ const c = C[color] ?? "";
110
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
111
+ console.log(`${C.dim}${ts}${C.reset} ${c}${msg}${C.reset}`);
112
+ }
package/dist/server.js ADDED
@@ -0,0 +1,303 @@
1
+ import { createServer } from "node:http";
2
+ import { execSync } from "node:child_process";
3
+ import { type as osType, release, arch } from "node:os";
4
+ import { getAccounts, getSettings, writePid, removePid } from "./config.js";
5
+ import { refreshAccount } from "./login.js";
6
+ import { AccountPool, log } from "./pool.js";
7
+ import { chatToResponsesRequest, responsesToChatResponse, createStreamTranslator } from "./translate.js";
8
+ const ROTATE_ON = new Set([429, 402]);
9
+ const STRIP_REQ = new Set([
10
+ "host", "authorization", "connection", "content-length",
11
+ "user-agent", "originator",
12
+ ]);
13
+ const STRIP_RES = new Set(["transfer-encoding", "connection"]);
14
+ // ── Codex-style User-Agent ──────────────────────────────────────
15
+ // Map TERM_PROGRAM values to Codex CLI terminal tokens
16
+ const TERMINAL_MAP = {
17
+ "iterm.app": "iterm2",
18
+ "iterm": "iterm2",
19
+ "apple_terminal": "apple-terminal",
20
+ "terminal": "apple-terminal",
21
+ "warpterminal": "warp",
22
+ "wezterm": "wezterm",
23
+ "vscode": "vscode",
24
+ "ghostty": "ghostty",
25
+ "alacritty": "alacritty",
26
+ "kitty": "kitty",
27
+ "konsole": "konsole",
28
+ "gnome-terminal": "gnome-terminal",
29
+ "windows terminal": "windows-terminal",
30
+ };
31
+ function buildUserAgent() {
32
+ let os = osType();
33
+ let ver = release();
34
+ if (os === "Darwin") {
35
+ os = "Mac OS";
36
+ try {
37
+ ver = execSync("sw_vers -productVersion", { encoding: "utf-8" }).trim();
38
+ }
39
+ catch { }
40
+ }
41
+ const raw = (process.env.TERM_PROGRAM ?? "").toLowerCase().replace(/\.app$/i, "");
42
+ const terminal = TERMINAL_MAP[raw] ?? (raw || "unknown-terminal");
43
+ return `codex_cli_rs/0.1.0 (${os} ${ver}; ${arch()}) ${terminal}`;
44
+ }
45
+ const CODEX_USER_AGENT = buildUserAgent();
46
+ function codexHeaders(account) {
47
+ const h = {
48
+ "user-agent": CODEX_USER_AGENT,
49
+ "originator": "codex_cli_rs",
50
+ };
51
+ if (account.accountId) {
52
+ h["chatgpt-account-id"] = account.accountId;
53
+ }
54
+ return h;
55
+ }
56
+ export function startProxy() {
57
+ const settings = getSettings();
58
+ const accounts = getAccounts();
59
+ const upstream = settings.upstream.replace(/\/$/, "");
60
+ if (accounts.length === 0) {
61
+ console.error("\x1b[31mNo accounts configured. Run `codex-proxy login` first.\x1b[0m");
62
+ process.exit(1);
63
+ }
64
+ const pool = new AccountPool(accounts, settings.cooldownMinutes);
65
+ const server = createServer(async (req, res) => {
66
+ const url = new URL(req.url ?? "/", `http://localhost:${settings.port}`);
67
+ // ── Internal endpoints ────────────────────────────────────
68
+ if (url.pathname === "/_status") {
69
+ json(res, 200, { accounts: pool.getStatus() });
70
+ return;
71
+ }
72
+ if (url.pathname === "/_reload") {
73
+ pool.reload(getAccounts());
74
+ log("green", "↻ accounts reloaded");
75
+ json(res, 200, { ok: true, accounts: pool.size });
76
+ return;
77
+ }
78
+ // ── Buffer body for retries ───────────────────────────────
79
+ const chunks = [];
80
+ for await (const chunk of req)
81
+ chunks.push(chunk);
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
+ }
102
+ // ── Forward headers ───────────────────────────────────────
103
+ const fwdHeaders = {};
104
+ for (const [k, v] of Object.entries(req.headers)) {
105
+ if (v && !STRIP_REQ.has(k.toLowerCase())) {
106
+ fwdHeaders[k] = Array.isArray(v) ? v.join(", ") : v;
107
+ }
108
+ }
109
+ // ── Try each account ──────────────────────────────────────
110
+ for (let attempt = 0; attempt < pool.size; attempt++) {
111
+ const entry = pool.getNext();
112
+ if (!entry)
113
+ break;
114
+ const target = `${upstream}${targetPath}${url.search}`;
115
+ log("cyan", `→ ${req.method} ${targetPath} via ${entry.name}`);
116
+ // Inner loop: try once, and if 401 + refreshable, refresh and retry
117
+ let currentToken = entry.account.token;
118
+ for (let retry = 0; retry < 2; retry++) {
119
+ try {
120
+ const fetchRes = await fetch(target, {
121
+ method: req.method,
122
+ headers: {
123
+ ...fwdHeaders,
124
+ ...codexHeaders(entry.account),
125
+ authorization: `Bearer ${currentToken}`,
126
+ "accept-encoding": "identity",
127
+ ...(body ? { "content-length": String(body.byteLength) } : {}),
128
+ },
129
+ body,
130
+ });
131
+ // ── 401: try token refresh before rotating ────────
132
+ if (fetchRes.status === 401 && retry === 0 && entry.account.refreshToken) {
133
+ await fetchRes.text();
134
+ log("yellow", `⟳ ${entry.name} got 401 — refreshing token`);
135
+ const newToken = await refreshAccount(entry.account);
136
+ if (newToken) {
137
+ currentToken = newToken;
138
+ entry.account.token = newToken;
139
+ pool.updateToken(entry.name, newToken);
140
+ log("green", `✓ ${entry.name} token refreshed`);
141
+ continue; // retry inner loop
142
+ }
143
+ log("red", `✗ ${entry.name} refresh failed — rotating`);
144
+ pool.markCooldown(entry.name);
145
+ break; // move to next account
146
+ }
147
+ // ── Rate limit / quota → rotate ───────────────────
148
+ if (ROTATE_ON.has(fetchRes.status)) {
149
+ await fetchRes.text();
150
+ log("red", `✗ ${entry.name} hit ${fetchRes.status} — rotating`);
151
+ pool.markCooldown(entry.name);
152
+ break;
153
+ }
154
+ if (fetchRes.status === 403) {
155
+ const text = await fetchRes.text();
156
+ if (/quota|limit|exceeded|rate/i.test(text)) {
157
+ log("red", `✗ ${entry.name} 403 quota — rotating`);
158
+ pool.markCooldown(entry.name);
159
+ break;
160
+ }
161
+ log("yellow", `✗ 403 (not quota) — forwarding`);
162
+ forward(res, 403, fetchRes.headers, text);
163
+ return;
164
+ }
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) ─────────────────
226
+ const resHeaders = {};
227
+ fetchRes.headers.forEach((v, k) => {
228
+ if (!STRIP_RES.has(k.toLowerCase()))
229
+ resHeaders[k] = v;
230
+ });
231
+ res.writeHead(fetchRes.status, resHeaders);
232
+ if (fetchRes.body) {
233
+ const reader = fetchRes.body.getReader();
234
+ try {
235
+ while (true) {
236
+ const { done, value } = await reader.read();
237
+ if (done)
238
+ break;
239
+ res.write(value);
240
+ }
241
+ }
242
+ catch { }
243
+ res.end();
244
+ }
245
+ else {
246
+ res.end();
247
+ }
248
+ return;
249
+ }
250
+ catch (err) {
251
+ log("red", `✗ ${entry.name} network error: ${err}`);
252
+ pool.markCooldown(entry.name);
253
+ break;
254
+ }
255
+ }
256
+ }
257
+ log("red", "✗ all accounts exhausted");
258
+ json(res, 503, {
259
+ error: {
260
+ message: "All accounts exhausted. Check /_status for cooldown times.",
261
+ type: "proxy_error",
262
+ },
263
+ });
264
+ });
265
+ // ── Lifecycle ─────────────────────────────────────────────────
266
+ writePid(process.pid);
267
+ const shutdown = () => {
268
+ log("yellow", "shutting down...");
269
+ removePid();
270
+ server.close(() => process.exit(0));
271
+ setTimeout(() => process.exit(0), 3000);
272
+ };
273
+ process.on("SIGTERM", shutdown);
274
+ process.on("SIGINT", shutdown);
275
+ server.listen(settings.port, () => {
276
+ console.log();
277
+ console.log(" \x1b[36mcodex-proxy\x1b[0m");
278
+ console.log(` upstream ${upstream}`);
279
+ console.log(` port ${settings.port}`);
280
+ console.log(` accounts ${accounts.map((a) => a.name).join(", ")}`);
281
+ console.log(` cooldown ${settings.cooldownMinutes}m`);
282
+ console.log();
283
+ log("green", `listening on http://localhost:${settings.port}`);
284
+ console.log();
285
+ });
286
+ }
287
+ function json(res, status, data) {
288
+ res.writeHead(status, { "content-type": "application/json" });
289
+ res.end(JSON.stringify(data, null, 2));
290
+ }
291
+ function forward(res, status, headers, body) {
292
+ const h = {};
293
+ headers.forEach((v, k) => {
294
+ if (!STRIP_RES.has(k.toLowerCase()))
295
+ h[k] = v;
296
+ });
297
+ res.writeHead(status, h);
298
+ res.end(body);
299
+ }
300
+ // Allow running directly for daemon mode
301
+ if (process.env.CODEX_PROXY_DAEMON === "1") {
302
+ startProxy();
303
+ }
@@ -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
+ }