From bd6db8eb25896f0a0efadfac7ef79164c27bfb47 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:43:42 +0800 Subject: [PATCH 1/3] fix(openai-chat): accept SSE data fields without the optional space (#1170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some OpenAI-compatible gateways emit valid SSE data fields with no space after the colon (`data:{...}`, `data:[DONE]`). The stream parser only matched `data: `, so every frame — including the terminal finish_reason chunk — was dropped and the turn failed as a false truncation. Match the field name per the SSE spec (value may begin immediately after the colon; one optional leading space is stripped), and skip bare `data:` keep-alive lines instead of reporting them as malformed frames. Regression coverage: unspaced frames with finish_reason and no [DONE] complete, unspaced [DONE] terminates, bare data: lines are ignored, and a genuinely truncated unspaced stream still fails closed. --- src/adapters/openai-chat.ts | 10 ++++++-- tests/openai-chat-eof.test.ts | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7e8aff56c5..09b4d74e9c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -947,8 +947,14 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that // must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state. const handleDataLine = function* (line: string): Generator { - if (!line.startsWith("data: ")) return "continue"; - const payload = line.slice(6).trim(); + // SSE field syntax: the value may begin immediately after the colon; one optional + // leading space is stripped when present. Some OpenAI-compatible upstreams emit + // `data:{...}` / `data:[DONE]` without that space, and every frame was dropped. + if (!line.startsWith("data:")) return "continue"; + const payload = line.slice(5).trim(); + // A bare `data:` line carries nothing (heartbeat-style keep-alive on some gateways); + // it is not a malformed frame, just nothing to parse. + if (payload.length === 0) return "continue"; if (payload === "[DONE]") { yield* flushToolCalls(); const stopReason = stopReasonFor(finishReason); diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index 4031b1e0a7..cf3853fdd9 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -293,3 +293,49 @@ describe("openai-chat EOF mid tool call (#735)", () => { expect(events.at(-1)?.type).toBe("done"); }); }); + +describe("openai-chat SSE data-field framing (#1170)", () => { + // The SSE spec strips at most one optional space after the colon, so `data:{...}` is a valid + // data field. Some OpenAI-compatible gateways emit that framing; the parser must accept it + // exactly like the spaced form. + test("unspaced data:{...} frames with a final finish_reason and no [DONE] complete", async () => { + const response = new Response([ + 'data:{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}\n\n', + 'data:{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + 'data:{"id":"x","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n', + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.find(e => e.type === "text_delta")).toMatchObject({ type: "text_delta", text: "hi" }); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("unspaced data:[DONE] sentinel terminates the stream", async () => { + const response = new Response([ + 'data:{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', + "data:[DONE]\n\n", + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("a bare data: line is ignored, not reported as a malformed frame", async () => { + const response = new Response([ + "data:\n\n", + 'data:{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(e => e.type === "error")).toBe(false); + }); + + test("unspaced truncated stream with no terminal signal still fails closed", async () => { + const response = new Response( + 'data:{"choices":[{"delta":{"reasoning_content":"thinking..."}}]}\n\n', + ); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(e => e.type === "done")).toBe(false); + }); +}); From 58333f5c3ce49ced64fd9583eeccef1bc5cfaa31 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:12:53 +0800 Subject: [PATCH 2/3] test(openai-chat): isolate the unspaced [DONE] sentinel path (#1170) Address CodeRabbit review on #1188: the preceding answer frame carried finish_reason, so the EOF fallback could emit done even if unspaced [DONE] handling were broken. Drive the test with the sentinel frame alone so it fails unless data:[DONE] is parsed. --- tests/openai-chat-eof.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index cf3853fdd9..6e67fe3984 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -311,10 +311,9 @@ describe("openai-chat SSE data-field framing (#1170)", () => { }); test("unspaced data:[DONE] sentinel terminates the stream", async () => { - const response = new Response([ - 'data:{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n', - "data:[DONE]\n\n", - ].join("")); + // Sentinel only: a preceding answer frame would let the finish_reason EOF fallback emit + // `done` even if unspaced [DONE] handling were broken, so this test must not carry one. + const response = new Response("data:[DONE]\n\n"); const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); expect(events.at(-1)?.type).toBe("done"); expect(events.some(e => e.type === "error")).toBe(false); From 20f693a62c9191a782b454e3e95fc54fea84627c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:03 +0800 Subject: [PATCH 3/3] test(openai-chat): prove the terminal finish_reason frame was parsed (#1170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the second CodeRabbit review on #1188: with answer text already emitted, the EOF fallback would yield done even if the terminal finish_reason frame were dropped. Switch the terminal frame to finish_reason "length" and assert stopReason "max_tokens" — the fallback emits done without a stopReason, so the assertion now fails unless the terminal frame was processed. (The literal suggestion of expect.any(String) would not hold for "stop", which maps to no stopReason.) --- tests/openai-chat-eof.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index 6e67fe3984..90b06aedeb 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -301,12 +301,15 @@ describe("openai-chat SSE data-field framing (#1170)", () => { test("unspaced data:{...} frames with a final finish_reason and no [DONE] complete", async () => { const response = new Response([ 'data:{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}\n\n', - 'data:{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + 'data:{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"length"}]}\n\n', 'data:{"id":"x","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}\n\n', ].join("")); const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); expect(events.find(e => e.type === "text_delta")).toMatchObject({ type: "text_delta", text: "hi" }); - expect(events.at(-1)?.type).toBe("done"); + // "length" maps to stopReason "max_tokens" ("stop" maps to none): the EOF fallback would + // emit done WITHOUT a stopReason, so this assertion only passes if the terminal + // finish_reason frame was actually parsed. + expect(events.at(-1)).toMatchObject({ type: "done", stopReason: "max_tokens" }); expect(events.some(e => e.type === "error")).toBe(false); });