@tanstack/ai-client 0.26.0 → 0.29.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 (56) hide show
  1. package/dist/esm/byok/client.d.ts +52 -0
  2. package/dist/esm/byok/client.js +300 -0
  3. package/dist/esm/byok/client.js.map +1 -0
  4. package/dist/esm/byok/passkey.d.ts +43 -0
  5. package/dist/esm/byok/passkey.js +251 -0
  6. package/dist/esm/byok/passkey.js.map +1 -0
  7. package/dist/esm/byok/resolve.d.ts +8 -0
  8. package/dist/esm/byok/resolve.js +20 -0
  9. package/dist/esm/byok/resolve.js.map +1 -0
  10. package/dist/esm/byok/storage.d.ts +15 -0
  11. package/dist/esm/byok/storage.js +16 -0
  12. package/dist/esm/byok/storage.js.map +1 -0
  13. package/dist/esm/byok.d.ts +8 -0
  14. package/dist/esm/byok.js +5 -0
  15. package/dist/esm/chat-client.d.ts +19 -3
  16. package/dist/esm/chat-client.js +82 -42
  17. package/dist/esm/chat-client.js.map +1 -1
  18. package/dist/esm/connection-adapters.d.ts +2 -0
  19. package/dist/esm/connection-adapters.js +35 -10
  20. package/dist/esm/connection-adapters.js.map +1 -1
  21. package/dist/esm/generation-client.d.ts +3 -1
  22. package/dist/esm/generation-client.js +23 -4
  23. package/dist/esm/generation-client.js.map +1 -1
  24. package/dist/esm/generation-types.d.ts +14 -0
  25. package/dist/esm/generation-types.js.map +1 -1
  26. package/dist/esm/interrupt-manager.d.ts +1 -0
  27. package/dist/esm/interrupt-manager.js +15 -6
  28. package/dist/esm/interrupt-manager.js.map +1 -1
  29. package/dist/esm/message-date-normalizer.d.ts +3 -0
  30. package/dist/esm/message-date-normalizer.js +35 -0
  31. package/dist/esm/message-date-normalizer.js.map +1 -0
  32. package/dist/esm/sse-parser.js +8 -1
  33. package/dist/esm/sse-parser.js.map +1 -1
  34. package/dist/esm/storage-adapters.js +13 -1
  35. package/dist/esm/storage-adapters.js.map +1 -1
  36. package/dist/esm/types.d.ts +34 -3
  37. package/dist/esm/types.js.map +1 -1
  38. package/dist/esm/video-generation-client.d.ts +3 -1
  39. package/dist/esm/video-generation-client.js +25 -6
  40. package/dist/esm/video-generation-client.js.map +1 -1
  41. package/package.json +7 -3
  42. package/src/byok/client.ts +360 -0
  43. package/src/byok/passkey.ts +399 -0
  44. package/src/byok/resolve.ts +30 -0
  45. package/src/byok/storage.ts +29 -0
  46. package/src/byok.ts +20 -0
  47. package/src/chat-client.ts +165 -72
  48. package/src/connection-adapters.ts +46 -14
  49. package/src/generation-client.ts +48 -4
  50. package/src/generation-types.ts +16 -0
  51. package/src/interrupt-manager.ts +20 -6
  52. package/src/message-date-normalizer.ts +29 -0
  53. package/src/sse-parser.ts +10 -0
  54. package/src/storage-adapters.ts +13 -2
  55. package/src/types.ts +36 -3
  56. package/src/video-generation-client.ts +45 -4
