packages/ai: ignore null chunks in openai-completions streams (#2466)

This commit is contained in:
Cheng-Zi-Qing
2026-03-21 03:01:28 +08:00
committed by GitHub
parent eda1082d4b
commit 7e2689ac18
2 changed files with 58 additions and 10 deletions

View File

@@ -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

View File

@@ -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<string, unknown>; 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<string, unknown>; 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;