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/README.md +13 -10
- package/dist/cli.js +273 -0
- package/dist/config.js +90 -0
- package/dist/login.js +217 -0
- package/dist/pool.js +112 -0
- package/dist/server.js +303 -0
- package/dist/translate.js +248 -0
- package/package.json +20 -5
- package/bin/codex-proxy +0 -10
- package/src/cli.ts +0 -307
- package/src/config.ts +0 -113
- package/src/login.ts +0 -261
- package/src/pool.ts +0 -136
- package/src/server.ts +0 -247
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
|
+
}
|