@tanstack/ai 0.47.3 → 0.48.0

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.
Files changed (101) hide show
  1. package/README.md +2 -1
  2. package/dist/esm/activities/chat/adapter.d.ts +5 -4
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/index.d.ts +4 -7
  5. package/dist/esm/activities/chat/index.js +201 -243
  6. package/dist/esm/activities/chat/index.js.map +1 -1
  7. package/dist/esm/activities/chat/messages.js +115 -25
  8. package/dist/esm/activities/chat/messages.js.map +1 -1
  9. package/dist/esm/activities/chat/stream/processor.d.ts +38 -17
  10. package/dist/esm/activities/chat/stream/processor.js +186 -101
  11. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  12. package/dist/esm/activities/chat/tools/tool-calls.d.ts +3 -2
  13. package/dist/esm/activities/chat/tools/tool-calls.js +15 -10
  14. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  15. package/dist/esm/activities/generateVideo/index.js +6 -6
  16. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  17. package/dist/esm/activities/stream-generation-result.js +7 -8
  18. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  19. package/dist/esm/activities/summarize/chat-stream-summarize.d.ts +2 -1
  20. package/dist/esm/activities/summarize/chat-stream-summarize.js +56 -53
  21. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  22. package/dist/esm/adapter-internals.d.ts +2 -0
  23. package/dist/esm/adapter-internals.js +3 -1
  24. package/dist/esm/client.d.ts +8 -1
  25. package/dist/esm/client.js +7 -2
  26. package/dist/esm/client.js.map +1 -1
  27. package/dist/esm/index.d.ts +6 -0
  28. package/dist/esm/index.js +6 -2
  29. package/dist/esm/middlewares/otel.js +11 -4
  30. package/dist/esm/middlewares/otel.js.map +1 -1
  31. package/dist/esm/stream-to-response.js +8 -5
  32. package/dist/esm/stream-to-response.js.map +1 -1
  33. package/dist/esm/stream-to-websocket.js +4 -2
  34. package/dist/esm/stream-to-websocket.js.map +1 -1
  35. package/dist/esm/strip-to-spec-middleware.d.ts +10 -13
  36. package/dist/esm/strip-to-spec-middleware.js +24 -22
  37. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  38. package/dist/esm/types.d.ts +82 -305
  39. package/dist/esm/utilities/adapter-yield-chunk.d.ts +31 -0
  40. package/dist/esm/utilities/ag-ui-usage.d.ts +24 -0
  41. package/dist/esm/utilities/ag-ui-usage.js +66 -0
  42. package/dist/esm/utilities/ag-ui-usage.js.map +1 -0
  43. package/dist/esm/utilities/ag-ui-wire.d.ts +14 -7
  44. package/dist/esm/utilities/ag-ui-wire.js +71 -30
  45. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  46. package/dist/esm/utilities/chat-params.d.ts +3 -3
  47. package/dist/esm/utilities/chat-params.js +10 -30
  48. package/dist/esm/utilities/chat-params.js.map +1 -1
  49. package/dist/esm/utilities/chunk-ids.d.ts +5 -0
  50. package/dist/esm/utilities/chunk-ids.js +25 -0
  51. package/dist/esm/utilities/chunk-ids.js.map +1 -0
  52. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  53. package/dist/esm/utilities/merge-metadata.js +43 -0
  54. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  55. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  56. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  57. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  58. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  59. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  60. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  61. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  62. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  63. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  64. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  65. package/dist/esm/utilities/spec-event-keys.js +56 -0
  66. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  67. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  68. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/activities/chat/adapter.ts +4 -4
  71. package/src/activities/chat/index.ts +398 -400
  72. package/src/activities/chat/messages.ts +174 -34
  73. package/src/activities/chat/stream/processor.ts +289 -218
  74. package/src/activities/chat/tools/tool-calls.ts +23 -22
  75. package/src/activities/generateVideo/index.ts +7 -6
  76. package/src/activities/stream-generation-result.ts +8 -12
  77. package/src/activities/summarize/chat-stream-summarize.ts +94 -70
  78. package/src/adapter-internals.ts +2 -0
  79. package/src/client.ts +13 -0
  80. package/src/index.ts +6 -0
  81. package/src/middlewares/otel.ts +16 -8
  82. package/src/stream-to-response.ts +11 -4
  83. package/src/stream-to-websocket.ts +3 -1
  84. package/src/strip-to-spec-middleware.ts +48 -24
  85. package/src/types.ts +109 -393
  86. package/src/utilities/adapter-yield-chunk.ts +30 -0
  87. package/src/utilities/ag-ui-usage.test.ts +194 -0
  88. package/src/utilities/ag-ui-usage.ts +148 -0
  89. package/src/utilities/ag-ui-wire.ts +149 -31
  90. package/src/utilities/chat-params.ts +22 -39
  91. package/src/utilities/chunk-ids.ts +24 -0
  92. package/src/utilities/merge-metadata.test.ts +117 -0
  93. package/src/utilities/merge-metadata.ts +59 -0
  94. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  95. package/src/utilities/normalize-stream-chunk.ts +186 -0
  96. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  97. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  98. package/src/utilities/restore-inbound-chunk.ts +72 -0
  99. package/src/utilities/spec-event-keys.test.ts +34 -0
  100. package/src/utilities/spec-event-keys.ts +74 -0
  101. package/src/utilities/structured-output-events.ts +3 -3
