@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
@@ -1,4 +1,12 @@
1
- import { EventType, uiMessagesToWire } from '@tanstack/ai/client'
1
+ import {
2
+ EventType,
3
+ getChunkRunId as getNormalizedChunkRunId,
4
+ restoreInboundChunk,
5
+ tanstackMetadata,
6
+ uiMessagesToWire,
7
+ withTanstackMetadata,
8
+ } from '@tanstack/ai/client'
9
+ import { ByokMissingError, isByokMissingBody } from '@tanstack/ai/byok'
2
10
  import {
3
11
  createResponseStreamTextDecoder,
4
12
  getResponseStreamReader,
@@ -8,7 +16,6 @@ import type {
8
16
  ModelMessage,
9
17
  RunAgentResumeItem,
10
18
  RunErrorEvent,
11
- RunFinishedEvent,
12
19
  StreamChunk,
13
20
  UIMessage,
14
21
  } from '@tanstack/ai/client'
@@ -34,12 +41,7 @@ export function getChunkRunId(chunk: StreamChunk): string | undefined {
34
41
  // the client's run identity to win when a provider stamps its own id; for
35
42
  // resumable reconnect/join the two ids match, so precedence is moot there.
36
43
  const requestRunId = chunkRunIds.get(chunk)
37
- return (
38
- requestRunId ??
39
- ('runId' in chunk && typeof chunk.runId === 'string'
40
- ? chunk.runId
41
- : undefined)
42
- )
44
+ return requestRunId ?? getNormalizedChunkRunId(chunk)
43
45
  }
44
46
 
45
47
  /**
@@ -385,6 +387,22 @@ function isNdjsonEnvelope(
385
387
  )
386
388
  }
387
389
 
390
+ /** Rebuild pre-wire extras after SSE/NDJSON ingest. */
391
+ function restoreInboundUsage(chunk: StreamChunk): StreamChunk {
392
+ return restoreInboundChunk(chunk)
393
+ }
394
+
395
+ function sseChunkModel(chunk: StreamChunk): string | undefined {
396
+ const tanstackModel = tanstackMetadata(chunk)?.model
397
+ if (typeof tanstackModel === 'string') return tanstackModel
398
+ const usage = 'usage' in chunk ? chunk.usage : undefined
399
+ if (Array.isArray(usage)) {
400
+ const model = (usage[0] as { model?: unknown } | undefined)?.model
401
+ if (typeof model === 'string') return model
402
+ }
403
+ return undefined
404
+ }
405
+
388
406
  /**
389
407
  * Parse SSE-format lines into stream events, pairing each chunk with the `id:`
390
408
  * offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE
@@ -431,27 +449,31 @@ async function* linesToSSEEvents(
431
449
  }
432
450
  const data = parseSseDataLine(line)
433
451
  if (data === '[DONE]') {
434
- const synthetic: RunFinishedEvent = {
435
- type: EventType.RUN_FINISHED,
436
- threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
437
- runId: lastRunId ?? fallbackIds?.runId ?? '',
438
- model: lastModel ?? '',
439
- timestamp: Date.now(),
440
- finishReason: 'stop',
452
+ yield {
453
+ chunk: withTanstackMetadata(
454
+ {
455
+ type: EventType.RUN_FINISHED,
456
+ threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
457
+ runId: lastRunId ?? fallbackIds?.runId ?? '',
458
+ timestamp: Date.now(),
459
+ },
460
+ {
461
+ finishReason: 'stop',
462
+ ...(lastModel !== undefined ? { model: lastModel } : {}),
463
+ },
464
+ ) as StreamChunk,
441
465
  }
442
- yield { chunk: synthetic }
443
466
  return
444
467
  }
445
- const chunk = JSON.parse(data) as StreamChunk
468
+ const chunk = restoreInboundUsage(JSON.parse(data) as StreamChunk)
446
469
  if ('threadId' in chunk && typeof chunk.threadId === 'string') {
447
470
  lastThreadId = chunk.threadId
448
471
  }
449
472
  if ('runId' in chunk && typeof chunk.runId === 'string') {
450
473
  lastRunId = chunk.runId
451
474
  }
452
- if ('model' in chunk && typeof chunk.model === 'string') {
453
- lastModel = chunk.model
454
- }
475
+ const model = sseChunkModel(chunk)
476
+ if (model !== undefined) lastModel = model
455
477
  const id = pendingId
456
478
  pendingId = undefined
457
479
  yield { chunk, ...(id !== undefined ? { id } : {}) }
@@ -470,19 +492,42 @@ async function* linesToNdjsonEvents(
470
492
  for await (const line of lines) {
471
493
  const parsed = JSON.parse(line) as unknown
472
494
  if (isNdjsonEnvelope(parsed)) {
473
- yield { chunk: parsed.chunk, id: parsed.id }
495
+ yield { chunk: restoreInboundUsage(parsed.chunk), id: parsed.id }
474
496
  } else {
475
- yield { chunk: parsed as StreamChunk }
497
+ yield { chunk: restoreInboundUsage(parsed as StreamChunk) }
476
498
  }
477
499
  }
478
500
  }
479
501
 
480
- function assertResponseOk(response: Response): void {
481
- if (!response.ok) {
482
- throw new Error(
483
- `HTTP error! status: ${response.status} ${response.statusText}`,
484
- )
502
+ async function assertResponseOk(response: Response): Promise<void> {
503
+ if (response.ok) return
504
+ if (response.status === 401) {
505
+ const body: unknown = await response
506
+ .clone()
507
+ .json()
508
+ .catch(() => null)
509
+ if (isByokMissingBody(body)) {
510
+ throw new ByokMissingError(body.error.provider)
511
+ }
512
+ }
513
+ throw new Error(
514
+ `HTTP error! status: ${response.status} ${response.statusText}`,
515
+ )
516
+ }
517
+
518
+ function errorFromXhrStatus(xhr: XMLHttpRequest): Error {
519
+ if (xhr.status === 401) {
520
+ let parsed: unknown = null
521
+ try {
522
+ parsed = JSON.parse(xhr.responseText)
523
+ } catch {
524
+ parsed = null
525
+ }
526
+ if (isByokMissingBody(parsed)) {
527
+ return new ByokMissingError(parsed.error.provider)
528
+ }
485
529
  }
530
+ return new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
486
531
  }
487
532
 
488
533
  /**
@@ -504,7 +549,7 @@ async function fetchThreadHydration(
504
549
  headers: { Accept: 'application/json', ...headers },
505
550
  credentials,
506
551
  })
507
- assertResponseOk(response)
552
+ await assertResponseOk(response)
508
553
  const data = (await response.json()) as {
509
554
  messages?: Array<UIMessage>
510
555
  activeRun?: { runId?: unknown } | null
@@ -553,7 +598,7 @@ async function fetchGenerationHydration(
553
598
  headers: { Accept: 'application/json', ...headers },
554
599
  credentials,
555
600
  })
556
- assertResponseOk(response)
601
+ await assertResponseOk(response)
557
602
  const raw: unknown = await response.json()
558
603
  // A 200 carrying `null` is a legitimate hydration miss — the server has no
559
604
  // record for this thread — and reading `.activeRun` off `null` would throw.
@@ -588,7 +633,7 @@ async function* responseToSSEEvents(
588
633
  abortSignal?: AbortSignal,
589
634
  fallbackIds?: { threadId?: string; runId?: string },
590
635
  ): AsyncGenerator<StreamEvent> {
591
- assertResponseOk(response)
636
+ await assertResponseOk(response)
592
637
  const reader = getResponseStreamReader(response)
593
638
  yield* linesToSSEEvents(readStreamLines(reader, abortSignal), fallbackIds)
594
639
  }
@@ -598,7 +643,7 @@ async function* responseToNdjsonEvents(
598
643
  response: Response,
599
644
  abortSignal?: AbortSignal,
600
645
  ): AsyncGenerator<StreamEvent> {
601
- assertResponseOk(response)
646
+ await assertResponseOk(response)
602
647
  const reader = getResponseStreamReader(response)
603
648
  yield* linesToNdjsonEvents(readStreamLines(reader, abortSignal))
604
649
  }
@@ -788,6 +833,8 @@ export interface RunAgentInputContext {
788
833
  }>
789
834
  /** Arbitrary user-controlled passthrough data. */
790
835
  forwardedProps?: Record<string, unknown>
836
+ /** Extra request headers for this run (e.g. BYOK keys). POST only. */
837
+ headers?: Record<string, string>
791
838
  }
