@tanstack/ai 0.47.3 → 0.49.1

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 (135) 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/byok/define-provider.d.ts +23 -0
  25. package/dist/esm/byok/define-provider.js +19 -0
  26. package/dist/esm/byok/define-provider.js.map +1 -0
  27. package/dist/esm/byok/errors.d.ts +13 -0
  28. package/dist/esm/byok/errors.js +29 -0
  29. package/dist/esm/byok/errors.js.map +1 -0
  30. package/dist/esm/byok/get-key.d.ts +10 -0
  31. package/dist/esm/byok/get-key.js +26 -0
  32. package/dist/esm/byok/get-key.js.map +1 -0
  33. package/dist/esm/byok/missing.d.ts +11 -0
  34. package/dist/esm/byok/missing.js +30 -0
  35. package/dist/esm/byok/missing.js.map +1 -0
  36. package/dist/esm/byok/providers.d.ts +13 -0
  37. package/dist/esm/byok/providers.js +18 -0
  38. package/dist/esm/byok/providers.js.map +1 -0
  39. package/dist/esm/byok/scrub.d.ts +2 -0
  40. package/dist/esm/byok/scrub.js +17 -0
  41. package/dist/esm/byok/scrub.js.map +1 -0
  42. package/dist/esm/byok/server.d.ts +3 -0
  43. package/dist/esm/byok/server.js +3 -0
  44. package/dist/esm/byok.d.ts +8 -0
  45. package/dist/esm/byok.js +6 -0
  46. package/dist/esm/client.d.ts +8 -1
  47. package/dist/esm/client.js +7 -2
  48. package/dist/esm/client.js.map +1 -1
  49. package/dist/esm/index.d.ts +6 -0
  50. package/dist/esm/index.js +6 -2
  51. package/dist/esm/interrupt-resume.js +4 -4
  52. package/dist/esm/interrupt-resume.js.map +1 -1
  53. package/dist/esm/middlewares/otel.js +11 -4
  54. package/dist/esm/middlewares/otel.js.map +1 -1
  55. package/dist/esm/stream-to-response.js +8 -5
  56. package/dist/esm/stream-to-response.js.map +1 -1
  57. package/dist/esm/stream-to-websocket.js +4 -2
  58. package/dist/esm/stream-to-websocket.js.map +1 -1
  59. package/dist/esm/strip-to-spec-middleware.d.ts +10 -13
  60. package/dist/esm/strip-to-spec-middleware.js +24 -22
  61. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  62. package/dist/esm/types.d.ts +82 -305
  63. package/dist/esm/utilities/adapter-yield-chunk.d.ts +31 -0
  64. package/dist/esm/utilities/ag-ui-usage.d.ts +24 -0
  65. package/dist/esm/utilities/ag-ui-usage.js +66 -0
  66. package/dist/esm/utilities/ag-ui-usage.js.map +1 -0
  67. package/dist/esm/utilities/ag-ui-wire.d.ts +14 -7
  68. package/dist/esm/utilities/ag-ui-wire.js +71 -30
  69. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  70. package/dist/esm/utilities/chat-params.d.ts +3 -3
  71. package/dist/esm/utilities/chat-params.js +10 -30
  72. package/dist/esm/utilities/chat-params.js.map +1 -1
  73. package/dist/esm/utilities/chunk-ids.d.ts +5 -0
  74. package/dist/esm/utilities/chunk-ids.js +25 -0
  75. package/dist/esm/utilities/chunk-ids.js.map +1 -0
  76. package/dist/esm/utilities/merge-metadata.d.ts +14 -0
  77. package/dist/esm/utilities/merge-metadata.js +43 -0
  78. package/dist/esm/utilities/merge-metadata.js.map +1 -0
  79. package/dist/esm/utilities/normalize-stream-chunk.d.ts +3 -0
  80. package/dist/esm/utilities/normalize-stream-chunk.js +100 -0
  81. package/dist/esm/utilities/normalize-stream-chunk.js.map +1 -0
  82. package/dist/esm/utilities/reasoning-encrypted-value.d.ts +8 -0
  83. package/dist/esm/utilities/reasoning-encrypted-value.js +16 -0
  84. package/dist/esm/utilities/reasoning-encrypted-value.js.map +1 -0
  85. package/dist/esm/utilities/restore-inbound-chunk.d.ts +15 -0
  86. package/dist/esm/utilities/restore-inbound-chunk.js +43 -0
  87. package/dist/esm/utilities/restore-inbound-chunk.js.map +1 -0
  88. package/dist/esm/utilities/spec-event-keys.d.ts +2 -0
  89. package/dist/esm/utilities/spec-event-keys.js +56 -0
  90. package/dist/esm/utilities/spec-event-keys.js.map +1 -0
  91. package/dist/esm/utilities/structured-output-events.d.ts +3 -3
  92. package/dist/esm/utilities/structured-output-events.js.map +1 -1
  93. package/package.json +10 -2
  94. package/skills/ai-core/chat-experience/SKILL.md +2 -2
  95. package/src/activities/chat/adapter.ts +4 -4
  96. package/src/activities/chat/index.ts +398 -400
  97. package/src/activities/chat/messages.ts +174 -34
  98. package/src/activities/chat/stream/processor.ts +289 -218
  99. package/src/activities/chat/tools/tool-calls.ts +23 -22
  100. package/src/activities/generateVideo/index.ts +7 -6
  101. package/src/activities/stream-generation-result.ts +8 -12
  102. package/src/activities/summarize/chat-stream-summarize.ts +94 -70
  103. package/src/adapter-internals.ts +2 -0
  104. package/src/byok/define-provider.ts +46 -0
  105. package/src/byok/errors.ts +36 -0
  106. package/src/byok/get-key.ts +27 -0
  107. package/src/byok/missing.ts +43 -0
  108. package/src/byok/providers.ts +27 -0
  109. package/src/byok/scrub.ts +16 -0
  110. package/src/byok/server.ts +3 -0
  111. package/src/byok.ts +17 -0
  112. package/src/client.ts +13 -0
  113. package/src/index.ts +6 -0
  114. package/src/interrupt-resume.ts +14 -13
  115. package/src/middlewares/otel.ts +16 -8
  116. package/src/stream-to-response.ts +11 -4
  117. package/src/stream-to-websocket.ts +3 -1
  118. package/src/strip-to-spec-middleware.ts +48 -24
  119. package/src/types.ts +109 -393
  120. package/src/utilities/adapter-yield-chunk.ts +30 -0
  121. package/src/utilities/ag-ui-usage.test.ts +194 -0
  122. package/src/utilities/ag-ui-usage.ts +148 -0
  123. package/src/utilities/ag-ui-wire.ts +149 -31
  124. package/src/utilities/chat-params.ts +22 -39
  125. package/src/utilities/chunk-ids.ts +24 -0
  126. package/src/utilities/merge-metadata.test.ts +117 -0
  127. package/src/utilities/merge-metadata.ts +59 -0
  128. package/src/utilities/normalize-stream-chunk.test.ts +423 -0
  129. package/src/utilities/normalize-stream-chunk.ts +186 -0
  130. package/src/utilities/reasoning-encrypted-value.ts +18 -0
  131. package/src/utilities/restore-inbound-chunk.test.ts +133 -0
  132. package/src/utilities/restore-inbound-chunk.ts +72 -0
  133. package/src/utilities/spec-event-keys.test.ts +34 -0
  134. package/src/utilities/spec-event-keys.ts +74 -0
  135. 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'
