From 98ffad0437c8b4bcbd56244be129899b4cc4e283 Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Fri, 15 May 2026 15:11:43 +0200 Subject: [PATCH] fix(ai): openai-completions - throw error on missing finish-reason - require \ before treating \ streams as successful - add regression coverage for truncated streams without \ - closes #4345 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/openai-completions.ts | 5 +++ .../openai-completions-tool-choice.test.ts | 32 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 01a633931..7fa3a37d3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)). +- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)). - Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents. - Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID. - Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off. diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 01bc6e78a..0d7533633 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -165,6 +165,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA let textBlock: TextContent | null = null; let thinkingBlock: ThinkingContent | null = null; + let hasFinishReason = false; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); const blocks = output.content as StreamingBlock[]; @@ -288,6 +289,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (finishReasonResult.errorMessage) { output.errorMessage = finishReasonResult.errorMessage; } + hasFinishReason = true; } if (choice.delta) { @@ -390,6 +392,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA if (output.stopReason === "error") { throw new Error(output.errorMessage || "Provider returned an error stop reason"); } + if (!hasFinishReason) { + throw new Error("Stream ended without finish_reason"); + } stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c9c8ec9fa..8ed6165df 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -441,6 +441,38 @@ describe("openai-completions tool_choice", () => { expect(response.content).toEqual([{ type: "text", text: "OK" }]); }); + it("errors when a stream ends after only null finish_reason chunks", async () => { + mockState.chunks = [ + { + id: "chatcmpl-truncated", + choices: [{ delta: { content: "partial answer" }, finish_reason: null }], + }, + { + id: "chatcmpl-truncated", + choices: [{ delta: { content: "partial answer" }, finish_reason: null }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const response = await streamSimple( + model, + { + messages: [ + { + role: "user", + content: "Reply with a longer sentence", + timestamp: Date.now(), + }, + ], + }, + { apiKey: "test" }, + ).result(); + + expect(response.stopReason).toBe("error"); + expect(response.errorMessage).toBe("Stream ended without finish_reason"); + }); + it("coalesces tool call deltas by stable index when provider mutates ids mid-stream", async () => { mockState.chunks = [ {