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
5 changes: 5 additions & 0 deletions .changeset/breezy-eagles-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ai-sdk/openai-compatible": patch
---

fix(openai-compatible): buffer tool call deltas until function.name arrives
Original file line number Diff line number Diff line change
Expand Up @@ -2217,6 +2217,165 @@ describe('doStream', () => {
`);
});

it('should stream tool deltas when function.name arrives in a later chunk', async () => {
server.urls['https://my.api.com/v1/chat/completions'].response = {
type: 'stream-chunks',
chunks: [
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}\n\n`,
// First tool_calls delta carries id and (empty) arguments but no function.name
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","type":"function","function":{"arguments":""}}]},` +
`"finish_reason":null}]}\n\n`,
// function.name arrives in the next chunk together with the start of the arguments
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_late","type":"function","function":{"name":"test-tool","arguments":"{\\""}}]},` +
`"finish_reason":null}]}\n\n`,
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},` +
`"finish_reason":null}]}\n\n`,
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\"hi\\"}"}}]},` +
`"finish_reason":null}]}\n\n`,
`data: {"id":"chatcmpl-late-name","object":"chat.completion.chunk","created":1729171479,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":18,"completion_tokens":10,"total_tokens":28}}\n\n`,
'data: [DONE]\n\n',
],
};