@@ -0,0 +1,46 @@
1
+ import { isProviderId } from './providers'
2
+
3
+ /**
4
+ * A BYOK provider declared by an adapter. `id` is the `x-byok-<id>` slug and
5
+ * is required — `{ id?: string }` is not a {@link ByokProvider}.
6
+ */
7
+ export interface ByokProvider<TId extends string = string> {
8
+ readonly id: TId
9
+ readonly label: string
10
+ /**
11
+ * Env var names the relay may read. Names only — never put `process.env`
12
+ * values here. This object is imported on the client.
13
+ */
14
+ readonly env?: ReadonlyArray<string>
15
+ }
16
+
17
+ /**
18
+ * Input for {@link defineByokProvider}. `id` cannot be optional: if `TId`
19
+ * includes `undefined`, `id` becomes `never` and the object is unassignable.
20
+ */
21
+ export type ByokProviderInit<TId extends string> = {
22
+ readonly id: undefined extends TId ? never : TId
23
+ readonly label: string
24
+ readonly env?: string | ReadonlyArray<string>
25
+ }
26
+
27
+ function normalizeEnv(
28
+ env: string | ReadonlyArray<string> | undefined,
29
+ ): ReadonlyArray<string> | undefined {
30
+ if (env === undefined) return undefined
31
+ return typeof env === 'string' ? [env] : env
32
+ }
33
+
34
+ export function defineByokProvider<const TId extends string>(
35
+ provider: ByokProviderInit<TId>,
36
+ ): ByokProvider<TId> {
37
+ if (!isProviderId(provider.id)) {
38
+ throw new Error(`Invalid BYOK provider id: ${String(provider.id)}`)
39
+ }
40
+ const env = normalizeEnv(provider.env)
41
+ return {
42
+ id: provider.id,
43
+ label: provider.label,
44
+ ...(env ? { env } : {}),
45
+ }
46
+ }
@@ -0,0 +1,36 @@
1
+ import type { ProviderId } from './providers'
2
+
3
+ export class ByokMissingError extends Error {
4
+ readonly provider: ProviderId
5
+
6
+ constructor(provider: ProviderId) {
7
+ super(`Missing ${provider} API key`)
8
+ this.name = 'ByokMissingError'
9
+ this.provider = provider
10
+ }
11
+ }
12
+
13
+ export class ByokBlockedError extends Error {
14
+ readonly provider: ProviderId
15
+ readonly reason: 'missing' | 'locked'
16
+
17
+ constructor(provider: ProviderId, reason: 'missing' | 'locked') {
18
+ super(
19
+ reason === 'locked'
20
+ ? `${provider} key is locked`
21
+ : `Missing ${provider} API key`,
22
+ )
23
+ this.name = 'ByokBlockedError'
24
+ this.provider = provider
25
+ this.reason = reason
26
+ }
27
+ }
28
+
29
+ export class ByokUnresolvedProviderError extends Error {
30
+ constructor() {
31
+ super(
32
+ 'BYOK is enabled but no provider slug was resolved. Pass byokProvider or forwardedProps.provider.',
33
+ )
34
+ this.name = 'ByokUnresolvedProviderError'
35
+ }
36
+ }
@@ -0,0 +1,27 @@
1
+ import { byokHeaderName, resolveProviderId } from './providers'
2
+ import type { ByokProvider } from './define-provider'
3
+ import type { ProviderId } from './providers'
4
+
5
+ /**
6
+ * Read a key on the relay. Import from `@tanstack/ai/byok/server` so this
7
+ * `process.env` access is not in the client graph.
8
+ *
9
+ * The header wins. A {@link ByokProvider} then tries `provider.env` in order.
10
+ * A slug is header-only.
11
+ */
12
+ export function getByokKey(
13
+ request: Request,
14
+ provider: ProviderId | ByokProvider,
15
+ ): string | null {
16
+ const value = request.headers.get(byokHeaderName(resolveProviderId(provider)))
17
+ if (typeof value === 'string') {
18
+ const trimmed = value.trim()
19
+ if (trimmed.length > 0) return trimmed
20
+ }
21
+ if (typeof provider === 'string') return null
22
+ for (const name of provider.env ?? []) {
23
+ const envValue = process.env[name]
24
+ if (typeof envValue === 'string' && envValue.length > 0) return envValue
25
+ }
26
+ return null
27
+ }
@@ -0,0 +1,43 @@
1
+ import { isProviderId, resolveProviderId } from './providers'
2
+ import type { ByokProvider } from './define-provider'
3
+ import type { ProviderId } from './providers'
4
+
5
+ export interface ByokMissingBody {
6
+ error: {
7
+ type: 'byok_missing'
8
+ provider: ProviderId
9
+ message: string
10
+ }
11
+ }
12
+
13
+ export function isByokMissingBody(value: unknown): value is ByokMissingBody {
14
+ if (typeof value !== 'object' || value === null) return false
15
+ if (!('error' in value)) return false
16
+ const error = value.error
17
+ if (typeof error !== 'object' || error === null) return false
18
+ if (!('type' in error) || error.type !== 'byok_missing') return false
19
+ if (!('provider' in error) || typeof error.provider !== 'string') {
20
+ return false
21
+ }
22
+ if (!isProviderId(error.provider)) return false
23
+ if (!('message' in error) || typeof error.message !== 'string') return false
24
+ return true
25
+ }
26
+
27
+ export function byokMissing(provider: ProviderId | ByokProvider): Response {
28
+ const id = resolveProviderId(provider)
29
+ if (!isProviderId(id)) {
30
+ throw new Error(`Invalid BYOK provider id: ${id}`)
31
+ }
32
+ const body: ByokMissingBody = {
33
+ error: {
34
+ type: 'byok_missing',
35
+ provider: id,
36
+ message: `Missing ${id} API key`,
37
+ },
38
+ }
39
+ return new Response(JSON.stringify(body), {
40
+ status: 401,
41
+ headers: { 'content-type': 'application/json' },
42
+ })
43
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Provider ids are open slugs, not a closed catalog. `@tanstack/ai` does not
3
+ * list adapters. Any matching string is a valid id and becomes `x-byok-<id>`.
4
+ */
5
+ export type ProviderId = string
6
+
7
+ /** `[a-z][a-z0-9-]{0,63}` — lowercase, no underscores, max 64 chars. */
8
+ export const BYOK_PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/
9
+
10
+ export const BYOK_HEADER_PREFIX = 'x-byok-'
11
+
12
+ export function isProviderId(value: unknown): value is ProviderId {
13
+ return typeof value === 'string' && BYOK_PROVIDER_ID_PATTERN.test(value)
14
+ }
15
+
16
+ export function resolveProviderId(
17
+ provider: string | { readonly id: string },
18
+ ): string {
19
+ return typeof provider === 'string' ? provider : provider.id
20
+ }
21
+
22
+ export function byokHeaderName(provider: string): string {
23
+ if (!isProviderId(provider)) {
24
+ throw new Error(`Invalid BYOK provider id: ${provider}`)
25
+ }
26
+ return `${BYOK_HEADER_PREFIX}${provider}`
27
+ }
@@ -0,0 +1,16 @@
1
+ export function maskKey(key: string): string {
2
+ if (key.length <= 4) return '••'
3
+ return key.slice(-4)
4
+ }
5
+
6
+ export function scrubSecrets(
7
+ input: string,
8
+ secrets: ReadonlyArray<string>,
9
+ ): string {
10
+ let next = input
11
+ for (const secret of secrets) {
12
+ if (secret.length === 0) continue
13
+ next = next.split(secret).join('[redacted]')
14
+ }
15
+ return next
16
+ }
@@ -0,0 +1,3 @@
1
+ export { getByokKey } from './get-key'
2
+ export { byokMissing, isByokMissingBody } from './missing'
3
+ export type { ByokMissingBody } from './missing'
package/src/byok.ts ADDED
@@ -0,0 +1,17 @@
1
+ export {
2
+ BYOK_PROVIDER_ID_PATTERN,
3
+ BYOK_HEADER_PREFIX,
4
+ byokHeaderName,
5
+ isProviderId,
6
+ } from './byok/providers'
7
+ export type { ProviderId } from './byok/providers'
8
+ export { defineByokProvider } from './byok/define-provider'
9
+ export type { ByokProvider, ByokProviderInit } from './byok/define-provider'
10
+ export { isByokMissingBody, byokMissing } from './byok/missing'
11
+ export type { ByokMissingBody } from './byok/missing'
12
+ export {
13
+ ByokMissingError,
14
+ ByokBlockedError,
15
+ ByokUnresolvedProviderError,
16
+ } from './byok/errors'
17
+ export { maskKey, scrubSecrets } from './byok/scrub'
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,