@tanstack/openai-base 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,27 @@ import type {
31
31
  TextOptions,
32
32
  } from '@tanstack/ai'
33
33
 
34
+ /**
35
+ * Provider-specific metadata that preserves the Responses API output item ID.
36
+ *
37
+ * Responses function calls have two identifiers: `call_id` correlates the
38
+ * function output with the call, while `id` identifies the output item itself.
39
+ * TanStack AI uses `call_id` as the canonical tool-call ID and carries the
40
+ * item ID here so stateless follow-up requests can replay both values.
41
+ */
42
+ export interface OpenAIResponsesToolCallMetadata {
43
+ itemId: string
44
+ }
45
+
46
+ interface StreamedFunctionCallMetadata {
47
+ callId: string
48
+ index: number
49
+ name: string
50
+ started: boolean
51
+ ended?: boolean
52
+ pendingArguments?: string | undefined
53
+ }
54
+
34
55
  /**
35
56
  * Shared implementation of the OpenAI Responses API. Holds the stream-event
36
57
  * accumulator + AG-UI lifecycle and calls the OpenAI SDK directly. Subclasses
@@ -49,7 +70,8 @@ export abstract class OpenAIBaseResponsesTextAdapter<
49
70
  TProviderOptions,
50
71
  TInputModalities,
51
72
  TMessageMetadata,
52
- TToolCapabilities
73
+ TToolCapabilities,
74
+ OpenAIResponsesToolCallMetadata
53
75
  > {
54
76
  override readonly kind = 'text' as const
55
77
  readonly name: string
@@ -64,26 +86,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
64
86
  async *chatStream(
65
87
  options: TextOptions<TProviderOptions>,
66
88
  ): AsyncIterable<StreamChunk> {
67
- // Track tool call metadata by unique ID
68
- // Responses API streams tool calls with deltas — first chunk has ID/name,
69
- // subsequent chunks only have args.
70
- // We assign our own indices as we encounter unique tool call IDs.
71
- const toolCallMetadata = new Map<
72
- string,
73
- {
74
- index: number
75
- name: string
76
- started: boolean
77
- // Set once TOOL_CALL_END has been emitted (via args.done or the
78
- // output_item.done backfill) so the two paths don't double-emit.
79
- ended?: boolean
80
- // Set when args.done arrives before TOOL_CALL_START could fire
81
- // (output_item.added lacked a name). output_item.done picks these
82
- // up to emit the missing END. Allow explicit `undefined` so the
83
- // emission paths can re-clear the slot after handing it off.
84
- pendingArguments?: string
85
- }
86
- >()
89
+ // Key streamed state by output item ID because argument deltas reference
90
+ // `item_id`. The state separately retains `call_id`, which is the public
91
+ // tool-call ID and the correlation key for function_call_output.
92
+ const toolCallMetadata = new Map<string, StreamedFunctionCallMetadata>()
87
93
 
88
94
  // AG-UI lifecycle tracking. Honor a caller-supplied `runId` (as `threadId`
89
95
  // already does) so the emitted RUN_STARTED matches the id the caller keys
@@ -254,9 +260,13 @@ export abstract class OpenAIBaseResponsesTextAdapter<
254
260
  // from strict mode is undone by the engine, not here.
255
261
  const transformed = this.transformStructuredOutput(parsed)
256
262
 
263
+ // Surface usage so non-stream structured paths (and
264
+ // fallbackStructuredOutputStream) can forward tokens to middleware.
265
+ const usage = buildResponsesUsage(response.usage)
257
266
  return {
258
267
  data: transformed,
259
268
  rawText,
269
+ ...(usage && { usage }),
260
270
  }
261
271
  } catch (error: unknown) {
262
272
  // Narrow before logging: raw SDK errors can carry request metadata
@@ -762,16 +772,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
762
772
  */
