Skip to content

Commit 7d92296

Browse files
fix: preserve Responses tool call correlation IDs (#936)
* fix: preserve Responses tool call correlation IDs * test: cover OpenRouter Responses tool round trips * fix: preserve streamed tool call names * 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. --------- Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
1 parent 40b2730 commit 7d92296

9 files changed

Lines changed: 490 additions & 111 deletions

File tree

.changeset/fuzzy-pandas-call.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/ai-openrouter': patch
3+
'@tanstack/openai-base': patch
4+
---
5+
6+
Preserve distinct Responses output item and function call IDs across server tool round trips.

packages/ai-openrouter/src/adapters/responses-text.ts

Lines changed: 71 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ import type {
3939
OpenRouterChatModelToolCapabilitiesByName,
4040
OpenRouterModelInputModalitiesByName,
4141
} from '../model-meta'
42-
import type { OpenRouterMessageMetadataByModality } from '../message-types'
42+
import type {
43+
OpenRouterMessageMetadataByModality,
44+
OpenRouterResponsesToolCallMetadata,
45+
} from '../message-types'
4346

4447
/** Element type of `ResponsesRequest.input` when it's the array form (the
4548
* SDK union also allows a bare string). Pinning to the array element lets
@@ -49,6 +52,16 @@ type InputsItem = Extract<InputsUnion, ReadonlyArray<unknown>>[number]
4952
/** ResponsesRequest input content part shape (per-content-part discriminated union). */
5053
type ResponsesInputContent = unknown
5154

55+
interface StreamedFunctionCallMetadata {
56+
callId: string
57+
index: number
58+
itemId: string
59+
name: string
60+
started: boolean
61+
ended?: boolean
62+
pendingArguments?: string
63+
}
64+
5265
export interface OpenRouterResponsesConfig extends SDKOptions {}
5366
export type OpenRouterResponsesTextModels =
5467
(typeof OPENROUTER_CHAT_MODELS)[number]
@@ -91,7 +104,8 @@ export class OpenRouterResponsesTextAdapter<
91104
OpenRouterResponsesTextProviderOptions,
92105
ResolveInputModalities<TModel>,
93106
OpenRouterMessageMetadataByModality,
94-
TToolCapabilities
107+
TToolCapabilities,
108+
OpenRouterResponsesToolCallMetadata
95109
> {
96110
override readonly kind = 'text' as const
97111
readonly name = 'openrouter-responses' as const
@@ -109,16 +123,7 @@ export class OpenRouterResponsesTextAdapter<
109123
// Track tool call metadata by unique ID. The Responses API streams tool
110124
// calls with deltas — first chunk has ID/name, subsequent chunks only
111125
// have args. We assign our own indices as we encounter unique ids.
112-
const toolCallMetadata = new Map<
113-
string,
114-
{
115-
index: number
116-
name: string
117-
started: boolean
118-
ended?: boolean
119-
pendingArguments?: string
120-
}
121-
>()
126+
const toolCallMetadata = new Map<string, StreamedFunctionCallMetadata>()
122127

123128
// AG-UI lifecycle tracking
124129
const aguiState = {
@@ -777,16 +782,7 @@ export class OpenRouterResponsesTextAdapter<
777782
*/
778783
protected async *processStreamChunks(
779784
stream: AsyncIterable<StreamEvents>,
780-
toolCallMetadata: Map<
781-
string,
782-
{
783-
index: number
784-
name: string
785-
started: boolean
786-
ended?: boolean
787-
pendingArguments?: string
788-
}
789-
>,
785+
toolCallMetadata: Map<string, StreamedFunctionCallMetadata>,
790786
options: TextOptions<OpenRouterResponsesTextProviderOptions>,
791787
aguiState: {
792788
runId: string
@@ -1159,24 +1155,30 @@ export class OpenRouterResponsesTextAdapter<
11591155
let metadata = toolCallMetadata.get(item.id)
11601156
if (!metadata) {
11611157
metadata = {
1158+
callId: item.callId || item.id,
11621159
index: chunk.outputIndex ?? 0,
1163-
name: item.name,
1160+
itemId: item.id,
1161+
name: item.name || '',
11641162
started: false,
11651163
}
11661164
toolCallMetadata.set(item.id, metadata)
1167-
} else if (!metadata.name) {
1168-
metadata.name = item.name
1165+
} else {
1166+
if (item.callId) metadata.callId = item.callId
1167+
if (!metadata.name && item.name) metadata.name = item.name
11691168
}
11701169
if (!metadata.started && metadata.name) {
11711170
yield {
11721171
type: EventType.TOOL_CALL_START,
1173-
toolCallId: item.id,
1172+
toolCallId: metadata.callId,
11741173
toolCallName: metadata.name,
11751174
toolName: metadata.name,
11761175
parentMessageId: aguiState.messageId,
11771176
model: model || options.model,
11781177
timestamp: Date.now(),
11791178
index: chunk.outputIndex ?? 0,
1179+
metadata: {
1180+
itemId: metadata.itemId,
1181+
} satisfies OpenRouterResponsesToolCallMetadata,
11801182
}
11811183
metadata.started = true
11821184
}
@@ -1195,15 +1197,17 @@ export class OpenRouterResponsesTextAdapter<
11951197
`${this.name}.processStreamChunks orphan function_call_arguments.delta`,
11961198
{
11971199
source: `${this.name}.processStreamChunks`,
1198-
toolCallId: itemId,
1200+
// No metadata yet, so the `call_id` is unknown here — only the
1201+
// output item id the delta referenced.
1202+
itemId,
11991203
rawDelta: chunk.delta,
12001204
},
12011205
)
12021206
continue
12031207
}
12041208
yield {
12051209
type: EventType.TOOL_CALL_ARGS,
1206-
toolCallId: itemId,
1210+
toolCallId: metadata.callId,
12071211
model: model || options.model,
12081212
timestamp: Date.now(),
12091213
delta: typeof chunk.delta === 'string' ? chunk.delta : '',
@@ -1222,7 +1226,8 @@ export class OpenRouterResponsesTextAdapter<
12221226
`${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`,
12231227
{
12241228
source: `${this.name}.processStreamChunks`,
1225-
toolCallId: itemId,
1229+
...(metadata && { toolCallId: metadata.callId }),
1230+
itemId,
12261231
rawArguments: chunk.arguments,
12271232
},
12281233
)
@@ -1243,10 +1248,11 @@ export class OpenRouterResponsesTextAdapter<
12431248
{
12441249
error: toRunErrorPayload(
12451250
parseError,
1246-
`tool ${name} (${itemId}) returned malformed JSON arguments`,
1251+
`tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
12471252
),
12481253
source: `${this.name}.processStreamChunks`,
1249-
toolCallId: itemId,
1254+
toolCallId: metadata.callId,
1255+
itemId,
12501256
toolName: name,
12511257
rawArguments: chunk.arguments,
12521258
},
@@ -1257,7 +1263,7 @@ export class OpenRouterResponsesTextAdapter<
12571263

12581264
yield {
12591265
type: EventType.TOOL_CALL_END,
1260-
toolCallId: itemId,
1266+
toolCallId: metadata.callId,
12611267
toolCallName: name,
12621268
toolName: name,
12631269
model: model || options.model,
@@ -1272,25 +1278,31 @@ export class OpenRouterResponsesTextAdapter<
12721278
const item = chunk.item
12731279
if (item?.type === 'function_call' && item.id) {
12741280
const metadata = toolCallMetadata.get(item.id) ?? {
1281+
callId: item.callId || item.id,
12751282
index: chunk.outputIndex ?? 0,
1276-
name: item.name,
1283+
itemId: item.id,
1284+
name: item.name || '',
12771285
started: false,
12781286
}
12791287
if (!toolCallMetadata.has(item.id)) {
12801288
toolCallMetadata.set(item.id, metadata)
1281-
} else if (!metadata.name) {
1282-
metadata.name = item.name
1289+
} else {
1290+
if (item.callId) metadata.callId = item.callId
1291+
if (!metadata.name && item.name) metadata.name = item.name
12831292
}
12841293
if (!metadata.started && metadata.name) {
12851294
yield {
12861295
type: EventType.TOOL_CALL_START,
1287-
toolCallId: item.id,
1296+
toolCallId: metadata.callId,
12881297
toolCallName: metadata.name,
12891298
toolName: metadata.name,
12901299
parentMessageId: aguiState.messageId,
12911300
model: model || options.model,
12921301
timestamp: Date.now(),
12931302
index: metadata.index,
1303+
metadata: {
1304+
itemId: metadata.itemId,
1305+
} satisfies OpenRouterResponsesToolCallMetadata,
12941306
}
12951307
metadata.started = true
12961308
}
@@ -1312,10 +1324,11 @@ export class OpenRouterResponsesTextAdapter<
13121324
{
13131325
error: toRunErrorPayload(
13141326
parseError,
1315-
`tool ${name} (${item.id}) returned malformed JSON arguments`,
1327+
`tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
13161328
),
13171329
source: `${this.name}.processStreamChunks`,
1318-
toolCallId: item.id,
1330+
toolCallId: metadata.callId,
1331+
itemId: item.id,
13191332
toolName: name,
13201333
rawArguments: rawArgs,
13211334
},
@@ -1325,7 +1338,7 @@ export class OpenRouterResponsesTextAdapter<
13251338
}
13261339
yield {
13271340
type: EventType.TOOL_CALL_END,
1328-
toolCallId: item.id,
1341+
toolCallId: metadata.callId,
13291342
toolCallName: name,
13301343
toolName: name,
13311344
model: model || options.model,
@@ -1348,25 +1361,31 @@ export class OpenRouterResponsesTextAdapter<
13481361
for (const item of outputItems) {
13491362
if (item.type !== 'function_call' || !item.id) continue
13501363
const metadata = toolCallMetadata.get(item.id) ?? {
1364+
callId: item.callId || item.id,
13511365
index: 0,
1366+
itemId: item.id,
13521367
name: item.name || '',
13531368
started: false,
13541369
}
13551370
if (!toolCallMetadata.has(item.id)) {
13561371
toolCallMetadata.set(item.id, metadata)
1357-
} else if (!metadata.name && item.name) {
1358-
metadata.name = item.name
1372+
} else {
1373+
if (item.callId) metadata.callId = item.callId
1374+
if (!metadata.name && item.name) metadata.name = item.name
13591375
}
13601376
if (!metadata.started && metadata.name) {
13611377
yield {
13621378
type: EventType.TOOL_CALL_START,
1363-
toolCallId: item.id,
1379+
toolCallId: metadata.callId,
13641380
toolCallName: metadata.name,
13651381
toolName: metadata.name,
13661382
parentMessageId: aguiState.messageId,
13671383
model: model || options.model,
13681384
timestamp: Date.now(),
13691385
index: metadata.index,
1386+
metadata: {
1387+
itemId: metadata.itemId,
1388+
} satisfies OpenRouterResponsesToolCallMetadata,
13701389
}
13711390
metadata.started = true
13721391
}
@@ -1388,10 +1407,11 @@ export class OpenRouterResponsesTextAdapter<
13881407
{
13891408
error: toRunErrorPayload(
13901409
parseError,
1391-
`tool ${name} (${item.id}) returned malformed JSON arguments`,
1410+
`tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
13921411
),
13931412
source: `${this.name}.processStreamChunks`,
1394-
toolCallId: item.id,
1413+
toolCallId: metadata.callId,
1414+
itemId: item.id,
13951415
toolName: name,
13961416
rawArguments: rawArgs,
13971417
},
@@ -1401,7 +1421,7 @@ export class OpenRouterResponsesTextAdapter<
14011421
}
14021422
yield {
14031423
type: EventType.TOOL_CALL_END,
1404-
toolCallId: item.id,
1424+
toolCallId: metadata.callId,
14051425
toolCallName: name,
14061426
toolName: name,
14071427
model: model || options.model,
@@ -1631,10 +1651,15 @@ export class OpenRouterResponsesTextAdapter<
16311651
typeof toolCall.function.arguments === 'string'
16321652
? toolCall.function.arguments
16331653
: JSON.stringify(toolCall.function.arguments)
1654+
const itemId = (
1655+
toolCall.metadata as
1656+
| OpenRouterResponsesToolCallMetadata
1657+
| undefined
1658+
)?.itemId
16341659
result.push({
16351660
type: 'function_call',
16361661
callId: toolCall.id,
1637-
id: toolCall.id,
1662+
id: itemId || toolCall.id,
16381663
name: toolCall.function.name,
16391664
arguments: argumentsString,
16401665
})

packages/ai-openrouter/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type {
5757
OpenRouterVideoMetadata,
5858
OpenRouterDocumentMetadata,
5959
OpenRouterMessageMetadataByModality,
60+
OpenRouterResponsesToolCallMetadata,
6061
} from './message-types'
6162
export type {
6263
WebPlugin,

packages/ai-openrouter/src/message-types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,9 @@ export interface OpenRouterMessageMetadataByModality {
1717
video: OpenRouterVideoMetadata
1818
document: OpenRouterDocumentMetadata
1919
}
20+
21+
/** Provider state required to replay an OpenRouter Responses tool call. */
22+
export interface OpenRouterResponsesToolCallMetadata {
23+
/** Responses output item ID, distinct from the function call's `call_id`. */
24+
itemId: string
25+
}

0 commit comments

Comments
 (0)