From 68db0a75ea5292a553c5fed7e77a989587c423b7 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Tue, 14 Jul 2026 17:42:03 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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 = [ { From 129aef0d40b623f3957a7ce7fdd097e78a885ad9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 7 Aug 2026 16:59:42 +0200 Subject: [PATCH 4/4] test(openai-base): add a real round-trip guard for the call_id split The mock-based E2E suite cannot catch this class of bug. aimock maps an incoming `function_call.call_id` straight onto `tool_calls[].id`, so a request that uses the WRONG id consistently in both the `function_call` and its `function_call_output` still correlates and still passes. I confirmed this by running the new `openrouter-responses -- tool-calling` matrix entry against the pre-fix adapter source: it passes. Only a provider that knows the real item -> call_id mapping rejects the old shape. So the regression net has to be a unit test. `openai-base` had none for the full round trip: its request-mapping test hand-builds the assistant message including `metadata.itemId`, which assumes the very propagation that can break. Add a two-turn test that drives the real `chat()` agent loop with a server tool and asserts the second request carries both identifiers. This covers every adapter inheriting the base -- ai-openai, ai-grok, ai-bedrock, and the OpenAI-compatible adapter -- none of which override convertMessagesToInput. Verified it fails against the pre-fix source (`call_id: fc_item_1`, no `id`). Also stop labelling the output item id as `toolCallId` in the diagnostic log payloads of both Responses adapters. Now that the two identifiers differ, those fields reported the item id under the tool-call name. Each site logs `itemId`, plus `toolCallId: metadata.callId` where the metadata is in scope. --- .../src/adapters/responses-text.ts | 22 +-- .../src/adapters/responses-text.ts | 22 +-- .../openai-base/tests/responses-text.test.ts | 128 +++++++++++++++++- 3 files changed, 155 insertions(+), 17 deletions(-) diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts index 0acb4c835..842530bc2 100644 --- a/packages/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/ai-openrouter/src/adapters/responses-text.ts @@ -1197,7 +1197,9 @@ export class OpenRouterResponsesTextAdapter< `${this.name}.processStreamChunks orphan function_call_arguments.delta`, { source: `${this.name}.processStreamChunks`, - toolCallId: itemId, + // No metadata yet, so the `call_id` is unknown here — only the + // output item id the delta referenced. + itemId, rawDelta: chunk.delta, }, ) @@ -1224,7 +1226,8 @@ export class OpenRouterResponsesTextAdapter< `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`, { source: `${this.name}.processStreamChunks`, - toolCallId: itemId, + ...(metadata && { toolCallId: metadata.callId }), + itemId, rawArguments: chunk.arguments, }, ) @@ -1245,10 +1248,11 @@ export class OpenRouterResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${itemId}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: itemId, + toolCallId: metadata.callId, + itemId, toolName: name, rawArguments: chunk.arguments, }, @@ -1320,10 +1324,11 @@ export class OpenRouterResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${item.id}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: item.id, + toolCallId: metadata.callId, + itemId: item.id, toolName: name, rawArguments: rawArgs, }, @@ -1402,10 +1407,11 @@ export class OpenRouterResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${item.id}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: item.id, + toolCallId: metadata.callId, + itemId: item.id, toolName: name, rawArguments: rawArgs, }, diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts index eb62a6941..7cb009788 100644 --- a/packages/openai-base/src/adapters/responses-text.ts +++ b/packages/openai-base/src/adapters/responses-text.ts @@ -1239,7 +1239,9 @@ export abstract class OpenAIBaseResponsesTextAdapter< `${this.name}.processStreamChunks orphan function_call_arguments.delta`, { source: `${this.name}.processStreamChunks`, - toolCallId: chunk.item_id, + // No metadata yet, so the `call_id` is unknown here — only the + // output item id the delta referenced. + itemId: chunk.item_id, rawDelta: chunk.delta, }, ) @@ -1274,7 +1276,8 @@ export abstract class OpenAIBaseResponsesTextAdapter< `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`, { source: `${this.name}.processStreamChunks`, - toolCallId: item_id, + ...(metadata && { toolCallId: metadata.callId }), + itemId: item_id, rawArguments: chunk.arguments, }, ) @@ -1301,10 +1304,11 @@ export abstract class OpenAIBaseResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${item_id}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: item_id, + toolCallId: metadata.callId, + itemId: item_id, toolName: name, rawArguments: chunk.arguments, }, @@ -1381,10 +1385,11 @@ export abstract class OpenAIBaseResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${item.id}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: item.id, + toolCallId: metadata.callId, + itemId: item.id, toolName: name, rawArguments: rawArgs, }, @@ -1463,10 +1468,11 @@ export abstract class OpenAIBaseResponsesTextAdapter< { error: toRunErrorPayload( parseError, - `tool ${name} (${item.id}) returned malformed JSON arguments`, + `tool ${name} (${metadata.callId}) returned malformed JSON arguments`, ), source: `${this.name}.processStreamChunks`, - toolCallId: item.id, + toolCallId: metadata.callId, + itemId: item.id, toolName: name, rawArguments: rawArgs, }, diff --git a/packages/openai-base/tests/responses-text.test.ts b/packages/openai-base/tests/responses-text.test.ts index 4e03ffe27..428c9e779 100644 --- a/packages/openai-base/tests/responses-text.test.ts +++ b/packages/openai-base/tests/responses-text.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { OpenAIBaseResponsesTextAdapter } from '../src/adapters/responses-text' import type OpenAI from 'openai' -import { EventType } from '@tanstack/ai' +import { EventType, chat } from '@tanstack/ai' import type { StreamChunk, Tool } from '@tanstack/ai' import { resolveDebugOption } from '@tanstack/ai/adapter-internals' @@ -2167,6 +2167,132 @@ describe('OpenAIBaseResponsesTextAdapter', () => { ) }) + /** + * End-to-end guard for the `call_id` / output-item-`id` split, covering + * every adapter that inherits this base (`@tanstack/ai-openai`, + * `@tanstack/ai-grok`, `@tanstack/ai-bedrock`, and the OpenAI-compatible + * adapter — none of them override `convertMessagesToInput`). + * + * Unlike the request-mapping test above, this drives the real `chat()` + * agent loop, so it proves the output item id actually survives the round + * trip: adapter -> TOOL_CALL_START metadata -> assistant ModelMessage -> + * adapter. Hand-building the assistant message would assume the very + * propagation that can break. + * + * This has to live here, in a unit test. The mock-based E2E suite cannot + * catch it: aimock maps an incoming `function_call.call_id` straight onto + * `tool_calls[].id`, so a request that uses the *wrong* id consistently in + * both the `function_call` and its `function_call_output` still correlates + * and still passes. Only a provider that knows the real item -> call_id + * mapping rejects it. + */ + it('round-trips distinct output item and call IDs through a server tool', async () => { + const firstTurn = [ + { + type: 'response.created', + response: { + id: 'resp-1', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { + type: 'function_call', + id: 'fc_item_1', + call_id: 'call_abc', + name: 'lookup_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_item_1', + output_index: 0, + arguments: '{"location":"Berlin"}', + }, + { + type: 'response.completed', + response: { + id: 'resp-1', + model: 'test-model', + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_item_1', + call_id: 'call_abc', + name: 'lookup_weather', + arguments: '{"location":"Berlin"}', + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ] + const secondTurn = [ + { + type: 'response.created', + response: { + id: 'resp-2', + model: 'test-model', + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + item_id: 'msg_1', + output_index: 0, + content_index: 0, + delta: 'Sunny', + }, + { + type: 'response.completed', + response: { + id: 'resp-2', + model: 'test-model', + status: 'completed', + output: [], + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }, + }, + ] + + mockResponsesCreate = vi + .fn() + .mockResolvedValueOnce(createAsyncIterable(firstTurn)) + .mockResolvedValueOnce(createAsyncIterable(secondTurn)) + const execute = vi.fn().mockReturnValue({ temperature: 72 }) + + for await (const _chunk of chat({ + adapter: new TestResponsesAdapter(testConfig, 'test-model'), + messages: [{ role: 'user', content: 'How is the weather?' }], + tools: [{ ...weatherTool, execute }], + })) { + // consume both agent-loop turns + } + + expect(execute).toHaveBeenCalledOnce() + expect(mockResponsesCreate).toHaveBeenCalledTimes(2) + + const secondRequest = mockResponsesCreate.mock.calls[1]![0] + expect(secondRequest.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'function_call', + id: 'fc_item_1', + call_id: 'call_abc', + }), + expect.objectContaining({ + type: 'function_call_output', + call_id: 'call_abc', + }), + ]), + ) + }) + it('converts a multimodal tool result to a structured function_call_output', async () => { const streamChunks = [ {