@tanstack/ai 0.45.1 → 0.47.1

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 (82) hide show
  1. package/dist/esm/activities/chat/index.d.ts +36 -11
  2. package/dist/esm/activities/chat/index.js +462 -66
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.d.ts +1 -0
  5. package/dist/esm/activities/chat/messages.js +12 -7
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/middleware/builder.d.ts +7 -2
  8. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  9. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -3
  10. package/dist/esm/activities/chat/middleware/compose.js +55 -0
  11. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  12. package/dist/esm/activities/chat/middleware/define.d.ts +6 -3
  13. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  14. package/dist/esm/activities/chat/middleware/generic-interrupts.d.ts +13 -0
  15. package/dist/esm/activities/chat/middleware/generic-interrupts.js +8 -0
  16. package/dist/esm/activities/chat/middleware/generic-interrupts.js.map +1 -0
  17. package/dist/esm/activities/chat/middleware/index.d.ts +4 -1
  18. package/dist/esm/activities/chat/middleware/types.d.ts +54 -3
  19. package/dist/esm/activities/chat/middleware/types.js +16 -0
  20. package/dist/esm/activities/chat/middleware/types.js.map +1 -0
  21. package/dist/esm/activities/chat/stream/processor.js +18 -5
  22. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  23. package/dist/esm/activities/chat/tools/unique-tool-names.d.ts +20 -0
  24. package/dist/esm/activities/chat/tools/unique-tool-names.js +57 -0
  25. package/dist/esm/activities/chat/tools/unique-tool-names.js.map +1 -0
  26. package/dist/esm/adapter-internals.d.ts +7 -0
  27. package/dist/esm/adapter-internals.js +5 -1
  28. package/dist/esm/client.d.ts +4 -0
  29. package/dist/esm/client.js +3 -1
  30. package/dist/esm/client.js.map +1 -1
  31. package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
  32. package/dist/esm/generic-interrupt-continuation.js +80 -0
  33. package/dist/esm/generic-interrupt-continuation.js.map +1 -0
  34. package/dist/esm/index.d.ts +9 -1
  35. package/dist/esm/index.js +7 -2
  36. package/dist/esm/interrupt-definition.d.ts +113 -0
  37. package/dist/esm/interrupt-definition.js +169 -0
  38. package/dist/esm/interrupt-definition.js.map +1 -0
  39. package/dist/esm/interrupt-resume.d.ts +3 -0
  40. package/dist/esm/interrupt-resume.js +77 -16
  41. package/dist/esm/interrupt-resume.js.map +1 -1
  42. package/dist/esm/interrupts.d.ts +12 -3
  43. package/dist/esm/interrupts.js.map +1 -1
  44. package/dist/esm/middlewares/usage-attributes.d.ts +2 -2
  45. package/dist/esm/middlewares/usage-attributes.js +9 -2
  46. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  47. package/dist/esm/stream-to-response.d.ts +26 -0
  48. package/dist/esm/stream-to-response.js +1 -1
  49. package/dist/esm/stream-to-response.js.map +1 -1
  50. package/dist/esm/stream-to-websocket.d.ts +123 -0
  51. package/dist/esm/stream-to-websocket.js +249 -0
  52. package/dist/esm/stream-to-websocket.js.map +1 -0
  53. package/dist/esm/types.d.ts +19 -10
  54. package/dist/esm/utilities/chat-params.js +10 -1
  55. package/dist/esm/utilities/chat-params.js.map +1 -1
  56. package/package.json +2 -2
  57. package/skills/ai-core/media-generation/SKILL.md +13 -9
  58. package/skills/ai-core/middleware/SKILL.md +53 -44
  59. package/skills/ai-core/structured-outputs/SKILL.md +59 -55
  60. package/skills/ai-core/tool-calling/SKILL.md +54 -1
  61. package/src/activities/chat/index.ts +1076 -194
  62. package/src/activities/chat/messages.ts +11 -3
  63. package/src/activities/chat/middleware/builder.ts +29 -4
  64. package/src/activities/chat/middleware/compose.ts +95 -5
  65. package/src/activities/chat/middleware/define.ts +13 -3
  66. package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
  67. package/src/activities/chat/middleware/index.ts +15 -0
  68. package/src/activities/chat/middleware/types.ts +127 -2
  69. package/src/activities/chat/stream/processor.ts +21 -0
  70. package/src/activities/chat/tools/unique-tool-names.ts +73 -0
  71. package/src/adapter-internals.ts +24 -0
  72. package/src/client.ts +20 -0
  73. package/src/generic-interrupt-continuation.ts +162 -0
  74. package/src/index.ts +51 -0
  75. package/src/interrupt-definition.ts +581 -0
  76. package/src/interrupt-resume.ts +156 -25
  77. package/src/interrupts.ts +13 -3
  78. package/src/middlewares/usage-attributes.ts +12 -2
  79. package/src/stream-to-response.ts +2 -2
  80. package/src/stream-to-websocket.ts +418 -0
  81. package/src/types.ts +21 -8
  82. package/src/utilities/chat-params.ts +16 -3
