@tanstack/ai 0.47.2 → 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 (105) 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 +126 -31
  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 +197 -110
  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 +59 -54
  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 +12 -5
  30. package/dist/esm/middlewares/otel.js.map +1 -1
  31. package/dist/esm/stream-to-response.js +13 -6
  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/errors.d.ts +9 -0
  53. package/dist/esm/utilities/errors.js +13 -1
  54. package/dist/esm/utilities/errors.js.map +1 -1
  55. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  56. package/dist/esm/utilities/merge-metadata.js +43 -0
  57. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  58. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  59. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  60. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  61. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  62. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  63. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  64. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  65. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  66. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  67. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  68. package/dist/esm/utilities/spec-event-keys.js +56 -0
  69. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  70. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  71. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  72. package/package.json +3 -3
  73. package/src/activities/chat/adapter.ts +4 -4
  74. package/src/activities/chat/index.ts +398 -400
  75. package/src/activities/chat/messages.ts +183 -36
  76. package/src/activities/chat/stream/processor.ts +313 -239
  77. package/src/activities/chat/tools/tool-calls.ts +23 -22
  78. package/src/activities/generateVideo/index.ts +7 -6
  79. package/src/activities/stream-generation-result.ts +8 -12
  80. package/src/activities/summarize/chat-stream-summarize.ts +98 -70
  81. package/src/adapter-internals.ts +2 -0
  82. package/src/client.ts +13 -0
  83. package/src/index.ts +6 -0
  84. package/src/middlewares/otel.ts +16 -8
  85. package/src/stream-to-response.ts +16 -4
  86. package/src/stream-to-websocket.ts +3 -1
  87. package/src/strip-to-spec-middleware.ts +48 -24
  88. package/src/types.ts +109 -393
  89. package/src/utilities/adapter-yield-chunk.ts +30 -0
  90. package/src/utilities/ag-ui-usage.test.ts +194 -0
  91. package/src/utilities/ag-ui-usage.ts +148 -0
  92. package/src/utilities/ag-ui-wire.ts +149 -31
  93. package/src/utilities/chat-params.ts +22 -39
  94. package/src/utilities/chunk-ids.ts +24 -0
  95. package/src/utilities/errors.ts +23 -0
  96. package/src/utilities/merge-metadata.test.ts +117 -0
  97. package/src/utilities/merge-metadata.ts +59 -0
  98. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  99. package/src/utilities/normalize-stream-chunk.ts +186 -0
  100. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  101. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  102. package/src/utilities/restore-inbound-chunk.ts +72 -0
  103. package/src/utilities/spec-event-keys.test.ts +34 -0
  104. package/src/utilities/spec-event-keys.ts +74 -0
  105. 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
  /**
@@ -38,6 +82,7 @@ export interface ChatStreamCapable {
38
82
  * - Groq: `max_completion_tokens`
39
83
  * - Gemini: `maxOutputTokens`
40
84
  * - OpenRouter: `maxCompletionTokens`
85
+ * - LLM Gateway: `max_tokens`
41
86
  * - Ollama: nested `options.num_predict` (no entry — see `applyMaxLength`)
42
87
  */
