@tanstack/ai-client 0.25.2 → 0.28.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 (51) hide show
  1. package/README.md +2 -1
  2. package/dist/esm/byok/client.d.ts +52 -0
  3. package/dist/esm/byok/client.js +300 -0
  4. package/dist/esm/byok/client.js.map +1 -0
  5. package/dist/esm/byok/passkey.d.ts +43 -0
  6. package/dist/esm/byok/passkey.js +251 -0
  7. package/dist/esm/byok/passkey.js.map +1 -0
  8. package/dist/esm/byok/resolve.d.ts +8 -0
  9. package/dist/esm/byok/resolve.js +20 -0
  10. package/dist/esm/byok/resolve.js.map +1 -0
  11. package/dist/esm/byok/storage.d.ts +15 -0
  12. package/dist/esm/byok/storage.js +16 -0
  13. package/dist/esm/byok/storage.js.map +1 -0
  14. package/dist/esm/byok.d.ts +8 -0
  15. package/dist/esm/byok.js +5 -0
  16. package/dist/esm/chat-client.d.ts +18 -5
  17. package/dist/esm/chat-client.js +96 -59
  18. package/dist/esm/chat-client.js.map +1 -1
  19. package/dist/esm/connection-adapters.d.ts +2 -0
  20. package/dist/esm/connection-adapters.js +70 -30
  21. package/dist/esm/connection-adapters.js.map +1 -1
  22. package/dist/esm/generation-client.d.ts +3 -1
  23. package/dist/esm/generation-client.js +27 -6
  24. package/dist/esm/generation-client.js.map +1 -1
  25. package/dist/esm/generation-types.d.ts +14 -0
  26. package/dist/esm/generation-types.js +4 -2
  27. package/dist/esm/generation-types.js.map +1 -1
  28. package/dist/esm/interrupt-manager.d.ts +1 -0
  29. package/dist/esm/interrupt-manager.js +15 -6
  30. package/dist/esm/interrupt-manager.js.map +1 -1
  31. package/dist/esm/sse-parser.js +8 -1
  32. package/dist/esm/sse-parser.js.map +1 -1
  33. package/dist/esm/types.d.ts +46 -3
  34. package/dist/esm/types.js.map +1 -1
  35. package/dist/esm/video-generation-client.d.ts +3 -1
  36. package/dist/esm/video-generation-client.js +29 -8
  37. package/dist/esm/video-generation-client.js.map +1 -1
  38. package/package.json +7 -3
  39. package/src/byok/client.ts +360 -0
  40. package/src/byok/passkey.ts +399 -0
  41. package/src/byok/resolve.ts +30 -0
  42. package/src/byok/storage.ts +29 -0
  43. package/src/byok.ts +20 -0
  44. package/src/chat-client.ts +138 -77
  45. package/src/connection-adapters.ts +108 -53
  46. package/src/generation-client.ts +53 -9
  47. package/src/generation-types.ts +24 -2
  48. package/src/interrupt-manager.ts +20 -6
  49. package/src/sse-parser.ts +10 -0
  50. package/src/types.ts +48 -3
  51. package/src/video-generation-client.ts +50 -9
@@ -3,9 +3,21 @@ import {
3
3
  convertSchemaToJsonSchema,
4
4
  generateMessageId,
5
5
  isStandardSchema,
6
+ mergeMetadata,
6
7
  normalizeToUIMessage,
7
8
  parseWithStandardSchema,
9
+ restoreInboundChunk,
10
+ tanstackMetadata,
8
11
  } from '@tanstack/ai/client'
12
+ import {
13
+ ByokBlockedError,
14
+ ByokMissingError,
15
+ ByokUnresolvedProviderError,
16
+ } from '@tanstack/ai/byok'
17
+ import {
18
+ prepareResolvedByokHeaders,
19
+ resolveByokProviderId,
20
+ } from './byok/resolve'
9
21
  import { createNoOpChatDevtoolsBridge } from './devtools-noop'