@@ -0,0 +1,418 @@
1
+ import { chatParamsFromRequestBody } from './utilities/chat-params'
2
+ import { durableStreamSource, runErrorChunk } from './stream-to-response'
3
+ import { resolveDebugOption } from './logger/resolve'
4
+ import type { StreamDurability } from './stream-durability'
5
+ import type { DebugOption } from './logger/types'
6
+ import type { ModelMessage, StreamChunk, UIMessage } from './types'
7
+
8
+ /**
9
+ * The minimal WHATWG WebSocket surface the core needs. Cloudflare
10
+ * `WebSocketPair` server sockets, Deno's upgraded sockets, and `ws` (Node)
11
+ * sockets already satisfy it; Bun's `ServerWebSocket` (handler-object API)
12
+ * gets a ~10-line adapter at the call site.
13
+ */
14
+ export interface WebSocketLike {
15
+ send: (data: string) => void
16
+ close: (code?: number, reason?: string) => void
17
+ addEventListener: {
18
+ (type: 'message', handler: (ev: { data: unknown }) => void): void
19
+ (type: 'close' | 'error', handler: () => void): void
20
+ }
21
+ }
22
+
23
+ /** One inbound WS text frame, after JSON parse + shape discrimination. */
24
+ export type InboundFrame =
25
+ | { kind: 'run'; input: unknown }
26
+ | { kind: 'abort'; runId: string }
27
+
28
+ /**
29
+ * Encode one server→client frame. Durable frames carry the opaque offset in an
30
+ * `{ id, chunk }` envelope (identical to the NDJSON wire); non-durable frames
31
+ * are the bare chunk. Unambiguous because a bare chunk always has a top-level
32
+ * `type` and the envelope never does.
33
+ */
34
+ export function encodeWsFrame(
35
+ chunk: StreamChunk,
36
+ id: string | undefined,
37
+ ): string {
38
+ return JSON.stringify(id === undefined ? chunk : { id, chunk })
39
+ }
40
+
41
+ /**
42
+ * Decode one client→server frame. An `{ type: 'abort', runId }` object is a
43
+ * control frame; anything else is treated as a `RunAgentInput` and validated
44
+ * downstream by `chatParamsFromRequestBody`.
45
+ */
46
+ export function decodeWsFrame(data: string): InboundFrame {
47
+ const parsed: unknown = JSON.parse(data)
48
+ if (
49
+ typeof parsed === 'object' &&
50
+ parsed !== null &&
51
+ (parsed as { type?: unknown }).type === 'abort' &&
52
+ typeof (parsed as { runId?: unknown }).runId === 'string'
53
+ ) {
54
+ return { kind: 'abort', runId: (parsed as { runId: string }).runId }
55
+ }
56
+ return { kind: 'run', input: parsed }
57
+ }
58
+
59
+ /** Per-turn context for one inbound `run` frame on a conversation-scoped socket. */
60
+ export interface WsRunContext {
61
+ messages: Array<UIMessage | ModelMessage>
62
+ threadId: string
63
+ runId: string
64
+ forwardedProps?: Record<string, unknown>
65
+ /** Synthetic per-turn request carrying `?runId=` so durability keys correctly. */
66
+ request: Request
67
+ /** Aborts on socket close or an `abort` control frame for this run. */
68
+ signal: AbortSignal
69
+ }
70
+
71
+ /**
72
+ * Build the synthetic per-turn request. A conversation-scoped socket multiplexes
73
+ * many runs; each turn's durability adapter must key on the frame's `runId`,
74
+ * which we carry in the URL query (`memoryStream`/`durableStream` already read
75
+ * `?runId` / `?offset` there). Headers are copied from the handshake so
76
+ * auth/cookies survive. A handshake carrying `?offset` is a resume and never
77
+ * reaches a fresh turn (`resumeWebSocketStream` serves it), so the offset is
78
+ * scrubbed here — otherwise a mis-routed resume handshake would make the turn's
79
+ * durability adapter silently take the replay branch instead of running onRun.
80
+ */
81
+ export function buildTurnRequest(handshake: Request, runId: string): Request {
82
+ const url = new URL(handshake.url)
83
+ url.searchParams.set('runId', runId)
84
+ url.searchParams.delete('offset')
85
+ return new Request(url, { headers: handshake.headers })
86
+ }
87
+
88
+ export interface WebSocketStreamInit<TOffset extends string = string> {
89
+ /** Build a fresh chat() stream for each inbound RunAgentInput frame. */
90
+ onRun: (ctx: WsRunContext) => AsyncIterable<StreamChunk>
91
+ /** Per-TURN durability factory, keyed by the frame's runId via ctx.request. */
92
+ durability?: (ctx: WsRunContext) => StreamDurability<TOffset>
93
+ /** Chunks buffered per durability append (default 32). */
94
+ batch?: number
95
+ /** Heartbeat ping interval in ms (default 30_000). */
96
+ heartbeatMs?: number
97
+ /**
98
+ * Close after this many ms without any inbound frame (default 300_000).
99
+ * Never fires while a turn is still streaming, so a long single generation
100
+ * (agentic loop, >5-min turn) is safe.
101
+ */
102
+ idleTimeoutMs?: number
103
+ debug?: DebugOption
104
+ }
105
+
106
+ /**
107
+ * Run a full-duplex, conversation-scoped chat over an already-accepted server
108
+ * socket. Each inbound RunAgentInput frame starts one chat() turn (via onRun)
109
+ * whose chunks are pumped back as frames; the socket stays open across turns
110
+ * (pending client-tool resubmit, next user message) until the client closes it
111
+ * or the idle timeout fires. An abort control frame aborts only its turn.
112
+ */
113
+ export function toWebSocketStream<TOffset extends string = string>(
114
+ socket: WebSocketLike,
115
+ request: Request,
116
+ init: WebSocketStreamInit<TOffset>,
117
+ ): void {
118
+ const logger = resolveDebugOption(init.debug)
119
+ const activeTurns = new Map<string, AbortController>()
120
+ // Abort frames that raced ahead of their run's registration: `handleInbound`
121
+ // awaits body validation before it registers into `activeTurns`, so an abort
122
+ // arriving inside that window would otherwise be silently discarded.
123
+ const earlyAborts = new Set<string>()
124
+ const heartbeatMs = init.heartbeatMs ?? 30_000
125
+ const idleTimeoutMs = init.idleTimeoutMs ?? 300_000
126
+ let lastActivity = Date.now()
127
+ let closed = false
128
+
129
+ const heartbeat = setInterval(() => {
130
+ try {
131
+ socket.send(JSON.stringify({ type: 'ping' }))
132
+ } catch {
133
+ // Socket is CLOSING/CLOSED between ticks — teardown below clears this
134
+ // interval; swallow so the timer callback doesn't throw uncaught in the
135
+ // meantime.
136
+ }
137
+ }, heartbeatMs)
138
+ const idle = setInterval(
139
+ () => {
140
+ // Never idle-reap while a turn is in flight: a long single onRun
141
+ // iteration (agentic loop / >5-min generation) sends no INBOUND
142
+ // frames, so idle would otherwise fire and kill live work.
143
+ if (activeTurns.size === 0 && Date.now() - lastActivity > idleTimeoutMs) {
144
+ socket.close(1000, 'idle')
145
+ }
146
+ },
147
+ Math.min(idleTimeoutMs, 30_000),
148
+ )
149
+
150
+ function teardown(): void {
151
+ closed = true
152
+ for (const controller of activeTurns.values()) controller.abort()
153
+ activeTurns.clear()
154
+ clearInterval(heartbeat)
155
+ clearInterval(idle)
156
+ }
157
+
158
+ socket.addEventListener('close', teardown)
159
+ // Without this, an errored socket whose `close` never follows would leak
160
+ // both intervals and never abort its turns — and on `ws` (an EventEmitter)
161
+ // an `error` event with no listener is thrown as an uncaught exception.
162
+ socket.addEventListener('error', () => {
163
+ logger.errors('WebSocket errored; aborting its turns')
164
+ teardown()
165
+ try {
166
+ socket.close(1011, 'socket error')
167
+ } catch {
168
+ // socket already closing/closed — nothing to do
169
+ }
170
+ })
171
+
172
+ socket.addEventListener('message', (event: { data: unknown }) => {
173
+ if (typeof event.data !== 'string') return
174
+ lastActivity = Date.now()
175
+
176
+ // Inbound frames are client-controlled: a malformed frame (bad JSON, or
177
+ // valid JSON that isn't an AG-UI RunAgentInput/abort shape) must be
178
+ // dropped, not crash the socket or leak an unhandled rejection.
179
+ let frame: InboundFrame
180
+ try {
181
+ frame = decodeWsFrame(event.data)
182
+ } catch (error) {
183
+ logger.errors('Failed to decode inbound WS frame; dropping it', {
184
+ error,
185
+ })
186
+ return
187
+ }
188
+
189
+ if (frame.kind === 'abort') {
190
+ const turn = activeTurns.get(frame.runId)
191
+ if (turn) turn.abort()
192
+ else earlyAborts.add(frame.runId)
193
+ return
194
+ }
195
+
196
+ void handleInbound(frame.input)
197
+ })
198
+
199
+ /**
200
+ * Surface a turn failure to the client as a live `RUN_ERROR` frame. The
201
+ * socket is conversation-scoped and stays open, so without this frame the
202
+ * client would see neither a terminal chunk nor a close — a permanent hang.
203
+ * Mirrors the HTTP transports, which synthesize the live `RUN_ERROR` when
204
+ * the producer rethrows (see `durableStreamSource`'s terminal contract).
205
+ */
206
+ function sendRunError(error: unknown): void {
207
+ try {
208
+ socket.send(encodeWsFrame(runErrorChunk(error), undefined))
209
+ } catch {
210
+ // Socket is CLOSING/CLOSED — the client sees onclose instead.
211
+ }
212
+ }
213
+
214
+ async function handleInbound(input: unknown): Promise<void> {
215
+ let params: Awaited<ReturnType<typeof chatParamsFromRequestBody>>
216
+ try {
217
+ params = await chatParamsFromRequestBody(input)
218
+ } catch (error) {
219
+ logger.errors('Invalid inbound WS run frame; dropping it', { error })
220
+ sendRunError(error)
221
+ return
222
+ }
223
+ // The socket may have closed (or errored) during the await above — the
224
+ // teardown that drains `activeTurns` already ran, so registering now
225
+ // would start a turn nothing can ever abort.
226
+ if (closed) return
227
+ const turnAbort = new AbortController()
228
+ // A second inbound frame with the same runId (client resubmit) must
229
+ // abort the earlier turn. Otherwise the old controller is overwritten
230
+ // and close/abort frames can no longer reach it.
231
+ activeTurns.get(params.runId)?.abort()
232
+ activeTurns.set(params.runId, turnAbort)
233
+ if (earlyAborts.delete(params.runId)) turnAbort.abort()
234
+ const ctx: WsRunContext = {
235
+ messages: params.messages,
236
+ threadId: params.threadId,
237
+ runId: params.runId,
238
+ forwardedProps: params.forwardedProps,
239
+ request: buildTurnRequest(request, params.runId),
240
+ signal: turnAbort.signal,
241
+ }
242
+ try {
243
+ if (init.durability) {
244
+ const adapter = init.durability(ctx)
245
+ const { source, getId } = durableStreamSource(
246
+ init.onRun(ctx),
247
+ adapter,
248
+ {
249
+ abortController: turnAbort,
250
+ ...(init.batch === undefined ? {} : { batch: init.batch }),
251
+ logger,
252
+ },
253
+ )
254
+ for await (const chunk of source) {
255
+ socket.send(encodeWsFrame(chunk, getId(chunk)))
256
+ }
257
+ } else {
258
+ for await (const chunk of init.onRun(ctx)) {
259
+ socket.send(encodeWsFrame(chunk, undefined))
260
+ }
261
+ }
262
+ } catch (error) {
263
+ // An aborted turn (socket close, abort frame, same-runId resubmit) is
264
+ // expected teardown, not a turn failure — nothing to report.
265
+ if (!turnAbort.signal.aborted) {
266
+ logger.errors('WS turn failed', { error })
267
+ sendRunError(error)
268
+ }
269
+ } finally {
270
+ // Only delete if this turn still owns the entry: a duplicate in-flight
271
+ // runId (e.g. a client resubmitting before the first turn finished)
272
+ // would otherwise let the OLDER turn's cleanup delete the NEWER turn's
273
+ // still-active controller (TOCTOU).
274
+ if (activeTurns.get(params.runId) === turnAbort) {
275
+ activeTurns.delete(params.runId)
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * A resume is served entirely from the durability log, so there is no
283
+ * producer to iterate. This empty source satisfies `durableStreamSource`'s
284
+ * signature; on a resume it replays from the log and never touches this.
285
+ * Mirrors the private helper of the same name in `stream-to-response.ts`.
286
+ */
287
+ function emptyDurableSource(): AsyncIterable<StreamChunk> {
288
+ return (async function* () {})()
289
+ }
290
+
291
+ /**
292
+ * Read-only replay of a run's durability log over a socket (mirrors
293
+ * `resumeServerSentEventsResponse`). The adapter captures the offset from the
294
+ * request (`?offset`/`Last-Event-ID`); no model runs. Closes 1008 when there
295
+ * is nothing to resume.
296
+ */
297
+ export function resumeWebSocketStream<TOffset extends string = string>(
298
+ socket: WebSocketLike,
299
+ options: {
300
+ adapter: StreamDurability<TOffset>
301
+ batch?: number
302
+ debug?: DebugOption
303
+ },
304
+ ): void {
305
+ const logger = resolveDebugOption(options.debug)
306
+ if (options.adapter.resumeFrom() === null) {
307
+ socket.close(1008, 'no resume offset')
308
+ return
309
+ }
310
+ const abortController = new AbortController()
311
+ socket.addEventListener('close', () => abortController.abort())
312
+ // An `error` with no listener is an uncaught exception on `ws`; abort the
313
+ // replay so the pump below stops instead of writing to a dead socket.
314
+ socket.addEventListener('error', () => abortController.abort())
315
+ const { source, getId } = durableStreamSource(
316
+ emptyDurableSource(),
317
+ options.adapter,
318
+ {
319
+ abortController,
320
+ ...(options.batch === undefined ? {} : { batch: options.batch }),
321
+ logger,
322
+ },
323
+ )
324
+ void (async () => {
325
+ for await (const chunk of source) {
326
+ socket.send(encodeWsFrame(chunk, getId(chunk)))
327
+ }
328
+ // Source exhausted = the durability log is complete/terminal; nothing more
329
+ // will arrive on this read-only socket. Close so the client's reconnect
330
+ // loop sees onclose and terminates (bounded) instead of awaiting a chunk
331
+ // that never comes. Safe across durability models: a live decoupled
332
+ // producer (e.g. durableStream) keeps `read` parked until the terminal,
333
+ // so the source doesn't exhaust until the run truly ends; a completed
334
+ // in-process log closes immediately.
335
+ try {
336
+ socket.close(1000)
337
+ } catch {
338
+ // socket already closing/closed — nothing to do
339
+ }
340
+ })().catch((error: unknown) => {
341
+ logger.errors('resume websocket replay failed', { error })
342
+ try {
343
+ socket.close(1011, 'resume failed')
344
+ } catch {
345
+ // socket already closing/closed — nothing to do
346
+ }
347
+ })
348
+ }
349
+
350
+ interface WebSocketPairCtor {
351
+ new (): { 0: unknown; 1: WebSocketLike & { accept?: () => void } }
352
+ }
353
+
354
+ function upgradeOrThrow(helper: string): {
355
+ client: unknown
356
+ server: WebSocketLike
357
+ } {
358
+ const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor })
359
+ .WebSocketPair
360
+ if (!Pair) {
361
+ throw new Error(
362
+ `${helper} requires a runtime with WebSocketPair (Cloudflare Workers/Durable Objects). ` +
363
+ `On other runtimes upgrade the socket yourself and call ${helper.replace('Response', 'Stream')}.`,
364
+ )
365
+ }
366
+ const pair = new Pair()
367
+ const server = pair[1]
368
+ server.accept?.()
369
+ return { client: pair[0], server }
370
+ }
371
+
372
+ function upgradeResponse(client: unknown): Response {
373
+ return new Response(null, {
374
+ status: 101,
375
+ // Cloudflare-specific field; typed loosely to avoid a DOM lib dependency.
376
+ webSocket: client,
377
+ } as ResponseInit & { webSocket: unknown })
378
+ }
379
+
380
+ /**
381
+ * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
382
+ * accepts the server socket, delegates to {@link toWebSocketStream}, and
383
+ * returns the 101 upgrade `Response` carrying the client socket. Throws when
384
+ * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
385
+ * yourself and call {@link toWebSocketStream} directly there.
386
+ */
387
+ export function toWebSocketResponse<TOffset extends string = string>(
388
+ request: Request,
389
+ init: WebSocketStreamInit<TOffset>,
390
+ ): Response {
391
+ const { client, server } = upgradeOrThrow('toWebSocketResponse')
392
+ toWebSocketStream(server, request, init)
393
+ return upgradeResponse(client)
394
+ }
395
+
396
+ /**
397
+ * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
398
+ * accepts the server socket, delegates to {@link resumeWebSocketStream}, and
399
+ * returns the 101 upgrade `Response` carrying the client socket. Throws when
400
+ * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
401
+ * yourself and call {@link resumeWebSocketStream} directly there.
402
+ *
403
+ * @example
404
+ * ```ts
405
+ * resumeWebSocketResponse({ adapter: memoryStream(request) })
406
+ * ```
407
+ */
408
+ export function resumeWebSocketResponse<
409
+ TOffset extends string = string,
410
+ >(options: {
411
+ adapter: StreamDurability<TOffset>
412
+ batch?: number
413
+ debug?: DebugOption
414
+ }): Response {
415
+ const { client, server } = upgradeOrThrow('resumeWebSocketResponse')
416
+ resumeWebSocketStream(server, options)
417
+ return upgradeResponse(client)
418
+ }
package/src/types.ts CHANGED
@@ -11,6 +11,8 @@ import type { ProviderTool } from './tools/provider-tool'
11
11
  // package (which `@tanstack/ai` already depends on) so there is a single source
12
12
  // of truth without a dependency cycle. They are re-exported below.
13
13
  import type {
14
+ BilledUsage,
15
+ BillingUnit,
14
16
  CompletionTokensDetails,
15
17
  PromptTokensDetails,
16
18
  ProviderUsageDetails,
@@ -367,6 +369,12 @@ export interface ModelMessage<
367
369
  toolCalls?: Array<ToolCall>
368
370
  toolCallId?: string
369
371
  thinking?: Array<{ content: string; signature?: string }>
372
+ /**
373
+ * Completed structured output represented by this assistant message.
374
+ * `content` remains the provider-facing JSON text; this field preserves the
375
+ * typed UI part across persistence and message conversion.
376
+ */
377
+ structuredOutput?: StructuredOutputPart
370
378
  /**
371
379
  * Optional stable message id. Providers ignore it; it exists so a persisted
372
380
  * transcript can retain the streaming `messageId` and survive the
@@ -1026,8 +1034,7 @@ export interface TextOptions<
1026
1034
 
1027
1035
  /**
1028
1036
  * AG-UI interrupt resume responses supplied by the client on a follow-up run.
1029
- * Threaded through request parsing now so later runtime behavior can resolve
1030
- * upstream-native interrupts.
1037
+ * A first-party generic item carries the original request in `metadata`.
1031
1038
  */
1032
1039
  resume?: Array<RunAgentResumeItem>
1033
1040
 
@@ -1106,6 +1113,8 @@ export interface RunStartedEvent extends AGUIRunStartedEvent {
1106
1113
  // Re-export the canonical usage types (defined in `@tanstack/ai-event-client`)
1107
1114
  // so `@tanstack/ai` consumers keep importing them from here unchanged.
1108
1115
  export type {
1116
+ BilledUsage,
1117
+ BillingUnit,
1109
1118
  CompletionTokensDetails,
1110
1119
  PromptTokensDetails,
1111
1120
  ProviderUsageDetails,
@@ -1124,7 +1133,10 @@ export type Interrupt = AGUIInterrupt
1124
1133
 
1125
1134
  export type RunFinishedOutcome = AGUIRunFinishedOutcome
1126
1135
 
1127
- export type RunAgentResumeItem = AGUIResumeEntry
1136
+ export type RunAgentResumeItem = AGUIResumeEntry & {
1137
+ /** AG-UI resume metadata. First-party generic requests ride here. */
1138
+ metadata?: Record<string, unknown>
1139
+ }
1128
1140
 
1129
1141
  /**
1130
1142
  * Emitted when a run completes successfully.
@@ -2078,9 +2090,10 @@ export interface RerankResult<TDocument = string> {
2078
2090
  rerankedDocuments: Array<TDocument>
2079
2091
  /**
2080
2092
  * Usage for the request. Rerank typically bills in provider-defined "search
2081
- * units" (`usage.unitsBilled`) rather than tokens. Some providers (e.g.
2082
- * OpenRouter) may also report `totalTokens` and `cost`; Cohere reports only
2083
- * search units and leaves the token counts at 0.
2093
+ * units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens.
2094
+ * Some providers (e.g. OpenRouter) may also report `totalTokens` and `cost`.
2095
+ * Cohere reports only search units and leaves the token counts at 0.
2096
+ * The deprecated `unitsBilled` field is still populated for compatibility.
2084
2097
  */
2085
2098
  usage: TokenUsage
2086
2099
  }
@@ -2463,8 +2476,8 @@ export interface VideoUrlResult {
2463
2476
  expiresAt?: Date
2464
2477
  /**
2465
2478
  * Usage information for the completed generation, when the adapter can report
2466
- * it. For usage-based providers (e.g. fal) this carries `unitsBilled` — the
2467
- * real billed quantity — so consumers can compute exact cost.
2479
+ * it. For usage-based providers (e.g. fal) this carries `billed` — the real
2480
+ * billed quantity paired with its unit — so consumers can compute exact cost.
2468
2481
  */
2469
2482
  usage?: TokenUsage
2470
2483
  /** Persisted artifact references for generated assets, when available */
@@ -2,7 +2,6 @@ import { AGUIError } from '@ag-ui/core'
2
2
  import type {
3
3
  Context as AGUIContext,
4
4
  Message as AGUIMessage,
5
- ResumeEntry as AGUIResumeEntry,
6
5
  Role as AGUIRole,
7
6
  } from '@ag-ui/core'
8
7
  import type {
@@ -22,6 +21,7 @@ const KNOWN_PART_TYPES = new Set([
22
21
  'tool-call',
23
22
  'tool-result',
24
23
  'thinking',
24
+ 'structured-output',
25
25
  ])
26
26
 
27
27
  function isValidParts(value: unknown): value is Array<{ type: string }> {
@@ -30,6 +30,10 @@ function isValidParts(value: unknown): value is Array<{ type: string }> {
30
30
  if (!p || typeof p !== 'object') return false
31
31
  const type = (p as { type?: unknown }).type
32
32
  if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false
33
+ if (type === 'structured-output') {
34
+ const raw = (p as { raw?: unknown }).raw
35
+ if (raw !== undefined && typeof raw !== 'string') return false
36
+ }
33
37
  }
34
38
  return true
35
39
  }
@@ -173,20 +177,29 @@ function validateContext(value: unknown, index: number): AGUIContext {
173
177
  }
174
178
  }
175
179
 
176
- function validateResumeEntry(value: unknown, index: number): AGUIResumeEntry {
180
+ function validateResumeEntry(
181
+ value: unknown,
182
+ index: number,
183
+ ): RunAgentResumeItem {
177
184
  const at = `resume[${index}]`
178
185
  if (!isRecord(value)) invalidBody(`${at} must be an object`)
179
186
  const status = value.status
180
187
  if (status !== 'resolved' && status !== 'cancelled') {
181
188
  invalidBody(`${at}.status must be "resolved" or "cancelled"`)
182
189
  }
183
- const entry: AGUIResumeEntry = {
190
+ const entry: RunAgentResumeItem = {
184
191
  interruptId: requireString(value.interruptId, `${at}.interruptId`),
185
192
  status,
186
193
  }
187
194
  // Omit the key entirely when absent, matching the optional-field shape the
188
195
  // schema produced.
189
196
  if (value.payload !== undefined) entry.payload = value.payload
197
+ if (value.metadata !== undefined) {
198
+ if (!isRecord(value.metadata)) {
199
+ invalidBody(`${at}.metadata must be an object`)
200
+ }
201
+ entry.metadata = value.metadata
202
+ }
190
203
  return entry
191
204
  }
192
205