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
13 changes: 10 additions & 3 deletions js/plugins/compat-oai/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,16 @@ export function fromOpenAIChunkChoice(
choice: ChatCompletionChunk.Choice,
jsonMode = false
): GenerateResponseData {
const toolRequestParts = choice.delta.tool_calls?.map((toolCall) =>
fromOpenAIToolCall(toolCall, choice)
);
// While streaming, OpenAI-compatible models emit the tool call's identity
// (id + function.name) only in the first delta; the following deltas carry
// incremental `arguments` fragments with no id and no name. Mapping those
// continuation deltas produced empty `{ toolRequest: { input: '' } }` chunks
// (see #5374). Keep only deltas that start a new tool call — the complete
// tool call (with assembled arguments) is reconstructed from the final
// response via `stream.finalChatCompletion()`.
const toolRequestParts = choice.delta.tool_calls
?.filter((toolCall) => toolCall.id || toolCall.function?.name)
.map((toolCall) => fromOpenAIToolCall(toolCall, choice));
Comment on lines +443 to +445

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a streaming chunk contains toolCall.id but toolCall.function is undefined (or toolCall.function.name is undefined), the current filter toolCall.id || toolCall.function?.name will evaluate to true.

This leads to two issues:

  1. If toolCall.function is undefined, fromOpenAIToolCall will throw a runtime error because of the check if (!toolCall.function) { throw Error(...) }.
  2. If toolCall.function is defined but toolCall.function.name is undefined, fromOpenAIToolCall will return a ToolRequestPart with name: undefined, which violates the type definition of ToolRequestPart (where name is a required string).

Filtering solely on toolCall.function?.name guarantees that both toolCall.function and toolCall.function.name are defined, preventing both the runtime crash and the invalid type output.

Suggested change
const toolRequestParts = choice.delta.tool_calls
?.filter((toolCall) => toolCall.id || toolCall.function?.name)
.map((toolCall) => fromOpenAIToolCall(toolCall, choice));
const toolRequestParts = choice.delta.tool_calls
?.filter((toolCall) => toolCall.function?.name)
.map((toolCall) => fromOpenAIToolCall(toolCall, choice));


// Build content array based on what's present in the delta
let content: Part[] = [];
Expand Down
62 changes: 62 additions & 0 deletions js/plugins/compat-oai/tests/compat_oai_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,68 @@ describe('fromOpenAiChunkChoice', () => {
finishReason: 'stop',
},
},
{
should:
'emit a tool request part for the first streaming tool_call delta',
chunkChoice: {
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'call_abc',
function: {
name: 'list_files',
arguments: '',
},
},
],
},
finish_reason: null,
},
expectedOutput: {
finishReason: 'unknown',
message: {
role: 'model',
content: [
{
toolRequest: {
name: 'list_files',
input: '',
ref: 'call_abc',
},
},
],
},
},
},
{
should:
'skip streaming tool_call argument-continuation deltas (no id, no name)',
chunkChoice: {
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
function: {
arguments: '{"path":',
},
},
],
} as any,
finish_reason: null,
},
expectedOutput: {
finishReason: 'unknown',
message: {
role: 'model',
content: [],
},
},
},
{
should: 'work with reasoning_content',
chunkChoice: {
Expand Down
Loading