
























1-import { describe, expect, it } from "vitest";
1+import type { ChatCompletionChunk } from "openai/resources/chat/completions.js";
2+import { describe, expect, it, vi } from "vitest";
23import type { Context, Model } from "../types.js";
4+5+type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
6+7+const mockChunksRef: { chunks: DeepPartial<ChatCompletionChunk>[] } = { chunks: [] };
8+9+vi.mock("openai", () => {
10+class MockOpenAI {
11+chat = {
12+completions: {
13+create: () => ({
14+withResponse: async () => {
15+async function* generate() {
16+for (const chunk of mockChunksRef.chunks) {
17+yield chunk;
18+}
19+}
20+return {
21+data: generate(),
22+response: { status: 200, headers: new Headers() },
23+};
24+},
25+}),
26+},
27+};
28+}
29+return { default: MockOpenAI };
30+});
31+332import { streamOpenAICompletions, streamSimpleOpenAICompletions } from "./openai-completions.js";
43334+const model = {
35+id: "gpt-5.5",
36+name: "GPT-5.5",
37+api: "openai-completions",
38+provider: "openai",
39+baseUrl: "https://api.openai.com/v1",
40+reasoning: false,
41+input: ["text"],
42+cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
43+contextWindow: 128_000,
44+maxTokens: 4096,
45+} satisfies Model<"openai-completions">;
46+547const context = {
648messages: [{ role: "user", content: "hi", timestamp: 1 }],
749} satisfies Context;
@@ -21,6 +63,44 @@ function createModel(maxTokens: number): Model<"openai-completions"> {
2163};
2264}
236566+function makeTextChunk(text: string): DeepPartial<ChatCompletionChunk> {
67+return {
68+id: "chatcmpl-test",
69+choices: [{ index: 0, delta: { content: text, role: "assistant" } }],
70+};
71+}
72+73+function makeToolCallChunk(
74+id: string,
75+name: string,
76+args: string,
77+finishReason?: string,
78+): DeepPartial<ChatCompletionChunk> {
79+return {
80+id: "chatcmpl-test",
81+choices: [
82+{
83+index: 0,
84+delta: {
85+tool_calls: [{ index: 0, id, function: { name, arguments: args }, type: "function" }],
86+},
87+finish_reason: finishReason as ChatCompletionChunk.Choice["finish_reason"],
88+},
89+],
90+};
91+}
92+93+function makeFinishChunk(
94+finishReason: string,
95+usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number },
96+): DeepPartial<ChatCompletionChunk> {
97+return {
98+id: "chatcmpl-test",
99+choices: [{ index: 0, delta: {}, finish_reason: finishReason as never }],
100+ ...(usage ? { usage } : {}),
101+};
102+}
103+24104describe("OpenAI-compatible completions params", () => {
25105it("clamps requested max tokens to the model output cap", async () => {
26106let capturedMaxTokens: unknown;
@@ -56,3 +136,65 @@ describe("OpenAI-compatible completions params", () => {
56136expect(capturedStop).toEqual(["STOP"]);
57137});
58138});
139+140+describe("openai-completions stop-reason tool-call guard", () => {
141+it("strips toolCall blocks when finish_reason is stop but tool_calls were accumulated", async () => {
142+mockChunksRef.chunks = [
143+makeTextChunk("Hello"),
144+makeToolCallChunk("call_1", "bash", '{"cmd":"ls"}'),
145+makeFinishChunk("stop"),
146+];
147+148+const stream = streamOpenAICompletions(model, context, {
149+apiKey: "sk-test",
150+});
151+const result = await stream.result();
152+153+expect(result.stopReason).toBe("stop");
154+expect(result.content.filter((b) => b.type === "toolCall")).toStrictEqual([]);
155+expect(result.content.some((b) => b.type === "text")).toBe(true);
156+});
157+158+it("preserves toolCall blocks when finish_reason is tool_calls", async () => {
159+mockChunksRef.chunks = [
160+makeToolCallChunk("call_1", "bash", '{"cmd":"ls"}'),
161+makeFinishChunk("tool_calls"),
162+];
163+164+const stream = streamOpenAICompletions(model, context, {
165+apiKey: "sk-test",
166+});
167+const result = await stream.result();
168+169+expect(result.stopReason).toBe("toolUse");
170+const toolCalls = result.content.filter((b) => b.type === "toolCall");
171+expect(toolCalls).toHaveLength(1);
172+});
173+174+it("strips toolCall blocks when finish_reason is length but tool_calls were accumulated", async () => {
175+mockChunksRef.chunks = [
176+makeToolCallChunk("call_1", "bash", '{"cmd":"ls"}'),
177+makeFinishChunk("length"),
178+];
179+180+const stream = streamOpenAICompletions(model, context, {
181+apiKey: "sk-test",
182+});
183+const result = await stream.result();
184+185+expect(result.stopReason).toBe("length");
186+expect(result.content.filter((b) => b.type === "toolCall")).toStrictEqual([]);
187+});
188+189+it("downgrades toolUse stop reason when finish_reason is tool_calls but no tool_calls accumulated", async () => {
190+mockChunksRef.chunks = [makeTextChunk("Just text"), makeFinishChunk("tool_calls")];
191+192+const stream = streamOpenAICompletions(model, context, {
193+apiKey: "sk-test",
194+});
195+const result = await stream.result();
196+197+expect(result.stopReason).toBe("stop");
198+expect(result.content.filter((b) => b.type === "toolCall")).toStrictEqual([]);
199+});
200+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。