@tanstack/ai-client 0.25.2 → 0.26.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.
@@ -1,4 +1,11 @@
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'
2
9
  import {
3
10
  createResponseStreamTextDecoder,
4
11
  getResponseStreamReader,
@@ -8,7 +15,6 @@ import type {
8
15
  ModelMessage,
9
16
  RunAgentResumeItem,
10
17
  RunErrorEvent,
11
- RunFinishedEvent,
12
18
  StreamChunk,
13
19
  UIMessage,
14
20
  } from '@tanstack/ai/client'
@@ -34,12 +40,7 @@ export function getChunkRunId(chunk: StreamChunk): string | undefined {
34
40
  // the client's run identity to win when a provider stamps its own id; for
35
41
  // resumable reconnect/join the two ids match, so precedence is moot there.
36
42
  const requestRunId = chunkRunIds.get(chunk)
37
- return (
38
- requestRunId ??
39
- ('runId' in chunk && typeof chunk.runId === 'string'
40
- ? chunk.runId
41
- : undefined)
42
- )
43
+ return requestRunId ?? getNormalizedChunkRunId(chunk)
43
44
  }
44
45
 
45
46
  /**
@@ -385,6 +386,22 @@ function isNdjsonEnvelope(
385
386
  )
386
387
  }
387
388
 
389
+ /** Rebuild pre-wire extras after SSE/NDJSON ingest. */
390
+ function restoreInboundUsage(chunk: StreamChunk): StreamChunk {
391
+ return restoreInboundChunk(chunk)
392
+ }
393
+
394
+ function sseChunkModel(chunk: StreamChunk): string | undefined {
395
+ const tanstackModel = tanstackMetadata(chunk)?.model
396
+ if (typeof tanstackModel === 'string') return tanstackModel
397
+ const usage = 'usage' in chunk ? chunk.usage : undefined
398
+ if (Array.isArray(usage)) {
399
+ const model = (usage[0] as { model?: unknown } | undefined)?.model
400
+ if (typeof model === 'string') return model
401
+ }
402
+ return undefined
403
+ }
404
+
388
405
  /**
389
406
  * Parse SSE-format lines into stream events, pairing each chunk with the `id:`
390
407
  * offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE
@@ -431,27 +448,31 @@ async function* linesToSSEEvents(
431
448
  }
432
449
  const data = parseSseDataLine(line)
433
450
  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',
451
+ yield {
452
+ chunk: withTanstackMetadata(
453
+ {
454
+ type: EventType.RUN_FINISHED,
455
+ threadId: lastThreadId ?? fallbackIds?.threadId ?? '',
456
+ runId: lastRunId ?? fallbackIds?.runId ?? '',
457
+ timestamp: Date.now(),
458
+ },
459
+ {
460
+ finishReason: 'stop',
461
+ ...(lastModel !== undefined ? { model: lastModel } : {}),
462
+ },
463
+ ) as StreamChunk,
441
464
  }
442
- yield { chunk: synthetic }
443
465
  return
444
466
  }
445
- const chunk = JSON.parse(data) as StreamChunk
467
+ const chunk = restoreInboundUsage(JSON.parse(data) as StreamChunk)
446
468
  if ('threadId' in chunk && typeof chunk.threadId === 'string') {
447
469
  lastThreadId = chunk.threadId
448
470
  }
449
471
  if ('runId' in chunk && typeof chunk.runId === 'string') {
450
472
  lastRunId = chunk.runId
451
473
  }
452
- if ('model' in chunk && typeof chunk.model === 'string') {
453
- lastModel = chunk.model
454
- }
474
+ const model = sseChunkModel(chunk)
475
+ if (model !== undefined) lastModel = model
455
476
  const id = pendingId
456
477
  pendingId = undefined
457
478
  yield { chunk, ...(id !== undefined ? { id } : {}) }
@@ -470,9 +491,9 @@ async function* linesToNdjsonEvents(
470
491
  for await (const line of lines) {
471
492
  const parsed = JSON.parse(line) as unknown
472
493
  if (isNdjsonEnvelope(parsed)) {
473
- yield { chunk: parsed.chunk, id: parsed.id }
494
+ yield { chunk: restoreInboundUsage(parsed.chunk), id: parsed.id }
474
495
  } else {
475
- yield { chunk: parsed as StreamChunk }
496
+ yield { chunk: restoreInboundUsage(parsed as StreamChunk) }
476
497
  }
477
498
  }
478
499
  }
@@ -1064,21 +1085,24 @@ export function normalizeConnectionAdapter(
1064
1085
  // observed, but stamp the caller's request runId so getChunkRunId()
1065
1086
  // correlates to activeRunIds / currentRunId (same as real stream chunks).
1066
1087
  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)
1088
+ push(
1089
+ withTanstackMetadata(
1090
+ {
1091
+ type: EventType.RUN_FINISHED,
1092
+ threadId: requireSyntheticId(
1093
+ upstreamThreadId ?? runContext?.threadId,
1094
+ 'threadId',
1095
+ ),
1096
+ runId: requireSyntheticId(
1097
+ upstreamRunId ?? runContext?.runId,
1098
+ 'runId',
1099
+ ),
1100
+ timestamp: Date.now(),
1101
+ },
1102
+ { finishReason: 'stop', model: 'connect-wrapper' },
1103
+ ) as StreamChunk,
1104
+ runContext?.runId,
1105
+ )
1082
1106
  }
1083
1107
  } catch (err) {
1084
1108
  if (!abortSignal?.aborted && !hasTerminalEvent) {
@@ -2143,9 +2167,9 @@ export function webSocket(
2143
2167
  }
2144
2168
  if (isPingFrame(parsed)) return
2145
2169
  const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : undefined
2146
- const chunk = isNdjsonEnvelope(parsed)
2147
- ? parsed.chunk
2148
- : (parsed as StreamChunk)
2170
+ const chunk = restoreInboundUsage(
2171
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2172
+ )
2149
2173
 
2150
2174
  // Thread durable chunks through the active run session's tracker (if
2151
2175
  // any) so a later reconnect knows the last offset and can skip a
@@ -2325,7 +2349,9 @@ export function webSocket(
2325
2349
  }
2326
2350
  if (isPingFrame(parsed)) return
2327
2351
  pipe.push(
2328
- isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2352
+ restoreInboundUsage(
2353
+ isNdjsonEnvelope(parsed) ? parsed.chunk : (parsed as StreamChunk),
2354
+ ),
2329
2355
  )
2330
2356
  }
2331
2357
  ws.onclose = (event?: CloseEvent) => {
@@ -10,6 +10,7 @@ import {
10
10
  } from './generation-types'
11
11
  import { createNoOpGenerationDevtoolsBridge } from './devtools-noop'
12
12
  import { parseSSEResponse } from './sse-parser'
13
+ import { restoreInboundChunk } from '@tanstack/ai/client'
13
14
  import type { StreamChunk } from '@tanstack/ai/client'
14
15
  import type {
15
16
  ConnectConnectionAdapter,
@@ -349,9 +350,10 @@ export class GenerationClient<
349
350
  let streamRunId: string | undefined
350
351
  let sawTerminalChunk = false
351
352
 
352
- for await (const chunk of source) {
353
+ for await (const raw of source) {
353
354
  if (signal.aborted) break
354
355
 
356
+ const chunk = restoreInboundChunk(raw)
355
357
  this.callbacksRef.onChunk?.(chunk)
356
358
  this.observeResumeSnapshot(chunk)
357
359
  const chunkRunId =
@@ -390,11 +392,9 @@ export class GenerationClient<
390
392
  this.devtoolsBridge.ensureRunStarted(
391
393
  chunkRunId ?? streamRunId ?? fallbackRunId,
392
394
  )
393
- // Prefer spec `message`; fall back to deprecated `error.message`
395
+ // Spec RUN_ERROR message. Missing message uses this fallback.
394
396
  const msg =
395
- (chunk.message as string | undefined) ||
396
- chunk.error?.message ||
397
- 'An error occurred'
397
+ (chunk.message as string | undefined) || 'An error occurred'
398
398
  throw new Error(msg)
399
399
  }
400
400
  default:
@@ -1,3 +1,4 @@
1
+ import { tanstackMetadata } from '@tanstack/ai/client'
1
2
  import type {
2
3
  MediaPrompt,
3
4
  PersistedArtifactRef,
@@ -461,8 +462,13 @@ export function updateGenerationResumeSnapshot(
461
462
  previous: GenerationResumeSnapshot | null | undefined,
462
463
  chunk: StreamChunk,
463
464
  ): GenerationResumeSnapshot {
464
- const threadId = stringField(chunk, 'threadId')
465
- const runId = stringField(chunk, 'runId')
465
+ const tanstack = tanstackMetadata(chunk)
466
+ const threadId =
467
+ stringField(chunk, 'threadId') ??
468
+ (typeof tanstack?.threadId === 'string' ? tanstack.threadId : undefined)
469
+ const runId =
470
+ stringField(chunk, 'runId') ??
471
+ (typeof tanstack?.runId === 'string' ? tanstack.runId : undefined)
466
472
  const carried = chunk.type === 'RUN_STARTED' ? undefined : previous
467
473
  const previousArtifacts = carried?.pendingArtifacts ?? []
468
474
  const next: GenerationResumeSnapshot = {
package/src/types.ts CHANGED
@@ -397,6 +397,18 @@ export interface MultimodalContent {
397
397
  * If not provided, a unique ID will be generated.
398
398
  */
399
399
  id?: string
400
+ /**
401
+ * Optional AG-UI metadata bag copied onto the resulting UIMessage.
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * await client.sendMessage({
406
+ * content: 'Show me failed logins',
407
+ * metadata: { author: { id: 'user-42', name: 'Dana' } },
408
+ * })
409
+ * ```
410
+ */
411
+ metadata?: Record<string, any>
400
412
  }
