@tanstack/ai-client 0.11.8 → 0.12.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.
@@ -5,7 +5,10 @@ import {
5
5
  normalizeToUIMessage,
6
6
  } from '@tanstack/ai'
7
7
  import { DefaultChatClientEventEmitter } from './events'
8
- import { normalizeConnectionAdapter } from './connection-adapters'
8
+ import {
9
+ fetcherToConnectionAdapter,
10
+ normalizeConnectionAdapter,
11
+ } from './connection-adapters'
9
12
  import type {
10
13
  AnyClientTool,
11
14
  ContentPart,
@@ -20,6 +23,7 @@ import type { ChatClientEventEmitter } from './events'
20
23
  import type {
21
24
  ChatClientOptions,
22
25
  ChatClientState,
26
+ ChatFetcher,
23
27
  ConnectionStatus,
24
28
  MessagePart,
25
29
  MultimodalContent,
@@ -27,6 +31,21 @@ import type {
27
31
  UIMessage,
28
32
  } from './types'
29
33
 
34
+ function resolveTransport(transport: {
35
+ connection?: ConnectionAdapter
36
+ fetcher?: ChatFetcher
37
+ }): ConnectionAdapter {
38
+ const { connection, fetcher } = transport
39
+ if (connection && fetcher) {
40
+ throw new Error(
41
+ 'ChatClient: pass either `connection` or `fetcher`, not both.',
42
+ )
43
+ }
44
+ if (connection) return connection
45
+ if (fetcher) return fetcherToConnectionAdapter(fetcher)
46
+ throw new Error('ChatClient: either `connection` or `fetcher` is required.')
47
+ }
48
+
30
49
  export class ChatClient {
31
50
  private readonly processor: StreamProcessor
32
51
  private connection: SubscribeConnectionAdapter
@@ -96,7 +115,7 @@ export class ChatClient {
96
115
  // winning on key collision.
97
116
  this.bodyOption = options.body || {}
98
117
  this.forwardedPropsOption = options.forwardedProps || {}
99
- this.connection = normalizeConnectionAdapter(options.connection)
118
+ this.connection = normalizeConnectionAdapter(resolveTransport(options))
100
119
  this.events = new DefaultChatClientEventEmitter(this.uniqueId)
101
120
 
102
121
  // Build client tools map
@@ -422,9 +441,9 @@ export class ChatClient {
422
441
  // both so a RUN_ERROR with a runId only clears that run, not every
423
442
  // active run in the session.
424
443
  const runId =
425
- chunk.type === 'RUN_FINISHED'
444
+ 'runId' in chunk && typeof chunk.runId === 'string'
426
445
  ? chunk.runId
427
- : (chunk as { runId?: string }).runId
446
+ : undefined
428
447
  if (runId) {
429
448
  this.activeRunIds.delete(runId)
430
449
  } else if (chunk.type === 'RUN_ERROR') {
@@ -964,9 +983,13 @@ export class ChatClient {
964
983
  */
965
984
  private shouldAutoSend(): boolean {
966
985
  const messages = this.processor.getMessages()
967
- const lastAssistant = messages.findLast((m) => m.role === 'assistant')
986
+ const lastAssistant = messages.findLast(
987
+ (m: UIMessage) => m.role === 'assistant',
988
+ )
968
989
  if (!lastAssistant) return false
969
- const hasToolCalls = lastAssistant.parts.some((p) => p.type === 'tool-call')
990
+ const hasToolCalls = lastAssistant.parts.some(
991
+ (p: MessagePart) => p.type === 'tool-call',
992
+ )
970
993
  if (!hasToolCalls) return false
971
994
  return this.processor.areAllToolsComplete()
972
995
  }
@@ -1035,6 +1058,7 @@ export class ChatClient {
1035
1058
  */
1036
1059
  updateOptions(options: {
1037
1060
  connection?: ConnectionAdapter
1061
+ fetcher?: ChatFetcher
1038
1062
  /** @deprecated Use `forwardedProps` instead. */
1039
1063
  body?: Record<string, any>
1040
1064
  forwardedProps?: Record<string, any>
@@ -1052,7 +1076,7 @@ export class ChatClient {
1052
1076
  context: { toolCallId?: string },
1053
1077
  ) => void
1054
1078
  }): void {
1055
- if (options.connection !== undefined) {
1079
+ if (options.connection !== undefined || options.fetcher !== undefined) {
1056
1080
  const wasSubscribed = this.isSubscribed
1057
1081
 
1058
1082
  if (this.isLoading) {
@@ -1067,7 +1091,12 @@ export class ChatClient {
1067
1091
  this.resetSessionGenerating()
1068
1092
  this.setIsSubscribed(false)
1069
1093
  this.setConnectionStatus('disconnected')
1070
- this.connection = normalizeConnectionAdapter(options.connection)
1094
+ this.connection = normalizeConnectionAdapter(
1095
+ resolveTransport({
1096
+ connection: options.connection,
1097
+ fetcher: options.fetcher,
1098
+ }),
1099
+ )
1071
1100
 
1072
1101
  if (wasSubscribed) {
1073
1102
  this.subscribe()
@@ -1,10 +1,49 @@
1
1
  import { EventType, uiMessagesToWire } from '@tanstack/ai'
2
- import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai'
2
+ import type {
3
+ ModelMessage,
4
+ RunErrorEvent,
5
+ RunFinishedEvent,
6
+ StreamChunk,
7
+ UIMessage,
8
+ } from '@tanstack/ai'
9
+ import type { ChatFetcher } from './types'
10
+
11
+ /**
12
+ * Thrown when an SSE/HTTP stream ends with a non-empty unterminated buffer.
13
+ * Indicates the connection was cut mid-line (server crash, dropped TCP, proxy
14
+ * timeout) so the partial content cannot be safely parsed.
15
+ */
16
+ export class StreamTruncatedError extends Error {
17
+ constructor() {
18
+ super(
19
+ 'Stream ended with unterminated trailing data — connection was likely cut short.',
20
+ )
21
+ this.name = 'StreamTruncatedError'
22
+ }
23
+ }
3
24
 
4
25
  function generateRunId(prefix: string): string {
5
26
  return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
6
27
  }
7
28
 
29
+ /**
30
+ * Asserts an id is present when synthesizing a terminal event. The chat
31
+ * client always supplies `runContext.threadId` / `runContext.runId`, so an
32
+ * absent id at this layer indicates the adapter was wired up by a caller
33
+ * that bypassed that contract — surface it rather than fabricating one.
34
+ */
35
+ function requireSyntheticId(
36
+ value: string | undefined,
37
+ field: 'threadId' | 'runId',
38
+ ): string {
39
+ if (!value) {
40
+ throw new Error(
41
+ `Cannot synthesize terminal event: ${field} not supplied via runContext and not observed in the upstream stream.`,
42
+ )
43
+ }
44
+ return value
45
+ }
46
+
8
47
  /**
9
48
  * Merge custom headers into request headers
10
49
  */
@@ -52,15 +91,83 @@ async function* readStreamLines(
52
91
  }
53
92
  }
54
93
 
55
- // Process any remaining data in the buffer
56
- if (buffer.trim()) {
57
- yield buffer
94
+ // A non-empty trailing buffer means the connection was cut mid-line.
95
+ // Surface this as an error so the chat client transitions to 'error'
96
+ // state instead of silently presenting a partial stream as success.
97
+ // Skip when the consumer aborted — a user-initiated stop() interrupting
98
+ // mid-line is expected, not a truncation bug.
99
+ if (buffer.trim() && !abortSignal?.aborted) {
100
+ throw new StreamTruncatedError()
58
101
  }
59
102
  } finally {
60
103
  reader.releaseLock()
61
104
  }
62
105
  }
63
106
 
107
+ /**
108
+ * Yield StreamChunks parsed from an SSE Response body.
109
+ *
110
+ * Accepts either `data: {...}` lines or bare JSON lines. Skips comments
111
+ * starting with `:` (proxies and CDNs inject these as keepalives) and the
112
+ * `event:` / `id:` / `retry:` SSE control fields. A `[DONE]` sentinel is
113
+ * treated as a terminal event: a synthesized RUN_FINISHED is yielded using
114
+ * the most recent upstream `threadId` / `runId`, ensuring the consumer sees
115
+ * a clean terminal event with real correlation ids.
116
+ *
117
+ * A JSON parse failure throws — the consumer surfaces it as an error.
118
+ */
119
+ async function* responseToSSEChunks(
120
+ response: Response,
121
+ abortSignal?: AbortSignal,
122
+ ): AsyncGenerator<StreamChunk> {
123
+ if (!response.ok) {
124
+ throw new Error(
125
+ `HTTP error! status: ${response.status} ${response.statusText}`,
126
+ )
127
+ }
128
+ const reader = response.body?.getReader()
129
+ if (!reader) {
130
+ throw new Error('Response body is not readable')
131
+ }
132
+ let lastThreadId: string | undefined
133
+ let lastRunId: string | undefined
134
+ let lastModel: string | undefined
135
+ for await (const line of readStreamLines(reader, abortSignal)) {
136
+ if (
137
+ line.startsWith(':') ||
138
+ line.startsWith('event:') ||
139
+ line.startsWith('id:') ||
140
+ line.startsWith('retry:')
141
+ ) {
142
+ continue
143
+ }
144
+ const data = line.startsWith('data: ') ? line.slice(6) : line
145
+ if (data === '[DONE]') {
146
+ const synthetic: RunFinishedEvent = {
147
+ type: EventType.RUN_FINISHED,
148
+ threadId: lastThreadId ?? '',
149
+ runId: lastRunId ?? '',
150
+ model: lastModel ?? '',
151
+ timestamp: Date.now(),
152
+ finishReason: 'stop',
153
+ }
154
+ yield synthetic
155
+ return
156
+ }
157
+ const chunk = JSON.parse(data) as StreamChunk
158
+ if ('threadId' in chunk && typeof chunk.threadId === 'string') {
159
+ lastThreadId = chunk.threadId
160
+ }
161
+ if ('runId' in chunk && typeof chunk.runId === 'string') {
162
+ lastRunId = chunk.runId
163
+ }
164
+ if ('model' in chunk && typeof chunk.model === 'string') {
165
+ lastModel = chunk.model
166
+ }
167
+ yield chunk
168
+ }
169
+ }
170
+
64
171
  /**
65
172
  * Per-send context provided by the chat client to the connection adapter.
66
173
  * The adapter combines this with serialized messages to build a full
@@ -196,6 +303,8 @@ export function normalizeConnectionAdapter(
196
303
  },
197
304
  async send(messages, data, abortSignal, runContext) {
198
305
  let hasTerminalEvent = false
306
+ let upstreamThreadId: string | undefined
307
+ let upstreamRunId: string | undefined
199
308
  try {
200
309
  const stream = connection.connect(
201
310
  messages,
@@ -204,6 +313,12 @@ export function normalizeConnectionAdapter(
204
313
  runContext,
205
314
  )
206
315
  for await (const chunk of stream) {
316
+ if ('threadId' in chunk && typeof chunk.threadId === 'string') {
317
+ upstreamThreadId = chunk.threadId
318
+ }
319
+ if ('runId' in chunk && typeof chunk.runId === 'string') {
320
+ upstreamRunId = chunk.runId
321
+ }
207
322
  if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
208
323
  hasTerminalEvent = true
209
324
  }
@@ -214,31 +329,40 @@ export function normalizeConnectionAdapter(
214
329
  // synthesize RUN_FINISHED so request-scoped consumers can complete.
215
330
  // Reuse the caller's threadId/runId so client-side activeRunIds tracking matches.
216
331
  if (!abortSignal?.aborted && !hasTerminalEvent) {
217
- push({
332
+ const synthetic: RunFinishedEvent = {
218
333
  type: EventType.RUN_FINISHED,
219
- threadId: runContext?.threadId ?? `thread-${Date.now()}`,
220
- runId: runContext?.runId ?? `run-${Date.now()}`,
334
+ threadId: requireSyntheticId(
335
+ upstreamThreadId ?? runContext?.threadId,
336
+ 'threadId',
337
+ ),
338
+ runId: requireSyntheticId(
339
+ upstreamRunId ?? runContext?.runId,
340
+ 'runId',
341
+ ),
221
342
  model: 'connect-wrapper',
222
343
  timestamp: Date.now(),
223
344
  finishReason: 'stop',
224
- })
345
+ }
346
+ push(synthetic)
225
347
  }
226
348
  } catch (err) {
227
349
  if (!abortSignal?.aborted && !hasTerminalEvent) {
228
- push({
350
+ const message =
351
+ err instanceof Error ? err.message : 'Unknown error in connect()'
352
+ const synthetic: RunErrorEvent = {
229
353
  type: EventType.RUN_ERROR,
230
- threadId: runContext?.threadId ?? `thread-${Date.now()}`,
231
- runId: runContext?.runId ?? `run-${Date.now()}`,
354
+ threadId: requireSyntheticId(
355
+ upstreamThreadId ?? runContext?.threadId,
356
+ 'threadId',
357
+ ),
358
+ runId: requireSyntheticId(
359
+ upstreamRunId ?? runContext?.runId,
360
+ 'runId',
361
+ ),
232
362
  timestamp: Date.now(),
233
- message:
234
- err instanceof Error ? err.message : 'Unknown error in connect()',
235
- error: {
236
- message:
237
- err instanceof Error
238
- ? err.message
239
- : 'Unknown error in connect()',
240
- },
241
- })
363
+ message,
364
+ }
365
+ push(synthetic)
242
366
  }
243
367
  throw err
244
368
  }
@@ -358,37 +482,7 @@ export function fetchServerSentEvents(
358
482
  ...(signal ? { signal } : {}),
359
483
  })
360
484
 
361
- if (!response.ok) {
362
- throw new Error(
363
- `HTTP error! status: ${response.status} ${response.statusText}`,
364
- )
365
- }
366
-
367
- // Parse Server-Sent Events format
368
- const reader = response.body?.getReader()
369
- if (!reader) {
370
- throw new Error('Response body is not readable')
371
- }
372
-
373
- for await (const line of readStreamLines(reader, abortSignal)) {
374
- // Handle Server-Sent Events format
375
- const data = line.startsWith('data: ') ? line.slice(6) : line
376
-
377
- if (data === '[DONE]') {
378
- console.warn(
379
- '[@tanstack/ai-client] Received [DONE] sentinel. This is deprecated — upgrade your @tanstack/ai server package. RUN_FINISHED is the stream terminator.',
380
- )
381
- continue
382
- }
383
-
384
- try {
385
- const parsed: StreamChunk = JSON.parse(data)
386
- yield parsed
387
- } catch (parseError) {
388
- // Skip non-JSON lines or malformed chunks
389
- console.warn('Failed to parse SSE chunk:', data)
390
- }
391
- }
485
+ yield* responseToSSEChunks(response, abortSignal)
392
486
  },
393
487
  }
394
488
  }
@@ -507,12 +601,7 @@ export function fetchHttpStream(
507
601
  }
508
602
 
509
603
  for await (const line of readStreamLines(reader, abortSignal)) {
510
- try {
511
- const parsed: StreamChunk = JSON.parse(line)
512
- yield parsed
513
- } catch (parseError) {
514
- console.warn('Failed to parse HTTP stream chunk:', line)
515
- }
604
+ yield JSON.parse(line) as StreamChunk
516
605
  }
517
606
  },
518
607
  }
@@ -536,17 +625,92 @@ export function stream(
536
625
  streamFactory: (
537
626
  messages: Array<UIMessage> | Array<ModelMessage>,
538
627
  data?: Record<string, any>,
628
+ abortSignal?: AbortSignal,
539
629
  ) => AsyncIterable<StreamChunk>,
540
630
  ): ConnectConnectionAdapter {
541
631
  return {
542
- async *connect(messages, data, _abortSignal, _runContext) {
632
+ async *connect(messages, data, abortSignal) {
543
633
  // Pass messages as-is (UIMessages with parts preserved)
544
634
  // Server-side chat() handles conversion to ModelMessages
545
- yield* streamFactory(messages, data)
635
+ yield* streamFactory(messages, data, abortSignal)
636
+ },
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Wrap a `ChatFetcher` as a `ConnectConnectionAdapter` so the chat client can
642
+ * consume it through the same `subscribe`/`send` plumbing used for SSE /
643
+ * HTTP-stream / RPC connections. May return either a `Response` (parsed as
644
+ * SSE) or an `AsyncIterable<StreamChunk>` (yielded directly).
645
+ *
646
+ * @internal
647
+ */
648
+ export function fetcherToConnectionAdapter(
649
+ fetcher: ChatFetcher,
650
+ ): ConnectConnectionAdapter {
651
+ return {
652
+ async *connect(messages, data, abortSignal, runContext) {
653
+ if (!abortSignal) {
654
+ throw new Error(
655
+ 'fetcherToConnectionAdapter requires an AbortSignal — the chat client always supplies one.',
656
+ )
657
+ }
658
+ if (!runContext) {
659
+ throw new Error(
660
+ 'fetcherToConnectionAdapter requires a RunAgentInputContext — the chat client always supplies one.',
661
+ )
662
+ }
663
+ const uiMessages = messages as Array<UIMessage>
664
+ const result = await fetcher(
665
+ {
666
+ messages: uiMessages,
667
+ data,
668
+ threadId: runContext.threadId,
669
+ runId: runContext.runId,
670
+ },
671
+ { signal: abortSignal },
672
+ )
673
+ if (result instanceof Response) {
674
+ yield* responseToSSEChunks(result, abortSignal)
675
+ } else {
676
+ yield* abortableIterable(result, abortSignal)
677
+ }
546
678
  },
547
679
  }
548
680
  }
549
681
 
682
+ /**
683
+ * Wrap an AsyncIterable so iteration aborts when `signal` fires. Without
684
+ * this, a fetcher that returns a generator ignoring its signal would leave
685
+ * the for-await loop hanging until the iterable naturally ends.
686
+ */
687
+ async function* abortableIterable<T>(
688
+ iterable: AsyncIterable<T>,
689
+ signal: AbortSignal,
690
+ ): AsyncGenerator<T> {
691
+ if (signal.aborted) return
692
+ const iterator = iterable[Symbol.asyncIterator]()
693
+ const abortPromise = new Promise<{ done: true; value: undefined }>(
694
+ (resolve) => {
695
+ signal.addEventListener(
696
+ 'abort',
697
+ () => resolve({ done: true, value: undefined }),
698
+ { once: true },
699
+ )
700
+ },
701
+ )
702
+ try {
703
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
704
+ while (true) {
705
+ const result = await Promise.race([iterator.next(), abortPromise])
706
+ if (result.done) return
707
+ yield result.value
708
+ }
709
+ } finally {
710
+ await iterator.return?.()
711
+ }
712
+ }
713
+
550
714
  /**
551
715
  * Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC)
552
716
  *
@@ -567,13 +731,14 @@ export function rpcStream(
567
731
  rpcCall: (
568
732
  messages: Array<UIMessage> | Array<ModelMessage>,
569
733
  data?: Record<string, any>,
734
+ abortSignal?: AbortSignal,
570
735
  ) => AsyncIterable<StreamChunk>,
571
736
  ): ConnectConnectionAdapter {
572
737
  return {
573
- async *connect(messages, data, _abortSignal, _runContext) {
738
+ async *connect(messages, data, abortSignal) {
574
739
  // Pass messages as-is (UIMessages with parts preserved)
575
740
  // Server-side chat() handles conversion to ModelMessages
576
- yield* rpcCall(messages, data)
741
+ yield* rpcCall(messages, data, abortSignal)
577
742
  },
578
743
  }
579
744
  }
package/src/index.ts CHANGED
@@ -17,7 +17,11 @@ export type {
17
17
  InferChatMessages,
18
18
  ChatClientState,
19
19
  ConnectionStatus,
20
- // Multimodal content input type
20
+ ChatFetcher,
21
+ ChatFetcherInput,
22
+ ChatFetcherOptions,
23
+ ChatTransport,
24
+ DistributedOmit,
21
25
  MultimodalContent,
22
26
  } from './types'
23
27
  // Generation client types
@@ -58,6 +62,7 @@ export {
58
62
  fetchHttpStream,
59
63
  stream,
60
64
  rpcStream,
65
+ StreamTruncatedError,
61
66
  type ConnectConnectionAdapter,
62
67
  type ConnectionAdapter,
63
68
  type FetchConnectionOptions,
package/src/types.ts CHANGED
@@ -16,6 +16,68 @@ import type { ConnectionAdapter } from './connection-adapters'
16
16
 
17
17
  export type { StructuredOutputPart } from '@tanstack/ai'
18
18
 
19
+ /**
20
+ * `messages` is the full UIMessage history (not a delta). `data` is the
21
+ * merged body — `ChatClientOptions.body` plus any per-call data passed to
22
+ * `sendMessage(...)`. `threadId` / `runId` are the AG-UI correlation ids
23
+ * the chat client uses to track this turn — forward them to your server
24
+ * if it needs to correlate requests.
25
+ */
26
+ export interface ChatFetcherInput {
27
+ messages: Array<UIMessage>
28
+ data?: Record<string, unknown>
29
+ threadId: string
30
+ runId: string
31
+ }
32
+
33
+ export interface ChatFetcherOptions {
34
+ /** Fires when `stop()` is called or the request is superseded. */
35
+ signal: AbortSignal
36
+ }
37
+
38
+ /**
39
+ * Direct function that performs a chat request. Mirrors
40
+ * `GenerationFetcher`. Returns either a `Response` (SSE body parsed by the
41
+ * chat client) or an `AsyncIterable<StreamChunk>` (yielded directly). May
42
+ * return the value synchronously, as a `Promise`, or as an async generator
43
+ * (`async function*`) — the chat client awaits whichever shape is returned.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * useChat({
48
+ * fetcher: ({ messages }, { signal }) =>
49
+ * chatFn({ data: { messages }, signal }),
50
+ * })
51
+ * ```
52
+ */
53
+ export type ChatFetcher = (
54
+ input: ChatFetcherInput,
55
+ options: ChatFetcherOptions,
56
+ ) =>
57
+ | Response
58
+ | AsyncIterable<StreamChunk>
59
+ | Promise<Response | AsyncIterable<StreamChunk>>
60
+
61
+ /**
62
+ * Distributive `Omit` — applies `Omit<O, K>` per branch of a union so
63
+ * discriminated unions survive omission. Plain `Omit` collapses unions
64
+ * into a single object shape, which would erase the `ChatTransport` XOR
65
+ * when framework hooks omit React-managed callbacks from
66
+ * `ChatClientOptions`.
67
+ */
68
+ export type DistributedOmit<
69
+ TObject,
70
+ TKeys extends keyof any,
71
+ > = TObject extends unknown ? Omit<TObject, TKeys> : never
72
+
73
+ /**
74
+ * Discriminated union enforcing that exactly one of `connection` or
75
+ * `fetcher` is provided. Mirrors `GenerationTransport`.
76
+ */
77
+ export type ChatTransport =
78
+ | { connection: ConnectionAdapter; fetcher?: never }
79
+ | { fetcher: ChatFetcher; connection?: never }
80
+
19
81
  /**
20
82
  * Tool call states - track the lifecycle of a tool call
21
83
  */
@@ -202,16 +264,13 @@ export interface UIMessage<
202
264
  createdAt?: Date
203
265
  }
204
266
 
205
- export interface ChatClientOptions<
267
+ /**
268
+ * Options for `ChatClient`. Exactly one of `connection` or `fetcher` must be
269
+ * provided — the type-level XOR is enforced via `ChatTransport`.
270
+ */
271
+ export type ChatClientOptions<
206
272
  TTools extends ReadonlyArray<AnyClientTool> = any,
207
- > {
208
- /**
209
- * Connection adapter for streaming.
210
- * Supports mutually exclusive modes: request-response via `connect()`, or
211
- * subscribe/send mode via `subscribe()` + `send()`.
212
- */
213
- connection: ConnectionAdapter
214
-
273
+ > = {
215
274
  /**
216
275
  * Initial messages to populate the chat
217
276
  */
@@ -339,7 +398,7 @@ export interface ChatClientOptions<
339
398
  */
340
399
  chunkStrategy?: ChunkStrategy
341
400
  }
342
- }
401
+ } & ChatTransport
343
402
 
344
403
  export interface ChatRequestBody {
345
404
  messages: Array<ModelMessage>