diff --git a/.changeset/fix-tool-input-end-streaming.md b/.changeset/fix-tool-input-end-streaming.md new file mode 100644 index 00000000..76e00685 --- /dev/null +++ b/.changeset/fix-tool-input-end-streaming.md @@ -0,0 +1,9 @@ +--- +"@openrouter/ai-sdk-provider": patch +--- + +Fix missing `tool-input-end` event in multi-chunk and flush tool call streaming paths + +The multi-chunk tool call merge path and the flush path for unsent tool calls were missing the `tool-input-end` event before emitting `tool-call`. This diverged from the stream event protocol used by `@ai-sdk/openai`, which consistently emits `tool-input-start → tool-input-delta → tool-input-end → tool-call`. + +The flush path for unsent tool calls also now emits the full `tool-input-start → tool-input-delta → tool-input-end` sequence before `tool-call`, matching the reference implementation. diff --git a/e2e/issues/issue-413-tool-input-end-streaming.test.ts b/e2e/issues/issue-413-tool-input-end-streaming.test.ts new file mode 100644 index 00000000..f0fb8953 --- /dev/null +++ b/e2e/issues/issue-413-tool-input-end-streaming.test.ts @@ -0,0 +1,95 @@ +/** + * Regression test for GitHub issue #413 + * https://github.com/OpenRouterTeam/ai-sdk-provider/issues/413 + * + * Issue: "Tool call events are buffered until stream ends (flush) causing + * perceived streaming delay" + * + * The reporter observed that streaming tool calls with @openrouter/ai-sdk-provider + * were missing `tool-input-end` events in the multi-chunk tool call path, diverging + * from the protocol used by @ai-sdk/openai. The single-chunk path correctly emitted + * tool-input-start -> tool-input-delta -> tool-input-end -> tool-call, but the + * multi-chunk merge path skipped tool-input-end before tool-call. + * + * This test verifies that streamText with tool calls emits the complete event + * sequence including tool-input-end before tool-call. + */ +import { streamText, tool } from 'ai'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; +import { createOpenRouter } from '@/src'; + +vi.setConfig({ + testTimeout: 60_000, +}); + +describe('Issue #413: Tool call streaming should emit tool-input-end before tool-call', () => { + const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + baseUrl: `${process.env.OPENROUTER_API_BASE}/api/v1`, + }); + + it('should emit tool-input-end before tool-call with openai/gpt-4.1-nano', async () => { + const getWeather = tool({ + description: 'Gets the current weather for a given city', + inputSchema: z.object({ + city: z.string().describe('The city to get weather for'), + }), + execute: async ({ city }) => { + return { city, temperature: 72, condition: 'sunny' }; + }, + }); + + const response = streamText({ + model: openrouter('openai/gpt-4.1-nano'), + prompt: 'What is the weather in San Francisco? Use the getWeather tool.', + tools: { getWeather }, + toolChoice: 'required', + }); + + const events: string[] = []; + for await (const event of response.fullStream) { + events.push(event.type); + } + + expect(events).toContain('tool-input-start'); + expect(events).toContain('tool-input-end'); + expect(events).toContain('tool-call'); + + const toolInputEndIndex = events.indexOf('tool-input-end'); + const toolCallIndex = events.indexOf('tool-call'); + expect(toolInputEndIndex).toBeLessThan(toolCallIndex); + }); + + it('should emit tool-input-end before tool-call with openai/gpt-4.1-mini', async () => { + const getWeather = tool({ + description: 'Gets the current weather for a given city', + inputSchema: z.object({ + city: z.string().describe('The city to get weather for'), + }), + execute: async ({ city }) => { + return { city, temperature: 72, condition: 'sunny' }; + }, + }); + + const response = streamText({ + model: openrouter('openai/gpt-4.1-mini'), + prompt: 'What is the weather in San Francisco? Use the getWeather tool.', + tools: { getWeather }, + toolChoice: 'required', + }); + + const events: string[] = []; + for await (const event of response.fullStream) { + events.push(event.type); + } + + expect(events).toContain('tool-input-start'); + expect(events).toContain('tool-input-end'); + expect(events).toContain('tool-call'); + + const toolInputEndIndex = events.indexOf('tool-input-end'); + const toolCallIndex = events.indexOf('tool-call'); + expect(toolInputEndIndex).toBeLessThan(toolCallIndex); + }); +}); diff --git a/src/chat/index.test.ts b/src/chat/index.test.ts index 97e45855..45bad00e 100644 --- a/src/chat/index.test.ts +++ b/src/chat/index.test.ts @@ -1626,6 +1626,10 @@ describe('doStream', () => { id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', delta: '"}', }, + { + type: 'tool-input-end', + id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', + }, { type: 'tool-call', toolCallId: 'call_O17Uplv4lJvD6DVdIvFFeRMw', @@ -1805,6 +1809,66 @@ describe('doStream', () => { ]); }); + it('should emit tool-input-end without duplicate delta when partially-streamed tool call is flushed', async () => { + server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { + type: 'stream-chunks', + chunks: [ + `data: {"id":"chatcmpl-flush","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + + `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` + + `"tool_calls":[{"index":0,"id":"call_flush_test","type":"function","function":{"name":"test-tool","arguments":""}}]},` + + `"logprobs":null,"finish_reason":null}]}\n\n`, + `data: {"id":"chatcmpl-flush","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + + `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\""}}]},` + + `"logprobs":null,"finish_reason":null}]}\n\n`, + `data: {"id":"chatcmpl-flush","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + + `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"val"}}]},` + + `"logprobs":null,"finish_reason":null}]}\n\n`, + `data: {"id":"chatcmpl-flush","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + + `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}\n\n`, + `data: {"id":"chatcmpl-flush","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + + `"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}\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, + }); + + const elements = await convertReadableStreamToArray(stream); + const types = elements.map((el) => el.type); + + expect(types).toContain('tool-input-start'); + expect(types).toContain('tool-input-delta'); + expect(types).toContain('tool-input-end'); + expect(types).toContain('tool-call'); + + const toolInputEndIndex = types.indexOf('tool-input-end'); + const toolCallIndex = types.indexOf('tool-call'); + expect(toolInputEndIndex).toBeLessThan(toolCallIndex); + + const toolInputDeltas = elements.filter( + (el) => el.type === 'tool-input-delta', + ); + expect(toolInputDeltas).toStrictEqual([ + { type: 'tool-input-delta', id: 'call_flush_test', delta: '{"' }, + { type: 'tool-input-delta', id: 'call_flush_test', delta: 'val' }, + ]); + }); + it('should override finishReason to tool-calls in streaming when tool calls and encrypted reasoning are present', async () => { server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { type: 'stream-chunks', diff --git a/src/chat/index.ts b/src/chat/index.ts index 7307d14b..115c7d1f 100644 --- a/src/chat/index.ts +++ b/src/chat/index.ts @@ -992,6 +992,11 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { toolCall.function?.arguments != null && isParsableJson(toolCall.function.arguments) ) { + controller.enqueue({ + type: 'tool-input-end', + id: toolCall.id, + }); + // Only attach reasoning_details to the first tool call to avoid // duplicating thinking blocks for parallel tool calls (Claude) controller.enqueue({ @@ -1045,16 +1050,36 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 { if (finishReason.unified === 'tool-calls') { for (const toolCall of toolCalls) { if (toolCall && !toolCall.sent) { + const toolInput = isParsableJson(toolCall.function.arguments) + ? toolCall.function.arguments + : '{}'; + + if (!toolCall.inputStarted) { + controller.enqueue({ + type: 'tool-input-start', + id: toolCall.id, + toolName: toolCall.function.name, + }); + + controller.enqueue({ + type: 'tool-input-delta', + id: toolCall.id, + delta: toolInput, + }); + } + + controller.enqueue({ + type: 'tool-input-end', + id: toolCall.id, + }); + // Only attach reasoning_details to the first tool call to avoid // duplicating thinking blocks for parallel tool calls (Claude) controller.enqueue({ type: 'tool-call', toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, - // Coerce invalid arguments to an empty JSON object - input: isParsableJson(toolCall.function.arguments) - ? toolCall.function.arguments - : '{}', + input: toolInput, providerMetadata: !reasoningDetailsAttachedToToolCall ? { openrouter: {