diff --git a/js/plugins/compat-oai/src/model.ts b/js/plugins/compat-oai/src/model.ts index c5f72deb48..3d530e233c 100644 --- a/js/plugins/compat-oai/src/model.ts +++ b/js/plugins/compat-oai/src/model.ts @@ -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)); // Build content array based on what's present in the delta let content: Part[] = []; diff --git a/js/plugins/compat-oai/tests/compat_oai_test.ts b/js/plugins/compat-oai/tests/compat_oai_test.ts index 91678261d5..8c9f4f20b6 100644 --- a/js/plugins/compat-oai/tests/compat_oai_test.ts +++ b/js/plugins/compat-oai/tests/compat_oai_test.ts @@ -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: {