Skip to content

Commit 9dcc703

Browse files
committed
fix(openai): keep stop-finished tool calls
1 parent 01f6ad6 commit 9dcc703

4 files changed

Lines changed: 161 additions & 2 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6984,7 +6984,134 @@ describe("openai transport stream", () => {
69846984
});
69856985
});
69866986

6987-
it("strips tool call blocks when provider signals finish_reason stop", async () => {
6987+
it("promotes silent tool calls when provider signals finish_reason stop", async () => {
6988+
const model = {
6989+
id: "qwen3.6-27b",
6990+
name: "Qwen 3.6 27B",
6991+
api: "openai-completions",
6992+
provider: "vllm",
6993+
baseUrl: "http://localhost:8000/v1",
6994+
reasoning: false,
6995+
input: ["text"],
6996+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
6997+
contextWindow: 131072,
6998+
maxTokens: 8192,
6999+
} satisfies Model<"openai-completions">;
7000+
7001+
const output = createAssistantOutput(model);
7002+
const stream = { push: () => {} };
7003+
7004+
const mockChunks = [
7005+
{
7006+
id: "chatcmpl-test",
7007+
object: "chat.completion.chunk" as const,
7008+
created: 1775425651,
7009+
model: "qwen3.6-27b",
7010+
choices: [
7011+
{
7012+
index: 0,
7013+
delta: { role: "assistant" as const, content: "" },
7014+
logprobs: null,
7015+
finish_reason: null,
7016+
},
7017+
],
7018+
},
7019+
{
7020+
id: "chatcmpl-test",
7021+
object: "chat.completion.chunk" as const,
7022+
created: 1775425651,
7023+
model: "qwen3.6-27b",
7024+
choices: [
7025+
{
7026+
index: 0,
7027+
delta: {
7028+
tool_calls: [
7029+
{
7030+
index: 0,
7031+
id: "call_legit",
7032+
function: { name: "bash", arguments: '{"cmd":"echo hi"}' },
7033+
},
7034+
],
7035+
},
7036+
logprobs: null,
7037+
finish_reason: "stop",
7038+
},
7039+
],
7040+
},
7041+
] as const;
7042+
7043+
async function* mockStream() {
7044+
for (const chunk of mockChunks) {
7045+
yield chunk as never;
7046+
}
7047+
}
7048+
7049+
await testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
7050+
7051+
expect(output.stopReason).toBe("toolUse");
7052+
const toolCalls = output.content.filter(
7053+
(block) => (block as { type?: string }).type === "toolCall",
7054+
);
7055+
expect(toolCalls).toHaveLength(1);
7056+
});
7057+
7058+
it("does not promote tool calls when provider omits final finish_reason", async () => {
7059+
const model = {
7060+
id: "qwen3.6-27b",
7061+
name: "Qwen 3.6 27B",
7062+
api: "openai-completions",
7063+
provider: "vllm",
7064+
baseUrl: "http://localhost:8000/v1",
7065+
reasoning: false,
7066+
input: ["text"],
7067+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
7068+
contextWindow: 131072,
7069+
maxTokens: 8192,
7070+
} satisfies Model<"openai-completions">;
7071+
7072+
const output = createAssistantOutput(model);
7073+
const stream = { push: () => {} };
7074+
7075+
const mockChunks = [
7076+
{
7077+
id: "chatcmpl-test",
7078+
object: "chat.completion.chunk" as const,
7079+
created: 1775425651,
7080+
model: "qwen3.6-27b",
7081+
choices: [
7082+
{
7083+
index: 0,
7084+
delta: {
7085+
tool_calls: [
7086+
{
7087+
index: 0,
7088+
id: "call_unfinished",
7089+
function: { name: "bash", arguments: '{"cmd":"echo hi"}' },
7090+
},
7091+
],
7092+
},
7093+
logprobs: null,
7094+
finish_reason: null,
7095+
},
7096+
],
7097+
},
7098+
] as const;
7099+
7100+
async function* mockStream() {
7101+
for (const chunk of mockChunks) {
7102+
yield chunk as never;
7103+
}
7104+
}
7105+
7106+
await testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
7107+
7108+
expect(output.stopReason).toBe("stop");
7109+
expect(
7110+
output.content.filter((block) => (block as { type?: string }).type === "toolCall"),
7111+
).toStrictEqual([]);
7112+
});
7113+
7114+
it("strips tool call blocks when provider signals finish_reason stop after visible text", async () => {
69887115
const model = {
69897116
id: "llama-3.3-70b",
69907117
name: "Llama 3.3 70B",

src/agents/openai-transport-stream.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2545,6 +2545,7 @@ async function processOpenAICompletionsStream(
25452545
const toolCallBlocksByIndex = new Map<number, ToolCallBlock>();
25462546
const toolCallBlocksById = new Map<string, ToolCallBlock>();
25472547
const toolCallBlockBytes = new WeakMap<ToolCallBlock, number>();
2548+
let sawStopFinishReason = false;
25482549
const blockIndex = () => output.content.length - 1;
25492550
const measureUtf8Bytes = (text: string) => Buffer.byteLength(text, "utf8");
25502551
const finishCurrentBlock = () => {
@@ -2693,6 +2694,9 @@ async function processOpenAICompletionsStream(
26932694
if (choice.finish_reason) {
26942695
const finishReasonResult = mapStopReason(choice.finish_reason);
26952696
output.stopReason = finishReasonResult.stopReason;
2697+
if (finishReasonResult.stopReason === "stop") {
2698+
sawStopFinishReason = true;
2699+
}
26962700
if (finishReasonResult.errorMessage) {
26972701
output.errorMessage = finishReasonResult.errorMessage;
26982702
}
@@ -2804,9 +2808,15 @@ async function processOpenAICompletionsStream(
28042808
currentBlock = null;
28052809
flushPendingPostToolCallDeltas();
28062810
const hasToolCalls = output.content.some((block) => block.type === "toolCall");
2811+
const hasVisibleText = output.content.some(
2812+
(block) => block.type === "text" && block.text.trim().length > 0,
2813+
);
28072814
if (output.stopReason === "toolUse" && !hasToolCalls) {
28082815
output.stopReason = "stop";
28092816
}
2817+
if (sawStopFinishReason && output.stopReason === "stop" && hasToolCalls && !hasVisibleText) {
2818+
output.stopReason = "toolUse";
2819+
}
28102820
if (hasToolCalls && output.stopReason !== "toolUse") {
28112821
output.content = output.content.filter((block) => block.type !== "toolCall");
28122822
}

src/llm/providers/openai-completions.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,23 @@ describe("OpenAI-compatible completions params", () => {
138138
});
139139

140140
describe("openai-completions stop-reason tool-call guard", () => {
141-
it("strips toolCall blocks when finish_reason is stop but tool_calls were accumulated", async () => {
141+
it("promotes silent tool_calls with finish_reason stop to toolUse", async () => {
142+
mockChunksRef.chunks = [
143+
makeToolCallChunk("call_1", "bash", '{"cmd":"ls"}'),
144+
makeFinishChunk("stop"),
145+
];
146+
147+
const stream = streamOpenAICompletions(model, context, {
148+
apiKey: "sk-test",
149+
});
150+
const result = await stream.result();
151+
152+
expect(result.stopReason).toBe("toolUse");
153+
const toolCalls = result.content.filter((b) => b.type === "toolCall");
154+
expect(toolCalls).toHaveLength(1);
155+
});
156+
157+
it("strips toolCall blocks when finish_reason is stop after visible text", async () => {
142158
mockChunksRef.chunks = [
143159
makeTextChunk("Hello"),
144160
makeToolCallChunk("call_1", "bash", '{"cmd":"ls"}'),

src/llm/providers/openai-completions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,15 @@ export const streamOpenAICompletions: StreamFunction<
430430
}
431431

432432
const hasToolCalls = output.content.some((block) => block.type === "toolCall");
433+
const hasVisibleText = output.content.some(
434+
(block) => block.type === "text" && block.text.trim().length > 0,
435+
);
433436
if (output.stopReason === "toolUse" && !hasToolCalls) {
434437
output.stopReason = "stop";
435438
}
439+
if (output.stopReason === "stop" && hasToolCalls && !hasVisibleText) {
440+
output.stopReason = "toolUse";
441+
}
436442
if (hasToolCalls && output.stopReason !== "toolUse") {
437443
output.content = output.content.filter((block) => block.type !== "toolCall");
438444
}

0 commit comments

Comments
 (0)