@jaralabs/codex-bridge 0.2.0 → 0.4.0

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/lib/server.mjs CHANGED
@@ -1,190 +1,309 @@
1
1
  #!/usr/bin/env node
2
- // codex-bridge: OpenAI-compatible HTTP server that reuses a local ChatGPT
3
- // Business/Team/Plus session by shelling out to `codex exec --json`.
4
- // Default port: 11434. Endpoints:
5
- // GET /v1/models -> { object:"list", data:[...] }
6
- // POST /v1/chat/completions -> OpenAI-style streaming or JSON response
2
+ // codex-bridge: OpenAI-compatible HTTP server that talks to ChatGPT
3
+ // Business/Team/Plus via the OpenAI Responses API directly.
7
4
  //
8
- // Auth: reads ~/.codex/auth.json (refreshes access_token via OpenAI's
9
- // refresh_token grant when expired). The token is only used so the
10
- // shell session stays authenticated; codex CLI handles the actual call.
5
+ // Endpoints:
6
+ // GET /v1/health -> { ok, ...metrics }
7
+ // GET /v1/models -> { object:"list", data:[...] }
8
+ // POST /v1/chat/completions -> OpenAI-style streaming or JSON response
9
+ // GET /v1/tools -> list registered MCP tools
10
+ // POST /v1/tools -> register a list of tools
11
+ // DELETE /v1/tools -> clear registry
12
+ //
13
+ // Auth lives in lib/auth.mjs. Upstream resilience (retry, circuit
14
+ // breaker, metrics) lives in lib/resilience.mjs. Boot-time capability
15
+ // probing lives in lib/capabilities.mjs.
11
16
 
12
17
  import http from "node:http";
13
- import { spawn } from "node:child_process";
14
- import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
15
- import { homedir, userInfo } from "node:os";
16
- import { join, dirname } from "node:path";
18
+ import { existsSync, readFileSync, appendFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ import { getAuth, refreshAccessToken } from "./auth.mjs";
23
+ import {
24
+ registerTool,
25
+ registerTools,
26
+ listTools,
27
+ clearTools,
28
+ renderToolsSystemBlock,
29
+ } from "./tools-registry.mjs";
30
+ import {
31
+ withResilience,
32
+ metrics as resilienceMetrics,
33
+ } from "./resilience.mjs";
34
+ import { probeCapabilities } from "./capabilities.mjs";
17
35
 
18
36
  const PORT = Number(process.env.PORT || 11434);
19
37
  const HOST = process.env.HOST || "127.0.0.1";
20
- const CODEX_HOME =
21
- process.env.CODEX_HOME || join(homedir(), ".codex");
22
- const AUTH_PATH = join(CODEX_HOME, "auth.json");
38
+ const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
23
39
  const MODELS_CACHE = join(CODEX_HOME, "models_cache.json");
40
+ const CHATGPT_BASE = "https://chatgpt.com/backend-api/codex";
41
+ const LOG_PATH = process.env.CODEX_BRIDGE_LOG || "/tmp/codex-bridge.log";
24
42
 
25
- const codexBin = (() => {
26
- const fromEnv = process.env.CODEX_BIN;
27
- if (fromEnv) return fromEnv;
28
- try {
29
- const which = spawn("which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] });
30
- const out = [];
31
- which.stdout.on("data", (c) => out.push(c));
32
- which.on("close", () => process.stdout.write(""));
33
- // Note: ignored; we instead use the env-default path lookup below.
34
- } catch {}
35
- return "codex";
36
- })();
37
-
38
- // ---- Logging (JSONL to stderr AND to LOG_FILE if set) ----
39
- const LOG_FILE = process.env.CODEX_BRIDGE_LOG || "";
40
- function writeLog(line) {
41
- try { process.stderr.write(line + "\n"); } catch {}
42
- if (LOG_FILE) {
43
- try {
44
- appendFileSync(LOG_FILE, line + "\n");
45
- } catch {}
46
- }
47
- }
48
43
  function log(level, msg, extra) {
49
- const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...extra });
50
- writeLog(line);
44
+ const line = JSON.stringify({ t: new Date().toISOString(), level, msg, ...(extra || {}) });
45
+ try { appendFileSync(LOG_PATH, line + "\n"); } catch {}
46
+ if (process.env.CODEX_BRIDGE_VERBOSE) {
47
+ process.stderr.write(line + "\n");
48
+ }
51
49
  }
