Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion src/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,53 @@ async function requestUpstream(headers, bodyStr) {
throw lastError;
}

// Codex 上游只回 SSE。非流式客户端(未显式 stream:true)拿到的 SSE 会被
// OpenAI SDK 当作字符串返回,调用方访问 .choices/.output 即报错。
// 这里把 SSE 聚合回单条 Responses JSON,保持流式客户端行为不变。
function collectCompletedResponse(sseText) {
let completed = null;
const deltas = [];
for (const rawLine of sseText.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let event;
try { event = JSON.parse(payload); } catch { continue; }
if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
deltas.push(event.delta);
}
if (event.response && (event.type === "response.completed" || event.type === "response.done" || event.type === "response.incomplete")) {
completed = event.response;
}
}
if (completed) return completed;

const fallbackId = `resp_${Date.now().toString(36)}`;
return {
id: fallbackId,
object: "response",
created_at: Math.floor(Date.now() / 1000),
status: "completed",
model: null,
output: deltas.length
? [{
type: "message",
id: `msg_${Date.now().toString(36)}`,
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: deltas.join(""), annotations: [] }],
}]
: [],
usage: null,
};
}

async function proxyResponses(req, res, accessToken, accountId) {
const clientStream = !!(req.body && req.body.stream === true);
const normalizedBody = normalizeBody(req.body);
const bodyStr = JSON.stringify(normalizedBody);
console.log(`[codex-bridge] path=${req.originalUrl || req.url} model=${req.body && req.body.model} client_stream=${JSON.stringify(req.body && req.body.stream)}`);
const headers = {
Authorization: `Bearer ${accessToken}`,
"ChatGPT-Account-Id": accountId,
Expand All @@ -92,7 +136,12 @@ async function proxyResponses(req, res, accessToken, accountId) {
}

const isSse = contentType.includes("text/event-stream") || normalizedBody.stream;
if (isSse) {
if (isSse && !clientStream) {
const text = await upRes.text();
const response = collectCompletedResponse(text);
console.log(`[codex-bridge] buffered non-stream response (client_stream=false) bytes=${text.length}`);
res.set("Content-Type", "application/json").send(JSON.stringify(response));
} else if (isSse) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
Expand Down
16 changes: 12 additions & 4 deletions test/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ async function main() {
if (req.url === "/sse") {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write('data: {"type":"response.output_text.delta","delta":"hello"}\n\n');
res.write('data: {"type":"response.completed","response":{"id":"resp_test","object":"response","status":"completed","model":"gpt-5","output":[{"type":"message","id":"msg_test","status":"completed","role":"assistant","content":[{"type":"output_text","text":"hello","annotations":[]}]}]}}\n\n');
res.write("data: [DONE]\n\n");
res.end();
return;
Expand Down Expand Up @@ -72,10 +73,17 @@ async function main() {
assert.strictEqual((await request(`${base}/fast`, { method: "POST" })).json().fast, false);
assert.ok((await request(`${base}/`)).text().includes("OpenCode Codex Bridge"));

const sse = await request(`${base}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5", input: "hi" }) });
assert.strictEqual(sse.status, 200);
assert.ok(sse.text().includes("response.output_text.delta"));
assert.ok(sse.text().includes("[DONE]"));
const streamed = await request(`${base}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5", input: "hi", stream: true }) });
assert.strictEqual(streamed.status, 200);
assert.ok(streamed.text().includes("response.output_text.delta"));
assert.ok(streamed.text().includes("[DONE]"));

const buffered = await request(`${base}/v1/responses`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "gpt-5", input: "hi" }) });
assert.strictEqual(buffered.status, 200);
const bufferedJson = buffered.json();
assert.strictEqual(bufferedJson.object, "response");
assert.strictEqual(bufferedJson.status, "completed");
assert.strictEqual(bufferedJson.output[0].content[0].text, "hello");

appServer.close();
upstream.close();
Expand Down
Loading