const { stream } = await model.doStream({
tools: [
{
type: 'function',
name: 'test-tool',
inputSchema: {
type: 'object',
properties: { value: { type: 'string' } },
required: ['value'],
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
prompt: TEST_PROMPT,
includeRawChunks: false,
});

expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
[
{
"type": "stream-start",
"warnings": [],
},
{
"id": "chatcmpl-late-name",
"modelId": "grok-3",
"timestamp": 2024-03-25T09:06:38.000Z,
"type": "response-metadata",
},
{
"id": "call_late",
"toolName": "test-tool",
"type": "tool-input-start",
},
{
"delta": "{"",
"id": "call_late",
"type": "tool-input-delta",
},
{
"delta": "value",
"id": "call_late",
"type": "tool-input-delta",
},
{
"delta": "":"hi"}",
"id": "call_late",
"type": "tool-input-delta",
},
{
"id": "call_late",
"type": "tool-input-end",
},
{
"input": "{"value":"hi"}",
"toolCallId": "call_late",
"toolName": "test-tool",
"type": "tool-call",
},
{
"finishReason": {
"raw": "tool_calls",
"unified": "tool-calls",
},
"providerMetadata": {
"test-provider": {},
},
"type": "finish",
"usage": {
"inputTokens": {
"cacheRead": 0,
"cacheWrite": undefined,
"noCache": 18,
"total": 18,
},
"outputTokens": {
"reasoning": 0,
"text": 10,
"total": 10,
},
"raw": {
"completion_tokens": 10,
"prompt_tokens": 18,
"total_tokens": 28,
},
},
},
]
`);
});

it('should error when streamed tool call never receives a function.name', async () => {
server.urls['https://my.api.com/v1/chat/completions'].response = {
type: 'stream-chunks',
chunks: [
`data: {"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}\n\n`,
`data: {"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1711357598,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_missing","type":"function","function":{"arguments":"{}"}}]},` +
`"finish_reason":null}]}\n\n`,
`data: {"id":"chatcmpl-no-name","object":"chat.completion.chunk","created":1729171479,"model":"grok-3",` +
`"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":18,"completion_tokens":10,"total_tokens":28}}\n\n`,
'data: [DONE]\n\n',
],
};

const { stream } = await model.doStream({
tools: [
{
type: 'function',
name: 'test-tool',
inputSchema: {
type: 'object',
properties: { value: { type: 'string' } },
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
prompt: TEST_PROMPT,
includeRawChunks: false,
});

await expect(
convertReadableStreamToArray(stream),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[AI_InvalidResponseDataError: Expected 'function.name' to be a string.]`,
);
});

it('should stream tool call with thought signature from extra_content', async () => {
server.urls['https://my.api.com/v1/chat/completions'].response = {
type: 'stream-chunks',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,18 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
thoughtSignature?: string;
}> = [];

// Buffers tool-call deltas by `index` until `function.name` is known.
// Some OpenAI-compatible providers send the first delta without
// `function.name`.
const pendingToolCalls = new Map<
number,
{
id: string | null;
bufferedArguments: string;
thoughtSignature: string | undefined;
}
>();

let finishReason: LanguageModelV3FinishReason = {
unified: 'other',
raw: undefined,
Expand Down Expand Up @@ -553,38 +565,103 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
const index = toolCallDelta.index ?? toolCalls.length;

if (toolCalls[index] == null) {
if (toolCallDelta.id == null) {
throw new InvalidResponseDataError({
data: toolCallDelta,
message: `Expected 'id' to be a string.`,
if (toolCallDelta.index != null) {
// Buffer deltas until `function.name` is known. Some
// OpenAI-compatible providers send the first delta
// without `function.name`.
let pending = pendingToolCalls.get(index);
if (pending == null) {
pending = {
id: toolCallDelta.id ?? null,
bufferedArguments: '',
thoughtSignature:
toolCallDelta.extra_content?.google
?.thought_signature ?? undefined,
};
pendingToolCalls.set(index, pending);
} else {
if (pending.id == null && toolCallDelta.id != null) {
pending.id = toolCallDelta.id;
}
if (
pending.thoughtSignature == null &&
toolCallDelta.extra_content?.google
?.thought_signature != null
) {
pending.thoughtSignature =
toolCallDelta.extra_content.google.thought_signature;
}
}

const argumentsDelta = toolCallDelta.function?.arguments;
if (argumentsDelta != null) {
pending.bufferedArguments += argumentsDelta;
}

const name = toolCallDelta.function?.name;
if (name == null) {
continue; // wait for the delta that carries the name
}

pendingToolCalls.delete(index);

if (pending.id == null) {
throw new InvalidResponseDataError({
data: toolCallDelta,
message: `Expected 'id' to be a string.`,
});
}

controller.enqueue({
type: 'tool-input-start',
id: pending.id,
toolName: name,
});
}

if (toolCallDelta.function?.name == null) {
throw new InvalidResponseDataError({
data: toolCallDelta,
message: `Expected 'function.name' to be a string.`,
toolCalls[index] = {
id: pending.id,
type: 'function',
function: {
name,
arguments: pending.bufferedArguments,
},
hasFinished: false,
thoughtSignature: pending.thoughtSignature,
};
} else {
if (toolCallDelta.id == null) {
throw new InvalidResponseDataError({
data: toolCallDelta,
message: `Expected 'id' to be a string.`,
});
}

if (toolCallDelta.function?.name == null) {
throw new InvalidResponseDataError({
data: toolCallDelta,
message: `Expected 'function.name' to be a string.`,
});
}

controller.enqueue({
type: 'tool-input-start',
id: toolCallDelta.id,
toolName: toolCallDelta.function.name,
});
}

controller.enqueue({
type: 'tool-input-start',
id: toolCallDelta.id,
toolName: toolCallDelta.function.name,
});

toolCalls[index] = {
id: toolCallDelta.id,
type: 'function',
function: {
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments ?? '',
},
hasFinished: false,
thoughtSignature:
toolCallDelta.extra_content?.google?.thought_signature ??
undefined,
};
toolCalls[index] = {
id: toolCallDelta.id,
type: 'function',
function: {
name: toolCallDelta.function.name,
arguments: toolCallDelta.function.arguments ?? '',
},
hasFinished: false,
thoughtSignature:
toolCallDelta.extra_content?.google
?.thought_signature ?? undefined,
};
}

const toolCall = toolCalls[index];

Expand Down Expand Up @@ -636,6 +713,19 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
controller.enqueue({ type: 'text-end', id: 'txt-0' });
}

// Tool-call deltas that never received a `function.name` are
// invalid, preserving the original invalid-response semantics.
for (const [index, pending] of pendingToolCalls) {
throw new InvalidResponseDataError({
data: {
index,
id: pending.id,
function: { arguments: pending.bufferedArguments },
},
message: `Expected 'function.name' to be a string.`,
});
}

// go through all tool calls and send the ones that are not finished
for (const toolCall of toolCalls.filter(
toolCall => !toolCall.hasFinished,
Expand Down
Loading