763
773
  protected async *processStreamChunks(
764
774
  stream: AsyncIterable<ResponseStreamEvent>,
765
- toolCallMetadata: Map<
766
- string,
767
- {
768
- index: number
769
- name: string
770
- started: boolean
771
- ended?: boolean
772
- pendingArguments?: string | undefined
773
- }
774
- >,
775
+ toolCallMetadata: Map<string, StreamedFunctionCallMetadata>,
775
776
  options: TextOptions<TProviderOptions>,
776
777
  aguiState: {
777
778
  runId: string
@@ -1187,26 +1188,33 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1187
1188
  let metadata = toolCallMetadata.get(item.id)
1188
1189
  if (!metadata) {
1189
1190
  metadata = {
1191
+ callId: item.call_id || item.id,
1190
1192
  index: chunk.output_index,
1191
1193
  name: item.name || '',
1192
1194
  started: false,
1193
1195
  }
1194
1196
  toolCallMetadata.set(item.id, metadata)
1195
- } else if (!metadata.name && item.name) {
1196
- // A later output_item.added for the same id finally carries
1197
- // the name. Update so the gated emission below can fire.
1198
- metadata.name = item.name
1197
+ } else {
1198
+ if (item.call_id) metadata.callId = item.call_id
1199
+ if (!metadata.name && item.name) {
1200
+ // A later output_item.added for the same id finally carries
1201
+ // the name. Update so the gated emission below can fire.
1202
+ metadata.name = item.name
1203
+ }
1199
1204
  }
1200
1205
  if (!metadata.started && metadata.name) {
1201
1206
  yield {
1202
1207
  type: EventType.TOOL_CALL_START,
1203
- toolCallId: item.id,
1208
+ toolCallId: metadata.callId,
1204
1209
  toolCallName: metadata.name,
1205
1210
  toolName: metadata.name,
1206
1211
  parentMessageId: aguiState.messageId,
1207
1212
  model: model || options.model,
1208
1213
  timestamp: Date.now(),
1209
1214
  index: chunk.output_index,
1215
+ metadata: {
1216
+ itemId: item.id,
1217
+ } satisfies OpenAIResponsesToolCallMetadata,
1210
1218
  }
1211
1219
  metadata.started = true
1212
1220
  }
@@ -1235,7 +1243,9 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1235
1243
  `${this.name}.processStreamChunks orphan function_call_arguments.delta`,
1236
1244
  {
1237
1245
  source: `${this.name}.processStreamChunks`,
1238
- toolCallId: chunk.item_id,
1246
+ // No metadata yet, so the `call_id` is unknown here — only the
1247
+ // output item id the delta referenced.
1248
+ itemId: chunk.item_id,
1239
1249
  rawDelta: chunk.delta,
1240
1250
  },
1241
1251
  )
@@ -1243,7 +1253,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1243
1253
  }
1244
1254
  yield {
1245
1255
  type: EventType.TOOL_CALL_ARGS,
1246
- toolCallId: chunk.item_id,
1256
+ toolCallId: metadata.callId,
1247
1257
  model: model || options.model,
1248
1258
  timestamp: Date.now(),
1249
1259
  delta: chunk.delta,
@@ -1270,7 +1280,8 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1270
1280
  `${this.name}.processStreamChunks deferring function_call_arguments.done — TOOL_CALL_START not yet emitted (waiting for name)`,
1271
1281
  {
1272
1282
  source: `${this.name}.processStreamChunks`,
1273
- toolCallId: item_id,
1283
+ ...(metadata && { toolCallId: metadata.callId }),
1284
+ itemId: item_id,
1274
1285
  rawArguments: chunk.arguments,
1275
1286
  },
1276
1287
  )
@@ -1297,10 +1308,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1297
1308
  {
1298
1309
  error: toRunErrorPayload(
1299
1310
  parseError,
1300
- `tool ${name} (${item_id}) returned malformed JSON arguments`,
1311
+ `tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
1301
1312
  ),
1302
1313
  source: `${this.name}.processStreamChunks`,
1303
- toolCallId: item_id,
1314
+ toolCallId: metadata.callId,
1315
+ itemId: item_id,
1304
1316
  toolName: name,
1305
1317
  rawArguments: chunk.arguments,
1306
1318
  },
@@ -1311,7 +1323,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1311
1323
 
1312
1324
  yield {
1313
1325
  type: EventType.TOOL_CALL_END,
1314
- toolCallId: item_id,
1326
+ toolCallId: metadata.callId,
1315
1327
  toolCallName: name,
1316
1328
  toolName: name,
1317
1329
  model: model || options.model,
@@ -1329,26 +1341,31 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1329
1341
  const item = chunk.item
1330
1342
  if (item.type === 'function_call' && item.id) {
1331
1343
  const metadata = toolCallMetadata.get(item.id) ?? {
1344
+ callId: item.call_id || item.id,
1332
1345
  index: chunk.output_index,
1333
1346
  name: item.name || '',
1334
1347
  started: false,
1335
1348
  }
1336
1349
  if (!toolCallMetadata.has(item.id)) {
1337
1350
  toolCallMetadata.set(item.id, metadata)
1338
- } else if (!metadata.name && item.name) {
1339
- metadata.name = item.name
1351
+ } else {
1352
+ if (item.call_id) metadata.callId = item.call_id
1353
+ if (!metadata.name && item.name) metadata.name = item.name
1340
1354
  }
1341
1355
  // Emit gated START if we now have a name and never started.
1342
1356
  if (!metadata.started && metadata.name) {
1343
1357
  yield {
1344
1358
  type: EventType.TOOL_CALL_START,
1345
- toolCallId: item.id,
1359
+ toolCallId: metadata.callId,
1346
1360
  toolCallName: metadata.name,
1347
1361
  toolName: metadata.name,
1348
1362
  parentMessageId: aguiState.messageId,
1349
1363
  model: model || options.model,
1350
1364
  timestamp: Date.now(),
1351
1365
  index: metadata.index,
1366
+ metadata: {
1367
+ itemId: item.id,
1368
+ } satisfies OpenAIResponsesToolCallMetadata,
1352
1369
  }
1353
1370
  metadata.started = true
1354
1371
  }
@@ -1372,10 +1389,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1372
1389
  {
1373
1390
  error: toRunErrorPayload(
1374
1391
  parseError,
1375
- `tool ${name} (${item.id}) returned malformed JSON arguments`,
1392
+ `tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
1376
1393
  ),
1377
1394
  source: `${this.name}.processStreamChunks`,
1378
- toolCallId: item.id,
1395
+ toolCallId: metadata.callId,
1396
+ itemId: item.id,
1379
1397
  toolName: name,
1380
1398
  rawArguments: rawArgs,
1381
1399
  },
