@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/auth.mjs +79 -0
- package/lib/capabilities.mjs +183 -0
- package/lib/plugin.ts +122 -23
- package/lib/resilience.mjs +182 -0
- package/lib/server.mjs +379 -208
- package/lib/tools-registry.mjs +57 -0
- package/package.json +11 -4
package/lib/server.mjs
CHANGED
|
@@ -1,190 +1,309 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// codex-bridge: OpenAI-compatible HTTP server that
|
|
3
|
-
// Business/Team/Plus
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
175
|
-
completion_tokens:
|
|
176
|
-
total_tokens:
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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();
|