@@ -1,4 +1,6 @@
1
1
  import { normalizeToolResult } from '../../../utilities/tool-result'
2
+ import { tanstackMetadata } from '../../../utilities/merge-metadata'
3
+ import type { AdapterYieldChunk } from '../../../utilities/adapter-yield-chunk'
2
4
  import { isStandardSchema, parseWithStandardSchema } from './schema-converter'
3
5
  import type { ToolApprovalResolution } from '../../../interrupts'
4
6
  import type {
@@ -231,10 +233,8 @@ export class ToolCallManager<
231
233
  * Add a TOOL_CALL_START event to begin tracking a tool call (AG-UI)
232
234
  */
233
235
  addToolCallStartEvent(event: ToolCallStartEvent): void {
234
- const index = event.index ?? this.toolCallsMap.size
235
- const runtimeEvent = event as Partial<ToolCallStartEvent> &
236
- Pick<ToolCallStartEvent, 'toolName'>
237
- const name = runtimeEvent.toolCallName ?? runtimeEvent.toolName
236
+ const index = (event as AdapterYieldChunk).index ?? this.toolCallsMap.size
237
+ const name = event.toolCallName ?? event.toolName
238
238
  this.toolCallsMap.set(index, {
239
239
  id: event.toolCallId,
240
240
  type: 'function',
@@ -250,10 +250,14 @@ export class ToolCallManager<
250
250
  * Add a TOOL_CALL_ARGS event to accumulate arguments (AG-UI)
251
251
  */
252
252
  addToolCallArgsEvent(event: ToolCallArgsEvent): void {
253
- // Find the tool call by ID
253
+ const extra = event as AdapterYieldChunk
254
254
  for (const [, toolCall] of this.toolCallsMap.entries()) {
255
255
  if (toolCall.id === event.toolCallId) {
256
- toolCall.function.arguments += event.delta
256
+ if (typeof extra.args === 'string' && extra.args !== '') {
257
+ toolCall.function.arguments = extra.args
258
+ } else {
259
+ toolCall.function.arguments += event.delta
260
+ }
257
261
  break
258
262
  }
259
263
  }
@@ -264,16 +268,13 @@ export class ToolCallManager<
264
268
  * Called when TOOL_CALL_END is received
265
269
  */
266
270
  completeToolCall(event: ToolCallEndEvent): void {
267
- for (const [, toolCall] of this.toolCallsMap.entries()) {
268
- if (toolCall.id === event.toolCallId) {
269
- if (event.input !== undefined) {
270
- // Normalize null/non-object to {} (e.g. Anthropic empty tool_use blocks)
271
- const normalized =
272
- event.input && typeof event.input === 'object' ? event.input : {}
273
- toolCall.function.arguments = JSON.stringify(normalized)
274
- }
275
- break
276
- }
271
+ for (const toolCall of this.toolCallsMap.values()) {
272
+ if (toolCall.id !== event.toolCallId) continue
273
+ if (event.input === undefined) return
274
+ const normalized =
275
+ event.input && typeof event.input === 'object' ? event.input : {}
276
+ toolCall.function.arguments = JSON.stringify(normalized)
277
+ return
277
278
  }
278
279
  }
279
280
 
@@ -301,7 +302,7 @@ export class ToolCallManager<
301
302
  async *executeTools(
302
303
  finishEvent: RunFinishedEvent,
303
304
  ...contextArgs: ExecuteToolsContextArgs<TContext>
304
- ): AsyncGenerator<ToolCallEndEvent, Array<ModelMessage>, void> {
305
+ ): AsyncGenerator<AdapterYieldChunk, Array<ModelMessage>, void> {
305
306
  const toolCallsArray = this.getToolCalls()
306
307
  const toolResults: Array<ModelMessage> = []
307
308
  const hasRuntimeContext = contextArgs.length > 0
@@ -313,9 +314,6 @@ export class ToolCallManager<
313
314
  let toolResultContent: string | Array<ContentPart>
314
315
  let toolResultState: ToolOutputState | undefined
315
316
  // Holds the parsed/validated execution output before serialization.
316
- // Surfaced on the emitted `TOOL_CALL_END` event as `output` so
317
- // consumers can read it typed (via `TypedStreamChunk` distribution
318
- // over the tools array) without re-parsing `result`.
319
317
  // Stays `undefined` when the tool has no `execute` (client-only
320
318
  // tools) or when execution throws.
321
319
  let toolOutput: unknown
@@ -397,7 +395,10 @@ export class ToolCallManager<
397
395
  toolCallId: toolCall.id,
398
396
  toolCallName: toolCall.function.name,
399
397
  toolName: toolCall.function.name,
400
- model: finishEvent.model,
398
+ model: (() => {
399
+ const model = tanstackMetadata(finishEvent)?.model
400
+ return typeof model === 'string' ? model : undefined
401
+ })(),
401
402
  timestamp: Date.now(),
402
403
  // Typed parsed output (undefined for failed exec / client-only tools).
403
404
  ...(toolOutput !== undefined ? { output: toolOutput } : {}),
@@ -433,7 +434,7 @@ export interface ToolResult {
433
434
  duration?: number
434
435
  /**
435
436
  * Parsed tool input (after JSON parse + optional Standard Schema validation).
436
- * Surfaced on engine-emitted `TOOL_CALL_END` events for TypedStreamChunk consumers.
437
+ * Parsed tool input after JSON parse + optional Standard Schema validation.
437
438
  */
438
439
  input?: unknown
439
440
  /**
@@ -33,6 +33,8 @@ import type {
33
33
  GenerationMiddlewareContext,
34
34
  } from '../middleware/types'
35
35
  import type { VideoAdapter } from './adapter'
36
+ import { normalizeStreamChunk } from '../../utilities/normalize-stream-chunk'
37
+ import type { AdapterYieldChunk } from '../../utilities/adapter-yield-chunk'
36
38
  import type {
37
39
  MediaPrompt,
38
40
  MediaPromptFor,
@@ -728,13 +730,13 @@ async function* runStreamingVideoGeneration<
728
730
  timestamp: Date.now(),
729
731
  }
730
732
 
731
- yield {
733
+ yield* normalizeStreamChunk({
732
734
  type: 'RUN_FINISHED',
733
735
  runId,
734
736
  threadId: wireThreadId,
735
737
  finishReason: 'stop',
736
738
  timestamp: Date.now(),
737
- } as StreamChunk
739
+ } as AdapterYieldChunk)
738
740
  return
739
741
  }
740
742
 
@@ -768,15 +770,14 @@ async function* runStreamingVideoGeneration<
768
770
  code: payload.code,
769
771
  source: 'generateVideo',
770
772
  })
771
- yield {
773
+ yield* normalizeStreamChunk({
772
774
  type: 'RUN_ERROR',
773
775
  runId,
774
776
  threadId: wireThreadId,
775
777
  message: payload.message,
776
- code: payload.code,
777
- error: payload,
778
+ ...(payload.code !== undefined ? { code: payload.code } : {}),
778
779
  timestamp: Date.now(),
779
- } as StreamChunk
780
+ } as AdapterYieldChunk)
780
781
  } finally {
781
782
  abortControls.clear()
782
783
  if (!settled) {
@@ -7,6 +7,7 @@
7
7
  import { EventType } from '@ag-ui/core'
8
8
  import { toRunErrorPayload } from './error-payload'
9
9
  import type { StreamChunk } from '../types'
10
+ import { normalizeStreamChunk } from '../utilities/normalize-stream-chunk'
10
11
 
11
12
  function createId(prefix: string): string {
12
13
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
@@ -74,31 +75,26 @@ export async function* streamGenerationResult<TResult>(
74
75
  timestamp: Date.now(),
75
76
  }
76
77
 
77
- yield {
78
+ yield* normalizeStreamChunk({
78
79
  type: EventType.RUN_FINISHED,
79
80
  runId,
80
81
  threadId,
81
82
  finishReason: 'stop',
82
83
  timestamp: Date.now(),
83
- }
84
+ })
84
85
  } catch (error: unknown) {
85
86
  const payload = toRunErrorPayload(error, 'Generation failed')
86
87
  // `code` is omitted entirely when undefined so the event matches the
87
- // AG-UI `code?: string` shape under `exactOptionalPropertyTypes`. The
88
- // deprecated nested `error` form preserves the same conditional
89
- // structure for backward compatibility.
88
+ // AG-UI `code?: string` shape under `exactOptionalPropertyTypes`.
90
89
  const codeFields =
91
90
  payload.code !== undefined ? { code: payload.code } : undefined
92
- yield {
91
+ yield* normalizeStreamChunk({
93
92
  type: EventType.RUN_ERROR,
93
+ runId,
94
+ threadId,
94
95
  message: payload.message,
95
96
  ...codeFields,
96
- // Deprecated nested form for backward compatibility
97
- error: {
98
- message: payload.message,
99
- ...codeFields,
100
- },
101
97
  timestamp: Date.now(),
102
- }
98
+ })
103
99
  }
104
100
  }
@@ -1,14 +1,58 @@
1
1
  import { EventType } from '@ag-ui/core'
2
2
  import { toRunErrorPayload } from '../error-payload'
3
3
  import { MAX_TOKENS_KEYS } from '../../utilities/sampling-keys'
4
+ import { rebuildTokenUsage } from '../../utilities/ag-ui-usage'
5
+ import type { AdapterYieldChunk } from '../../utilities/adapter-yield-chunk'
6
+ import { tanstackMetadata } from '../../utilities/merge-metadata'
7
+ import { normalizeStreamChunk } from '../../utilities/normalize-stream-chunk'
4
8
  import { BaseSummarizeAdapter } from './adapter'
5
9
  import type {
6
10
  StreamChunk,
7
11
  SummarizationOptions,
8
12
  SummarizationResult,
9
13
  TextOptions,
14
+ TokenUsage,
10
15
  } from '../../types'
11
16
 
17
+ function consumeSpecSummarizeChunk(
18
+ chunk: StreamChunk,
19
+ state: { summary: string; model: string; usage: TokenUsage },
20
+ ): void {
21
+ if (chunk.type === EventType.TEXT_MESSAGE_CONTENT) {
22
+ if (chunk.delta) state.summary += chunk.delta
23
+ return
24
+ }
25
+
26
+ const tanstack = tanstackMetadata(chunk)
27
+ if (
28
+ (chunk.type === EventType.RUN_STARTED ||
29
+ chunk.type === EventType.RUN_FINISHED ||
30
+ chunk.type === EventType.TEXT_MESSAGE_START) &&
31
+ typeof tanstack?.model === 'string'
32
+ ) {
33
+ state.model = tanstack.model
34
+ }
35
+
36
+ if (chunk.type === EventType.RUN_FINISHED) {
37
+ const rebuilt = rebuildTokenUsage(chunk.usage, tanstack?.usage)
38
+ if (rebuilt) state.usage = rebuilt
39
+ }
40
+ }
41
+
42
+ function throwRunError(
43
+ chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
44
+ ): never {
45
+ const message =
46
+ typeof chunk.message === 'string' && chunk.message.length > 0
47
+ ? chunk.message
48
+ : 'Summarization failed'
49
+ const err = new Error(message)
50
+ if (typeof chunk.code === 'string') {
51
+ ;(err as Error & { code?: string }).code = chunk.code
52
+ }
53
+ throw err
54
+ }
55
+
12
56
  /**
13
57
  * Minimal contract for a text adapter that supports `chatStream`. Lets
14
58
  * `ChatStreamSummarizeAdapter` work with any text adapter without coupling
@@ -21,7 +65,7 @@ import type {
21
65
  * `SummarizationOptions<TProviderOptions>` on the wrapper itself.
22
66
  */
23
67
  export interface ChatStreamCapable {
24
- chatStream: (options: TextOptions<any>) => AsyncIterable<StreamChunk>
68
+ chatStream: (options: TextOptions<any>) => AsyncIterable<AdapterYieldChunk>
25
69
  }
26
70
 
27
71
  /**
@@ -201,10 +245,12 @@ export class ChatStreamSummarizeAdapter<
201
245
  ): Promise<SummarizationResult> {
202
246
  const systemPrompt = this.buildSummarizationPrompt(options)
203
247
 
204
- let summary = ''
205
248
  const id = this.generateId()
206
- let model = options.model
207
- let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
249
+ const state = {
250
+ summary: '',
251
+ model: options.model,
252
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
253
+ }
208
254
 
209
255
  options.logger.request(
210
256
  `activity=summarize provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,
@@ -212,41 +258,15 @@ export class ChatStreamSummarizeAdapter<
212
258
  )
213
259
 
214
260
  try {
215
- for await (const chunk of this.textAdapter.chatStream(
261
+ for await (const raw of this.textAdapter.chatStream(
216
262
  this.buildTextOptions(options, systemPrompt),
217
263
  )) {
218
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
219
- if (chunk.content) {
220
- summary = chunk.content
221
- } else if (chunk.delta) {
222
- // Append delta only when present — a content-less chunk with no
223
- // delta would otherwise concat literal `'undefined'`.
224
- summary += chunk.delta
225
- }
226
- model = chunk.model || model
227
- }
228
- if (chunk.type === 'RUN_FINISHED') {
229
- if (chunk.usage) {
230
- usage = chunk.usage
231
- }
232
- }
233
- // Surface failures: the underlying chatStream emits RUN_ERROR instead
234
- // of throwing, so without this branch summarize() would return an
235
- // empty summary and pretend a failed run succeeded.
236
- if (chunk.type === 'RUN_ERROR') {
237
- const message =
238
- (chunk.error && typeof chunk.error.message === 'string'
239
- ? chunk.error.message
240
- : null) ?? 'Summarization failed'
241
- const code =
242
- chunk.error && typeof chunk.error.code === 'string'
243
- ? chunk.error.code
244
- : undefined
245
- const err = new Error(message)
246
- if (code) {
247
- ;(err as Error & { code?: string }).code = code
248
- }
249
- throw err
264
+ for (const chunk of normalizeStreamChunk(raw as AdapterYieldChunk)) {
265
+ // Surface failures: the underlying chatStream emits RUN_ERROR instead
266
+ // of throwing, so without this branch summarize() would return an
267
+ // empty summary and pretend a failed run succeeded.
268
+ if (chunk.type === EventType.RUN_ERROR) throwRunError(chunk)
269
+ consumeSpecSummarizeChunk(chunk, state)
250
270
  }
251
271
  }
252
272
  } catch (error: unknown) {
@@ -259,7 +279,12 @@ export class ChatStreamSummarizeAdapter<
259
279
  throw error
260
280
  }
261
281
 
262
- return { id, model, summary, usage }
282
+ return {
283
+ id,
284
+ model: state.model,
285
+ summary: state.summary,
286
+ usage: state.usage,
287
+ }
263
288
  }
264
289
 
265
290
  override async *summarizeStream(
@@ -273,46 +298,45 @@ export class ChatStreamSummarizeAdapter<
273
298
  )
274
299
 
275
300
  const id = this.generateId()
276
- let summary = ''
277
- let model = options.model
278
- let usage: SummarizationResult['usage'] = {
279
- promptTokens: 0,
280
- completionTokens: 0,
281
- totalTokens: 0,
301
+ const state = {
302
+ summary: '',
303
+ model: options.model,
304
+ usage: {
305
+ promptTokens: 0,
306
+ completionTokens: 0,
307
+ totalTokens: 0,
308
+ } satisfies SummarizationResult['usage'],
282
309
  }
283
310
 
284
311
  try {
285
- for await (const chunk of this.textAdapter.chatStream(
312
+ for await (const raw of this.textAdapter.chatStream(
286
313
  this.buildTextOptions(options, systemPrompt),
287
314
  )) {
288
- // Accumulate the same way `summarize()` does so consumers see deltas
289
- // AND the terminal `generation:result` event below carries the same
290
- // final summary that non-streaming returns.
291
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
292
- if (chunk.content) {
293
- summary = chunk.content
294
- } else if (chunk.delta) {
295
- summary += chunk.delta
315
+ for (const chunk of normalizeStreamChunk(raw as AdapterYieldChunk)) {
316
+ // Accumulate the same way `summarize()` does so consumers see deltas
317
+ // AND the terminal `generation:result` event below carries the same
318
+ // final summary that non-streaming returns.
319
+ consumeSpecSummarizeChunk(chunk, state)
320
+
321
+ // Emit the GenerationClient-shaped result event just before the
322
+ // terminal RUN_FINISHED so subscribers (useSummarize) populate
323
+ // `result` before flipping `status` to success.
324
+ if (chunk.type === EventType.RUN_FINISHED) {
325
+ yield {
326
+ type: EventType.CUSTOM,
327
+ name: 'generation:result',
328
+ value: {
329
+ id,
330
+ model: state.model,
331
+ summary: state.summary,
332
+ usage: state.usage,
333
+ } satisfies SummarizationResult,
334
+ timestamp: Date.now(),
335
+ }
296
336
  }
297
- if (chunk.model) model = chunk.model
298
- }
299
337
 
300
- // Emit the GenerationClient-shaped result event just before the
301
- // terminal RUN_FINISHED so subscribers (useSummarize) populate
302
- // `result` before flipping `status` to success.
303
- if (chunk.type === 'RUN_FINISHED') {
304
- if (chunk.usage) usage = chunk.usage
305
- if (chunk.model) model = chunk.model
306
- yield {
307
- type: EventType.CUSTOM,
308
- name: 'generation:result',
309
- value: { id, model, summary, usage } satisfies SummarizationResult,
310
- model,
311
- timestamp: Date.now(),
312
- }
338
+ yield chunk
313
339
  }
314
-
315
- yield chunk
316
340
  }
317
341
  } catch (error: unknown) {
318
342
  options.logger.errors(`${this.name}.summarizeStream fatal`, {
@@ -57,3 +57,5 @@ export {
57
57
  structuredOutputCompleteChunk,
58
58
  structuredOutputStartChunk,
59
59
  } from './utilities/structured-output-events'
60
+ export { tanstackMetadata } from './utilities/merge-metadata'
61
+ export { isSpecTopLevelKey } from './utilities/spec-event-keys'
package/src/client.ts CHANGED
@@ -297,6 +297,17 @@ export type {
297
297
  } from './activities/chat/stream/index'
298
298
 
299
299
  export { uiMessagesToWire } from './utilities/ag-ui-wire'
300
+ export {
301
+ mergeMetadata,
302
+ tanstackMetadata,
303
+ withTanstackMetadata,
304
+ } from './utilities/merge-metadata'
305
+ export { fromSpecTokenUsage, toSpecTokenUsage } from './utilities/ag-ui-usage'
306
+ export type { SpecTokenUsage } from './utilities/ag-ui-usage'
307
+ export { normalizeStreamChunk } from './utilities/normalize-stream-chunk'
308
+ export { restoreInboundChunk } from './utilities/restore-inbound-chunk'
309
+ export type { AdapterYieldChunk } from './utilities/adapter-yield-chunk'
310
+ export { getChunkRunId, getChunkThreadId } from './utilities/chunk-ids'
300
311
  export type { WireMessage } from './utilities/ag-ui-wire'
301
312
 
302
313
  export type {
@@ -326,6 +337,8 @@ export type {
326
337
  StreamChunk,
327
338
  StructuredOutputPart,
328
339
  TextPart,
340
+ TanStackMessageMetadata,
341
+ TanStackRunMetadata,
329
342
  ThinkingPart,
330
343
  ToolCall,
331
344
  ToolCallPart,
package/src/index.ts CHANGED
@@ -459,6 +459,12 @@ export { generationParamsFromBody, generationParamsFromRequest } from './client'
459
459
 
460
460
  // AG-UI wire serialization (used internally by @tanstack/ai-client)
461
461
  export { uiMessagesToWire } from './utilities/ag-ui-wire'
462
+ export { mergeMetadata, withTanstackMetadata } from './utilities/merge-metadata'
463
+ export { fromSpecTokenUsage, toSpecTokenUsage } from './utilities/ag-ui-usage'
464
+ export type { SpecTokenUsage } from './utilities/ag-ui-usage'
465
+ export { normalizeStreamChunk } from './utilities/normalize-stream-chunk'
466
+ export type { AdapterYieldChunk } from './utilities/adapter-yield-chunk'
467
+ export { getChunkRunId, getChunkThreadId } from './utilities/chunk-ids'
462
468
  export type { WireMessage } from './utilities/ag-ui-wire'
463
469
  export {
464
470
  isContentPart,
@@ -10,6 +10,8 @@ import {
10
10
  } from '../utilities/sampling-keys'
11
11
  import { firstNumber } from '../utilities/numbers'
12
12
  import { errorMessage, errorTypeName } from '../utilities/errors'
13
+ import { rebuildTokenUsage } from '../utilities/ag-ui-usage'
14
+ import { tanstackMetadata } from '../utilities/merge-metadata'
13
15
  import { usageAttributes } from './usage-attributes'
14
16
  import type {
15
17
  AttributeValue,
@@ -631,26 +633,32 @@ export function otelMiddleware(
631
633
  }
632
634
 
633
635
  if (chunk.type !== 'RUN_FINISHED') return
636
+ const tanstack = tanstackMetadata(chunk)
637
+ const extra = chunk as {
638
+ finishReason?: string | null
639
+ model?: string
640
+ }
641
+ const finishReason = extra.finishReason ?? tanstack?.finishReason
642
+ const model = extra.model ?? tanstack?.model
634
643
  // Capture for the root-span finish_reasons attribute set in onFinish,
635
644
  // which receives base-shaped info without a finishReason field.
636
- if (chunk.finishReason) state.lastFinishReason = chunk.finishReason
645
+ if (finishReason) state.lastFinishReason = finishReason
637
646
  const span = state.currentIterationSpan
638
647
  if (!span) return
639
648
 
640
- if (chunk.finishReason) {
641
- span.setAttribute('gen_ai.response.finish_reasons', [
642
- chunk.finishReason,
643
- ])
649
+ if (finishReason) {
650
+ span.setAttribute('gen_ai.response.finish_reasons', [finishReason])
644
651
  }
645
- if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model)
652
+ if (model) span.setAttribute('gen_ai.response.model', model)
646
653
 
647
654
  // Set usage attributes on the iteration span directly from the chunk
648
655
  // so they're available before `onUsage` fires. Histogram recording is
649
656
  // deliberately NOT done here — the chat runner always invokes
650
657
  // `runOnUsage` when `chunk.usage` is present, and `onUsage` is the
651
658
  // canonical place for the metric. Recording in both would double-count.
652
- if (chunk.usage) {
653
- span.setAttributes(usageAttributes(chunk.usage))
659
+ const tokenUsage = rebuildTokenUsage(chunk.usage, tanstack?.usage)
660
+ if (tokenUsage) {
661
+ span.setAttributes(usageAttributes(tokenUsage))
654
662
  }
655
663
 
656
664
  if (captureContent && state.assistantTextBuffer.length > 0) {
@@ -8,6 +8,7 @@ import { wasRunDetached } from './delivery-detach'
8
8
  import { notifyRunDisconnected } from './delivery-disconnect'
9
9
  import { resolveResumeRunId } from './stream-durability'
10
10
  import { EventType } from './types'
11
+ import { toWireChunk } from './strip-to-spec-middleware'
11
12
  import { resolveDebugOption } from './logger/resolve'
12
13
  import { runErrorEventToError } from './utilities/errors'
13
14
  import type { LockStore } from './activities/chat/middleware/locks'
@@ -287,10 +288,13 @@ function sseEncoders(
287
288
  encodeChunk: (chunk, index) => {
288
289
  const id = getId?.(chunk, index)
289
290
  const idLine = id === undefined ? '' : `id: ${id}\n`
290
- return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\n\n`)
291
+ const wire = toWireChunk(chunk)
292
+ return encoder.encode(`${idLine}data: ${JSON.stringify(wire)}\n\n`)
291
293
  },
292
294
  encodeError: (error) =>
293
- encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\n\n`),
295
+ encoder.encode(
296
+ `data: ${JSON.stringify(toWireChunk(runErrorChunk(error)))}\n\n`,
297
+ ),
294
298
  }
295
299
  }
296
300
 
@@ -1069,12 +1073,15 @@ function ndjsonEncoders(
1069
1073
  return {
1070
1074
  encodeChunk: (chunk, index) => {
1071
1075
  const id = getId?.(chunk, index)
1076
+ const wire = toWireChunk(chunk)
1072
1077
  const line =
1073
- id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })
1078
+ id === undefined
1079
+ ? JSON.stringify(wire)
1080
+ : JSON.stringify({ id, chunk: wire })
1074
1081
  return encoder.encode(`${line}\n`)
1075
1082
  },
1076
1083
  encodeError: (error) =>
1077
- encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`),
1084
+ encoder.encode(`${JSON.stringify(toWireChunk(runErrorChunk(error)))}\n`),
1078
1085
  }
1079
1086
  }
1080
1087
 
@@ -1,5 +1,6 @@
1
1
  import { chatParamsFromRequestBody } from './utilities/chat-params'
2
2
  import { durableStreamSource, runErrorChunk } from './stream-to-response'
3
+ import { toWireChunk } from './strip-to-spec-middleware'
3
4
  import { resolveDebugOption } from './logger/resolve'
4
5
  import type { StreamDurability } from './stream-durability'
5
6
  import type { DebugOption } from './logger/types'
@@ -35,7 +36,8 @@ export function encodeWsFrame(
35
36
  chunk: StreamChunk,
36
37
  id: string | undefined,
37
38
  ): string {
38
- return JSON.stringify(id === undefined ? chunk : { id, chunk })
39
+ const wire = toWireChunk(chunk)
40
+ return JSON.stringify(id === undefined ? wire : { id, chunk: wire })
39
41
  }
40
42
 
41
43
  /**
@@ -1,34 +1,58 @@
1
- import type { ChatMiddleware } from './activities/chat/middleware/types'
2
1
  import type { StreamChunk } from './types'
2
+ import { EventType } from './types'
3
+ import type { AdapterYieldChunk } from './utilities/adapter-yield-chunk'
4
+ import { isTanstackUsage, toSpecTokenUsage } from './utilities/ag-ui-usage'
5
+ import {
6
+ tanstackMetadata,
7
+ withTanstackMetadata,
8
+ } from './utilities/merge-metadata'
9
+ import { normalizeStreamChunk } from './utilities/normalize-stream-chunk'
10
+ import { isSpecTopLevelKey } from './utilities/spec-event-keys'
3
11
 
4
12
  /**
5
- * Strip only the deprecated nested `error` object from RUN_ERROR events.
6
- * The flat `message`/`code` fields are the spec-compliant form.
7
- *
8
- * All other fields pass through unchanged. @ag-ui/core's BaseEventSchema
9
- * uses `.passthrough()`, so extra fields (model, content, usage,
10
- * finishReason, toolName, stepId, etc.) are allowed and won't break
11
- * spec validation or verifyEvents.
13
+ * Delete unknown top-level keys from a stream chunk.
14
+ * Keep only AG-UI spec keys for this event type.
15
+ * Convert TanStack TokenUsage objects to the spec `usage[]` array.
12
16
  */
13
- export function stripToSpec(chunk: StreamChunk): StreamChunk {
14
- // Only strip the deprecated nested error object from RUN_ERROR.
15
- if (chunk.type === 'RUN_ERROR' && 'error' in chunk) {
16
- const { error: _deprecated, ...rest } = chunk
17
- return rest
17
+ export function stripToSpec(
18
+ chunk: StreamChunk | AdapterYieldChunk,
19
+ ): StreamChunk {
20
+ const out: Record<string, unknown> = {}
21
+ for (const [key, value] of Object.entries(chunk)) {
22
+ if (isSpecTopLevelKey(chunk.type, key) && value !== undefined) {
23
+ out[key] = value
24
+ }
18
25
  }
19
- return chunk
26
+
27
+ if (
28
+ (chunk.type === EventType.RUN_FINISHED ||
29
+ chunk.type === EventType.RUN_ERROR) &&
30
+ isTanstackUsage(out.usage)
31
+ ) {
32
+ const model = tanstackMetadata(chunk)?.model
33
+ const { usage, leftover } = toSpecTokenUsage(out.usage, {
34
+ model: typeof model === 'string' ? model : undefined,
35
+ })
36
+ out.usage = usage
37
+ if (leftover !== undefined) {
38
+ return withTanstackMetadata(out as StreamChunk, {
39
+ usage: leftover,
40
+ }) as StreamChunk
41
+ }
42
+ }
43
+
44
+ return out as StreamChunk
20
45
  }
21
46
 
22
47
  /**
23
- * Middleware that ensures events are AG-UI spec compliant.
24
- * Currently only strips the deprecated nested `error` object from RUN_ERROR.
25
- * All other fields pass through unchanged (passthrough allowed by spec).
48
+ * Move TanStack extras into `metadata.tanstack`, then keep only spec keys.
49
+ * Custom servers that skip `chat()` still round-trip `finishReason` on SSE/HTTP/WS.
50
+ * Fan-out extras (encrypted-value, TOOL_CALL_RESULT) stay on the `chat()` path;
51
+ * this encoder is 1:1 with the durability log offset.
26
52
  */
27
- export function stripToSpecMiddleware(): ChatMiddleware {
28
- return {
29
- name: 'strip-to-spec',
30
- onChunk(_ctx, chunk) {
31
- return stripToSpec(chunk)
32
- },
33
- }
53
+ export function toWireChunk(
54
+ chunk: StreamChunk | AdapterYieldChunk,
55
+ ): StreamChunk {
56
+ const [normalized] = normalizeStreamChunk(chunk)
57
+ return stripToSpec(normalized ?? chunk)
34
58
  }