792
839
 
793
840
  export interface ConnectConnectionAdapter {
@@ -1064,21 +1111,24 @@ export function normalizeConnectionAdapter(
1064
1111
  // observed, but stamp the caller's request runId so getChunkRunId()
1065
1112
  // correlates to activeRunIds / currentRunId (same as real stream chunks).
1066
1113
  if (!abortSignal?.aborted && !hasTerminalEvent) {
1067
- const synthetic: RunFinishedEvent = {
1068
- type: EventType.RUN_FINISHED,
1069
- threadId: requireSyntheticId(
1070
- upstreamThreadId ?? runContext?.threadId,
1071
- 'threadId',
1072
- ),
1073
- runId: requireSyntheticId(
1074
- upstreamRunId ?? runContext?.runId,
1075
- 'runId',
1076
- ),
1077
- model: 'connect-wrapper',
1078
- timestamp: Date.now(),
1079
- finishReason: 'stop',
1080
- }
1081
- push(synthetic, runContext?.runId)
1114
+ push(
1115
+ withTanstackMetadata(
1116
+ {
1117
+ type: EventType.RUN_FINISHED,
1118
+ threadId: requireSyntheticId(
1119
+ upstreamThreadId ?? runContext?.threadId,
1120
+ 'threadId',
1121
+ ),
1122
+ runId: requireSyntheticId(
1123
+ upstreamRunId ?? runContext?.runId,
1124
+ 'runId',
1125
+ ),
1126
+ timestamp: Date.now(),
1127
+ },
1128
+ { finishReason: 'stop', model: 'connect-wrapper' },
1129
+ ) as StreamChunk,
1130
+ runContext?.runId,
1131
+ )
1082
1132
  }
1083
1133
  } catch (err) {
1084
1134
  if (!abortSignal?.aborted && !hasTerminalEvent) {
@@ -1248,6 +1298,7 @@ export function fetchServerSentEvents(
1248
1298
  const requestHeaders: Record<string, string> = {
1249
1299
  'Content-Type': 'application/json',
1250
1300
  ...mergeHeaders(resolvedOptions.headers),
1301
+ ...mergeHeaders(runContext?.headers),
1251
1302
  ...runIdHeader(runContext?.runId),
1252
1303
  }
1253
1304
 
@@ -1419,6 +1470,7 @@ export function fetchHttpStream(
1419
1470
  const requestHeaders: Record<string, string> = {
1420
1471
  'Content-Type': 'application/json',
1421
1472
  ...mergeHeaders(resolvedOptions.headers),
1473
+ ...mergeHeaders(runContext?.headers),
1422
1474
  ...runIdHeader(runContext?.runId),
1423
1475
  }
1424
1476
 
@@ -1572,7 +1624,7 @@ function readXhrLines(
1572
1624
 
1573
1625
  const enqueueDelta = () => {
1574
1626
  if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
1575
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
1627
+ error = errorFromXhrStatus(xhr)
1576
1628
  done = true
1577
1629
  return
1578
1630
  }
@@ -1601,7 +1653,7 @@ function readXhrLines(
1601
1653
  // is an error, but status 0 here is not — treat the trailing buffer as a
1602
1654
  // truncation check instead of fabricating a bogus "status: 0" error.
1603
1655
  if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
1604
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`)
1656
+ error = errorFromXhrStatus(xhr)
1605
1657
  } else if (buffer.trim() && !aborted) {
1606
1658
  error = new StreamTruncatedError()
1607
1659
  }
@@ -1697,6 +1749,7 @@ function createConfiguredXhrRequest(
1697
1749
  const requestHeaders: Record<string, string> = {
1698
1750
  'Content-Type': 'application/json',
1699
1751
  ...mergeHeaders(options.headers),
1752
+ ...mergeHeaders(method === 'POST' ? runContext?.headers : undefined),
1700
1753
  // Client-chosen run id for durability (POST only; the GET join carries it
1701
1754
  // in the query instead).
1702
1755
  ...(method === 'POST' ? runIdHeader(runContext?.runId) : {}),
@@ -2143,9 +2196,9 @@ export function webSocket(
2143
2196
  }
2144
2197
  if (isPingFrame(parsed)) return
2145
2198
  const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : undefined
2146
- const chunk = isNdjsonEnvelope(parsed)
2147
- ? parsed.chunk
2148
- : (parsed as StreamChunk)
2199
+ const chunk = restoreInboundUsage(
2200
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2201
+ )
2149
2202
 
2150
2203
  // Thread durable chunks through the active run session's tracker (if
2151
2204
  // any) so a later reconnect knows the last offset and can skip a
@@ -2325,7 +2378,9 @@ export function webSocket(
2325
2378
  }
2326
2379
  if (isPingFrame(parsed)) return
2327
2380
  pipe.push(
2328
- isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2381
+ restoreInboundUsage(
2382
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2383
+ ),
2329
2384
  )
2330
2385
  }
2331
2386
  ws.onclose = (event?: CloseEvent) => {
@@ -2468,7 +2523,7 @@ export function fetcherToConnectionAdapter(
2468
2523
  ? { resume: runContext.resume }
2469
2524
  : {}),
2470
2525
  },
2471
- { signal: abortSignal },
2526
+ { signal: abortSignal, headers: runContext.headers },
2472
2527
  )
2473
2528
  if (result instanceof Response) {
2474
2529
  yield* responseToSSEChunks(result, abortSignal)
@@ -1,3 +1,8 @@
1
+ import { ByokBlockedError, ByokMissingError } from '@tanstack/ai/byok'
2
+ import {
3
+ prepareResolvedByokHeaders,
4
+ resolveByokProviderId,
5
+ } from './byok/resolve'
1
6
  import {
2
7
  GENERATION_EVENTS,
3
8
  GENERATION_STREAM_TRUNCATED_MESSAGE,
@@ -10,7 +15,9 @@ import {
10
15
  } from './generation-types'
11
16
  import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
12
17
  import { parseSSEResponse } from './sse-parser'
18
+ import { restoreInboundChunk } from '@tanstack/ai/client'
13
19
  import type { StreamChunk } from '@tanstack/ai/client'
20
+ import type { ByokClient } from './byok'
14
21
  import type {
15
22
  ConnectConnectionAdapter,
16
23
  GenerationHydrationResult,
@@ -119,6 +126,8 @@ export class GenerationClient<
119
126
  // the client hydrates the last generation for `threadId` from the server.
120
127
  private readonly serverDriven: boolean = false
121
128
  private body: Record<string, any>
129
+ private byok: ByokClient | undefined
130
+ private byokProvider: (() => string | undefined) | undefined
122
131
  private result: TOutput | null = null
123
132
  private input: TInput | null = null
124
133
  private progress: AIDevtoolsGenerationProgress | null = null
@@ -155,6 +164,8 @@ export class GenerationClient<
155
164
  this.hydrateGenerationHandler = options.hydrateGeneration
156
165
  this.joinRunHandler = options.joinRun
157
166
  this.body = options.body ?? {}
167
+ this.byok = options.byok
168
+ this.byokProvider = options.byokProvider
158
169
  // `persistence` is `false`/omitted (ephemeral) or `true` (server-driven:
159
170
  // hydrate the last generation for `threadId` from the server on mount).
160
171
  this.serverDriven = options.persistence === true
@@ -264,9 +275,21 @@ export class GenerationClient<
264
275
  const { signal } = abortController
265
276
 
266
277
  try {
278
+ let headers: Record<string, string> | undefined
279
+ if (this.byok) {
280
+ const provider = resolveByokProviderId(
281
+ this.byokProvider,
282
+ this.body.provider,
283
+ )
284
+ headers = await prepareResolvedByokHeaders(this.byok, provider)
285
+ }
286
+
267
287
  if (this.fetcher) {
268
288
  // Direct fetch path
269
- const result = await this.fetcher(input, { signal })
289
+ const result = await this.fetcher(
290
+ input,
291
+ headers === undefined ? { signal } : { signal, headers },
292
+ )
270
293
  if (signal.aborted) return
271
294
  if (result instanceof Response) {
272
295
  // Server function returned SSE Response — parse stream
@@ -288,7 +311,7 @@ export class GenerationClient<
288
311
  [],
289
312
  mergedData,
290
313
  signal,
291
- this.createRunContext(runId),
314
+ this.createRunContext(runId, headers),
292
315
  )
293
316
  await this.processStream(stream, runId, signal)
294
317
  } else {
@@ -311,6 +334,12 @@ export class GenerationClient<
311
334
  } catch (err: unknown) {
312
335
  if (signal.aborted) return
313
336
  const error = err instanceof Error ? err : new Error(String(err))
337
+ if (error instanceof ByokMissingError) {
338
+ this.byok?.request(error.provider, 'missing')
339
+ }
340
+ if (error instanceof ByokBlockedError && error.reason === 'locked') {
341
+ this.byok?.request(error.provider, 'locked')
342
+ }
314
343
  this.setError(error)
315
344
  this.setStatus('error')
316
345
  this.recordResumeSnapshotError(error)
@@ -349,9 +378,10 @@ export class GenerationClient<
349
378
  let streamRunId: string | undefined
350
379
  let sawTerminalChunk = false
351
380
 
352
- for await (const chunk of source) {
381
+ for await (const raw of source) {
353
382
  if (signal.aborted) break
354
383
 
384
+ const chunk = restoreInboundChunk(raw)
355
385
  this.callbacksRef.onChunk?.(chunk)
356
386
  this.observeResumeSnapshot(chunk)
357
387
  const chunkRunId =
@@ -390,11 +420,9 @@ export class GenerationClient<
390
420
  this.devtoolsBridge.ensureRunStarted(
391
421
  chunkRunId ?? streamRunId ?? fallbackRunId,
392
422
  )
393
- // Prefer spec `message`; fall back to deprecated `error.message`
423
+ // Spec RUN_ERROR message. Missing message uses this fallback.
394
424
  const msg =
395
- (chunk.message as string | undefined) ||
396
- chunk.error?.message ||
397
- 'An error occurred'
425
+ (chunk.message as string | undefined) || 'An error occurred'
398
426
  throw new Error(msg)
399
427
  }
400
428
  default:
@@ -462,13 +490,25 @@ export class GenerationClient<
462
490
  options: Partial<
463
491
  Pick<
464
492
  GenerationClientOptions<TInput, TResult, TOutput>,
465
- 'body' | 'onResult' | 'onError' | 'onProgress' | 'onChunk'
493
+ | 'body'
494
+ | 'byok'
495
+ | 'byokProvider'
496
+ | 'onResult'
497
+ | 'onError'
498
+ | 'onProgress'
499
+ | 'onChunk'
466
500
  >
467
501
  >,
468
502
  ): void {
469
503
  if (options.body !== undefined) {
470
504
  this.body = options.body ?? {}
471
505
  }
506
+ if (options.byok !== undefined) {
507
+ this.byok = options.byok
508
+ }
509
+ if (options.byokProvider !== undefined) {
510
+ this.byokProvider = options.byokProvider
511
+ }
472
512
  if (options.onResult !== undefined) {
473
513
  this.callbacksRef.onResult = options.onResult
474
514
  }
@@ -645,10 +685,14 @@ export class GenerationClient<
645
685
  return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
646
686
  }
647
687
 
648
- private createRunContext(runId: string): RunAgentInputContext {
688
+ private createRunContext(
689
+ runId: string,
690
+ headers?: Record<string, string>,
691
+ ): RunAgentInputContext {
649
692
  return {
650
693
  threadId: this.threadId,
651
694
  runId,
695
+ ...(headers ? { headers } : {}),
652
696
  }
653
697
  }
654
698
 
@@ -1,9 +1,11 @@
1
+ import { tanstackMetadata } from '@tanstack/ai/client'
1
2
  import type {
2
3
  MediaPrompt,
3
4
  PersistedArtifactRef,
4
5
  StreamChunk,
5
6
  } from '@tanstack/ai/client'
6
7
  import type { TokenUsage, TranscriptionResponseFormat } from '@tanstack/ai'
8
+ import type { ByokClient } from './byok'
7
9
  import type { ConnectConnectionAdapter } from './connection-adapters'
8
10
  import type { AIDevtoolsClientMetadata } from './devtools'
9
11
  import type {
@@ -270,6 +272,8 @@ export const GENERATION_EVENTS = {
270
272
  export interface GenerationFetcherOptions {
271
273
  /** AbortSignal that is triggered when the user calls `stop()` */
272
274
  signal: AbortSignal
275
+ /** Extra request headers for this run (e.g. BYOK keys). */
276
+ headers?: Record<string, string>
273
277
  }
274
278
 
275
279
  /**
@@ -337,6 +341,19 @@ export interface GenerationClientOptions<_TInput, TResult, TOutput = TResult> {
337
341
  /** Additional body parameters to send with connect-based adapter requests */
338
342
  body?: Record<string, any>
339
343
 
344
+ /**
345
+ * Optional BYOK keyring. On each generate the client prepares the resolved
346
+ * provider and stamps `x-byok-*` request headers. Keys never go in the body.
347
+ */
348
+ byok?: ByokClient
349
+
350
+ /**
351
+ * Optional provider id for this generation. If it returns a provider slug,
352
+ * only that key is prepared and sent. Otherwise `body.provider` is used.
353
+ * If no slug resolves, generate throws instead of attaching every stored key.
354
+ */
355
+ byokProvider?: () => string | undefined
356
+
340
357
  /** Metadata used to register this generation hook with TanStack AI Devtools */
341
358
  devtools?: Partial<AIDevtoolsClientMetadata>
342
359
 
@@ -461,8 +478,13 @@ export function updateGenerationResumeSnapshot(
461
478
  previous: GenerationResumeSnapshot | null | undefined,
462
479
  chunk: StreamChunk,
463
480
  ): GenerationResumeSnapshot {
464
- const threadId = stringField(chunk, 'threadId')
465
- const runId = stringField(chunk, 'runId')
481
+ const tanstack = tanstackMetadata(chunk)
482
+ const threadId =
483
+ stringField(chunk, 'threadId') ??
484
+ (typeof tanstack?.threadId === 'string' ? tanstack.threadId : undefined)
485
+ const runId =
486
+ stringField(chunk, 'runId') ??
487
+ (typeof tanstack?.runId === 'string' ? tanstack.runId : undefined)
466
488
  const carried = chunk.type === 'RUN_STARTED' ? undefined : previous
467
489
  const previousArtifacts = carried?.pendingArtifacts ?? []
468
490
  const next: GenerationResumeSnapshot = {
@@ -102,6 +102,10 @@ interface TransactionToken {
102
102
  active: boolean
103
103
  }
104
104
 
105
+ interface SubmissionOperation {
106
+ submission: InterruptManagerSubmission
107
+ }
108
+
105
109
  interface RuntimeInterruptCheckpoint {
106
110
  status: InterruptItemStatus
107
111
  resolution?: RunAgentResumeItem
@@ -511,6 +515,7 @@ export class InterruptManager<
511
515
  resuming: false,
512
516
  })
513
517
  private activeTransaction: TransactionToken | undefined
518
+ private activeSubmissionOperation: SubmissionOperation | undefined
514
519
  private retrySubmission: InterruptManagerSubmission | undefined
515
520
  private resuming = false
516
521
  private tools: TTools | undefined
@@ -544,6 +549,7 @@ export class InterruptManager<
544
549
  hydration: InterruptManagerHydration,
545
550
  source: InterruptManagerChangeSource = 'live',
546
551
  ): void {
552
+ this.activeSubmissionOperation = undefined
547
553
  this.hydration = {
548
554
  threadId: hydration.threadId,
549
555
  interruptedRunId: hydration.interruptedRunId,
@@ -597,6 +603,7 @@ export class InterruptManager<
597
603
  preserveRootErrors?: boolean
598
604
  source?: InterruptManagerChangeSource
599
605
  }): void {
606
+ this.activeSubmissionOperation = undefined
600
607
  this.hydration = undefined
601
608
  this.items = []
602
609
  this.snapshot = Object.freeze([])
@@ -1528,25 +1535,32 @@ export class InterruptManager<
1528
1535
  }
1529
1536
 
1530
1537
  private submitBatch(submission: InterruptManagerSubmission): void {
1538
+ // Track ownership so a superseded submission cannot mutate current state.
1539
+ const operation = { submission }
1540
+ this.activeSubmissionOperation = operation
1531
1541
  this.resuming = true
1532
1542
  this.retrySubmission = undefined
1533
1543
  for (const item of this.items) {
1534
1544
  if (isClientOwnedInterrupt(item)) item.status = 'submitting'
1535
1545
  }
1536
1546
  this.publish()
1537
- void this.performSubmission(submission)
1547
+ void this.performSubmission(operation)
1538
1548
  }
1539
1549
 
1540
1550
  private async performSubmission(
1541
- submission: InterruptManagerSubmission,
1551
+ operation: SubmissionOperation,
1542
1552
  ): Promise<void> {
1543
1553
  try {
1544
- await this.options.submit(submission)
1554
+ await this.options.submit(operation.submission)
1545
1555
  } catch (error) {
1546
- this.handleSubmissionFailure(error, submission)
1556
+ if (this.activeSubmissionOperation !== operation) return
1557
+ this.handleSubmissionFailure(error, operation.submission)
1547
1558
  } finally {
1548
- this.resuming = false
1549
- this.publish()
1559
+ if (this.activeSubmissionOperation === operation) {
1560
+ this.activeSubmissionOperation = undefined
1561
+ this.resuming = false
1562
+ this.publish()
1563
+ }
1550
1564
  }
1551
1565
  }
1552
1566
 
package/src/sse-parser.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ByokMissingError, isByokMissingBody } from '@tanstack/ai/byok'
1
2
  import {
2
3
  createResponseStreamTextDecoder,
3
4
  getResponseStreamReader,
@@ -51,6 +52,15 @@ export async function* parseSSEResponse(
51
52
  abortSignal?: AbortSignal,
52
53
  ): AsyncGenerator<StreamChunk> {
53
54
  if (!response.ok) {
55
+ if (response.status === 401) {
56
+ const body: unknown = await response
57
+ .clone()
58
+ .json()
59
+ .catch(() => null)
60
+ if (isByokMissingBody(body)) {
61
+ throw new ByokMissingError(body.error.provider)
62
+ }
63
+ }
54
64
  throw new Error(
55
65
  `HTTP error! status: ${response.status} ${response.statusText}`,
56
66
  )