@@ -9,6 +9,15 @@ import {
9
9
  restoreInboundChunk,
10
10
  tanstackMetadata,
11
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'
12
21
  import { createNoOpChatDevtoolsBridge } from './devtools-noop'
13
22
  import {
14
23
  fetcherToConnectionAdapter,
@@ -17,6 +26,7 @@ import {
17
26
  } from './connection-adapters'
18
27
  import { ChatPersistor } from './client-persistor'
19
28
  import { ClearedStreamTracker } from './cleared-stream-tracker'
29
+ import { normalizeMessagesDates } from './message-date-normalizer'
20
30
  import { InterruptManager } from './interrupt-manager'
21
31
  import type {
22
32
  AnyClientTool,
@@ -27,6 +37,7 @@ import type {
27
37
  RunAgentResumeItem,
28
38
  StreamChunk,
29
39
  } from '@tanstack/ai/client'
40
+ import type { ByokClient } from './byok'
30
41
  import type {
31
42
  ChatHydrationResult,
32
43
  ConnectionAdapter,
@@ -97,6 +108,8 @@ type ChatClientUpdateOptionsWithoutContext<
97
108
  /** @deprecated Use `forwardedProps` instead. */
98
109
  body?: Record<string, any>
99
110
  forwardedProps?: Record<string, any>
111
+ byok?: ByokClient
112
+ byokProvider?: () => string | undefined
100
113
  tools?: TTools
101
114
  interrupts?: TInterrupts
102
115
  queue?: QueueOption
@@ -348,6 +361,8 @@ export class ChatClient<
348
361
  // merged on every send, with `forwardedProps` winning on key collision.
349
362
  private bodyOption: Record<string, any> = {}
350
363
  private forwardedPropsOption: Record<string, any> = {}
364
+ private byok: ByokClient | undefined
365
+ private byokProvider: (() => string | undefined) | undefined
351
366
  private context: TContext | undefined = undefined
352
367
  private pendingMessageBody: Record<string, any> | undefined = undefined
353
368
  private queueConfig: NormalizedQueueConfig
@@ -403,6 +418,11 @@ export class ChatClient<
403
418
  private processingResolve: (() => void) | null = null
404
419
  private errorReportedGeneration: number | null = null
405
420
  private streamGeneration = 0
421
+ private continuationGeneration = 0
422
+ // Generation of the run that opened the current stream. Public
423
+ // `addToolResult` must use this, not the live counter: `stop()` increments
424
+ // the live counter, so a post-stop call would otherwise look current.
425
+ private streamContinuationGeneration = 0
406
426
  // Tracks whether a queued checkForContinuation was skipped because
407
427
  // continuationPending was true (chained approval scenario)
408
428
  private continuationSkipped = false
@@ -494,6 +514,8 @@ export class ChatClient<
494
514
  // winning on key collision.
495
515
  this.bodyOption = options.body || {}
496
516
  this.forwardedPropsOption = options.forwardedProps || {}
517
+ this.byok = options.byok
518
+ this.byokProvider = options.byokProvider
497
519
  this.context = options.context
498
520
  this.queueConfig = normalizeQueueOption(options.queue)
499
521
  this.connection = normalizeConnectionAdapter(resolveTransport(options))
@@ -721,6 +743,7 @@ export class ChatClient<
721
743
  const clientTool = clientTools.get(args.toolName)
722
744
  const executeFunc = clientTool?.execute
723
745
  if (executeFunc) {
746
+ const continuationGeneration = this.continuationGeneration
724
747
  // Capture the run context at execution-start so a tool whose
725
748
  // result lands AFTER the originating run finishes still reports
726
749
  // back against the originating run, not whatever run is
@@ -747,6 +770,7 @@ export class ChatClient<
747
770
  state: 'output-available',
748
771
  },
749
772
  clientTool,
773
+ continuationGeneration,
750
774
  runEventContext,
751
775
  )
752
776
  } catch (error: any) {
@@ -759,6 +783,7 @@ export class ChatClient<
759
783
  errorText: error.message,
760
784
  },
761
785
  clientTool,
786
+ continuationGeneration,
762
787
  runEventContext,
763
788
  )
764
789
  } finally {
@@ -1001,7 +1026,7 @@ export class ChatClient<
1001
1026
  // A send may have started while the fetch was in flight — don't stomp it.
1002
1027
  if (this.isLoading || this.abortController) return
1003
1028
  if (result.messages.length > 0) {
1004
- this.processor.setMessages(result.messages)
1029
+ this.processor.setMessages(normalizeMessagesDates(result.messages))
1005
1030
  }
1006
1031
  if (result.interrupts && result.interrupts.pending.length > 0) {
1007
1032
  // Pending interrupt = the thread is paused awaiting a human decision, so
@@ -1288,6 +1313,21 @@ export class ChatClient<
1288
1313
  ): Promise<boolean> {
1289
1314
  const target = state ?? this.lastResume
1290
1315
  if (!target) return Promise.resolve(false)
1316
+ return this.resumeInterruptsUnsafeForGeneration(
1317
+ resume,
1318
+ target,
1319
+ this.continuationGeneration,
1320
+ )
1321
+ }
1322
+
1323
+ private resumeInterruptsUnsafeForGeneration(
1324
+ resume: Array<RunAgentResumeItem>,
1325
+ target: ChatResumeState,
1326
+ continuationGeneration: number,
1327
+ ): Promise<boolean> {
1328
+ if (continuationGeneration !== this.continuationGeneration) {
1329
+ return Promise.resolve(false)
1330
+ }
1291
1331
  // Auto-executed client tools resolve during the parent stream's
1292
1332
  // `pendingToolExecutions` wait — while `isLoading` is still true.
1293
1333
  // Defer the child continuation until that stream settles so we do not
@@ -1296,7 +1336,13 @@ export class ChatClient<
1296
1336
  return new Promise<boolean>((resolve, reject) => {
1297
1337
  this.queuePostStreamAction(async () => {
1298
1338
  try {
1299
- resolve(await this.resumeInterruptsUnsafe(resume, target))
1339
+ resolve(
1340
+ await this.resumeInterruptsUnsafeForGeneration(
1341
+ resume,
1342
+ target,
1343
+ continuationGeneration,
1344
+ ),
1345
+ )
1300
1346
  } catch (error) {
1301
1347
  reject(error)
1302
1348
  }
@@ -1320,6 +1366,7 @@ export class ChatClient<
1320
1366
  private async submitInterruptBatch(
1321
1367
  submission: InterruptManagerSubmission,
1322
1368
  ): Promise<void> {
1369
+ const continuationGeneration = this.continuationGeneration
1323
1370
  this.activeInterruptSubmission = submission
1324
1371
  this.interruptSubmissionFailure = undefined
1325
1372
  // Reflect approval decisions in the local message tree immediately so a
@@ -1331,15 +1378,21 @@ export class ChatClient<
1331
1378
  const approvalId = resolution.interruptId
1332
1379
  this.processor.addToolApprovalResponse(approvalId, approved)
1333
1380
  }
1334
- const resumed = await this.resumeInterruptsUnsafe(
1381
+ const resumed = await this.resumeInterruptsUnsafeForGeneration(
1335
1382
  [...submission.resolutions],
1336
1383
  {
1337
1384
  threadId: submission.threadId,
1338
1385
  runId: submission.interruptedRunId,
1339
1386
  },
1387
+ continuationGeneration,
1340
1388
  ).finally(() => {
1341
- this.activeInterruptSubmission = undefined
1389
+ // Only clear if this resume still owns the client: `stop()` may have
1390
+ // invalidated it while the submission was settling.
1391
+ if (this.activeInterruptSubmission === submission) {
1392
+ this.activeInterruptSubmission = undefined
1393
+ }
1342
1394
  })
1395
+ if (continuationGeneration !== this.continuationGeneration) return
1343
1396
  const failure = this.takeInterruptSubmissionFailure()
1344
1397
  if (failure !== undefined) {
1345
1398
  throw { errors: failure.errors }
@@ -1662,6 +1715,7 @@ export class ChatClient<
1662
1715
  // persisted pointer with the provider id — so a SECOND reload would
1663
1716
  // `joinRun` an id the log isn't keyed by and never re-attach.
1664
1717
  this.lastResume = { threadId: this.threadId, runId }
1718
+ this.streamContinuationGeneration = this.continuationGeneration
1665
1719
  this.setIsLoading(true)
1666
1720
  this.setStatus('streaming')
1667
1721
  void (async () => {
@@ -1882,8 +1936,12 @@ export class ChatClient<
1882
1936
  * - A MultimodalContent object with content array and optional custom ID
1883
1937
  * @param body - Optional body parameters to merge with the client's base body for this request.
1884
1938
  * Uses shallow merge with per-message body taking priority.
1885
- * @param sendOptions - Per-call overrides, e.g. `{ whenBusy: 'interrupt' }` to
1886
- * override the configured queue policy for this one send.
1939
+ * @param sendOptions - Per-call overrides. `{ whenBusy }` overrides the
1940
+ * queue policy for this one send. `{ body }`
1941
+ * shallow-merges with `body` and with the chat-level
1942
+ * `body` / `forwardedProps`. `sendOptions.body` wins
1943
+ * on key collisions. Framework hooks forward this
1944
+ * object as their second argument.
1887
1945
  *
1888
1946
  * @example
1889
1947
  * ```ts
@@ -1893,9 +1951,13 @@ export class ChatClient<
1893
1951
  * // Text message with custom body params
1894
1952
  * await client.sendMessage('Hello!', { temperature: 0.7 })
1895
1953
  *
1896
- * // Per-call whenBusy override (body must still be the 2nd arg on ChatClient)
1954
+ * // Per-call whenBusy override
1897
1955
  * await client.sendMessage('Urgent', undefined, { whenBusy: 'interrupt' })
1898
1956
  *
1957
+ * // Per-call body via options. Same effect as the positional arg.
1958
+ * // This is the shape the framework hooks (`useChat`, `injectChat`) forward.
1959
+ * await client.sendMessage('Hello!', undefined, { body: { temperature: 0.7 } })
1960
+ *
1899
1961
  * // Multimodal message with image
1900
1962
  * await client.sendMessage({
1901
1963
  * content: [
@@ -1933,13 +1995,15 @@ export class ChatClient<
1933
1995
  )
1934
1996
  }
1935
1997
 
1998
+ const resolvedBody = { ...body, ...sendOptions?.body }
1999
+
1936
2000
  if (this.isSendBusy()) {
1937
2001
  const { action, id } = this.decideWhenBusy(content, sendOptions)
1938
2002
  if (action === 'drop') {
1939
2003
  return
1940
2004
  }
1941
2005
  if (action === 'queue') {
1942
- this.enqueueMessage(content, body, id)
2006
+ this.enqueueMessage(content, resolvedBody, id)
1943
2007
  return
1944
2008
  }
1945
2009
  // 'interrupt': abort the current stream, then send now.
@@ -1956,7 +2020,7 @@ export class ChatClient<
1956
2020
  }
1957
2021
 
1958
2022
  try {
1959
- await this.deliverMessage(content, body)
2023
+ await this.deliverMessage(content, resolvedBody)
1960
2024
  } finally {
1961
2025
  this.sendInFlight = false
1962
2026
  }
@@ -2137,6 +2201,7 @@ export class ChatClient<
2137
2201
 
2138
2202
  // Track generation so a superseded stream's cleanup doesn't clobber the new one
2139
2203
  const generation = ++this.streamGeneration
2204
+ this.streamContinuationGeneration = this.continuationGeneration
2140
2205
  // Native interrupt continuation is a fresh child run. The interrupted run
2141
2206
  // is carried as parentRunId and the complete resolution batch as resume.
2142
2207
  const resumeThreadId = this.pendingResumeThreadId
@@ -2191,7 +2256,7 @@ export class ChatClient<
2191
2256
  // order (later spreads win):
2192
2257
  // 1. Legacy `body` option (deprecated).
2193
2258
  // 2. Canonical `forwardedProps` option (wins over `body`).
2194
- // 3. Per-message `body` arg passed to `sendMessage` (highest).
2259
+ // 3. Per-call body (`pendingMessageBody`: positional + sendOptions.body).
2195
2260
  // The AG-UI standard `threadId` is sent at the wire's top level for
2196
2261
  // run/conversation correlation, so we no longer auto-emit a separate
2197
2262
  // `conversationId` here — `chat({ threadId })` server-side covers the
@@ -2231,6 +2296,15 @@ export class ChatClient<
2231
2296
  // AG-UI servers consuming `RunAgentInput.tools[].parameters` expect
2232
2297
  // JSON Schema; sending a Standard Schema instance directly would
2233
2298
  // serialize to an unusable shape.
2299
+ let byokHeaders: Record<string, string> | undefined
2300
+ if (this.byok) {
2301
+ const provider = resolveByokProviderId(
2302
+ this.byokProvider,
2303
+ mergedBody.provider,
2304
+ )
2305
+ byokHeaders = await prepareResolvedByokHeaders(this.byok, provider)
2306
+ }
2307
+
2234
2308
  const runContext = {
2235
2309
  threadId: resumeThreadId ?? this.threadId,
2236
2310
  runId,
@@ -2246,6 +2320,7 @@ export class ChatClient<
2246
2320
  })),
2247
2321
  forwardedProps: { ...mergedBody },
2248
2322
  ...(resumeItems ? { resume: resumeItems } : {}),
2323
+ ...(byokHeaders ? { headers: byokHeaders } : {}),
2249
2324
  }
2250
2325
  this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId)
2251
2326
  activeDevtoolsRunId = runContext.runId
@@ -2301,32 +2376,45 @@ export class ChatClient<
2301
2376
  // Finalize (idempotent — may already be done by RUN_FINISHED handler)
2302
2377
  this.processor.finalizeStream()
2303
2378
  streamCompletedSuccessfully = true
2304
- } catch (err) {
2305
- if (err instanceof Error) {
2306
- if (err.name === 'AbortError') {
2307
- if (activeDevtoolsRunId) {
2308
- this.devtoolsBridge.emitRunLifecycle(
2309
- 'run:cancelled',
2310
- activeDevtoolsRunId,
2311
- 'cancelled',
2312
- )
2313
- runTerminalEventEmitted = true
2314
- }
2315
- return false
2379
+ } catch (err: unknown) {
2380
+ const error = err instanceof Error ? err : new Error(String(err))
2381
+ if (error.name === 'AbortError') {
2382
+ if (activeDevtoolsRunId) {
2383
+ this.devtoolsBridge.emitRunLifecycle(
2384
+ 'run:cancelled',
2385
+ activeDevtoolsRunId,
2386
+ 'cancelled',
2387
+ )
2388
+ runTerminalEventEmitted = true
2316
2389
  }
2317
- if (generation === this.streamGeneration) {
2318
- this.reportStreamError(err)
2319
- if (activeDevtoolsRunId) {
2320
- this.devtoolsBridge.emitRunLifecycle(
2321
- 'run:errored',
2322
- activeDevtoolsRunId,
2323
- 'errored',
2324
- { error: err.message },
2325
- )
2326
- runTerminalEventEmitted = true
2327
- }
2390
+ return false
2391
+ }
2392
+ if (error instanceof ByokMissingError) {
2393
+ this.byok?.request(error.provider, 'missing')
2394
+ }
2395
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
2396
+ this.byok?.request(error.provider, 'locked')
2397
+ }
2398
+ if (generation === this.streamGeneration) {
2399
+ this.reportStreamError(error)
2400
+ if (activeDevtoolsRunId) {
2401
+ this.devtoolsBridge.emitRunLifecycle(
2402
+ 'run:errored',
2403
+ activeDevtoolsRunId,
2404
+ 'errored',
2405
+ { error: error.message },
2406
+ )
2407
+ runTerminalEventEmitted = true
2328
2408
  }
2329
2409
  }
2410
+ if (
2411
+ generation === this.streamGeneration &&
2412
+ (error instanceof ByokMissingError ||
2413
+ error instanceof ByokBlockedError ||
2414
+ error instanceof ByokUnresolvedProviderError)
2415
+ ) {
2416
+ throw error
2417
+ }
2330
2418
  } finally {
2331
2419
  // Only clean up if this is still the active stream.
2332
2420
  // A superseded stream (e.g. reload() started a new one) must not
@@ -2361,43 +2449,18 @@ export class ChatClient<
2361
2449
  // Drain any actions that were queued while the stream was in progress
2362
2450
  await this.drainPostStreamActions()
2363
2451
 
2364
- // Continue conversation if the stream ended with a tool result (server tool completed)
2365
- // but ONLY if the model indicated it wants to continue (finishReason !== 'stop').
2366
- // When finishReason is 'stop', the model is done — don't re-send.
2367
2452
  if (streamCompletedSuccessfully) {
2368
- const messages = this.processor.getMessages()
2369
- const lastPart = messages.at(-1)?.parts.at(-1)
2370
- const { finishReason } = this.processor.getState()
2371
-
2372
- if (
2373
- lastPart?.type === 'tool-result' &&
2374
- finishReason !== 'stop' &&
2375
- this.shouldAutoSend()
2376
- ) {
2377
- try {
2378
- await this.checkForContinuation()
2379
- } catch (error) {
2380
- console.error('Failed to continue flow after tool result:', error)
2381
- // Continuation failed without starting a new stream — don't
2382
- // leave queued user messages stranded forever. (isLoading is
2383
- // already false in this finally block.)
2384
- await this.drainQueue()
2385
- }
2386
- } else {
2387
- if (this.status !== 'ready') {
2388
- // Terminal run, but onStreamEnd never fired: the processor had
2389
- // no assistant message to emit it for (e.g. a bare
2390
- // RUN_FINISHED{stop}, #421). The normal path already set
2391
- // 'ready', so this is a no-op.
2392
- this.setStatus('ready')
2393
- }
2394
- // Auto-send queued messages once the run fully settles. When a
2395
- // continuation runs instead (tool-result branch above), that
2396
- // continuation's own finally drains the queue. Skip if a drain
2397
- // loop is already walking the queue (avoids nested re-entry).
2398
- if (!this.messageQueueDraining) {
2399
- await this.drainQueue()
2400
- }
2453
+ if (this.status !== 'ready') {
2454
+ // Terminal run, but onStreamEnd never fired: the processor had
2455
+ // no assistant message to emit it for (e.g. a bare
2456
+ // RUN_FINISHED{stop}, #421). The normal path already set
2457
+ // 'ready', so this is a no-op.
2458
+ this.setStatus('ready')
2459
+ }
2460
+ // Auto-send queued messages once the run fully settles. Skip if a
2461
+ // drain loop is already walking the queue (avoids nested re-entry).
2462
+ if (!this.messageQueueDraining) {
2463
+ await this.drainQueue()
2401
2464
  }
2402
2465
  } else {
2403
2466
  // Error/abort settle for the active generation: don't strand or
@@ -2482,9 +2545,14 @@ export class ChatClient<
2482
2545
  * Stop the current stream
2483
2546
  */
2484
2547
  stop(): void {
2548
+ // Invalidate deferred work from the stopped continuation.
2549
+ this.continuationGeneration++
2485
2550
  const hadLocalStream = this.abortController !== null
2486
2551
  this.cancelInFlightStream({ setReadyStatus: true })
2487
2552
  this.discardPendingSends()
2553
+ this.lastResume = null
2554
+ this.activeInterruptSubmission = undefined
2555
+ this.interruptManager.reset()
2488
2556
  if (hadLocalStream) {
2489
2557
  this.resetSessionGenerating()
2490
2558
  }
@@ -2528,12 +2596,17 @@ export class ChatClient<
2528
2596
  */
2529
2597
  async addToolResult(result: ClientToolResult): Promise<void> {
2530
2598
  const clientTool = this.clientToolsRef.current.get(result.tool)
2531
- await this.addToolResultForClientTool(result, clientTool)
2599
+ await this.addToolResultForClientTool(
2600
+ result,
2601
+ clientTool,
2602
+ this.streamContinuationGeneration,
2603
+ )
2532
2604
  }
2533
2605
 
2534
2606
  private async addToolResultForClientTool(
2535
2607
  result: ClientToolResult,
2536
2608
  clientTool: AnyClientTool | undefined,
2609
+ continuationGeneration: number,
2537
2610
  context?: ChatClientRunEventContext,
2538
2611
  ): Promise<void> {
2539
2612
  if (clientTool && result.state !== 'output-error') {
@@ -2560,6 +2633,8 @@ export class ChatClient<
2560
2633
  context,
2561
2634
  )
2562
2635
 
2636
+ if (continuationGeneration !== this.continuationGeneration) return
2637
+
2563
2638
  // Always update local message state so the tool-call part is terminal in
2564
2639
  // the UI even when the AG-UI interrupt path owns server continuation.
2565
2640
  this.processor.addToolResult(
@@ -2585,7 +2660,11 @@ export class ChatClient<
2585
2660
 
2586
2661
  // If stream is in progress, queue continuation check for after it ends
2587
2662
  if (this.isLoading) {
2588
- this.queuePostStreamAction(() => this.checkForContinuation())
2663
+ this.queuePostStreamAction(() =>
2664
+ continuationGeneration === this.continuationGeneration
2665
+ ? this.checkForContinuation()
2666
+ : Promise.resolve(),
2667
+ )
2589
2668
  return
2590
2669
  }
2591
2670
 
@@ -2665,7 +2744,11 @@ export class ChatClient<
2665
2744
  * Queue an action to be executed after the current stream ends
2666
2745
  */
2667
2746
  private queuePostStreamAction(action: () => Promise<void>): void {
2668
- this.postStreamActions.push(action)
2747
+ const continuationGeneration = this.continuationGeneration
2748
+ this.postStreamActions.push(async () => {
2749
+ if (continuationGeneration !== this.continuationGeneration) return
2750
+ await action()
2751
+ })
2669
2752
  }
2670
2753
 
2671
2754
  /**
@@ -2688,6 +2771,10 @@ export class ChatClient<
2688
2771
  * Check if we should continue the flow and do so if needed
2689
2772
  */
2690
2773
  private async checkForContinuation(): Promise<void> {
2774
+ // stop() bumps continuationGeneration without opening a new stream.
2775
+ if (this.streamContinuationGeneration !== this.continuationGeneration) {
2776
+ return
2777
+ }
2691
2778
  if (this.hasPendingInterrupts()) return
2692
2779
 
2693
2780
  // Prevent duplicate continuation attempts
@@ -2979,6 +3066,12 @@ export class ChatClient<
2979
3066
  if (options.forwardedProps !== undefined) {
2980
3067
  this.forwardedPropsOption = options.forwardedProps
2981
3068
  }
3069
+ if (options.byok !== undefined) {
3070
+ this.byok = options.byok
3071
+ }
3072
+ if (options.byokProvider !== undefined) {
3073
+ this.byokProvider = options.byokProvider
3074
+ }
2982
3075
  if ('context' in options) {
2983
3076
  this.context = options.context
2984
3077
  }
@@ -6,6 +6,7 @@ import {
6
6
  uiMessagesToWire,
7
7
  withTanstackMetadata,
8
8
  } from '@tanstack/ai/client'
9
+ import { ByokMissingError, isByokMissingBody } from '@tanstack/ai/byok'
9
10
  import {
10
11
  createResponseStreamTextDecoder,
11
12
  getResponseStreamReader,
@@ -19,6 +20,7 @@ import type {
19
20
  UIMessage,
20
21
  } from '@tanstack/ai/client'
21
22
  import type { ChatFetcher, ChatPendingInterrupt } from './types'
23
+ import { normalizeMessagesDates } from './message-date-normalizer'
22
24
 
23
25
  /**
24
26
  * Associates connect-wrapped chunks with the run they were produced under.
@@ -498,12 +500,35 @@ async function* linesToNdjsonEvents(
498
500
  }
499
501
  }
500
502
 
501
- function assertResponseOk(response: Response): void {
502
- if (!response.ok) {
503
- throw new Error(
504
- `HTTP error! status: ${response.status} ${response.statusText}`,
505
- )
503
+ async function assertResponseOk(response: Response): Promise<void> {
504
+ if (response.ok) return
505
+ if (response.status === 401) {
506
+ const body: unknown = await response
507
+ .clone()
508
+ .json()
509
+ .catch(() => null)
510
+ if (isByokMissingBody(body)) {
511
+ throw new ByokMissingError(body.error.provider)
512
+ }
513
+ }
514
+ throw new Error(
515
+ `HTTP error! status: ${response.status} ${response.statusText}`,
516
+ )
517
+ }
518
+
519
+ function errorFromXhrStatus(xhr: XMLHttpRequest): Error {
520
+ if (xhr.status === 401) {
521
+ let parsed: unknown = null
522
+ try {
523
+ parsed = JSON.parse(xhr.responseText)
524
+ } catch {
525
+ parsed = null
526
+ }
527
+ if (isByokMissingBody(parsed)) {
528
+ return new ByokMissingError(parsed.error.provider)
529
+ }
506
530
  }
531
+ return new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
507
532
  }
508
533
 
509
534
  /**
@@ -525,7 +550,7 @@ async function fetchThreadHydration(
525
550
  headers: { Accept: 'application/json', ...headers },
526
551
  credentials,
527
552
  })
528
- assertResponseOk(response)
553
+ await assertResponseOk(response)
529
554
  const data = (await response.json()) as {
530
555
  messages?: Array<UIMessage>
531
556
  activeRun?: { runId?: unknown } | null
@@ -549,7 +574,9 @@ async function fetchThreadHydration(
549
574
  }
550
575
  : null
551
576
  return {
552
- messages: Array.isArray(data.messages) ? data.messages : [],
577
+ messages: Array.isArray(data.messages)
578
+ ? normalizeMessagesDates(data.messages)
579
+ : [],
553
580
  activeRun,
554
581
  interrupts,
555
582
  }
@@ -574,7 +601,7 @@ async function fetchGenerationHydration(
574
601
  headers: { Accept: 'application/json', ...headers },
575
602
  credentials,
576
603
  })
577
- assertResponseOk(response)
604
+ await assertResponseOk(response)
578
605
  const raw: unknown = await response.json()
579
606
  // A 200 carrying `null` is a legitimate hydration miss — the server has no
580
607
  // record for this thread — and reading `.activeRun` off `null` would throw.
@@ -609,7 +636,7 @@ async function* responseToSSEEvents(
609
636
  abortSignal?: AbortSignal,
610
637
  fallbackIds?: { threadId?: string; runId?: string },
611
638
  ): AsyncGenerator<StreamEvent> {
612
- assertResponseOk(response)
639
+ await assertResponseOk(response)
613
640
  const reader = getResponseStreamReader(response)
614
641
  yield* linesToSSEEvents(readStreamLines(reader, abortSignal), fallbackIds)
615
642
  }
@@ -619,7 +646,7 @@ async function* responseToNdjsonEvents(
619
646
  response: Response,
620
647
  abortSignal?: AbortSignal,
621
648
  ): AsyncGenerator<StreamEvent> {
622
- assertResponseOk(response)
649
+ await assertResponseOk(response)
623
650
  const reader = getResponseStreamReader(response)
624
651
  yield* linesToNdjsonEvents(readStreamLines(reader, abortSignal))
625
652
  }
@@ -809,6 +836,8 @@ export interface RunAgentInputContext {
809
836
  }>
810
837
  /** Arbitrary user-controlled passthrough data. */
811
838
  forwardedProps?: Record<string, unknown>
839
+ /** Extra request headers for this run (e.g. BYOK keys). POST only. */
840
+ headers?: Record<string, string>
812
841
  }
813
842
 
814
843
  export interface ConnectConnectionAdapter {
@@ -1198,7 +1227,7 @@ function buildRunAgentInputBody(
1198
1227
  ): Record<string, unknown> {
1199
1228
  // Precedence (later spreads win): static adapter `body` is the base,
1200
1229
  // overridden by `runContext.forwardedProps`, overridden by per-message `data`.
1201
- const wireMessages = uiMessagesToWire(messages as Array<UIMessage>)
1230
+ const wireMessages = uiMessagesToWire(messages)
1202
1231
  const forwardedProps = {
1203
1232
  ...options.body,
1204
1233
  ...(runContext?.forwardedProps ?? {}),
@@ -1272,6 +1301,7 @@ export function fetchServerSentEvents(
1272
1301
  const requestHeaders: Record<string, string> = {
1273
1302
  'Content-Type': 'application/json',
1274
1303
  ...mergeHeaders(resolvedOptions.headers),
1304
+ ...mergeHeaders(runContext?.headers),
1275
1305
  ...runIdHeader(runContext?.runId),
1276
1306
  }
1277
1307
 
@@ -1443,6 +1473,7 @@ export function fetchHttpStream(
1443
1473
  const requestHeaders: Record<string, string> = {
1444
1474
  'Content-Type': 'application/json',
1445
1475
  ...mergeHeaders(resolvedOptions.headers),
1476
+ ...mergeHeaders(runContext?.headers),
1446
1477
  ...runIdHeader(runContext?.runId),
1447
1478
  }
1448
1479
 
@@ -1596,7 +1627,7 @@ function readXhrLines(
1596
1627
 
1597
1628
  const enqueueDelta = () => {
1598
1629
  if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
1599
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
1630
+ error = errorFromXhrStatus(xhr)
1600
1631
  done = true
1601
1632
  return
1602
1633
  }
@@ -1625,7 +1656,7 @@ function readXhrLines(
1625
1656
  // is an error, but status 0 here is not — treat the trailing buffer as a
1626
1657
  // truncation check instead of fabricating a bogus "status: 0" error.
1627
1658
  if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
1628
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
1659
+ error = errorFromXhrStatus(xhr)
1629
1660
  } else if (buffer.trim() && !aborted) {
1630
1661
  error = new StreamTruncatedError()
1631
1662
  }
@@ -1721,6 +1752,7 @@ function createConfiguredXhrRequest(
1721
1752
  const requestHeaders: Record<string, string> = {
1722
1753
  'Content-Type': 'application/json',
1723
1754
  ...mergeHeaders(options.headers),
1755
+ ...mergeHeaders(method === 'POST' ? runContext?.headers : undefined),
1724
1756
  // Client-chosen run id for durability (POST only; the GET join carries it
1725
1757
  // in the query instead).
1726
1758
  ...(method === 'POST' ? runIdHeader(runContext?.runId) : {}),
@@ -2494,7 +2526,7 @@ export function fetcherToConnectionAdapter(
2494
2526
  ? { resume: runContext.resume }
2495
2527
  : {}),
2496
2528
  },
2497
- { signal: abortSignal },
2529
+ { signal: abortSignal, headers: runContext.headers },
2498
2530
  )
2499
2531
  if (result instanceof Response) {
2500
2532
  yield* responseToSSEChunks(result, abortSignal)