401
413
 
402
414
  /**
@@ -616,6 +628,11 @@ export interface UIMessage<
616
628
  role: 'system' | 'user' | 'assistant'
617
629
  parts: Array<MessagePart<TTools, TData>>
618
630
  createdAt?: Date
631
+ /**
632
+ * Optional AG-UI metadata bag. TanStack writes the `tanstack` key.
633
+ * User keys stay at the top.
634
+ */
635
+ metadata?: Record<string, any>
619
636
  }
620
637
 
621
638
  /**
@@ -10,6 +10,7 @@ import {
10
10
  } from './generation-types'
11
11
  import { createNoOpVideoDevtoolsBridge } from './devtools-noop'
12
12
  import { parseSSEResponse } from './sse-parser'
13
+ import { restoreInboundChunk } from '@tanstack/ai/client'
13
14
  import type { StreamChunk } from '@tanstack/ai/client'
14
15
  import type {
15
16
  ConnectConnectionAdapter,
@@ -343,9 +344,10 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
343
344
  let streamRunId: string | undefined
344
345
  let sawTerminalChunk = false
345
346
 
346
- for await (const chunk of source) {
347
+ for await (const raw of source) {
347
348
  if (signal.aborted) break
348
349
 
350
+ const chunk = restoreInboundChunk(raw)
349
351
  this.callbacksRef.onChunk?.(chunk)
350
352
  this.observeResumeSnapshot(chunk)
351
353
  const chunkRunId =
@@ -395,11 +397,9 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
395
397
  this.devtoolsBridge.ensureRunStarted(
396
398
  chunkRunId ?? streamRunId ?? fallbackRunId,
397
399
  )
398
- // Prefer spec `message`; fall back to deprecated `error.message`
400
+ // Spec RUN_ERROR message. Missing message uses this fallback.
399
401
  const msg =
400
- (chunk.message as string | undefined) ||
401
- chunk.error?.message ||
402
- 'An error occurred'
402
+ (chunk.message as string | undefined) || 'An error occurred'
403
403
  throw new Error(msg)
404
404
  }
405
405
  default: