Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/fix-streaming-tool-call-early-finalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@ai-sdk/openai': patch
'@ai-sdk/openai-compatible': patch
'@ai-sdk/groq': patch
'@ai-sdk/deepseek': patch
'@ai-sdk/alibaba': patch
---

fix(security): prevent streaming tool calls from finalizing on parsable partial JSON

Streaming tool call arguments were finalized using `isParsableJson()` as a heuristic for completion. If partial accumulated JSON happened to be valid JSON before all chunks arrived, the tool call would be executed with incomplete arguments. Tool call finalization now only occurs in `flush()` after the stream is fully consumed.
Original file line number Diff line number Diff line change
Expand Up @@ -2966,6 +2966,11 @@ exports[`doStream > tool call > should stream tool call 1`] = `
"id": "call_eee11723464a4b9eb8cee71d",
"type": "tool-input-delta",
},
{
"delta": "",
"id": "call_eee11723464a4b9eb8cee71d",
"type": "tool-input-delta",
},
{
"id": "call_eee11723464a4b9eb8cee71d",
"type": "tool-input-end",
Expand Down
54 changes: 19 additions & 35 deletions packages/alibaba/src/alibaba-chat-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
createEventSourceResponseHandler,
createJsonResponseHandler,
generateId,
isParsableJson,
parseProviderOptions,
postJsonToApi,
type ParseResult,
Expand Down Expand Up @@ -426,23 +425,6 @@ export class AlibabaLanguageModel implements LanguageModelV3 {
});
}

// Check if already complete (some providers send full tool call at once)
if (isParsableJson(toolCall.function.arguments)) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});

toolCall.hasFinished = true;
}

continue;
}

Expand All @@ -464,23 +446,6 @@ export class AlibabaLanguageModel implements LanguageModelV3 {
delta: toolCallDelta.function.arguments,
});
}

// Check if tool call is now complete
if (isParsableJson(toolCall.function.arguments)) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});

toolCall.hasFinished = true;
}
}
}

Expand All @@ -505,6 +470,25 @@ export class AlibabaLanguageModel implements LanguageModelV3 {
controller.enqueue({ type: 'text-end', id: '0' });
}

// Finalize any unfinished tool calls on stream end to
// prevent premature execution from parsable partial JSON.
for (const toolCall of toolCalls) {
if (!toolCall.hasFinished) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id,
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}

controller.enqueue({
type: 'finish',
finishReason,
Expand Down
38 changes: 0 additions & 38 deletions packages/deepseek/src/chat/deepseek-chat-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
FetchFunction,
generateId,
InferSchema,
isParsableJson,
parseProviderOptions,
ParseResult,
postJsonToApi,
Expand Down Expand Up @@ -421,23 +420,6 @@ export class DeepSeekChatLanguageModel implements LanguageModelV3 {
delta: toolCall.function.arguments,
});
}

// check if tool call is complete
// (some providers send the full tool call in one chunk):
if (isParsableJson(toolCall.function.arguments)) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}

continue;
Expand All @@ -461,26 +443,6 @@ export class DeepSeekChatLanguageModel implements LanguageModelV3 {
id: toolCall.id,
delta: toolCallDelta.function.arguments ?? '',
});

// check if tool call is complete
if (
toolCall.function?.name != null &&
toolCall.function?.arguments != null &&
isParsableJson(toolCall.function.arguments)
) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}
}
},
Expand Down
5 changes: 5 additions & 0 deletions packages/groq/src/groq-chat-language-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,11 @@ describe('doStream', () => {
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-delta",
},
{
"delta": "",
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-delta",
},
{
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-end",
Expand Down
57 changes: 19 additions & 38 deletions packages/groq/src/groq-chat-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
createEventSourceResponseHandler,
createJsonResponseHandler,
generateId,
isParsableJson,
parseProviderOptions,
postJsonToApi,
} from '@ai-sdk/provider-utils';
Expand Down Expand Up @@ -465,23 +464,6 @@ export class GroqChatLanguageModel implements LanguageModelV3 {
delta: toolCall.function.arguments,
});
}

// check if tool call is complete
// (some providers send the full tool call in one chunk):
if (isParsableJson(toolCall.function.arguments)) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}

continue;
Expand All @@ -505,26 +487,6 @@ export class GroqChatLanguageModel implements LanguageModelV3 {
id: toolCall.id,
delta: toolCallDelta.function.arguments ?? '',
});

// check if tool call is complete
if (
toolCall.function?.name != null &&
toolCall.function?.arguments != null &&
isParsableJson(toolCall.function.arguments)
) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}
}
},
Expand All @@ -538,6 +500,25 @@ export class GroqChatLanguageModel implements LanguageModelV3 {
controller.enqueue({ type: 'text-end', id: 'txt-0' });
}

// Finalize any unfinished tool calls on stream end to
// prevent premature execution from parsable partial JSON.
for (const toolCall of toolCalls) {
if (!toolCall.hasFinished) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
});
toolCall.hasFinished = true;
}
}

controller.enqueue({
type: 'finish',
finishReason,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,11 @@ describe('doStream', () => {
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-delta",
},
{
"delta": "",
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-delta",
},
{
"id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
"type": "tool-input-end",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
createJsonResponseHandler,
FetchFunction,
generateId,
isParsableJson,
parseProviderOptions,
ParseResult,
postJsonToApi,
Expand Down Expand Up @@ -567,32 +566,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
delta: toolCall.function.arguments,
});
}

// check if tool call is complete
// (some providers send the full tool call in one chunk):
if (isParsableJson(toolCall.function.arguments)) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
...(toolCall.thoughtSignature
? {
providerMetadata: {
[providerOptionsName]: {
thoughtSignature: toolCall.thoughtSignature,
},
},
}
: {}),
});
toolCall.hasFinished = true;
}
}

continue;
Expand All @@ -616,35 +589,6 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
id: toolCall.id,
delta: toolCallDelta.function.arguments ?? '',
});

// check if tool call is complete
if (
toolCall.function?.name != null &&
toolCall.function?.arguments != null &&
isParsableJson(toolCall.function.arguments)
) {
controller.enqueue({
type: 'tool-input-end',
id: toolCall.id,
});

controller.enqueue({
type: 'tool-call',
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
...(toolCall.thoughtSignature
? {
providerMetadata: {
[providerOptionsName]: {
thoughtSignature: toolCall.thoughtSignature,
},
},
}
: {}),
});
toolCall.hasFinished = true;
}
}
}
},
Expand Down
Loading
Loading