Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/model/providers/openai/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,9 @@ function toolCallEvents(
if (typeof record.id === "string") {
current.id = record.id;
}
if (typeof fn.name === "string") {
current.name = fn.name;
const name = readToolCallName(record, fn);
if (name) {
current.name = name;
}

if (!state.toolCalls.has(index)) {
Expand All @@ -201,12 +202,13 @@ function toolCallEvents(
});
}

if (typeof fn.arguments === "string") {
current.argumentsBuffer = `${current.argumentsBuffer ?? ""}${fn.arguments}`;
const argumentsDelta = readToolCallArgumentsDelta(record, fn);
if (argumentsDelta !== undefined) {
current.argumentsBuffer = `${current.argumentsBuffer ?? ""}${argumentsDelta}`;
events.push({
type: "tool_call_delta",
id: current.id ?? generateStreamToolCallId(index),
delta: fn.arguments,
delta: argumentsDelta,
raw,
});
}
Expand All @@ -221,6 +223,18 @@ function finishToolCalls(state: OpenAIStreamState, raw: unknown): CanonicalModel
const events: CanonicalModelEvent[] = [];

for (const [index, toolCall] of state.toolCalls.entries()) {
const name = readNonEmptyString(toolCall.name);
if (!name) {
throw new ModelProviderError({
provider: "openai",
protocol: "openai",
code: "missing_tool_name",
message: "OpenAI stream emitted a tool call without a function name.",
retryable: true,
raw,
});
}

const rawArguments = toolCall.argumentsBuffer ?? "{}";
let input: unknown;
try {
Expand Down Expand Up @@ -255,7 +269,7 @@ function finishToolCalls(state: OpenAIStreamState, raw: unknown): CanonicalModel
type: "tool_call_end",
toolCall: {
id: readNonEmptyString(toolCall.id) ?? generateStreamToolCallId(index),
name: toolCall.name ?? "",
name,
input,
raw,
},
Expand All @@ -277,6 +291,27 @@ function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}

function readToolCallName(
record: Record<string, unknown>,
fn: Record<string, unknown>,
): string | undefined {
return readNonEmptyString(fn.name)
?? readNonEmptyString(record.name)
?? readNonEmptyString(record.function_name)
?? readNonEmptyString(record.tool_name);
}

function readToolCallArgumentsDelta(
record: Record<string, unknown>,
fn: Record<string, unknown>,
): string | undefined {
if (typeof fn.arguments === "string") return fn.arguments;
if (typeof record.arguments === "string") return record.arguments;
if (typeof record.input === "string") return record.input;
if (record.input !== undefined) return JSON.stringify(record.input);
return undefined;
}

function generateStreamToolCallId(index: number): string {
return `call_${index}`;
}
143 changes: 143 additions & 0 deletions tests/model/providers/openai/stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
createOpenAIStreamState,
normalizeOpenAIStreamEvent,
} from "../../../../src/model/providers/openai/stream.js";
import { ModelProviderError } from "../../../../src/model/protocol/errors.js";

test("normalizeOpenAIStreamEvent parses standard OpenAI streaming tool calls", () => {
const state = createOpenAIStreamState();

normalizeOpenAIStreamEvent({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: {
name: "read_file",
arguments: "{\"file_path\":\"README.md\"",
},
},
],
},
},
],
}, state);

const events = normalizeOpenAIStreamEvent({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: "}",
},
},
],
},
finish_reason: "tool_calls",
},
],
}, state);

const toolCallEnd = events.find((event) => event.type === "tool_call_end");
assert.ok(toolCallEnd);
if (toolCallEnd.type !== "tool_call_end") {
throw new Error(`Expected tool_call_end, got ${toolCallEnd.type}`);
}
assert.equal(toolCallEnd.toolCall.id, "call_1");
assert.equal(toolCallEnd.toolCall.name, "read_file");
assert.deepEqual(toolCallEnd.toolCall.input, { file_path: "README.md" });
});

test("normalizeOpenAIStreamEvent accepts top-level tool call name and arguments variants", () => {
const state = createOpenAIStreamState();

normalizeOpenAIStreamEvent({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_2",
name: "read_file",
arguments: "{\"file_path\":\"README.md\"",
},
],
},
},
],
}, state);

const events = normalizeOpenAIStreamEvent({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
input: "}",
},
],
},
finish_reason: "tool_calls",
},
],
}, state);

const toolCallEnd = events.find((event) => event.type === "tool_call_end");
assert.ok(toolCallEnd);
if (toolCallEnd.type !== "tool_call_end") {
throw new Error(`Expected tool_call_end, got ${toolCallEnd.type}`);
}
assert.equal(toolCallEnd.toolCall.id, "call_2");
assert.equal(toolCallEnd.toolCall.name, "read_file");
assert.deepEqual(toolCallEnd.toolCall.input, { file_path: "README.md" });
});

test("normalizeOpenAIStreamEvent rejects tool calls without a function name", () => {
const state = createOpenAIStreamState();

normalizeOpenAIStreamEvent({
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_3",
function: {
arguments: "{\"file_path\":\"README.md\"}",
},
},
],
},
},
],
}, state);

assert.throws(
() => normalizeOpenAIStreamEvent({
choices: [
{
delta: {},
finish_reason: "tool_calls",
},
],
}, state),
(error: unknown) => {
assert.ok(error instanceof ModelProviderError);
assert.equal(error.error.code, "missing_tool_name");
return true;
},
);
});