From 7e2689ac187bc1a98f82a9ed6f41f25fa6e915bf Mon Sep 17 00:00:00 2001 From: Cheng-Zi-Qing Date: Sat, 21 Mar 2026 03:01:28 +0800 Subject: [PATCH] packages/ai: ignore null chunks in openai-completions streams (#2466) --- .../ai/src/providers/openai-completions.ts | 4 +- .../openai-completions-tool-choice.test.ts | 64 ++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2714e91c..dba0b641 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -127,6 +127,8 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA }; for await (const chunk of openaiStream) { + if (!chunk || typeof chunk !== "object") continue; + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, // and each chunk in a streamed completion carries the same id. output.responseId ||= chunk.id; @@ -134,7 +136,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA output.usage = parseChunkUsage(chunk.usage, model); } - const choice = chunk.choices?.[0]; + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (!choice) continue; // Fallback: some providers (e.g., Moonshot) return usage diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 50bf0da0..53f40ab0 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -7,15 +7,19 @@ import type { Tool } from "../src/types.js"; const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, chunks: undefined as - | Array<{ - choices: Array<{ delta: Record; finish_reason: string | null }>; - usage?: { - prompt_tokens: number; - completion_tokens: number; - prompt_tokens_details: { cached_tokens: number }; - completion_tokens_details: { reasoning_tokens: number }; - }; - }> + | Array< + | null + | { + id?: string; + choices?: Array<{ delta: Record; finish_reason: string | null; usage?: unknown }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + prompt_tokens_details: { cached_tokens: number }; + completion_tokens_details: { reasoning_tokens: number }; + }; + } + > | undefined, })); @@ -234,6 +238,48 @@ describe("openai-completions tool_choice", () => { expect(response.errorMessage).toBe("Provider finish_reason: network_error"); }); + it("ignores null stream chunks from openai-compatible providers", async () => { + mockState.chunks = [ + null, + { + id: "chatcmpl-test", + choices: [{ delta: { content: "OK" }, finish_reason: null }], + }, + { + id: "chatcmpl-test", + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 3, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + + 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 exactly OK", + timestamp: Date.now(), + }, + ], + }, + { apiKey: "test" }, + ).result(); + + expect(response.stopReason).toBe("stop"); + expect(response.errorMessage).toBeUndefined(); + expect(response.responseId).toBe("chatcmpl-test"); + expect(response.usage.totalTokens).toBe(4); + expect(response.content).toEqual([{ type: "text", text: "OK" }]); + }); + it("uses OpenRouter reasoning object instead of reasoning_effort", async () => { const model = getModel("openrouter", "deepseek/deepseek-r1")!; let payload: unknown;