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) {
|
for await (const chunk of openaiStream) {
|
||||||
|
if (!chunk || typeof chunk !== "object") continue;
|
||||||
|
|
||||||
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
|
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
|
||||||
// and each chunk in a streamed completion carries the same id.
|
// and each chunk in a streamed completion carries the same id.
|
||||||
output.responseId ||= chunk.id;
|
output.responseId ||= chunk.id;
|
||||||
@@ -134,7 +136,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
output.usage = parseChunkUsage(chunk.usage, model);
|
output.usage = parseChunkUsage(chunk.usage, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
const choice = chunk.choices?.[0];
|
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
|
||||||
if (!choice) continue;
|
if (!choice) continue;
|
||||||
|
|
||||||
// Fallback: some providers (e.g., Moonshot) return usage
|
// Fallback: some providers (e.g., Moonshot) return usage
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ import type { Tool } from "../src/types.js";
|
|||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
lastParams: undefined as unknown,
|
lastParams: undefined as unknown,
|
||||||
chunks: undefined as
|
chunks: undefined as
|
||||||
| Array<{
|
| Array<
|
||||||
choices: Array<{ delta: Record<string, unknown>; finish_reason: string | null }>;
|
| null
|
||||||
usage?: {
|
| {
|
||||||
prompt_tokens: number;
|
id?: string;
|
||||||
completion_tokens: number;
|
choices?: Array<{ delta: Record<string, unknown>; finish_reason: string | null; usage?: unknown }>;
|
||||||
prompt_tokens_details: { cached_tokens: number };
|
usage?: {
|
||||||
completion_tokens_details: { reasoning_tokens: number };
|
prompt_tokens: number;
|
||||||
};
|
completion_tokens: number;
|
||||||
}>
|
prompt_tokens_details: { cached_tokens: number };
|
||||||
|
completion_tokens_details: { reasoning_tokens: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>
|
||||||
| undefined,
|
| undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -234,6 +238,48 @@ describe("openai-completions tool_choice", () => {
|
|||||||
expect(response.errorMessage).toBe("Provider finish_reason: network_error");
|
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 () => {
|
it("uses OpenRouter reasoning object instead of reasoning_effort", async () => {
|
||||||
const model = getModel("openrouter", "deepseek/deepseek-r1")!;
|
const model = getModel("openrouter", "deepseek/deepseek-r1")!;
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user