@@ -1385,7 +1403,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1385
1403
  }
1386
1404
  yield {
1387
1405
  type: EventType.TOOL_CALL_END,
1388
- toolCallId: item.id,
1406
+ toolCallId: metadata.callId,
1389
1407
  toolCallName: name,
1390
1408
  toolName: name,
1391
1409
  model: model || options.model,
@@ -1409,25 +1427,30 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1409
1427
  for (const item of chunk.response.output) {
1410
1428
  if (item.type !== 'function_call' || !item.id) continue
1411
1429
  const metadata = toolCallMetadata.get(item.id) ?? {
1430
+ callId: item.call_id || item.id,
1412
1431
  index: 0,
1413
1432
  name: item.name || '',
1414
1433
  started: false,
1415
1434
  }
1416
1435
  if (!toolCallMetadata.has(item.id)) {
1417
1436
  toolCallMetadata.set(item.id, metadata)
1418
- } else if (!metadata.name && item.name) {
1419
- metadata.name = item.name
1437
+ } else {
1438
+ if (item.call_id) metadata.callId = item.call_id
1439
+ if (!metadata.name && item.name) metadata.name = item.name
1420
1440
  }
1421
1441
  if (!metadata.started && metadata.name) {
1422
1442
  yield {
1423
1443
  type: EventType.TOOL_CALL_START,
1424
- toolCallId: item.id,
1444
+ toolCallId: metadata.callId,
1425
1445
  toolCallName: metadata.name,
1426
1446
  toolName: metadata.name,
1427
1447
  parentMessageId: aguiState.messageId,
1428
1448
  model: model || options.model,
1429
1449
  timestamp: Date.now(),
1430
1450
  index: metadata.index,
1451
+ metadata: {
1452
+ itemId: item.id,
1453
+ } satisfies OpenAIResponsesToolCallMetadata,
1431
1454
  }
1432
1455
  metadata.started = true
1433
1456
  }
@@ -1449,10 +1472,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1449
1472
  {
1450
1473
  error: toRunErrorPayload(
1451
1474
  parseError,
1452
- `tool ${name} (${item.id}) returned malformed JSON arguments`,
1475
+ `tool ${name} (${metadata.callId}) returned malformed JSON arguments`,
1453
1476
  ),
1454
1477
  source: `${this.name}.processStreamChunks`,
1455
- toolCallId: item.id,
1478
+ toolCallId: metadata.callId,
1479
+ itemId: item.id,
1456
1480
  toolName: name,
1457
1481
  rawArguments: rawArgs,
1458
1482
  },
@@ -1462,7 +1486,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1462
1486
  }
1463
1487
  yield {
1464
1488
  type: EventType.TOOL_CALL_END,
1465
- toolCallId: item.id,
1489
+ toolCallId: metadata.callId,
1466
1490
  toolCallName: name,
1467
1491
  toolName: name,
1468
1492
  model: model || options.model,
@@ -1728,10 +1752,14 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1728
1752
  typeof toolCall.function.arguments === 'string'
1729
1753
  ? toolCall.function.arguments
1730
1754
  : JSON.stringify(toolCall.function.arguments)
1755
+ const itemId = (
1756
+ toolCall.metadata as OpenAIResponsesToolCallMetadata | undefined
1757
+ )?.itemId
1731
1758
 
1732
1759
  result.push({
1733
1760
  type: 'function_call',
1734
1761
  call_id: toolCall.id,
1762
+ ...(itemId && { id: itemId }),
1735
1763
  name: toolCall.function.name,
1736
1764
  arguments: argumentsString,
1737
1765
  })
package/src/index.ts CHANGED
@@ -11,7 +11,10 @@ export {
11
11
  convertToolsToChatCompletionsFormat,
12
12
  type ChatCompletionFunctionTool,
13
13
  } from './adapters/chat-completions-tool-converter'
14
- export { OpenAIBaseResponsesTextAdapter } from './adapters/responses-text'
14
+ export {
15
+ OpenAIBaseResponsesTextAdapter,
16
+ type OpenAIResponsesToolCallMetadata,
17
+ } from './adapters/responses-text'
15
18
  export {
16
19
  convertFunctionToolToResponsesFormat,
17
20
  convertToolsToResponsesFormat,