From 68db0a75ea5292a553c5fed7e77a989587c423b7 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 14 Jul 2026 17:42:03 +0200 Subject: [PATCH 1/3] fix: preserve Responses tool call correlation IDs --- .changeset/fuzzy-pandas-call.md | 6 + .../src/adapters/responses-text.ts | 91 ++++++++------ packages/ai-openrouter/src/index.ts | 1 + packages/ai-openrouter/src/message-types.ts | 6 + .../openrouter-responses-adapter.test.ts | 119 +++++++++++++++++- .../src/adapters/responses-text.ts | 110 +++++++++------- packages/openai-base/src/index.ts | 5 +- .../openai-base/tests/responses-text.test.ts | 18 +-- 8 files changed, 264 insertions(+), 92 deletions(-) create mode 100644 .changeset/fuzzy-pandas-call.md diff --git a/.changeset/fuzzy-pandas-call.md b/.changeset/fuzzy-pandas-call.md new file mode 100644 index 000000000..9b1ab067e --- /dev/null +++ b/.changeset/fuzzy-pandas-call.md @@ -0,0 +1,6 @@ +--- +'@tanstack/ai-openrouter': patch +'@tanstack/openai-base': patch +--- + +Preserve distinct Responses output item and function call IDs across server tool round trips. diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 7659366eb..a0edd701f 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -39,7 +39,10 @@ import type { OpenRouterChatModelToolCapabilitiesByName, OpenRouterModelInputModalitiesByName, } from '../model-meta' -import type { OpenRouterMessageMetadataByModality } from '../message-types' +import type { + OpenRouterMessageMetadataByModality, + OpenRouterResponsesToolCallMetadata, +} from '../message-types' /** Element type of `ResponsesRequest.input` when it's the array form (the * SDK union also allows a bare string). Pinning to the array element lets @@ -49,6 +52,16 @@ type InputsItem = Extract>[number] /** ResponsesRequest input content part shape (per-content-part discriminated union). */ type ResponsesInputContent = unknown +interface StreamedFunctionCallMetadata { + callId: string + index: number + itemId: string + name: string + started: boolean + ended?: boolean + pendingArguments?: string +} + export interface OpenRouterResponsesConfig extends SDKOptions {} export type OpenRouterResponsesTextModels = (typeof OPENROUTER_CHAT_MODELS)[number] @@ -91,7 +104,8 @@ export class OpenRouterResponsesTextAdapter< OpenRouterResponsesTextProviderOptions, ResolveInputModalities, OpenRouterMessageMetadataByModality, - TToolCapabilities + TToolCapabilities, + OpenRouterResponsesToolCallMetadata > { override readonly kind = 'text' as const readonly name = 'openrouter-responses' as const @@ -109,16 +123,7 @@ export class OpenRouterResponsesTextAdapter< // Track tool call metadata by unique ID. The Responses API streams tool // calls with deltas — first chunk has ID/name, subsequent chunks only // have args. We assign our own indices as we encounter unique ids. - const toolCallMetadata = new Map< - string, - { - index: number - name: string - started: boolean - ended?: boolean - pendingArguments?: string - } - >() + const toolCallMetadata = new Map() // AG-UI lifecycle tracking const aguiState = { @@ -777,16 +782,7 @@ export class OpenRouterResponsesTextAdapter< */ protected async *processStreamChunks( stream: AsyncIterable, - toolCallMetadata: Map< - string, - { - index: number - name: string - started: boolean - ended?: boolean - pendingArguments?: string - } - >, + toolCallMetadata: Map, options: TextOptions, aguiState: { runId: string @@ -1159,24 +1155,30 @@ export class OpenRouterResponsesTextAdapter< let metadata = toolCallMetadata.get(item.id) if (!metadata) { metadata = { + callId: item.callId || item.id, index: chunk.outputIndex ?? 0, + itemId: item.id, name: item.name, started: false, } toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name) { - metadata.name = item.name + } else { + if (item.callId) metadata.callId = item.callId + if (!metadata.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: chunk.outputIndex ?? 0, + metadata: { + itemId: metadata.itemId, + } satisfies OpenRouterResponsesToolCallMetadata, } metadata.started = true } @@ -1203,7 +1205,7 @@ export class OpenRouterResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_ARGS, - toolCallId: itemId, + toolCallId: metadata.callId, model: model || options.model, timestamp: Date.now(), delta: typeof chunk.delta === 'string' ? chunk.delta : '', @@ -1257,7 +1259,7 @@ export class OpenRouterResponsesTextAdapter< yield { type: EventType.TOOL_CALL_END, - toolCallId: itemId, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1272,25 +1274,31 @@ export class OpenRouterResponsesTextAdapter< const item = chunk.item if (item?.type === 'function_call' && item.id) { const metadata = toolCallMetadata.get(item.id) ?? { + callId: item.callId || item.id, index: chunk.outputIndex ?? 0, + itemId: item.id, name: item.name, started: false, } if (!toolCallMetadata.has(item.id)) { toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name) { - metadata.name = item.name + } else { + if (item.callId) metadata.callId = item.callId + if (!metadata.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, + metadata: { + itemId: metadata.itemId, + } satisfies OpenRouterResponsesToolCallMetadata, } metadata.started = true } @@ -1325,7 +1333,7 @@ export class OpenRouterResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_END, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1348,25 +1356,31 @@ export class OpenRouterResponsesTextAdapter< for (const item of outputItems) { if (item.type !== 'function_call' || !item.id) continue const metadata = toolCallMetadata.get(item.id) ?? { + callId: item.callId || item.id, index: 0, + itemId: item.id, name: item.name || '', started: false, } if (!toolCallMetadata.has(item.id)) { toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name && item.name) { - metadata.name = item.name + } else { + if (item.callId) metadata.callId = item.callId + if (!metadata.name && item.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, + metadata: { + itemId: metadata.itemId, + } satisfies OpenRouterResponsesToolCallMetadata, } metadata.started = true } @@ -1401,7 +1415,7 @@ export class OpenRouterResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_END, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1631,10 +1645,15 @@ export class OpenRouterResponsesTextAdapter< typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments) + const itemId = ( + toolCall.metadata as + | OpenRouterResponsesToolCallMetadata + | undefined + )?.itemId result.push({ type: 'function_call', callId: toolCall.id, - id: toolCall.id, + id: itemId || toolCall.id, name: toolCall.function.name, arguments: argumentsString, }) diff --git a/packages/ai-openrouter/src/index.ts b/packages/ai-openrouter/src/index.ts index fa2b67747..63ceffaed 100644 --- a/packages/ai-openrouter/src/index.ts +++ b/packages/ai-openrouter/src/index.ts @@ -57,6 +57,7 @@ export type { OpenRouterVideoMetadata, OpenRouterDocumentMetadata, OpenRouterMessageMetadataByModality, + OpenRouterResponsesToolCallMetadata, } from './message-types' export type { WebPlugin, diff --git a/packages/ai-openrouter/src/message-types.ts b/packages/ai-openrouter/src/message-types.ts index 490ad6451..b914d3e17 100644 --- a/packages/ai-openrouter/src/message-types.ts +++ b/packages/ai-openrouter/src/message-types.ts @@ -17,3 +17,9 @@ export interface OpenRouterMessageMetadataByModality { video: OpenRouterVideoMetadata document: OpenRouterDocumentMetadata } + +/** Provider state required to replay an OpenRouter Responses tool call. */ +export interface OpenRouterResponsesToolCallMetadata { + /** Responses output item ID, distinct from the function call's `call_id`. */ + itemId: string +} diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 84c74522e..191d15223 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -698,21 +698,138 @@ describe('OpenRouter responses adapter — stream event bridge', () => { const start = chunks.find((c) => c.type === 'TOOL_CALL_START') as any expect(start).toMatchObject({ type: 'TOOL_CALL_START', - toolCallId: 'item_1', + toolCallId: 'call_abc', toolCallName: 'lookup_weather', + metadata: { itemId: 'item_1' }, }) const args = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') as any[] expect(args.length).toBe(1) + expect(args[0]!.toolCallId).toBe('call_abc') expect(args[0]!.delta).toBe('{"location":"Berlin"}') const end = chunks.find((c) => c.type === 'TOOL_CALL_END') as any + expect(end.toolCallId).toBe('call_abc') expect(end.input).toEqual({ location: 'Berlin' }) const finished = chunks.find((c) => c.type === 'RUN_FINISHED') as any expect(finished.finishReason).toBe('tool_calls') }) + it('round-trips distinct Responses item and call IDs through a server tool', async () => { + const firstTurn = [ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.done', + sequenceNumber: 2, + itemId: 'item_1', + outputIndex: 0, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + sequenceNumber: 3, + response: { + model: 'm', + output: [ + { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }, + }, + ] + const secondTurn = [ + { + type: 'response.created', + sequenceNumber: 0, + response: { model: 'm', output: [] }, + }, + { + type: 'response.output_text.delta', + sequenceNumber: 1, + itemId: 'msg_1', + outputIndex: 0, + contentIndex: 0, + delta: 'Sunny', + }, + { + type: 'response.completed', + sequenceNumber: 2, + response: { + model: 'm', + output: [], + usage: { inputTokens: 2, outputTokens: 1, totalTokens: 3 }, + }, + }, + ] + mockSend = vi + .fn() + .mockResolvedValueOnce(createAsyncIterable(firstTurn)) + .mockResolvedValueOnce(createAsyncIterable(secondTurn)) + const execute = vi.fn().mockReturnValue({ temperature: 72 }) + + for await (const _ of chat({ + adapter: createAdapter(), + messages: [{ role: 'user', content: 'How is the weather?' }], + tools: [{ ...weatherTool, execute }], + })) { + // consume both agent-loop turns + } + + expect(execute).toHaveBeenCalledOnce() + expect(mockSend).toHaveBeenCalledTimes(2) + const secondRequest = mockSend.mock.calls[1]![0].responsesRequest + expect(secondRequest.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + }), + expect.objectContaining({ + type: 'function_call_output', + callId: 'call_abc', + }), + ]), + ) + const serialized = ResponsesRequest$outboundSchema.parse(secondRequest) + expect(serialized.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'function_call', + id: 'item_1', + call_id: 'call_abc', + }), + expect.objectContaining({ + type: 'function_call_output', + call_id: 'call_abc', + }), + ]), + ) + }) + it('emits parentMessageId on tool-first tool calls matching the assistant message id', async () => { // The function call streams before any text. parentMessageId must bind the // tool call to the same assistant message id the eventual TEXT_MESSAGE_START diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index d48018209..446004fd3 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -31,6 +31,27 @@ import type { TextOptions, } from '@tanstack/ai' +/** + * Provider-specific metadata that preserves the Responses API output item ID. + * + * Responses function calls have two identifiers: `call_id` correlates the + * function output with the call, while `id` identifies the output item itself. + * TanStack AI uses `call_id` as the canonical tool-call ID and carries the + * item ID here so stateless follow-up requests can replay both values. + */ +export interface OpenAIResponsesToolCallMetadata { + itemId: string +} + +interface StreamedFunctionCallMetadata { + callId: string + index: number + name: string + started: boolean + ended?: boolean + pendingArguments?: string | undefined +} + /** * Shared implementation of the OpenAI Responses API. Holds the stream-event * accumulator + AG-UI lifecycle and calls the OpenAI SDK directly. Subclasses @@ -49,7 +70,8 @@ export abstract class OpenAIBaseResponsesTextAdapter< TProviderOptions, TInputModalities, TMessageMetadata, - TToolCapabilities + TToolCapabilities, + OpenAIResponsesToolCallMetadata > { override readonly kind = 'text' as const readonly name: string @@ -64,26 +86,10 @@ export abstract class OpenAIBaseResponsesTextAdapter< async *chatStream( options: TextOptions, ): AsyncIterable { - // Track tool call metadata by unique ID - // Responses API streams tool calls with deltas — first chunk has ID/name, - // subsequent chunks only have args. - // We assign our own indices as we encounter unique tool call IDs. - const toolCallMetadata = new Map< - string, - { - index: number - name: string - started: boolean - // Set once TOOL_CALL_END has been emitted (via args.done or the - // output_item.done backfill) so the two paths don't double-emit. - ended?: boolean - // Set when args.done arrives before TOOL_CALL_START could fire - // (output_item.added lacked a name). output_item.done picks these - // up to emit the missing END. Allow explicit `undefined` so the - // emission paths can re-clear the slot after handing it off. - pendingArguments?: string - } - >() + // Key streamed state by output item ID because argument deltas reference + // `item_id`. The state separately retains `call_id`, which is the public + // tool-call ID and the correlation key for function_call_output. + const toolCallMetadata = new Map() // AG-UI lifecycle tracking const aguiState = { @@ -759,16 +765,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< */ protected async *processStreamChunks( stream: AsyncIterable, - toolCallMetadata: Map< - string, - { - index: number - name: string - started: boolean - ended?: boolean - pendingArguments?: string | undefined - } - >, + toolCallMetadata: Map, options: TextOptions, aguiState: { runId: string @@ -1184,26 +1181,33 @@ export abstract class OpenAIBaseResponsesTextAdapter< let metadata = toolCallMetadata.get(item.id) if (!metadata) { metadata = { + callId: item.call_id || item.id, index: chunk.output_index, name: item.name || '', started: false, } toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name && item.name) { - // A later output_item.added for the same id finally carries - // the name. Update so the gated emission below can fire. - metadata.name = item.name + } else { + if (item.call_id) metadata.callId = item.call_id + if (!metadata.name && item.name) { + // A later output_item.added for the same id finally carries + // the name. Update so the gated emission below can fire. + metadata.name = item.name + } } if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: chunk.output_index, + metadata: { + itemId: item.id, + } satisfies OpenAIResponsesToolCallMetadata, } metadata.started = true } @@ -1240,7 +1244,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_ARGS, - toolCallId: chunk.item_id, + toolCallId: metadata.callId, model: model || options.model, timestamp: Date.now(), delta: chunk.delta, @@ -1308,7 +1312,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< yield { type: EventType.TOOL_CALL_END, - toolCallId: item_id, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1326,26 +1330,31 @@ export abstract class OpenAIBaseResponsesTextAdapter< const item = chunk.item if (item.type === 'function_call' && item.id) { const metadata = toolCallMetadata.get(item.id) ?? { + callId: item.call_id || item.id, index: chunk.output_index, name: item.name || '', started: false, } if (!toolCallMetadata.has(item.id)) { toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name && item.name) { - metadata.name = item.name + } else { + if (item.call_id) metadata.callId = item.call_id + if (!metadata.name && item.name) metadata.name = item.name } // Emit gated START if we now have a name and never started. if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, + metadata: { + itemId: item.id, + } satisfies OpenAIResponsesToolCallMetadata, } metadata.started = true } @@ -1382,7 +1391,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_END, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1406,25 +1415,30 @@ export abstract class OpenAIBaseResponsesTextAdapter< for (const item of chunk.response.output) { if (item.type !== 'function_call' || !item.id) continue const metadata = toolCallMetadata.get(item.id) ?? { + callId: item.call_id || item.id, index: 0, name: item.name || '', started: false, } if (!toolCallMetadata.has(item.id)) { toolCallMetadata.set(item.id, metadata) - } else if (!metadata.name && item.name) { - metadata.name = item.name + } else { + if (item.call_id) metadata.callId = item.call_id + if (!metadata.name && item.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { type: EventType.TOOL_CALL_START, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: metadata.name, toolName: metadata.name, parentMessageId: aguiState.messageId, model: model || options.model, timestamp: Date.now(), index: metadata.index, + metadata: { + itemId: item.id, + } satisfies OpenAIResponsesToolCallMetadata, } metadata.started = true } @@ -1459,7 +1473,7 @@ export abstract class OpenAIBaseResponsesTextAdapter< } yield { type: EventType.TOOL_CALL_END, - toolCallId: item.id, + toolCallId: metadata.callId, toolCallName: name, toolName: name, model: model || options.model, @@ -1725,10 +1739,14 @@ export abstract class OpenAIBaseResponsesTextAdapter< typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : JSON.stringify(toolCall.function.arguments) + const itemId = ( + toolCall.metadata as OpenAIResponsesToolCallMetadata | undefined + )?.itemId result.push({ type: 'function_call', call_id: toolCall.id, + ...(itemId && { id: itemId }), name: toolCall.function.name, arguments: argumentsString, }) diff --git a/packages/openai-base/src/index.ts b/packages/openai-base/src/index.ts index 7ad643f4a..b8ebb1643 100644 --- a/packages/openai-base/src/index.ts +++ b/packages/openai-base/src/index.ts @@ -11,7 +11,10 @@ export { convertToolsToChatCompletionsFormat, type ChatCompletionFunctionTool, } from './adapters/chat-completions-tool-converter' -export { OpenAIBaseResponsesTextAdapter } from './adapters/responses-text' +export { + OpenAIBaseResponsesTextAdapter, + type OpenAIResponsesToolCallMetadata, +} from './adapters/responses-text' export { convertFunctionToolToResponsesFormat, convertToolsToResponsesFormat, diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 884954267..4e03ffe27 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -960,7 +960,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { } }) - it('uses the internal function_call item id for tool call correlation', async () => { + it('uses call_id for tool correlation and preserves the item id as metadata', async () => { const streamChunks = [ { type: 'response.created', @@ -1027,26 +1027,26 @@ describe('OpenAIBaseResponsesTextAdapter', () => { chunks.push(chunk) } - // TOOL_CALL_* events should use the internal function_call item id - // (matches main's OpenAI adapter behavior; the agent loop carries this - // id back as `toolCallId` on the tool ModelMessage, which the Responses - // API accepts as `call_id` for function_call_output). + // `call_id` correlates the eventual function_call_output. The separate + // output item ID is provider metadata that the agent loop carries into + // the next request so both opaque identifiers can be replayed. const toolStart = chunks.find((c) => c.type === 'TOOL_CALL_START') expect(toolStart).toBeDefined() if (toolStart?.type === 'TOOL_CALL_START') { - expect(toolStart.toolCallId).toBe('fc_internal_001') + expect(toolStart.toolCallId).toBe('call_api_abc123') + expect(toolStart.metadata).toEqual({ itemId: 'fc_internal_001' }) } const toolArgs = chunks.filter((c) => c.type === 'TOOL_CALL_ARGS') expect(toolArgs.length).toBeGreaterThan(0) if (toolArgs[0]?.type === 'TOOL_CALL_ARGS') { - expect(toolArgs[0].toolCallId).toBe('fc_internal_001') + expect(toolArgs[0].toolCallId).toBe('call_api_abc123') } const toolEnd = chunks.find((c) => c.type === 'TOOL_CALL_END') expect(toolEnd).toBeDefined() if (toolEnd?.type === 'TOOL_CALL_END') { - expect(toolEnd.toolCallId).toBe('fc_internal_001') + expect(toolEnd.toolCallId).toBe('call_api_abc123') } }) @@ -2128,6 +2128,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { name: 'lookup_weather', arguments: '{"location":"Berlin"}', }, + metadata: { itemId: 'fc_123' }, }, ], }, @@ -2147,6 +2148,7 @@ describe('OpenAIBaseResponsesTextAdapter', () => { expect.arrayContaining([ expect.objectContaining({ type: 'function_call', + id: 'fc_123', call_id: 'call_123', name: 'lookup_weather', arguments: '{"location":"Berlin"}', From 922159a9d6a411a68e59ce0a2d1d329e1c29a1ce Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 14 Jul 2026 21:23:39 +0200 Subject: [PATCH 2/3] test: cover OpenRouter Responses tool round trips --- testing/e2e/src/lib/feature-support.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index cd3e2203d..943f33656 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -58,6 +58,7 @@ export const matrix: Record> = { 'bedrock', 'bedrock-responses', 'openrouter', + 'openrouter-responses', 'openai-compatible', 'mistral', ]), From a00829e92e40916af4636c9478b06210068eca14 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 14 Jul 2026 21:50:07 +0200 Subject: [PATCH 3/3] fix: preserve streamed tool call names --- .../src/adapters/responses-text.ts | 8 +-- .../openrouter-responses-adapter.test.ts | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index a0edd701f..a5b70a2f8 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1158,13 +1158,13 @@ export class OpenRouterResponsesTextAdapter< callId: item.callId || item.id, index: chunk.outputIndex ?? 0, itemId: item.id, - name: item.name, + name: item.name || '', started: false, } toolCallMetadata.set(item.id, metadata) } else { if (item.callId) metadata.callId = item.callId - if (!metadata.name) metadata.name = item.name + if (!metadata.name && item.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { @@ -1277,14 +1277,14 @@ export class OpenRouterResponsesTextAdapter< callId: item.callId || item.id, index: chunk.outputIndex ?? 0, itemId: item.id, - name: item.name, + name: item.name || '', started: false, } if (!toolCallMetadata.has(item.id)) { toolCallMetadata.set(item.id, metadata) } else { if (item.callId) metadata.callId = item.callId - if (!metadata.name) metadata.name = item.name + if (!metadata.name && item.name) metadata.name = item.name } if (!metadata.started && metadata.name) { yield { diff --git a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts index 191d15223..d01870783 100644 --- a/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-responses-adapter.test.ts @@ -716,6 +716,74 @@ describe('OpenRouter responses adapter — stream event bridge', () => { expect(finished.finishReason).toBe('tool_calls') }) + it('preserves a streamed function-call name when later items omit it', async () => { + setupMockSdkClient([ + { + type: 'response.output_item.added', + sequenceNumber: 0, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + arguments: '', + }, + }, + { + type: 'response.output_item.added', + sequenceNumber: 1, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.output_item.done', + sequenceNumber: 2, + outputIndex: 0, + item: { + type: 'function_call', + id: 'item_1', + callId: 'call_abc', + arguments: '{"location":"Berlin"}', + }, + }, + { + type: 'response.completed', + sequenceNumber: 3, + response: { + model: 'm', + output: [], + usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + }, + }, + ]) + + const chunks: Array = [] + for await (const chunk of chat({ + adapter: createAdapter(), + messages: [{ role: 'user', content: 'hi' }], + tools: [weatherTool], + })) { + chunks.push(chunk) + } + + expect( + chunks.find((chunk) => chunk.type === 'TOOL_CALL_START'), + ).toMatchObject({ + toolCallName: 'lookup_weather', + }) + expect( + chunks.find((chunk) => chunk.type === 'TOOL_CALL_END'), + ).toMatchObject({ + toolCallName: 'lookup_weather', + }) + }) + it('round-trips distinct Responses item and call IDs through a server tool', async () => { const firstTurn = [ {