diff --git a/src/proxy.js b/src/proxy.js index facad5d..0cda53a 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -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, @@ -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"); diff --git a/test/smoke.js b/test/smoke.js index 18246b1..689cea7 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -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; @@ -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();