43
88
  const MAX_TOKENS_KEY_BY_ADAPTER: Record<string, string> = {
@@ -47,6 +92,9 @@ const MAX_TOKENS_KEY_BY_ADAPTER: Record<string, string> = {
47
92
  groq: 'max_completion_tokens',
48
93
  gemini: 'maxOutputTokens',
49
94
  openrouter: 'maxCompletionTokens',
95
+ // LLM Gateway exposes an OpenAI-compatible Chat Completions surface whose
96
+ // only output cap is `max_tokens` — it does not read `max_completion_tokens`.
97
+ llmgateway: 'max_tokens',
50
98
  }
51
99
 
52
100
  /**
@@ -197,10 +245,12 @@ export class ChatStreamSummarizeAdapter<
197
245
  ): Promise<SummarizationResult> {
198
246
  const systemPrompt = this.buildSummarizationPrompt(options)
199
247
 
200
- let summary = ''
201
248
  const id = this.generateId()
202
- let model = options.model
203
- 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
+ }
204
254
 
205
255
  options.logger.request(
206
256
  `activity=summarize provider=${this.name} model=${options.model} text-length=${options.text.length} maxLength=${options.maxLength ?? 'unset'}`,
@@ -208,41 +258,15 @@ export class ChatStreamSummarizeAdapter<
208
258
  )
209
259
 
210
260
  try {
211
- for await (const chunk of this.textAdapter.chatStream(
261
+ for await (const raw of this.textAdapter.chatStream(
212
262
  this.buildTextOptions(options, systemPrompt),
213
263
  )) {
214
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
215
- if (chunk.content) {
216
- summary = chunk.content
217
- } else if (chunk.delta) {
218
- // Append delta only when present — a content-less chunk with no
219
- // delta would otherwise concat literal `'undefined'`.
220
- summary += chunk.delta
221
- }
222
- model = chunk.model || model
223
- }
224
- if (chunk.type === 'RUN_FINISHED') {
225
- if (chunk.usage) {
226
- usage = chunk.usage
227
- }
228
- }
229
- // Surface failures: the underlying chatStream emits RUN_ERROR instead
230
- // of throwing, so without this branch summarize() would return an
231
- // empty summary and pretend a failed run succeeded.
232
- if (chunk.type === 'RUN_ERROR') {
233
- const message =
234
- (chunk.error && typeof chunk.error.message === 'string'
235
- ? chunk.error.message
236
- : null) ?? 'Summarization failed'
237
- const code =
238
- chunk.error && typeof chunk.error.code === 'string'
239
- ? chunk.error.code
240
- : undefined
241
- const err = new Error(message)
242
- if (code) {
243
- ;(err as Error & { code?: string }).code = code
244
- }
245
- 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)
246
270
  }
247
271
  }
248
272
  } catch (error: unknown) {
@@ -255,7 +279,12 @@ export class ChatStreamSummarizeAdapter<
255
279
  throw error
256
280
  }
257
281
 
258
- return { id, model, summary, usage }
282
+ return {
283
+ id,
284
+ model: state.model,
285
+ summary: state.summary,
286
+ usage: state.usage,
287
+ }
259
288
  }
260
289
 
261
290
  override async *summarizeStream(
@@ -269,46 +298,45 @@ export class ChatStreamSummarizeAdapter<
269
298
  )
270
299
 
271
300
  const id = this.generateId()
272
- let summary = ''
273
- let model = options.model
274
- let usage: SummarizationResult['usage'] = {
275
- promptTokens: 0,
276
- completionTokens: 0,
277
- 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'],
278
309
  }
279
310
 
280
311
  try {
281
- for await (const chunk of this.textAdapter.chatStream(
312
+ for await (const raw of this.textAdapter.chatStream(
282
313
  this.buildTextOptions(options, systemPrompt),
283
314
  )) {
284
- // Accumulate the same way `summarize()` does so consumers see deltas
285
- // AND the terminal `generation:result` event below carries the same
286
- // final summary that non-streaming returns.
287
- if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
288
- if (chunk.content) {
289
- summary = chunk.content
290
- } else if (chunk.delta) {
291
- 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
+ }
292
336
  }
293
- if (chunk.model) model = chunk.model
294
- }
295
337
 
296
- // Emit the GenerationClient-shaped result event just before the
297
- // terminal RUN_FINISHED so subscribers (useSummarize) populate
298
- // `result` before flipping `status` to success.
299
- if (chunk.type === 'RUN_FINISHED') {
300
- if (chunk.usage) usage = chunk.usage
301
- if (chunk.model) model = chunk.model
302
- yield {
303
- type: EventType.CUSTOM,
304
- name: 'generation:result',
305
- value: { id, model, summary, usage } satisfies SummarizationResult,
306
- model,
307
- timestamp: Date.now(),
308
- }
338
+ yield chunk
309
339
  }
310
-
311
- yield chunk
312
340
  }
313
341
  } catch (error: unknown) {
314
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,7 +8,9 @@ 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'
13
+ import { runErrorEventToError } from './utilities/errors'
12
14
  import type { LockStore } from './activities/chat/middleware/locks'
13
15
  import type {
14
16
  RunRecord,
@@ -46,6 +48,10 @@ export async function streamToText(
46
48
  let accumulatedContent = ''
47
49
 
48
50
  for await (const chunk of stream) {
51
+ if (chunk.type === 'RUN_ERROR') {
52
+ throw runErrorEventToError(chunk)
53
+ }
54
+
49
55
  if (chunk.type === 'TEXT_MESSAGE_CONTENT' && chunk.delta) {
50
56
  accumulatedContent += chunk.delta
51
57
  }
@@ -282,10 +288,13 @@ function sseEncoders(
282
288
  encodeChunk: (chunk, index) => {
283
289
  const id = getId?.(chunk, index)
284
290
  const idLine = id === undefined ? '' : `id: ${id}\n`
285
- 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`)
286
293
  },
287
294
  encodeError: (error) =>
288
- encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\n\n`),
295
+ encoder.encode(
296
+ `data: ${JSON.stringify(toWireChunk(runErrorChunk(error)))}\n\n`,
297
+ ),
289
298
  }
290
299
  }
291
300
 
@@ -1064,12 +1073,15 @@ function ndjsonEncoders(
1064
1073
  return {
1065
1074
  encodeChunk: (chunk, index) => {
1066
1075
  const id = getId?.(chunk, index)
1076
+ const wire = toWireChunk(chunk)
1067
1077
  const line =
1068
- id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })
1078
+ id === undefined
1079
+ ? JSON.stringify(wire)
1080
+ : JSON.stringify({ id, chunk: wire })
1069
1081
  return encoder.encode(`${line}\n`)
1070
1082
  },
1071
1083
  encodeError: (error) =>
1072
- encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`),
1084
+ encoder.encode(`${JSON.stringify(toWireChunk(runErrorChunk(error)))}\n`),
1073
1085
  }
1074
1086
  }
1075
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
  /**