10
22
  import {
11
23
  fetcherToConnectionAdapter,
@@ -24,6 +36,7 @@ import type {
24
36
  RunAgentResumeItem,
25
37
  StreamChunk,
26
38
  } from '@tanstack/ai/client'
39
+ import type { ByokClient } from './byok'
27
40
  import type {
28
41
  ChatHydrationResult,
29
42
  ConnectionAdapter,
@@ -94,6 +107,8 @@ type ChatClientUpdateOptionsWithoutContext<
94
107
  /** @deprecated Use `forwardedProps` instead. */
95
108
  body?: Record<string, any>
96
109
  forwardedProps?: Record<string, any>
110
+ byok?: ByokClient
111
+ byokProvider?: () => string | undefined
97
112
  tools?: TTools
98
113
  interrupts?: TInterrupts
99
114
  queue?: QueueOption
@@ -189,6 +204,7 @@ export function normalizeQueueOption(
189
204
  * Merge a run of queued messages into a single send for `drain: 'batch'`.
190
205
  * All-string content is joined with newlines; mixed/multimodal content is
191
206
  * flattened into a single `ContentPart` array. The last item's `body` wins.
207
+ * Object-form metadata is merged last-write-wins per key.
192
208
  */
193
209
  function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
194
210
  content: string | MultimodalContent
@@ -209,17 +225,24 @@ function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
209
225
  }
210
226
  }
211
227
  const parts: Array<ContentPart> = []
228
+ let metadata: Record<string, any> | undefined
212
229
  for (const item of items) {
213
230
  if (typeof item.content === 'string') {
214
231
  parts.push({ type: 'text', content: item.content })
215
- } else if (typeof item.content.content === 'string') {
232
+ continue
233
+ }
234
+ if (typeof item.content.content === 'string') {
216
235
  parts.push({ type: 'text', content: item.content.content })
217
236
  } else {
218
237
  parts.push(...item.content.content)
219
238
  }
239
+ metadata = mergeMetadata(metadata, item.content.metadata)
220
240
  }
221
241
  return {
222
- content: { content: parts },
242
+ content: {
243
+ content: parts,
244
+ ...(metadata !== undefined ? { metadata } : {}),
245
+ },
223
246
  ...(body !== undefined ? { body } : {}),
224
247
  }
225
248
  }
@@ -337,6 +360,8 @@ export class ChatClient<
337
360
  // merged on every send, with `forwardedProps` winning on key collision.
338
361
  private bodyOption: Record<string, any> = {}
339
362
  private forwardedPropsOption: Record<string, any> = {}
363
+ private byok: ByokClient | undefined
364
+ private byokProvider: (() => string | undefined) | undefined
340
365
  private context: TContext | undefined = undefined
341
366
  private pendingMessageBody: Record<string, any> | undefined = undefined
342
367
  private queueConfig: NormalizedQueueConfig
@@ -483,6 +508,8 @@ export class ChatClient<
483
508
  // winning on key collision.
484
509
  this.bodyOption = options.body || {}
485
510
  this.forwardedPropsOption = options.forwardedProps || {}
511
+ this.byok = options.byok
512
+ this.byokProvider = options.byokProvider
486
513
  this.context = options.context
487
514
  this.queueConfig = normalizeQueueOption(options.queue)
488
515
  this.connection = normalizeConnectionAdapter(resolveTransport(options))
@@ -1747,12 +1774,14 @@ export class ChatClient<
1747
1774
  chunk: StreamChunk,
1748
1775
  options?: { defer?: boolean },
