diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 7e8aff56c..09b4d74e9 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 4031b1e0a..90b06aede 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -293,3 +293,51 @@ 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":"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" }); + // "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); + }); + + test("unspaced data:[DONE] sentinel terminates the stream", async () => { + // 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); + }); + + 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); + }); +});