|
| 1 | +diff --git a/src/adapters/responses-text.ts b/src/adapters/responses-text.ts |
| 2 | +--- a/src/adapters/responses-text.ts |
| 3 | ++++ b/src/adapters/responses-text.ts |
| 4 | +@@ -37,10 +37,15 @@ |
| 5 | + * Responses function calls have two identifiers: `call_id` correlates the |
| 6 | + * function output with the call, while `id` identifies the output item itself. |
| 7 | + * TanStack AI uses `call_id` as the canonical tool-call ID and carries the |
| 8 | +- * item ID here so stateless follow-up requests can replay both values. |
| 9 | ++ * item ID here so stateless follow-up requests can replay both values. The |
| 10 | ++ * response ID lets an immediately-following server tool result continue the |
| 11 | ++ * complete provider response, including opaque reasoning items that must not |
| 12 | ++ * be exposed through AG-UI. |
| 13 | + */ |
| 14 | + export interface OpenAIResponsesToolCallMetadata { |
| 15 | + itemId: string |
| 16 | ++ responseId?: string |
| 17 | ++ model?: string |
| 18 | + } |
| 19 | + |
| 20 | + interface StreamedFunctionCallMetadata { |
| 21 | +@@ -790,6 +795,7 @@ |
| 22 | + |
| 23 | + // Preserve response metadata across events |
| 24 | + let model: string = options.model |
| 25 | ++ let responseId: string | undefined |
| 26 | + |
| 27 | + // AG-UI lifecycle tracking |
| 28 | + let stepId: string | null = null |
| 29 | +@@ -887,6 +893,7 @@ |
| 30 | + // response.created marks the start of a fresh run — safe to reset |
| 31 | + // the per-run accumulators here. |
| 32 | + if (chunk.type === 'response.created') { |
| 33 | ++ responseId = chunk.response.id |
| 34 | + hasStreamedContentDeltas = false |
| 35 | + hasStreamedReasoningDeltas = false |
| 36 | + hasEmittedTextMessageStart = false |
| 37 | +@@ -1214,6 +1221,7 @@ |
| 38 | + index: chunk.output_index, |
| 39 | + metadata: { |
| 40 | + itemId: item.id, |
| 41 | ++ ...(responseId && { responseId, model }), |
| 42 | + } satisfies OpenAIResponsesToolCallMetadata, |
| 43 | + } |
| 44 | + metadata.started = true |
| 45 | +@@ -1365,6 +1373,7 @@ |
| 46 | + index: metadata.index, |
| 47 | + metadata: { |
| 48 | + itemId: item.id, |
| 49 | ++ ...(responseId && { responseId, model }), |
| 50 | + } satisfies OpenAIResponsesToolCallMetadata, |
| 51 | + } |
| 52 | + metadata.started = true |
| 53 | +@@ -1417,6 +1426,7 @@ |
| 54 | + } |
| 55 | + |
| 56 | + if (chunk.type === 'response.completed') { |
| 57 | ++ responseId = chunk.response.id |
| 58 | + // Final backstop for function_call lifecycle: if a function_call |
| 59 | + // appears in `response.output[]` but was never matched by an |
| 60 | + // output_item.added/done with a name, recover the missing START |
| 61 | +@@ -1450,6 +1460,8 @@ |
| 62 | + index: metadata.index, |
| 63 | + metadata: { |
| 64 | + itemId: item.id, |
| 65 | ++ responseId, |
| 66 | ++ model, |
| 67 | + } satisfies OpenAIResponsesToolCallMetadata, |
| 68 | + } |
| 69 | + metadata.started = true |
| 70 | +@@ -1638,7 +1650,12 @@ |
| 71 | + protected mapOptionsToRequest( |
| 72 | + options: TextOptions<TProviderOptions>, |
| 73 | + ): Omit<ResponseCreateParams, 'stream'> { |
| 74 | +- const input = this.convertMessagesToInput(options.messages) |
| 75 | ++ const continuation = this.getToolContinuation( |
| 76 | ++ options.messages, |
| 77 | ++ options.model, |
| 78 | ++ ) |
| 79 | ++ const input = |
| 80 | ++ continuation?.input ?? this.convertMessagesToInput(options.messages) |
| 81 | + |
| 82 | + const tools = options.tools |
| 83 | + ? convertToolsToResponsesFormat( |
| 84 | +@@ -1685,6 +1702,9 @@ |
| 85 | + return { |
| 86 | + ...modelOptions, |
| 87 | + model: options.model, |
| 88 | ++ ...(continuation && { |
| 89 | ++ previous_response_id: continuation.responseId, |
| 90 | ++ }), |
| 91 | + ...(options.metadata !== undefined && { metadata: options.metadata }), |
| 92 | + ...(() => { |
| 93 | + const prompts = normalizeSystemPrompts(options.systemPrompts) |
| 94 | +@@ -1706,6 +1726,70 @@ |
| 95 | + */ |
| 96 | + supportsCombinedToolsAndSchema(): boolean { |
| 97 | + return true |
| 98 | ++ } |
| 99 | ++ |
| 100 | ++ /** |
| 101 | ++ * Continue the provider response only for the server-tool phase immediately |
| 102 | ++ * following it. `previous_response_id` makes OpenAI restore every output |
| 103 | ++ * item in its original order, including reasoning items that cannot safely |
| 104 | ++ * cross the public AG-UI stream. |
| 105 | ++ */ |
| 106 | ++ private getToolContinuation( |
| 107 | ++ messages: Array<ModelMessage>, |
| 108 | ++ model: string, |
| 109 | ++ ): { responseId: string; input: ResponseInput } | undefined { |
| 110 | ++ for (let index = messages.length - 1; index >= 0; index--) { |
| 111 | ++ const message = messages[index] |
| 112 | ++ if (!message) continue |
| 113 | ++ |
| 114 | ++ if (message.role === 'user') return undefined |
| 115 | ++ if (message.role !== 'assistant' || !message.toolCalls?.length) continue |
| 116 | ++ |
| 117 | ++ const responseIds = new Set<string>() |
| 118 | ++ const toolCallIds = new Set<string>() |
| 119 | ++ for (const toolCall of message.toolCalls) { |
| 120 | ++ const metadata = toolCall.metadata |
| 121 | ++ if ( |
| 122 | ++ typeof metadata !== 'object' || |
| 123 | ++ metadata === null || |
| 124 | ++ !('responseId' in metadata) || |
| 125 | ++ typeof metadata.responseId !== 'string' || |
| 126 | ++ !('model' in metadata) || |
| 127 | ++ metadata.model !== model |
| 128 | ++ ) { |
| 129 | ++ return undefined |
| 130 | ++ } |
| 131 | ++ responseIds.add(metadata.responseId) |
| 132 | ++ toolCallIds.add(toolCall.id) |
| 133 | ++ } |
| 134 | ++ |
| 135 | ++ if (responseIds.size !== 1) return undefined |
| 136 | ++ |
| 137 | ++ const toolResults = messages.slice(index + 1) |
| 138 | ++ if (toolResults.length !== toolCallIds.size) return undefined |
| 139 | ++ |
| 140 | ++ const resultIds = new Set<string>() |
| 141 | ++ for (const toolResult of toolResults) { |
| 142 | ++ if ( |
| 143 | ++ toolResult.role !== 'tool' || |
| 144 | ++ !toolResult.toolCallId || |
| 145 | ++ !toolCallIds.has(toolResult.toolCallId) |
| 146 | ++ ) { |
| 147 | ++ return undefined |
| 148 | ++ } |
| 149 | ++ resultIds.add(toolResult.toolCallId) |
| 150 | ++ } |
| 151 | ++ if (resultIds.size !== toolCallIds.size) return undefined |
| 152 | ++ |
| 153 | ++ const responseId = responseIds.values().next().value |
| 154 | ++ if (!responseId) return undefined |
| 155 | ++ return { |
| 156 | ++ responseId, |
| 157 | ++ input: this.convertMessagesToInput(toolResults), |
| 158 | ++ } |
| 159 | ++ } |
| 160 | ++ |
| 161 | ++ return undefined |
| 162 | + } |
| 163 | + |
| 164 | + /** |
| 165 | +diff --git a/dist/esm/adapters/responses-text.js b/dist/esm/adapters/responses-text.js |
| 166 | +--- a/dist/esm/adapters/responses-text.js |
| 167 | ++++ b/dist/esm/adapters/responses-text.js |
| 168 | +@@ -508,6 +508,7 @@ |
| 169 | + let hasStreamedContentDeltas = false; |
| 170 | + let hasStreamedReasoningDeltas = false; |
| 171 | + let model = options.model; |
| 172 | ++ let responseId; |
| 173 | + let stepId = null; |
| 174 | + let hasEmittedTextMessageStart = false; |
| 175 | + let hasEmittedStepStarted = false; |
| 176 | +@@ -571,6 +572,7 @@ |
| 177 | + }; |
| 178 | + if (chunk.type === "response.created" || chunk.type === "response.incomplete" || chunk.type === "response.failed") model = chunk.response.model; |
| 179 | + if (chunk.type === "response.created") { |
| 180 | ++ responseId = chunk.response.id; |
| 181 | + hasStreamedContentDeltas = false; |
| 182 | + hasStreamedReasoningDeltas = false; |
| 183 | + hasEmittedTextMessageStart = false; |
| 184 | +@@ -778,7 +780,10 @@ |
| 185 | + model: model || options.model, |
| 186 | + timestamp: Date.now(), |
| 187 | + index: chunk.output_index, |
| 188 | +- metadata: { itemId: item.id } |
| 189 | ++ metadata: { |
| 190 | ++ itemId: item.id, |
| 191 | ++ ...responseId && { responseId, model } |
| 192 | ++ } |
| 193 | + }; |
| 194 | + metadata.started = true; |
| 195 | + } |
| 196 | +@@ -867,7 +872,10 @@ |
| 197 | + model: model || options.model, |
| 198 | + timestamp: Date.now(), |
| 199 | + index: metadata.index, |
| 200 | +- metadata: { itemId: item.id } |
| 201 | ++ metadata: { |
| 202 | ++ itemId: item.id, |
| 203 | ++ ...responseId && { responseId, model } |
| 204 | ++ } |
| 205 | + }; |
| 206 | + metadata.started = true; |
| 207 | + } |
| 208 | +@@ -904,6 +912,7 @@ |
| 209 | + } |
| 210 | + } |
| 211 | + if (chunk.type === "response.completed") { |
| 212 | ++ responseId = chunk.response.id; |
| 213 | + for (const item of chunk.response.output) { |
| 214 | + if (item.type !== "function_call" || !item.id) continue; |
| 215 | + const metadata = toolCallMetadata.get(item.id) ?? { |
| 216 | +@@ -927,7 +936,11 @@ |
| 217 | + model: model || options.model, |
| 218 | + timestamp: Date.now(), |
| 219 | + index: metadata.index, |
| 220 | +- metadata: { itemId: item.id } |
| 221 | ++ metadata: { |
| 222 | ++ itemId: item.id, |
| 223 | ++ responseId, |
| 224 | ++ model |
| 225 | ++ } |
| 226 | + }; |
| 227 | + metadata.started = true; |
| 228 | + } |
| 229 | +@@ -1044,7 +1057,8 @@ |
| 230 | + * Override this in subclasses to add provider-specific options. |
| 231 | + */ |
| 232 | + mapOptionsToRequest(options) { |
| 233 | +- const input = this.convertMessagesToInput(options.messages); |
| 234 | ++ const continuation = this.getToolContinuation(options.messages, options.model); |
| 235 | ++ const input = continuation?.input ?? this.convertMessagesToInput(options.messages); |
| 236 | + const tools = options.tools ? convertToolsToResponsesFormat(options.tools, this.makeStructuredOutputCompatible.bind(this)) : void 0; |
| 237 | + const modelOptions = options.modelOptions; |
| 238 | + const combinedSchema = options.outputSchema; |
| 239 | +@@ -1057,6 +1071,7 @@ |
| 240 | + return { |
| 241 | + ...modelOptions, |
| 242 | + model: options.model, |
| 243 | ++ ...continuation && { previous_response_id: continuation.responseId }, |
| 244 | + ...options.metadata !== void 0 && { metadata: options.metadata }, |
| 245 | + ...(() => { |
| 246 | + const prompts = normalizeSystemPrompts(options.systemPrompts); |
| 247 | +@@ -1075,6 +1090,43 @@ |
| 248 | + */ |
| 249 | + supportsCombinedToolsAndSchema() { |
| 250 | + return true; |
| 251 | ++ } |
| 252 | ++ /** |
| 253 | ++ * Continue the provider response only for the server-tool phase immediately |
| 254 | ++ * following it. `previous_response_id` makes OpenAI restore every output |
| 255 | ++ * item in its original order, including reasoning items that cannot safely |
| 256 | ++ * cross the public AG-UI stream. |
| 257 | ++ */ |
| 258 | ++ getToolContinuation(messages, model) { |
| 259 | ++ for (let index = messages.length - 1; index >= 0; index--) { |
| 260 | ++ const message = messages[index]; |
| 261 | ++ if (!message) continue; |
| 262 | ++ if (message.role === "user") return; |
| 263 | ++ if (message.role !== "assistant" || !message.toolCalls?.length) continue; |
| 264 | ++ const responseIds = /* @__PURE__ */ new Set(); |
| 265 | ++ const toolCallIds = /* @__PURE__ */ new Set(); |
| 266 | ++ for (const toolCall of message.toolCalls) { |
| 267 | ++ const metadata = toolCall.metadata; |
| 268 | ++ if (typeof metadata !== "object" || metadata === null || !("responseId" in metadata) || typeof metadata.responseId !== "string" || !("model" in metadata) || metadata.model !== model) return; |
| 269 | ++ responseIds.add(metadata.responseId); |
| 270 | ++ toolCallIds.add(toolCall.id); |
| 271 | ++ } |
| 272 | ++ if (responseIds.size !== 1) return; |
| 273 | ++ const toolResults = messages.slice(index + 1); |
| 274 | ++ if (toolResults.length !== toolCallIds.size) return; |
| 275 | ++ const resultIds = /* @__PURE__ */ new Set(); |
| 276 | ++ for (const toolResult of toolResults) { |
| 277 | ++ if (toolResult.role !== "tool" || !toolResult.toolCallId || !toolCallIds.has(toolResult.toolCallId)) return; |
| 278 | ++ resultIds.add(toolResult.toolCallId); |
| 279 | ++ } |
| 280 | ++ if (resultIds.size !== toolCallIds.size) return; |
| 281 | ++ const responseId = responseIds.values().next().value; |
| 282 | ++ if (!responseId) return; |
| 283 | ++ return { |
| 284 | ++ responseId, |
| 285 | ++ input: this.convertMessagesToInput(toolResults) |
| 286 | ++ }; |
| 287 | ++ } |
| 288 | + } |
| 289 | + /** |
| 290 | + * Converts ModelMessage[] to Responses API ResponseInput format. |
| 291 | +diff --git a/dist/esm/adapters/responses-text.d.ts b/dist/esm/adapters/responses-text.d.ts |
| 292 | +--- a/dist/esm/adapters/responses-text.d.ts |
| 293 | ++++ b/dist/esm/adapters/responses-text.d.ts |
| 294 | +@@ -8,10 +8,15 @@ |
| 295 | + * Responses function calls have two identifiers: `call_id` correlates the |
| 296 | + * function output with the call, while `id` identifies the output item itself. |
| 297 | + * TanStack AI uses `call_id` as the canonical tool-call ID and carries the |
| 298 | +- * item ID here so stateless follow-up requests can replay both values. |
| 299 | ++ * item ID here so stateless follow-up requests can replay both values. The |
| 300 | ++ * response ID lets an immediately-following server tool result continue the |
| 301 | ++ * complete provider response, including opaque reasoning items that must not |
| 302 | ++ * be exposed through AG-UI. |
| 303 | + */ |
| 304 | + export interface OpenAIResponsesToolCallMetadata { |
| 305 | + itemId: string; |
| 306 | ++ responseId?: string; |
| 307 | ++ model?: string; |
| 308 | + } |
| 309 | + interface StreamedFunctionCallMetadata { |
| 310 | + callId: string; |
| 311 | +@@ -119,6 +124,7 @@ |
| 312 | + * that route to providers without this capability should override. |
| 313 | + */ |
| 314 | + supportsCombinedToolsAndSchema(): boolean; |
| 315 | ++ private getToolContinuation; |
| 316 | + /** |
| 317 | + * Converts ModelMessage[] to Responses API ResponseInput format. |
| 318 | + * Override this in subclasses for provider-specific message format quirks. |
0 commit comments