1749
1776
  ): Promise<void> {
1777
+ chunk = restoreInboundChunk(chunk)
1750
1778
  if (
1751
1779
  chunk.type === 'RUN_ERROR' &&
1752
1780
  this.isActiveInterruptSubmissionFailure(chunk)
1753
1781
  ) {
1782
+ const interruptErrors = tanstackMetadata(chunk)?.interruptErrors
1754
1783
  this.interruptSubmissionFailure = {
1755
- errors: chunk['tanstack:interruptErrors'] ?? [],
1784
+ errors: Array.isArray(interruptErrors) ? interruptErrors : [],
1756
1785
  }
1757
1786
  }
1758
1787
  if (this.connectionStatus === 'connecting') {
@@ -1792,8 +1821,10 @@ export class ChatClient<
1792
1821
  chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
1793
1822
  ): boolean {
1794
1823
  const submission = this.activeInterruptSubmission
1795
- const errors = chunk['tanstack:interruptErrors']
1796
- if (!submission || !errors || errors.length === 0) return false
1824
+ const errors = tanstackMetadata(chunk)?.interruptErrors
1825
+ if (!submission || !Array.isArray(errors) || errors.length === 0) {
1826
+ return false
1827
+ }
1797
1828
  const runId = getChunkRunId(chunk)
1798
1829
  if (runId !== undefined && runId !== this.currentRunId) return false
1799
1830
  if (
@@ -1802,12 +1833,22 @@ export class ChatClient<
1802
1833
  ) {
1803
1834
  return false
1804
1835
  }
1805
- return errors.every(
1806
- (error) =>
1836
+ return errors.every((error) => {
1837
+ if (
1838
+ error == null ||
1839
+ typeof error !== 'object' ||
1840
+ typeof error.threadId !== 'string' ||
1841
+ typeof error.interruptedRunId !== 'string' ||
1842
+ typeof error.generation !== 'number'
1843
+ ) {
1844
+ return false
1845
+ }
1846
+ return (
1807
1847
  error.threadId === submission.threadId &&
1808
1848
  error.interruptedRunId === submission.interruptedRunId &&
1809
- error.generation === submission.generation,
1810
- )
1849
+ error.generation === submission.generation
1850
+ )
1851
+ })
1811
1852
  }
1812
1853
 
1813
1854
  private resolveJoinedRun(chunk: StreamChunk): void {
@@ -1857,8 +1898,12 @@ export class ChatClient<
1857
1898
  * - A MultimodalContent object with content array and optional custom ID
1858
1899
  * @param body - Optional body parameters to merge with the client's base body for this request.
1859
1900
  * Uses shallow merge with per-message body taking priority.
1860
- * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
1861
- * override the configured queue policy for this one send.
1901
+ * @param sendOptions - Per-call overrides. `{ whenBusy }` overrides the
1902
+ * queue policy for this one send. `{ body }`
1903
+ * shallow-merges with `body` and with the chat-level
1904
+ * `body` / `forwardedProps`. `sendOptions.body` wins
1905
+ * on key collisions. Framework hooks forward this
1906
+ * object as their second argument.
1862
1907
  *
1863
1908
  * @example
1864
1909
  * ```ts
@@ -1868,9 +1913,13 @@ export class ChatClient<
1868
1913
  * // Text message with custom body params
1869
1914
  * await client.sendMessage('Hello!', { temperature: 0.7 })
1870
1915
  *
1871
- * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
1916
+ * // Per-call whenBusy override
1872
1917
  * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
1873
1918
  *
1919
+ * // Per-call body via options. Same effect as the positional arg.
1920
+ * // This is the shape the framework hooks (`useChat`, `injectChat`) forward.
1921
+ * await client.sendMessage('Hello!', undefined, { body: { temperature: 0.7 } })
1922
+ *
1874
1923
  * // Multimodal message with image
1875
1924
  * await client.sendMessage({
1876
1925
  * content: [
@@ -1908,13 +1957,15 @@ export class ChatClient<
1908
1957
  )
1909
1958
  }
1910
1959
 
1960
+ const resolvedBody = { ...body, ...sendOptions?.body }
1961
+
1911
1962
  if (this.isSendBusy()) {
1912
1963
  const { action, id } = this.decideWhenBusy(content, sendOptions)
1913
1964
  if (action === 'drop') {
1914
1965
  return
1915
1966
  }
1916
1967
  if (action === 'queue') {
1917
- this.enqueueMessage(content, body, id)
1968
+ this.enqueueMessage(content, resolvedBody, id)
1918
1969
  return
1919
1970
  }
1920
1971
  // 'interrupt': abort the current stream, then send now.
@@ -1931,7 +1982,7 @@ export class ChatClient<
1931
1982
  }
1932
1983
 
1933
1984
  try {
1934
- await this.deliverMessage(content, body)
1985
+ await this.deliverMessage(content, resolvedBody)
1935
1986
  } finally {
1936
1987
  this.sendInFlight = false
1937
1988
  }
@@ -1982,6 +2033,7 @@ export class ChatClient<
1982
2033
  const userMessage = this.processor.addUserMessage(
1983
2034
  normalizedContent.content,
1984
2035
  normalizedContent.id,
2036
+ normalizedContent.metadata,
1985
2037
  )
1986
2038
  this.events.messageSent(userMessage.id, normalizedContent.content)
1987
2039
  return await this.streamResponse()
@@ -2041,17 +2093,22 @@ export class ChatClient<
2041
2093
  }
2042
2094
 
2043
2095
  /**
2044
- * Normalize the message input to extract content and optional id.
2045
- * Trims string content automatically.
2096
+ * Normalize the message input to extract content, optional id, and
2097
+ * optional metadata. String form has no metadata. Trims string content.
2046
2098
  */
2047
2099
  private normalizeMessageInput(input: string | MultimodalContent): {
2048
2100
  content: string | Array<ContentPart>
2049
2101
  id?: string
2102
+ metadata?: Record<string, any>
2050
2103
  } {
2051
2104
  if (typeof input === 'string') {
2052
2105
  return { content: input.trim() }
2053
2106
  }
2054
- return { content: input.content, id: input.id }
2107
+ return {
2108
+ content: input.content,
2109
+ id: input.id,
2110
+ ...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
2111
+ }
2055
2112
  }
2056
2113
 
2057
2114
  /**
@@ -2160,7 +2217,7 @@ export class ChatClient<
2160
2217
  // order (later spreads win):
2161
2218
  // 1. Legacy `body` option (deprecated).
2162
2219
  // 2. Canonical `forwardedProps` option (wins over `body`).
2163
- // 3. Per-message `body` arg passed to `sendMessage` (highest).
2220
+ // 3. Per-call body (`pendingMessageBody`: positional + sendOptions.body).
2164
2221
  // The AG-UI standard `threadId` is sent at the wire's top level for
2165
2222
  // run/conversation correlation, so we no longer auto-emit a separate
2166
2223
  // `conversationId` here — `chat({ threadId })` server-side covers the
@@ -2200,6 +2257,15 @@ export class ChatClient<
2200
2257
  // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect
2201
2258
  // JSON Schema; sending a Standard Schema instance directly would
2202
2259
  // serialize to an unusable shape.
2260
+ let byokHeaders: Record<string, string> | undefined
2261
+ if (this.byok) {
2262
+ const provider = resolveByokProviderId(
2263
+ this.byokProvider,
2264
+ mergedBody.provider,
2265
+ )
2266
+ byokHeaders = await prepareResolvedByokHeaders(this.byok, provider)
2267
+ }
2268
+
2203
2269
  const runContext = {
2204
2270
  threadId: resumeThreadId ?? this.threadId,
2205
2271
  runId,
@@ -2215,6 +2281,7 @@ export class ChatClient<
2215
2281
  })),
2216
2282
  forwardedProps: { ...mergedBody },
2217
2283
  ...(resumeItems ? { resume: resumeItems } : {}),
2284
+ ...(byokHeaders ? { headers: byokHeaders } : {}),
2218
2285
  }
2219
2286
  this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId)
2220
2287
  activeDevtoolsRunId = runContext.runId
@@ -2270,32 +2337,45 @@ export class ChatClient<
2270
2337
  // Finalize (idempotent — may already be done by RUN_FINISHED handler)
2271
2338
  this.processor.finalizeStream()
2272
2339
  streamCompletedSuccessfully = true
2273
- } catch (err) {
2274
- if (err instanceof Error) {
2275
- if (err.name === 'AbortError') {
2276
- if (activeDevtoolsRunId) {
2277
- this.devtoolsBridge.emitRunLifecycle(
2278
- 'run:cancelled',
2279
- activeDevtoolsRunId,
2280
- 'cancelled',
2281
- )
2282
- runTerminalEventEmitted = true
2283
- }
2284
- return false
2340
+ } catch (err: unknown) {
2341
+ const error = err instanceof Error ? err : new Error(String(err))
2342
+ if (error.name === 'AbortError') {
2343
+ if (activeDevtoolsRunId) {
2344
+ this.devtoolsBridge.emitRunLifecycle(
2345
+ 'run:cancelled',
2346
+ activeDevtoolsRunId,
2347
+ 'cancelled',
2348
+ )
2349
+ runTerminalEventEmitted = true
2285
2350
  }
2286
- if (generation === this.streamGeneration) {
2287
- this.reportStreamError(err)
2288
- if (activeDevtoolsRunId) {
2289
- this.devtoolsBridge.emitRunLifecycle(
2290
- 'run:errored',
2291
- activeDevtoolsRunId,
2292
- 'errored',
2293
- { error: err.message },
2294
- )
2295
- runTerminalEventEmitted = true
2296
- }
2351
+ return false
2352
+ }
2353
+ if (error instanceof ByokMissingError) {
2354
+ this.byok?.request(error.provider, 'missing')
2355
+ }
2356
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
2357
+ this.byok?.request(error.provider, 'locked')
2358
+ }
2359
+ if (generation === this.streamGeneration) {
2360
+ this.reportStreamError(error)
2361
+ if (activeDevtoolsRunId) {
2362
+ this.devtoolsBridge.emitRunLifecycle(
2363
+ 'run:errored',
2364
+ activeDevtoolsRunId,
2365
+ 'errored',
2366
+ { error: error.message },
2367
+ )
2368
+ runTerminalEventEmitted = true
2297
2369
  }
2298
2370
  }
2371
+ if (
2372
+ generation === this.streamGeneration &&
2373
+ (error instanceof ByokMissingError ||
2374
+ error instanceof ByokBlockedError ||
2375
+ error instanceof ByokUnresolvedProviderError)
2376
+ ) {
2377
+ throw error
2378
+ }
2299
2379
  } finally {
2300
2380
  // Only clean up if this is still the active stream.
2301
2381
  // A superseded stream (e.g. reload() started a new one) must not
@@ -2330,43 +2410,18 @@ export class ChatClient<
2330
2410
  // Drain any actions that were queued while the stream was in progress
2331
2411
  await this.drainPostStreamActions()
2332
2412
 
2333
- // Continue conversation if the stream ended with a tool result (server tool completed)
2334
- // but ONLY if the model indicated it wants to continue (finishReason !== 'stop').
2335
- // When finishReason is 'stop', the model is done — don't re-send.
2336
2413
  if (streamCompletedSuccessfully) {
2337
- const messages = this.processor.getMessages()
2338
- const lastPart = messages.at(-1)?.parts.at(-1)
2339
- const { finishReason } = this.processor.getState()
2340
-
2341
- if (
2342
- lastPart?.type === 'tool-result' &&
2343
- finishReason !== 'stop' &&
2344
- this.shouldAutoSend()
2345
- ) {
2346
- try {
2347
- await this.checkForContinuation()
2348
- } catch (error) {
2349
- console.error('Failed to continue flow after tool result:', error)
2350
- // Continuation failed without starting a new stream — don't
2351
- // leave queued user messages stranded forever. (isLoading is
2352
- // already false in this finally block.)
2353
- await this.drainQueue()
2354
- }
2355
- } else {
2356
- if (this.status !== 'ready') {
2357
- // Terminal run, but onStreamEnd never fired: the processor had
2358
- // no assistant message to emit it for (e.g. a bare
2359
- // RUN_FINISHED{stop}, #421). The normal path already set
2360
- // 'ready', so this is a no-op.
2361
- this.setStatus('ready')
2362
- }
2363
- // Auto-send queued messages once the run fully settles. When a
2364
- // continuation runs instead (tool-result branch above), that
2365
- // continuation's own finally drains the queue. Skip if a drain
2366
- // loop is already walking the queue (avoids nested re-entry).
2367
- if (!this.messageQueueDraining) {
2368
- await this.drainQueue()
2369
- }
2414
+ if (this.status !== 'ready') {
2415
+ // Terminal run, but onStreamEnd never fired: the processor had
2416
+ // no assistant message to emit it for (e.g. a bare
2417
+ // RUN_FINISHED{stop}, #421). The normal path already set
2418
+ // 'ready', so this is a no-op.
2419
+ this.setStatus('ready')
2420
+ }
2421
+ // Auto-send queued messages once the run fully settles. Skip if a
2422
+ // drain loop is already walking the queue (avoids nested re-entry).
2423
+ if (!this.messageQueueDraining) {
2424
+ await this.drainQueue()
2370
2425
  }
2371
2426
  } else {
2372
2427
  // Error/abort settle for the active generation: don't strand or
@@ -2948,6 +3003,12 @@ export class ChatClient<
2948
3003
  if (options.forwardedProps !== undefined) {
2949
3004
  this.forwardedPropsOption = options.forwardedProps
2950
3005
  }
3006
+ if (options.byok !== undefined) {
3007
+ this.byok = options.byok
3008
+ }
3009
+ if (options.byokProvider !== undefined) {
3010
+ this.byokProvider = options.byokProvider
3011
+ }
2951
3012
  if ('context' in options) {
2952
3013
  this.context = options.context
2953
3014
  }