packages/ai: ignore null chunks in openai-completions streams (#2466)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user