52
50
 
53
- import { appendFileSync } from "node:fs";
54
- process.on("SIGPIPE", () => {});
55
-
56
- // ---- Auth handling ----
57
- let authCache = null;
58
- function loadAuth() {
59
- if (!existsSync(AUTH_PATH)) {
60
- throw new Error(`codex-bridge: no auth.json at ${AUTH_PATH}. Run 'codex login' first.`);
51
+ // ---- models ----
52
+ function listModels() {
53
+ if (!existsSync(MODELS_CACHE)) {
54
+ return { object: "list", data: [] };
61
55
  }
62
- const raw = JSON.parse(readFileSync(AUTH_PATH, "utf8"));
63
- if (raw.auth_mode !== "chatgpt") {
64
- throw new Error(`codex-bridge: auth mode is '${raw.auth_mode}', need 'chatgpt'.`);
56
+ try {
57
+ const cache = JSON.parse(readFileSync(MODELS_CACHE, "utf8"));
58
+ if (!Array.isArray(cache.models)) return { object: "list", data: [] };
59
+ const data = cache.models
60
+ .filter((m) => ["list", "default", null, undefined].includes(m.visibility ?? null))
61
+ .filter((m) => m.supported_in_api !== false)
62
+ .map((m) => ({ id: m.slug, object: "model", created: 0, owned_by: "openai-chatgpt" }));
63
+ return { object: "list", data };
64
+ } catch (e) {
65
+ log("warn", "listModels failed", { err: String(e) });
66
+ return { object: "list", data: [] };
65
67
  }
66
- if (!raw.tokens?.access_token) throw new Error("codex-bridge: missing tokens.");
67
- return raw;
68
68
  }
69
- function getAuth() {
70
- if (!authCache) authCache = loadAuth();
71
- return authCache;
69
+
70
+ // ---- OpenAI Chat Completions <-> Responses API translation ----
71
+
72
+ function chatToolsToResponsesTools(tools) {
73
+ if (!Array.isArray(tools) || tools.length === 0) return undefined;
74
+ return tools
75
+ .map((t) => {
76
+ const fn = t.function || t;
77
+ if (!fn?.name) return null;
78
+ return {
79
+ type: "function",
80
+ name: fn.name,
81
+ description: fn.description || "",
82
+ parameters: fn.parameters || { type: "object", properties: {} },
83
+ strict: false,
84
+ };
85
+ })
86
+ .filter(Boolean);
72
87
  }
73
- // Token refresh is delegated to codex itself; we just rely on the CLI.
74
88
 
75
- // ---- Models listing ----
76
- function listModels() {
77
- const slugId = (s) => ({
78
- id: s,
79
- object: "model",
80
- created: 0,
81
- owned_by: "openai-chatgpt",
82
- });
83
- let slugs = [];
84
- if (existsSync(MODELS_CACHE)) {
85
- try {
86
- const cache = JSON.parse(readFileSync(MODELS_CACHE, "utf8"));
87
- if (Array.isArray(cache.models)) {
88
- slugs = cache.models
89
- .filter((m) => m.visibility === "list" || m.visibility == null)
90
- .map((m) => m.slug)
91
- .filter(Boolean);
89
+ function chatMessagesToResponsesInput(messages) {
90
+ let instructions = "";
91
+ const input = [];
92
+ for (const m of messages) {
93
+ if (!m || !m.role) continue;
94
+ const role = m.role;
95
+ if (role === "system" || role === "developer") {
96
+ const txt = textOf(m.content);
97
+ if (txt) instructions = (instructions ? instructions + "\n\n" : "") + txt;
98
+ continue;
99
+ }
100
+ if (role === "user") {
101
+ const txt = textOf(m.content);
102
+ if (txt) input.push({ type: "message", role: "user", content: [{ type: "input_text", text: txt }] });
103
+ continue;
104
+ }
105
+ if (role === "assistant") {
106
+ if (Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
107
+ for (const tc of m.tool_calls) {
108
+ const name = tc.function?.name || tc.name;
109
+ const args = typeof tc.function?.arguments === "string"
110
+ ? tc.function.arguments
111
+ : (typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}));
112
+ input.push({ type: "function_call", call_id: tc.id, name, arguments: args });
113
+ }
92
114
  }
93
- } catch (e) {
94
- log("warn", "models_cache.json parse failed", { err: String(e) });
115
+ const txt = textOf(m.content);
116
+ if (txt) {
117
+ input.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: txt }] });
118
+ }
119
+ continue;
120
+ }
121
+ if (role === "tool") {
122
+ const callId = m.tool_call_id || m.call_id;
123
+ const out = textOf(m.content);
124
+ if (callId) {
125
+ input.push({ type: "function_call_output", call_id: callId, output: out });
126
+ }
127
+ continue;
95
128
  }
96
129
  }
97
- if (slugs.length === 0) {
98
- slugs = ["gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-6-astra"];
99
- }
100
- return { object: "list", data: slugs.map(slugId) };
130
+ return { instructions, input };
101
131
  }
102
132
 
103
- // ---- codex exec runner ----
104
- function runCodex({ model, prompt, signal }) {
105
- return new Promise((resolve, reject) => {
106
- // Ensure auth is fresh enough for codex exec by reading the file (codex
107
- // itself will refresh via its internal logic on the next call).
108
- getAuth();
109
- const args = [
110
- "exec",
111
- "--json",
112
- "--skip-git-repo-check",
113
- "-m", model,
114
- prompt,
115
- ];
116
- const child = spawn(codexBin, args, {
117
- env: {
118
- ...process.env,
119
- CODEX_HOME,
120
- NO_COLOR: "1",
121
- TERM: "dumb",
122
- },
123
- stdio: ["ignore", "pipe", "pipe"],
124
- });
125
- let stdout = "";
126
- let stderr = "";
127
- let aborted = false;
128
- const onAbort = () => {
129
- aborted = true;
130
- try { child.kill("SIGTERM"); } catch {}
131
- };
132
- if (signal) signal.addEventListener("abort", onAbort, { once: true });
133
- child.stdout.on("data", (c) => (stdout += c.toString()));
134
- child.stderr.on("data", (c) => (stderr += c.toString()));
135
- child.on("error", (e) => reject(e));
136
- child.on("close", (code) => {
137
- if (signal) signal.removeEventListener("abort", onAbort);
138
- if (aborted) return reject(new Error("aborted"));
139
- if (code !== 0) {
140
- return reject(
141
- new Error(`codex exec exited ${code}: ${stderr.slice(0, 400)}`),
142
- );
143
- }
144
- // Parse the last item.completed agent_message + usage.
145
- let lastText = "";
146
- let usage = null;
147
- for (const line of stdout.split("\n")) {
148
- const l = line.trim();
149
- if (!l.startsWith("{")) continue;
150
- let evt;
151
- try { evt = JSON.parse(l); } catch { continue; }
152
- if (evt.type === "item.completed" && evt.item?.type === "agent_message") {
153
- lastText = evt.item.text ?? lastText;
154
- }
155
- if (evt.type === "turn.completed") {
156
- usage = evt.usage ?? null;
157
- }
158
- }
159
- resolve({ text: lastText, usage, stderr });
160
- });
161
- });
133
+ function textOf(content) {
134
+ if (typeof content === "string") return content;
135
+ if (Array.isArray(content)) {
136
+ return content
137
+ .map((p) => {
138
+ if (typeof p === "string") return p;
139
+ if (!p) return "";
140
+ if (p.type === "text" || p.type === "input_text" || p.type === "output_text") return p.text || "";
141
+ if (p.type === "image_url" && p.image_url?.url) return `[image: ${p.image_url.url.slice(0, 60)}]`;
142
+ return "";
143
+ })
144
+ .filter(Boolean)
145
+ .join("\n");
146
+ }
147
+ if (content && typeof content === "object") return JSON.stringify(content);
148
+ return "";
162
149
  }
163
150
 
164
- // ---- OpenAI shape helpers ----
165
151
  function now() { return Math.floor(Date.now() / 1000); }
166
152
  function genId(prefix) {
167
153
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
168
154
  }
155
+
169
156
  function toUsage(u) {
170
157
  if (!u) return { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
171
- const input = u.input_tokens ?? 0;
172
- const output = u.output_tokens ?? 0;
173
158
  return {
174
- prompt_tokens: input,
175
- completion_tokens: output,
176
- total_tokens: input + output,
159
+ prompt_tokens: u.input_tokens ?? 0,
160
+ completion_tokens: u.output_tokens ?? 0,
161
+ total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
162
+ };
163
+ }
164
+
165
+ // ---- HTTP client to ChatGPT ----
166
+ async function callResponses({ model, instructions, input, tools, signal }) {
167
+ const auth = await getAuth();
168
+ const url = `${CHATGPT_BASE}/responses`;
169
+ const body = {
170
+ model,
171
+ store: false,
172
+ stream: true,
173
+ instructions: instructions || undefined,
174
+ input,
175
+ tool_choice: "auto",
176
+ parallel_tool_calls: true,
177
+ prompt_cache_key: `codex-bridge-${Date.now()}`,
178
+ };
179
+ if (tools && tools.length > 0) body.tools = tools;
180
+
181
+ const headers = {
182
+ authorization: `Bearer ${auth.access_token}`,
183
+ "content-type": "application/json",
184
+ accept: "text/event-stream",
185
+ "OpenAI-Beta": "responses=experimental",
186
+ "chatgpt-account-id": auth.account_id || "",
187
+ };
188
+
189
+ log("info", "responses.request", {
190
+ model,
191
+ instructionsLen: (instructions || "").length,
192
+ inputItems: input.length,
193
+ tools: (tools || []).length,
194
+ });
195
+
196
+ const doFetch = async () => {
197
+ let res;
198
+ try {
199
+ res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
200
+ } catch (e) {
201
+ const err = new Error(`fetch failed: ${e?.message || e}`);
202
+ err.code = "fetch_failed";
203
+ err.status = 0;
204
+ throw err;
205
+ }
206
+ if (res.status === 401 && auth.refresh_token) {
207
+ log("info", "responses.401 refreshing token");
208
+ const refreshed = await refreshAccessToken(auth.refresh_token);
209
+ headers.authorization = `Bearer ${refreshed.access_token}`;
210
+ res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal });
211
+ }
212
+ if (!res.ok) {
213
+ const text = await res.text();
214
+ const err = new Error(`chatgpt ${res.status}: ${text.slice(0, 400)}`);
215
+ err.status = res.status;
216
+ throw err;
217
+ }
218
+ return res;
177
219
  };
220
+
221
+ return withResilience("responses", doFetch, { signal });
178
222
  }
179
- function renderTemplate(tmpl, vars) {
180
- if (typeof tmpl === "string") return tmpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "");
181
- // Multimodal array: replace text parts with {{key}} expansion.
182
- return tmpl
183
- .map((part) =>
184
- part?.type === "text"
185
- ? { ...part, text: part.text.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "") }
186
- : part,
187
- );
223
+
224
+ // Parse SSE stream from Responses API.
225
+ async function readSse(res) {
226
+ const reader = res.body.getReader();
227
+ const decoder = new TextDecoder("utf8");
228
+ let buf = "";
229
+ let text = "";
230
+ const toolCalls = new Map();
231
+ let usage = null;
232
+ let currentItem = null;
233
+
234
+ function ensureCall(id, name) {
235
+ if (!toolCalls.has(id)) toolCalls.set(id, { id, name: name || "", arguments: "" });
236
+ return toolCalls.get(id);
237
+ }
238
+
239
+ while (true) {
240
+ const { value, done } = await reader.read();
241
+ if (done) break;
242
+ buf += decoder.decode(value, { stream: true });
243
+ let idx;
244
+ while ((idx = buf.indexOf("\n\n")) !== -1) {
245
+ const block = buf.slice(0, idx);
246
+ buf = buf.slice(idx + 2);
247
+ let event = "";
248
+ let data = "";
249
+ for (const line of block.split("\n")) {
250
+ if (line.startsWith("event: ")) event = line.slice(7).trim();
251
+ else if (line.startsWith("data:")) data += (data ? "\n" : "") + line.slice(5).trim();
252
+ }
253
+ if (!data) continue;
254
+ if (data === "[DONE]") continue;
255
+ let json;
256
+ try { json = JSON.parse(data); } catch { continue; }
257
+ const t = json.type || event;
258
+ switch (t) {
259
+ case "response.output_item.added": {
260
+ const item = json.item || {};
261
+ currentItem = { type: item.type, id: item.id, call_id: item.call_id, name: item.name, content_index: 0 };
262
+ if (item.type === "function_call") {
263
+ ensureCall(item.call_id, item.name);
264
+ }
265
+ break;
266
+ }
267
+ case "response.function_call_arguments.delta": {
268
+ const id = json.call_id || currentItem?.call_id;
269
+ if (id) {
270
+ const call = ensureCall(id, currentItem?.name || json.name);
271
+ call.arguments += json.delta || "";
272
+ }
273
+ break;
274
+ }
275
+ case "response.function_call_arguments.done": {
276
+ const id = json.call_id || currentItem?.call_id;
277
+ if (id && typeof json.arguments === "string") {
278
+ const call = ensureCall(id, currentItem?.name || json.name);
279
+ call.arguments = json.arguments;
280
+ }
281
+ break;
282
+ }
283
+ case "response.output_text.delta": {
284
+ text += json.delta || "";
285
+ break;
286
+ }
287
+ case "response.completed": {
288
+ const r = json.response || {};
289
+ if (r.usage) usage = r.usage;
290
+ if (!text && Array.isArray(r.output)) {
291
+ for (const item of r.output) {
292
+ if (item.type === "message") {
293
+ for (const part of item.content || []) {
294
+ if (part.type === "output_text" && part.text) text += part.text;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ break;
300
+ }
301
+ default:
302
+ break;
303
+ }
304
+ }
305
+ }
306
+ return { text, toolCalls: Array.from(toolCalls.values()), usage };
188
307
  }
189
308
 
190
309
  // ---- HTTP server ----
@@ -194,9 +313,33 @@ const server = http.createServer(async (req, res) => {
194
313
  if (req.method === "GET" && url.pathname === "/healthz") {
195
314
  return json(res, 200, { ok: true, port: PORT });
196
315
  }
316
+ if (req.method === "GET" && url.pathname === "/v1/health") {
317
+ return json(res, 200, {
318
+ ok: true,
319
+ port: PORT,
320
+ uptime: process.uptime(),
321
+ ...resilienceMetrics(),
322
+ });
323
+ }
197
324
  if (req.method === "GET" && url.pathname === "/v1/models") {
198
325
  return json(res, 200, listModels());
199
326
  }
327
+ if (req.method === "GET" && url.pathname === "/v1/tools") {
328
+ return json(res, 200, { object: "list", data: listTools() });
329
+ }
330
+ if (req.method === "POST" && url.pathname === "/v1/tools") {
331
+ const body = await readBody(req);
332
+ let payload;
333
+ try { payload = JSON.parse(body); } catch { return json(res, 400, { error: { message: "invalid JSON" } }); }
334
+ if (Array.isArray(payload)) registerTools(payload);
335
+ else if (Array.isArray(payload?.tools)) registerTools(payload.tools);
336
+ else return json(res, 400, { error: { message: "expected array or { tools: [...] }" } });
337
+ return json(res, 200, { object: "list", data: listTools(), count: listTools().length });
338
+ }
339
+ if (req.method === "DELETE" && url.pathname === "/v1/tools") {
340
+ clearTools();
341
+ return json(res, 200, { object: "list", data: [], count: 0 });
342
+ }
200
343
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
201
344
  const body = await readBody(req);
202
345
  let payload;
@@ -249,99 +392,127 @@ async function handleChatCompletions(payload, req, res) {
249
392
  temperature,
250
393
  top_p,
251
394
  max_tokens,
395
+ tools,
252
396
  } = payload || {};
253
397
  if (!model) return json(res, 400, { error: { message: "model is required" } });
254
398
  if (!Array.isArray(messages) || messages.length === 0) {
255
399
  return json(res, 400, { error: { message: "messages[] required" } });
256
400
  }
257
401
 
258
- // Build prompt: concatenate system/user/assistant turns.
259
- const parts = [];
260
- let lastRole = null;
261
- for (const m of messages) {
262
- if (!m || !m.role) continue;
263
- let text = "";
264
- if (typeof m.content === "string") text = m.content;
265
- else if (Array.isArray(m.content)) {
266
- text = m.content
267
- .filter((p) => p?.type === "text")
268
- .map((p) => p.text || "")
269
- .join("\n");
270
- } else if (m.content) text = String(m.content);
271
- if (!text && m.role !== lastRole) parts.push("");
272
- if (text) {
273
- const tag = m.role.toUpperCase();
274
- parts.push(`${tag}: ${text}`);
402
+ const ac = new AbortController();
403
+ req.on("close", () => ac.abort());
404
+
405
+ const responsesTools = chatToolsToResponsesTools(tools);
406
+ let { instructions, input } = chatMessagesToResponsesInput(messages);
407
+ if (!(responsesTools && responsesTools.length > 0)) {
408
+ const block = renderToolsSystemBlock();
409
+ if (block) {
410
+ instructions = instructions ? `${instructions}\n\n${block}` : block;
275
411
  }
276
- lastRole = m.role;
277
412
  }
278
- parts.push("ASSISTANT:");
279
- const prompt = parts.filter(Boolean).join("\n\n");
280
413
 
281
- const ac = new AbortController();
282
- req.on("close", () => ac.abort());
414
+ log("info", "chat.completions", { model, stream, msgs: messages.length, toolCount: (tools || []).length });
283
415
 
284
- log("info", "chat.completions", { model, stream, msgs: messages.length });
285
416
  let result;
286
417
  try {
287
- result = await runCodex({ model, prompt, signal: ac.signal });
418
+ const upstream = await callResponses({
419
+ model,
420
+ instructions,
421
+ input,
422
+ tools: responsesTools,
423
+ signal: ac.signal,
424
+ });
425
+ result = await readSse(upstream);
288
426
  } catch (e) {
289
- return json(res, 502, { error: { message: String(e.message || e), type: "codex_error" } });
427
+ const status = e?.status || 502;
428
+ const body = {
429
+ error: {
430
+ message: String(e.message || e),
431
+ type: e?.code || "upstream_error",
432
+ },
433
+ };
434
+ if (status === 503 && e?.code === "circuit_open") {
435
+ return json(res, 503, { ...body, error: { ...body.error, message: "bridge: circuit open, upstream unavailable" } });
436
+ }
437
+ return json(res, status === 401 ? 502 : status, body);
290
438
  }
291
439
 
292
440
  const id = genId("chatcmpl");
293
441
  const created = now();
294
442
  const usage = toUsage(result.usage);
443
+ const hasToolCalls = result.toolCalls && result.toolCalls.length > 0;
444
+ const finishReason = hasToolCalls ? "tool_calls" : "stop";
445
+ const message = { role: "assistant", content: result.text || "" };
446
+ if (hasToolCalls) {
447
+ message.tool_calls = result.toolCalls.map((c, i) => ({
448
+ index: i,
449
+ id: c.id,
450
+ function: { name: c.name, arguments: c.arguments || "" },
451
+ }));
452
+ }
295
453
 
296
454
  if (stream) {
297
455
  startSse(res);
298
- // First chunk: role.
299
456
  sse(res, null, {
300
- id, object: "chat.completion.chunk", created,
301
- model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
457
+ id, object: "chat.completion.chunk", created, model,
458
+ choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
302
459
  });
303
- // Content chunk (codex exec doesn't stream per-token; emit as one).
304
460
  if (result.text) {
305
461
  sse(res, null, {
306
- id, object: "chat.completion.chunk", created,
307
- model,
308
- choices: [{
309
- index: 0,
310
- delta: { content: result.text },
311
- finish_reason: null,
312
- }],
462
+ id, object: "chat.completion.chunk", created, model,
463
+ choices: [{ index: 0, delta: { content: result.text }, finish_reason: null }],
313
464
  });
314
465
  }
315
- // Final chunk with finish_reason + usage.
316
- sse(res, null, {
317
- id, object: "chat.completion.chunk", created, model,
318
- choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
319
- });
466
+ if (hasToolCalls) {
467
+ for (let i = 0; i < result.toolCalls.length; i++) {
468
+ const c = result.toolCalls[i];
469
+ sse(res, null, {
470
+ id, object: "chat.completion.chunk", created, model,
471
+ choices: [{
472
+ index: 0,
473
+ delta: {
474
+ tool_calls: [{
475
+ index: i,
476
+ id: c.id,
477
+ function: { name: c.name, arguments: c.arguments || "" },
478
+ }],
479
+ },
480
+ finish_reason: null,
481
+ }],
482
+ });
483
+ }
484
+ }
320
485
  sse(res, null, {
321
486
  id, object: "chat.completion.chunk", created, model,
322
- choices: [],
323
- usage,
487
+ choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
324
488
  });
489
+ sse(res, null, { id, object: "chat.completion.chunk", created, model, choices: [], usage });
325
490
  res.write("data: [DONE]\n\n");
326
- res.end();
327
- return;
491
+ return res.end();
328
492
  }
329
493
 
330
494
  return json(res, 200, {
331
495
  id, object: "chat.completion", created, model,
332
- choices: [{
333
- index: 0,
334
- message: { role: "assistant", content: result.text },
335
- finish_reason: "stop",
336
- logprobs: null,
337
- }],
496
+ choices: [{ index: 0, message, finish_reason: finishReason, logprobs: null }],
338
497
  usage,
339
498
  });
340
499
  }
341
500
 
342
- server.listen(PORT, HOST, () => {
343
- log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
344
- });
501
+ // ---- boot ----
502
+ async function boot() {
503
+ try {
504
+ const caps = await probeCapabilities({ log: (level, msg, extra) => log(level, msg, extra) });
505
+ log("info", "capabilities", { ok: caps.ok, resolvedModel: caps.resolvedModel, events: caps.events, latencyMs: caps.latencyMs });
506
+ } catch (e) {
507
+ log("warn", "probe failed; starting anyway", { err: String(e) });
508
+ }
509
+ server.listen(PORT, HOST, () => {
510
+ log("info", "listening", { url: `http://${HOST}:${PORT}`, codex_home: CODEX_HOME });
511
+ process.stderr.write(`codex-bridge listening on http://${HOST}:${PORT}\n`);
512
+ });
513
+ }
345
514
 
346
515
  process.on("SIGTERM", () => server.close(() => process.exit(0)));
347
516
  process.on("SIGINT", () => server.close(() => process.exit(0)));
517
+
518
+ boot();