@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,249 @@
1
+ import { resolveDebugOption } from "./logger/resolve.js";
2
+ import { durableStreamSource, runErrorChunk } from "./stream-to-response.js";
3
+ import { chatParamsFromRequestBody } from "./utilities/chat-params.js";
4
+ //#region src/stream-to-websocket.ts
5
+ /**
6
+ * Encode one server→client frame. Durable frames carry the opaque offset in an
7
+ * `{ id, chunk }` envelope (identical to the NDJSON wire); non-durable frames
8
+ * are the bare chunk. Unambiguous because a bare chunk always has a top-level
9
+ * `type` and the envelope never does.
10
+ */
11
+ function encodeWsFrame(chunk, id) {
12
+ return JSON.stringify(id === void 0 ? chunk : {
13
+ id,
14
+ chunk
15
+ });
16
+ }
17
+ /**
18
+ * Decode one client→server frame. An `{ type: 'abort', runId }` object is a
19
+ * control frame; anything else is treated as a `RunAgentInput` and validated
20
+ * downstream by `chatParamsFromRequestBody`.
21
+ */
22
+ function decodeWsFrame(data) {
23
+ const parsed = JSON.parse(data);
24
+ if (typeof parsed === "object" && parsed !== null && parsed.type === "abort" && typeof parsed.runId === "string") return {
25
+ kind: "abort",
26
+ runId: parsed.runId
27
+ };
28
+ return {
29
+ kind: "run",
30
+ input: parsed
31
+ };
32
+ }
33
+ /**
34
+ * Build the synthetic per-turn request. A conversation-scoped socket multiplexes
35
+ * many runs; each turn's durability adapter must key on the frame's `runId`,
36
+ * which we carry in the URL query (`memoryStream`/`durableStream` already read
37
+ * `?runId` / `?offset` there). Headers are copied from the handshake so
38
+ * auth/cookies survive. A handshake carrying `?offset` is a resume and never
39
+ * reaches a fresh turn (`resumeWebSocketStream` serves it), so the offset is
40
+ * scrubbed here — otherwise a mis-routed resume handshake would make the turn's
41
+ * durability adapter silently take the replay branch instead of running onRun.
42
+ */
43
+ function buildTurnRequest(handshake, runId) {
44
+ const url = new URL(handshake.url);
45
+ url.searchParams.set("runId", runId);
46
+ url.searchParams.delete("offset");
47
+ return new Request(url, { headers: handshake.headers });
48
+ }
49
+ /**
50
+ * Run a full-duplex, conversation-scoped chat over an already-accepted server
51
+ * socket. Each inbound RunAgentInput frame starts one chat() turn (via onRun)
52
+ * whose chunks are pumped back as frames; the socket stays open across turns
53
+ * (pending client-tool resubmit, next user message) until the client closes it
54
+ * or the idle timeout fires. An abort control frame aborts only its turn.
55
+ */
56
+ function toWebSocketStream(socket, request, init) {
57
+ const logger = resolveDebugOption(init.debug);
58
+ const activeTurns = /* @__PURE__ */ new Map();
59
+ const earlyAborts = /* @__PURE__ */ new Set();
60
+ const heartbeatMs = init.heartbeatMs ?? 3e4;
61
+ const idleTimeoutMs = init.idleTimeoutMs ?? 3e5;
62
+ let lastActivity = Date.now();
63
+ let closed = false;
64
+ const heartbeat = setInterval(() => {
65
+ try {
66
+ socket.send(JSON.stringify({ type: "ping" }));
67
+ } catch {}
68
+ }, heartbeatMs);
69
+ const idle = setInterval(() => {
70
+ if (activeTurns.size === 0 && Date.now() - lastActivity > idleTimeoutMs) socket.close(1e3, "idle");
71
+ }, Math.min(idleTimeoutMs, 3e4));
72
+ function teardown() {
73
+ closed = true;
74
+ for (const controller of activeTurns.values()) controller.abort();
75
+ activeTurns.clear();
76
+ clearInterval(heartbeat);
77
+ clearInterval(idle);
78
+ }
79
+ socket.addEventListener("close", teardown);
80
+ socket.addEventListener("error", () => {
81
+ logger.errors("WebSocket errored; aborting its turns");
82
+ teardown();
83
+ try {
84
+ socket.close(1011, "socket error");
85
+ } catch {}
86
+ });
87
+ socket.addEventListener("message", (event) => {
88
+ if (typeof event.data !== "string") return;
89
+ lastActivity = Date.now();
90
+ let frame;
91
+ try {
92
+ frame = decodeWsFrame(event.data);
93
+ } catch (error) {
94
+ logger.errors("Failed to decode inbound WS frame; dropping it", { error });
95
+ return;
96
+ }
97
+ if (frame.kind === "abort") {
98
+ const turn = activeTurns.get(frame.runId);
99
+ if (turn) turn.abort();
100
+ else earlyAborts.add(frame.runId);
101
+ return;
102
+ }
103
+ handleInbound(frame.input);
104
+ });
105
+ /**
106
+ * Surface a turn failure to the client as a live `RUN_ERROR` frame. The
107
+ * socket is conversation-scoped and stays open, so without this frame the
108
+ * client would see neither a terminal chunk nor a close — a permanent hang.
109
+ * Mirrors the HTTP transports, which synthesize the live `RUN_ERROR` when
110
+ * the producer rethrows (see `durableStreamSource`'s terminal contract).
111
+ */
112
+ function sendRunError(error) {
113
+ try {
114
+ socket.send(encodeWsFrame(runErrorChunk(error), void 0));
115
+ } catch {}
116
+ }
117
+ async function handleInbound(input) {
118
+ let params;
119
+ try {
120
+ params = await chatParamsFromRequestBody(input);
121
+ } catch (error) {
122
+ logger.errors("Invalid inbound WS run frame; dropping it", { error });
123
+ sendRunError(error);
124
+ return;
125
+ }
126
+ if (closed) return;
127
+ const turnAbort = new AbortController();
128
+ activeTurns.get(params.runId)?.abort();
129
+ activeTurns.set(params.runId, turnAbort);
130
+ if (earlyAborts.delete(params.runId)) turnAbort.abort();
131
+ const ctx = {
132
+ messages: params.messages,
133
+ threadId: params.threadId,
134
+ runId: params.runId,
135
+ forwardedProps: params.forwardedProps,
136
+ request: buildTurnRequest(request, params.runId),
137
+ signal: turnAbort.signal
138
+ };
139
+ try {
140
+ if (init.durability) {
141
+ const adapter = init.durability(ctx);
142
+ const { source, getId } = durableStreamSource(init.onRun(ctx), adapter, {
143
+ abortController: turnAbort,
144
+ ...init.batch === void 0 ? {} : { batch: init.batch },
145
+ logger
146
+ });
147
+ for await (const chunk of source) socket.send(encodeWsFrame(chunk, getId(chunk)));
148
+ } else for await (const chunk of init.onRun(ctx)) socket.send(encodeWsFrame(chunk, void 0));
149
+ } catch (error) {
150
+ if (!turnAbort.signal.aborted) {
151
+ logger.errors("WS turn failed", { error });
152
+ sendRunError(error);
153
+ }
154
+ } finally {
155
+ if (activeTurns.get(params.runId) === turnAbort) activeTurns.delete(params.runId);
156
+ }
157
+ }
158
+ }
159
+ /**
160
+ * A resume is served entirely from the durability log, so there is no
161
+ * producer to iterate. This empty source satisfies `durableStreamSource`'s
162
+ * signature; on a resume it replays from the log and never touches this.
163
+ * Mirrors the private helper of the same name in `stream-to-response.ts`.
164
+ */
165
+ function emptyDurableSource() {
166
+ return (async function* () {})();
167
+ }
168
+ /**
169
+ * Read-only replay of a run's durability log over a socket (mirrors
170
+ * `resumeServerSentEventsResponse`). The adapter captures the offset from the
171
+ * request (`?offset`/`Last-Event-ID`); no model runs. Closes 1008 when there
172
+ * is nothing to resume.
173
+ */
174
+ function resumeWebSocketStream(socket, options) {
175
+ const logger = resolveDebugOption(options.debug);
176
+ if (options.adapter.resumeFrom() === null) {
177
+ socket.close(1008, "no resume offset");
178
+ return;
179
+ }
180
+ const abortController = new AbortController();
181
+ socket.addEventListener("close", () => abortController.abort());
182
+ socket.addEventListener("error", () => abortController.abort());
183
+ const { source, getId } = durableStreamSource(emptyDurableSource(), options.adapter, {
184
+ abortController,
185
+ ...options.batch === void 0 ? {} : { batch: options.batch },
186
+ logger
187
+ });
188
+ (async () => {
189
+ for await (const chunk of source) socket.send(encodeWsFrame(chunk, getId(chunk)));
190
+ try {
191
+ socket.close(1e3);
192
+ } catch {}
193
+ })().catch((error) => {
194
+ logger.errors("resume websocket replay failed", { error });
195
+ try {
196
+ socket.close(1011, "resume failed");
197
+ } catch {}
198
+ });
199
+ }
200
+ function upgradeOrThrow(helper) {
201
+ const Pair = globalThis.WebSocketPair;
202
+ if (!Pair) throw new Error(`${helper} requires a runtime with WebSocketPair (Cloudflare Workers/Durable Objects). On other runtimes upgrade the socket yourself and call ${helper.replace("Response", "Stream")}.`);
203
+ const pair = new Pair();
204
+ const server = pair[1];
205
+ server.accept?.();
206
+ return {
207
+ client: pair[0],
208
+ server
209
+ };
210
+ }
211
+ function upgradeResponse(client) {
212
+ return new Response(null, {
213
+ status: 101,
214
+ webSocket: client
215
+ });
216
+ }
217
+ /**
218
+ * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
219
+ * accepts the server socket, delegates to {@link toWebSocketStream}, and
220
+ * returns the 101 upgrade `Response` carrying the client socket. Throws when
221
+ * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
222
+ * yourself and call {@link toWebSocketStream} directly there.
223
+ */
224
+ function toWebSocketResponse(request, init) {
225
+ const { client, server } = upgradeOrThrow("toWebSocketResponse");
226
+ toWebSocketStream(server, request, init);
227
+ return upgradeResponse(client);
228
+ }
229
+ /**
230
+ * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,
231
+ * accepts the server socket, delegates to {@link resumeWebSocketStream}, and
232
+ * returns the 101 upgrade `Response` carrying the client socket. Throws when
233
+ * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket
234
+ * yourself and call {@link resumeWebSocketStream} directly there.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * resumeWebSocketResponse({ adapter: memoryStream(request) })
239
+ * ```
240
+ */
241
+ function resumeWebSocketResponse(options) {
242
+ const { client, server } = upgradeOrThrow("resumeWebSocketResponse");
243
+ resumeWebSocketStream(server, options);
244
+ return upgradeResponse(client);
245
+ }
246
+ //#endregion
247
+ export { buildTurnRequest, decodeWsFrame, encodeWsFrame, resumeWebSocketResponse, resumeWebSocketStream, toWebSocketResponse, toWebSocketStream };
248
+
249
+ //# sourceMappingURL=stream-to-websocket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-to-websocket.js","names":[],"sources":["../../src/stream-to-websocket.ts"],"sourcesContent":["import { chatParamsFromRequestBody } from './utilities/chat-params'\nimport { durableStreamSource, runErrorChunk } from './stream-to-response'\nimport { resolveDebugOption } from './logger/resolve'\nimport type { StreamDurability } from './stream-durability'\nimport type { DebugOption } from './logger/types'\nimport type { ModelMessage, StreamChunk, UIMessage } from './types'\n\n/**\n * The minimal WHATWG WebSocket surface the core needs. Cloudflare\n * `WebSocketPair` server sockets, Deno's upgraded sockets, and `ws` (Node)\n * sockets already satisfy it; Bun's `ServerWebSocket` (handler-object API)\n * gets a ~10-line adapter at the call site.\n */\nexport interface WebSocketLike {\n send: (data: string) => void\n close: (code?: number, reason?: string) => void\n addEventListener: {\n (type: 'message', handler: (ev: { data: unknown }) => void): void\n (type: 'close' | 'error', handler: () => void): void\n }\n}\n\n/** One inbound WS text frame, after JSON parse + shape discrimination. */\nexport type InboundFrame =\n | { kind: 'run'; input: unknown }\n | { kind: 'abort'; runId: string }\n\n/**\n * Encode one server→client frame. Durable frames carry the opaque offset in an\n * `{ id, chunk }` envelope (identical to the NDJSON wire); non-durable frames\n * are the bare chunk. Unambiguous because a bare chunk always has a top-level\n * `type` and the envelope never does.\n */\nexport function encodeWsFrame(\n chunk: StreamChunk,\n id: string | undefined,\n): string {\n return JSON.stringify(id === undefined ? chunk : { id, chunk })\n}\n\n/**\n * Decode one client→server frame. An `{ type: 'abort', runId }` object is a\n * control frame; anything else is treated as a `RunAgentInput` and validated\n * downstream by `chatParamsFromRequestBody`.\n */\nexport function decodeWsFrame(data: string): InboundFrame {\n const parsed: unknown = JSON.parse(data)\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n (parsed as { type?: unknown }).type === 'abort' &&\n typeof (parsed as { runId?: unknown }).runId === 'string'\n ) {\n return { kind: 'abort', runId: (parsed as { runId: string }).runId }\n }\n return { kind: 'run', input: parsed }\n}\n\n/** Per-turn context for one inbound `run` frame on a conversation-scoped socket. */\nexport interface WsRunContext {\n messages: Array<UIMessage | ModelMessage>\n threadId: string\n runId: string\n forwardedProps?: Record<string, unknown>\n /** Synthetic per-turn request carrying `?runId=` so durability keys correctly. */\n request: Request\n /** Aborts on socket close or an `abort` control frame for this run. */\n signal: AbortSignal\n}\n\n/**\n * Build the synthetic per-turn request. A conversation-scoped socket multiplexes\n * many runs; each turn's durability adapter must key on the frame's `runId`,\n * which we carry in the URL query (`memoryStream`/`durableStream` already read\n * `?runId` / `?offset` there). Headers are copied from the handshake so\n * auth/cookies survive. A handshake carrying `?offset` is a resume and never\n * reaches a fresh turn (`resumeWebSocketStream` serves it), so the offset is\n * scrubbed here — otherwise a mis-routed resume handshake would make the turn's\n * durability adapter silently take the replay branch instead of running onRun.\n */\nexport function buildTurnRequest(handshake: Request, runId: string): Request {\n const url = new URL(handshake.url)\n url.searchParams.set('runId', runId)\n url.searchParams.delete('offset')\n return new Request(url, { headers: handshake.headers })\n}\n\nexport interface WebSocketStreamInit<TOffset extends string = string> {\n /** Build a fresh chat() stream for each inbound RunAgentInput frame. */\n onRun: (ctx: WsRunContext) => AsyncIterable<StreamChunk>\n /** Per-TURN durability factory, keyed by the frame's runId via ctx.request. */\n durability?: (ctx: WsRunContext) => StreamDurability<TOffset>\n /** Chunks buffered per durability append (default 32). */\n batch?: number\n /** Heartbeat ping interval in ms (default 30_000). */\n heartbeatMs?: number\n /**\n * Close after this many ms without any inbound frame (default 300_000).\n * Never fires while a turn is still streaming, so a long single generation\n * (agentic loop, >5-min turn) is safe.\n */\n idleTimeoutMs?: number\n debug?: DebugOption\n}\n\n/**\n * Run a full-duplex, conversation-scoped chat over an already-accepted server\n * socket. Each inbound RunAgentInput frame starts one chat() turn (via onRun)\n * whose chunks are pumped back as frames; the socket stays open across turns\n * (pending client-tool resubmit, next user message) until the client closes it\n * or the idle timeout fires. An abort control frame aborts only its turn.\n */\nexport function toWebSocketStream<TOffset extends string = string>(\n socket: WebSocketLike,\n request: Request,\n init: WebSocketStreamInit<TOffset>,\n): void {\n const logger = resolveDebugOption(init.debug)\n const activeTurns = new Map<string, AbortController>()\n // Abort frames that raced ahead of their run's registration: `handleInbound`\n // awaits body validation before it registers into `activeTurns`, so an abort\n // arriving inside that window would otherwise be silently discarded.\n const earlyAborts = new Set<string>()\n const heartbeatMs = init.heartbeatMs ?? 30_000\n const idleTimeoutMs = init.idleTimeoutMs ?? 300_000\n let lastActivity = Date.now()\n let closed = false\n\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ type: 'ping' }))\n } catch {\n // Socket is CLOSING/CLOSED between ticks — teardown below clears this\n // interval; swallow so the timer callback doesn't throw uncaught in the\n // meantime.\n }\n }, heartbeatMs)\n const idle = setInterval(\n () => {\n // Never idle-reap while a turn is in flight: a long single onRun\n // iteration (agentic loop / >5-min generation) sends no INBOUND\n // frames, so idle would otherwise fire and kill live work.\n if (activeTurns.size === 0 && Date.now() - lastActivity > idleTimeoutMs) {\n socket.close(1000, 'idle')\n }\n },\n Math.min(idleTimeoutMs, 30_000),\n )\n\n function teardown(): void {\n closed = true\n for (const controller of activeTurns.values()) controller.abort()\n activeTurns.clear()\n clearInterval(heartbeat)\n clearInterval(idle)\n }\n\n socket.addEventListener('close', teardown)\n // Without this, an errored socket whose `close` never follows would leak\n // both intervals and never abort its turns — and on `ws` (an EventEmitter)\n // an `error` event with no listener is thrown as an uncaught exception.\n socket.addEventListener('error', () => {\n logger.errors('WebSocket errored; aborting its turns')\n teardown()\n try {\n socket.close(1011, 'socket error')\n } catch {\n // socket already closing/closed — nothing to do\n }\n })\n\n socket.addEventListener('message', (event: { data: unknown }) => {\n if (typeof event.data !== 'string') return\n lastActivity = Date.now()\n\n // Inbound frames are client-controlled: a malformed frame (bad JSON, or\n // valid JSON that isn't an AG-UI RunAgentInput/abort shape) must be\n // dropped, not crash the socket or leak an unhandled rejection.\n let frame: InboundFrame\n try {\n frame = decodeWsFrame(event.data)\n } catch (error) {\n logger.errors('Failed to decode inbound WS frame; dropping it', {\n error,\n })\n return\n }\n\n if (frame.kind === 'abort') {\n const turn = activeTurns.get(frame.runId)\n if (turn) turn.abort()\n else earlyAborts.add(frame.runId)\n return\n }\n\n void handleInbound(frame.input)\n })\n\n /**\n * Surface a turn failure to the client as a live `RUN_ERROR` frame. The\n * socket is conversation-scoped and stays open, so without this frame the\n * client would see neither a terminal chunk nor a close — a permanent hang.\n * Mirrors the HTTP transports, which synthesize the live `RUN_ERROR` when\n * the producer rethrows (see `durableStreamSource`'s terminal contract).\n */\n function sendRunError(error: unknown): void {\n try {\n socket.send(encodeWsFrame(runErrorChunk(error), undefined))\n } catch {\n // Socket is CLOSING/CLOSED — the client sees onclose instead.\n }\n }\n\n async function handleInbound(input: unknown): Promise<void> {\n let params: Awaited<ReturnType<typeof chatParamsFromRequestBody>>\n try {\n params = await chatParamsFromRequestBody(input)\n } catch (error) {\n logger.errors('Invalid inbound WS run frame; dropping it', { error })\n sendRunError(error)\n return\n }\n // The socket may have closed (or errored) during the await above — the\n // teardown that drains `activeTurns` already ran, so registering now\n // would start a turn nothing can ever abort.\n if (closed) return\n const turnAbort = new AbortController()\n // A second inbound frame with the same runId (client resubmit) must\n // abort the earlier turn. Otherwise the old controller is overwritten\n // and close/abort frames can no longer reach it.\n activeTurns.get(params.runId)?.abort()\n activeTurns.set(params.runId, turnAbort)\n if (earlyAborts.delete(params.runId)) turnAbort.abort()\n const ctx: WsRunContext = {\n messages: params.messages,\n threadId: params.threadId,\n runId: params.runId,\n forwardedProps: params.forwardedProps,\n request: buildTurnRequest(request, params.runId),\n signal: turnAbort.signal,\n }\n try {\n if (init.durability) {\n const adapter = init.durability(ctx)\n const { source, getId } = durableStreamSource(\n init.onRun(ctx),\n adapter,\n {\n abortController: turnAbort,\n ...(init.batch === undefined ? {} : { batch: init.batch }),\n logger,\n },\n )\n for await (const chunk of source) {\n socket.send(encodeWsFrame(chunk, getId(chunk)))\n }\n } else {\n for await (const chunk of init.onRun(ctx)) {\n socket.send(encodeWsFrame(chunk, undefined))\n }\n }\n } catch (error) {\n // An aborted turn (socket close, abort frame, same-runId resubmit) is\n // expected teardown, not a turn failure — nothing to report.\n if (!turnAbort.signal.aborted) {\n logger.errors('WS turn failed', { error })\n sendRunError(error)\n }\n } finally {\n // Only delete if this turn still owns the entry: a duplicate in-flight\n // runId (e.g. a client resubmitting before the first turn finished)\n // would otherwise let the OLDER turn's cleanup delete the NEWER turn's\n // still-active controller (TOCTOU).\n if (activeTurns.get(params.runId) === turnAbort) {\n activeTurns.delete(params.runId)\n }\n }\n }\n}\n\n/**\n * A resume is served entirely from the durability log, so there is no\n * producer to iterate. This empty source satisfies `durableStreamSource`'s\n * signature; on a resume it replays from the log and never touches this.\n * Mirrors the private helper of the same name in `stream-to-response.ts`.\n */\nfunction emptyDurableSource(): AsyncIterable<StreamChunk> {\n return (async function* () {})()\n}\n\n/**\n * Read-only replay of a run's durability log over a socket (mirrors\n * `resumeServerSentEventsResponse`). The adapter captures the offset from the\n * request (`?offset`/`Last-Event-ID`); no model runs. Closes 1008 when there\n * is nothing to resume.\n */\nexport function resumeWebSocketStream<TOffset extends string = string>(\n socket: WebSocketLike,\n options: {\n adapter: StreamDurability<TOffset>\n batch?: number\n debug?: DebugOption\n },\n): void {\n const logger = resolveDebugOption(options.debug)\n if (options.adapter.resumeFrom() === null) {\n socket.close(1008, 'no resume offset')\n return\n }\n const abortController = new AbortController()\n socket.addEventListener('close', () => abortController.abort())\n // An `error` with no listener is an uncaught exception on `ws`; abort the\n // replay so the pump below stops instead of writing to a dead socket.\n socket.addEventListener('error', () => abortController.abort())\n const { source, getId } = durableStreamSource(\n emptyDurableSource(),\n options.adapter,\n {\n abortController,\n ...(options.batch === undefined ? {} : { batch: options.batch }),\n logger,\n },\n )\n void (async () => {\n for await (const chunk of source) {\n socket.send(encodeWsFrame(chunk, getId(chunk)))\n }\n // Source exhausted = the durability log is complete/terminal; nothing more\n // will arrive on this read-only socket. Close so the client's reconnect\n // loop sees onclose and terminates (bounded) instead of awaiting a chunk\n // that never comes. Safe across durability models: a live decoupled\n // producer (e.g. durableStream) keeps `read` parked until the terminal,\n // so the source doesn't exhaust until the run truly ends; a completed\n // in-process log closes immediately.\n try {\n socket.close(1000)\n } catch {\n // socket already closing/closed — nothing to do\n }\n })().catch((error: unknown) => {\n logger.errors('resume websocket replay failed', { error })\n try {\n socket.close(1011, 'resume failed')\n } catch {\n // socket already closing/closed — nothing to do\n }\n })\n}\n\ninterface WebSocketPairCtor {\n new (): { 0: unknown; 1: WebSocketLike & { accept?: () => void } }\n}\n\nfunction upgradeOrThrow(helper: string): {\n client: unknown\n server: WebSocketLike\n} {\n const Pair = (globalThis as { WebSocketPair?: WebSocketPairCtor })\n .WebSocketPair\n if (!Pair) {\n throw new Error(\n `${helper} requires a runtime with WebSocketPair (Cloudflare Workers/Durable Objects). ` +\n `On other runtimes upgrade the socket yourself and call ${helper.replace('Response', 'Stream')}.`,\n )\n }\n const pair = new Pair()\n const server = pair[1]\n server.accept?.()\n return { client: pair[0], server }\n}\n\nfunction upgradeResponse(client: unknown): Response {\n return new Response(null, {\n status: 101,\n // Cloudflare-specific field; typed loosely to avoid a DOM lib dependency.\n webSocket: client,\n } as ResponseInit & { webSocket: unknown })\n}\n\n/**\n * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,\n * accepts the server socket, delegates to {@link toWebSocketStream}, and\n * returns the 101 upgrade `Response` carrying the client socket. Throws when\n * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket\n * yourself and call {@link toWebSocketStream} directly there.\n */\nexport function toWebSocketResponse<TOffset extends string = string>(\n request: Request,\n init: WebSocketStreamInit<TOffset>,\n): Response {\n const { client, server } = upgradeOrThrow('toWebSocketResponse')\n toWebSocketStream(server, request, init)\n return upgradeResponse(client)\n}\n\n/**\n * Cloudflare wrapper (Workers/Durable Objects): creates a `WebSocketPair`,\n * accepts the server socket, delegates to {@link resumeWebSocketStream}, and\n * returns the 101 upgrade `Response` carrying the client socket. Throws when\n * the runtime has no `WebSocketPair` (Node, Deno, Bun) — upgrade the socket\n * yourself and call {@link resumeWebSocketStream} directly there.\n *\n * @example\n * ```ts\n * resumeWebSocketResponse({ adapter: memoryStream(request) })\n * ```\n */\nexport function resumeWebSocketResponse<\n TOffset extends string = string,\n>(options: {\n adapter: StreamDurability<TOffset>\n batch?: number\n debug?: DebugOption\n}): Response {\n const { client, server } = upgradeOrThrow('resumeWebSocketResponse')\n resumeWebSocketStream(server, options)\n return upgradeResponse(client)\n}\n"],"mappings":"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"}
@@ -4,7 +4,7 @@ import { SystemPrompt } from './system-prompts.js';
4
4
  import { CapabilityContext } from './activities/chat/middleware/capabilities.js';
5
5
  import { InterruptSubmissionError } from './interrupts.js';
6
6
  import { ProviderTool } from './tools/provider-tool.js';
7
- import { CompletionTokensDetails, PromptTokensDetails, ProviderUsageDetails, TokenUsage, UsageCostBreakdown } from '@tanstack/ai-event-client';
7
+ import { BilledUsage, BillingUnit, CompletionTokensDetails, PromptTokensDetails, ProviderUsageDetails, TokenUsage, UsageCostBreakdown } from '@tanstack/ai-event-client';
8
8
  import { BaseEvent as AGUIBaseEvent, CustomEvent as AGUICustomEvent, Interrupt as AGUIInterrupt, MessagesSnapshotEvent as AGUIMessagesSnapshotEvent, ReasoningEncryptedValueEvent as AGUIReasoningEncryptedValueEvent, ReasoningEndEvent as AGUIReasoningEndEvent, ReasoningMessageContentEvent as AGUIReasoningMessageContentEvent, ReasoningMessageEndEvent as AGUIReasoningMessageEndEvent, ReasoningMessageStartEvent as AGUIReasoningMessageStartEvent, ReasoningStartEvent as AGUIReasoningStartEvent, ResumeEntry as AGUIResumeEntry, RunErrorEvent as AGUIRunErrorEvent, RunFinishedEvent as AGUIRunFinishedEvent, RunFinishedOutcome as AGUIRunFinishedOutcome, RunStartedEvent as AGUIRunStartedEvent, StateDeltaEvent as AGUIStateDeltaEvent, StateSnapshotEvent as AGUIStateSnapshotEvent, StepFinishedEvent as AGUIStepFinishedEvent, StepStartedEvent as AGUIStepStartedEvent, TextMessageContentEvent as AGUITextMessageContentEvent, TextMessageEndEvent as AGUITextMessageEndEvent, TextMessageStartEvent as AGUITextMessageStartEvent, ToolCallArgsEvent as AGUIToolCallArgsEvent, ToolCallEndEvent as AGUIToolCallEndEvent, ToolCallResultEvent as AGUIToolCallResultEvent, ToolCallStartEvent as AGUIToolCallStartEvent, EventType } from '@ag-ui/core';
9
9
  export type { ProviderTool } from './tools/provider-tool.js';
10
10
  /**
@@ -250,6 +250,12 @@ export interface ModelMessage<TContent extends string | null | Array<ContentPart
250
250
  content: string;
251
251
  signature?: string;
252
252
  }>;
253
+ /**
254
+ * Completed structured output represented by this assistant message.
255
+ * `content` remains the provider-facing JSON text; this field preserves the
256
+ * typed UI part across persistence and message conversion.
257
+ */
258
+ structuredOutput?: StructuredOutputPart;
253
259
  /**
254
260
  * Optional stable message id. Providers ignore it; it exists so a persisted
255
261
  * transcript can retain the streaming `messageId` and survive the
@@ -830,8 +836,7 @@ export interface TextOptions<TProviderOptionsSuperset extends Record<string, any
830
836
  state?: unknown;
831
837
  /**
832
838
  * AG-UI interrupt resume responses supplied by the client on a follow-up run.
833
- * Threaded through request parsing now so later runtime behavior can resolve
834
- * upstream-native interrupts.
839
+ * A first-party generic item carries the original request in `metadata`.
835
840
  */
836
841
  resume?: Array<RunAgentResumeItem>;
837
842
  /**
@@ -891,7 +896,7 @@ export interface RunStartedEvent extends AGUIRunStartedEvent {
891
896
  /** Model identifier for multi-model support */
892
897
  model?: string;
893
898
  }
894
- export type { CompletionTokensDetails, PromptTokensDetails, ProviderUsageDetails, TokenUsage, UsageCostBreakdown, };
899
+ export type { BilledUsage, BillingUnit, CompletionTokensDetails, PromptTokensDetails, ProviderUsageDetails, TokenUsage, UsageCostBreakdown, };
895
900
  /**
896
901
  * @deprecated Renamed to {@link TokenUsage}. Kept as an alias for backward
897
902
  * compatibility with `@tanstack/ai@0.23` and earlier; will be removed in a
@@ -900,7 +905,10 @@ export type { CompletionTokensDetails, PromptTokensDetails, ProviderUsageDetails
900
905
  export type UsageTotals = TokenUsage;
901
906
  export type Interrupt = AGUIInterrupt;
902
907
  export type RunFinishedOutcome = AGUIRunFinishedOutcome;
903
- export type RunAgentResumeItem = AGUIResumeEntry;
908
+ export type RunAgentResumeItem = AGUIResumeEntry & {
909
+ /** AG-UI resume metadata. First-party generic requests ride here. */
910
+ metadata?: Record<string, unknown>;
911
+ };
904
912
  /**
905
913
  * Emitted when a run completes successfully.
906
914
  *
@@ -1734,9 +1742,10 @@ export interface RerankResult<TDocument = string> {
1734
1742
  rerankedDocuments: Array<TDocument>;
1735
1743
  /**
1736
1744
  * Usage for the request. Rerank typically bills in provider-defined "search
1737
- * units" (`usage.unitsBilled`) rather than tokens. Some providers (e.g.
1738
- * OpenRouter) may also report `totalTokens` and `cost`; Cohere reports only
1739
- * search units and leaves the token counts at 0.
1745
+ * units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens.
1746
+ * Some providers (e.g. OpenRouter) may also report `totalTokens` and `cost`.
1747
+ * Cohere reports only search units and leaves the token counts at 0.
1748
+ * The deprecated `unitsBilled` field is still populated for compatibility.
1740
1749
  */
1741
1750
  usage: TokenUsage;
1742
1751
  }
@@ -2054,8 +2063,8 @@ export interface VideoUrlResult {
2054
2063
  expiresAt?: Date;
2055
2064
  /**
2056
2065
  * Usage information for the completed generation, when the adapter can report
2057
- * it. For usage-based providers (e.g. fal) this carries `unitsBilled` — the
2058
- * real billed quantity — so consumers can compute exact cost.
2066
+ * it. For usage-based providers (e.g. fal) this carries `billed` — the real
2067
+ * billed quantity paired with its unit — so consumers can compute exact cost.
2059
2068
  */
2060
2069
  usage?: TokenUsage;
2061
2070
  /** Persisted artifact references for generated assets, when available */
@@ -8,7 +8,8 @@ var KNOWN_PART_TYPES = /* @__PURE__ */ new Set([
8
8
  "document",
9
9
  "tool-call",
10
10
  "tool-result",
11
- "thinking"
11
+ "thinking",
12
+ "structured-output"
12
13
  ]);
13
14
  function isValidParts(value) {
14
15
  if (!Array.isArray(value)) return false;
@@ -16,6 +17,10 @@ function isValidParts(value) {
16
17
  if (!p || typeof p !== "object") return false;
17
18
  const type = p.type;
18
19
  if (typeof type !== "string" || !KNOWN_PART_TYPES.has(type)) return false;
20
+ if (type === "structured-output") {
21
+ const raw = p.raw;
22
+ if (raw !== void 0 && typeof raw !== "string") return false;
23
+ }
19
24
  }
20
25
  return true;
21
26
  }
@@ -121,6 +126,10 @@ function validateResumeEntry(value, index) {
121
126
  status
122
127
  };
123
128
  if (value.payload !== void 0) entry.payload = value.payload;
129
+ if (value.metadata !== void 0) {
130
+ if (!isRecord(value.metadata)) invalidBody(`${at}.metadata must be an object`);
131
+ entry.metadata = value.metadata;
132
+ }
124
133
  return entry;
125
134
  }
126
135
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"chat-params.js","names":[],"sources":["../../../src/utilities/chat-params.ts"],"sourcesContent":["import { AGUIError } from '@ag-ui/core'\nimport type {\n Context as AGUIContext,\n Message as AGUIMessage,\n ResumeEntry as AGUIResumeEntry,\n Role as AGUIRole,\n} from '@ag-ui/core'\nimport type {\n AnyTool,\n JSONSchema,\n ModelMessage,\n RunAgentResumeItem,\n UIMessage,\n} from '../types'\n\nconst KNOWN_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n 'tool-call',\n 'tool-result',\n 'thinking',\n])\n\nfunction isValidParts(value: unknown): value is Array<{ type: string }> {\n if (!Array.isArray(value)) return false\n for (const p of value) {\n if (!p || typeof p !== 'object') return false\n const type = (p as { type?: unknown }).type\n if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false\n }\n return true\n}\n\n/**\n * Keyed by `AGUIRole` so a role added upstream fails to compile here until it\n * is handled, rather than silently falling through as an unknown role.\n */\nconst AGUI_ROLES: Record<AGUIRole, true> = {\n developer: true,\n system: true,\n assistant: true,\n user: true,\n tool: true,\n activity: true,\n reasoning: true,\n}\n\nfunction isAGUIRole(value: unknown): value is AGUIRole {\n return typeof value === 'string' && value in AGUI_ROLES\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reject the request body, pointing at the migration guide. Mirrors the\n * message the previous `RunAgentInputSchema.safeParse` failure produced.\n */\nfunction invalidBody(reason: string): never {\n throw new AGUIError(\n `Request body is not a valid AG-UI RunAgentInput. ` +\n `If you're upgrading from a previous @tanstack/ai-client release, ` +\n `see docs/migration/ag-ui-compliance.md. ` +\n `Validation errors: ${reason}`,\n )\n}\n\nfunction requireString(value: unknown, at: string): string {\n if (typeof value !== 'string') invalidBody(`${at} must be a string`)\n return value\n}\n\nfunction requireArray(value: unknown, at: string): Array<unknown> {\n if (!Array.isArray(value)) invalidBody(`${at} must be an array`)\n return value\n}\n\n/**\n * Assert one AG-UI `Message`, discriminating on `role` exactly as the upstream\n * `MessageSchema` discriminated union does. The record view is retained on the\n * asserted type so callers can still inspect non-AG-UI extras like `parts`.\n */\nfunction assertAGUIMessage(\n value: Record<string, unknown>,\n at: string,\n): asserts value is Record<string, unknown> & AGUIMessage {\n requireString(value.id, `${at}.id`)\n\n const role = value.role\n if (!isAGUIRole(role)) {\n invalidBody(\n `${at}.role must be one of ${Object.keys(AGUI_ROLES).join(' | ')}`,\n )\n }\n\n switch (role) {\n case 'assistant':\n // Both optional: a tool-calling turn carries no text content.\n if (value.content !== undefined) {\n requireString(value.content, `${at}.content`)\n }\n if (value.toolCalls !== undefined) {\n requireArray(value.toolCalls, `${at}.toolCalls`)\n }\n break\n case 'user':\n if (typeof value.content !== 'string' && !Array.isArray(value.content)) {\n invalidBody(\n `${at}.content must be a string or an array of content parts`,\n )\n }\n break\n case 'tool':\n requireString(value.content, `${at}.content`)\n requireString(value.toolCallId, `${at}.toolCallId`)\n break\n case 'activity':\n requireString(value.activityType, `${at}.activityType`)\n if (!isRecord(value.content)) {\n invalidBody(`${at}.content must be an object`)\n }\n break\n case 'developer':\n case 'system':\n case 'reasoning':\n requireString(value.content, `${at}.content`)\n break\n }\n}\n\nfunction validateMessage(value: unknown, index: number): AGUIMessage {\n const at = `messages[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n assertAGUIMessage(value, at)\n\n // `parts` is TanStack's canonical extra, carried through so the UIMessage\n // path inside `chat()` can use it. Keep it only when it holds recognized\n // part types — the previous schema-based path dropped `parts` during parse\n // and re-attached it from the raw body behind this same check.\n if ('parts' in value && !isValidParts(value.parts)) {\n const withoutParts = { ...value }\n Reflect.deleteProperty(withoutParts, 'parts')\n return withoutParts\n }\n return value\n}\n\nfunction validateTool(\n value: unknown,\n index: number,\n): { name: string; description: string; parameters: JSONSchema } {\n const at = `tools[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n return {\n name: requireString(value.name, `${at}.name`),\n description: requireString(value.description, `${at}.description`),\n // Upstream `ToolSchema` types this as optional `any`; it reaches the\n // provider as a raw JSON Schema either way.\n parameters: value.parameters as JSONSchema,\n }\n}\n\nfunction validateContext(value: unknown, index: number): AGUIContext {\n const at = `context[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n return {\n description: requireString(value.description, `${at}.description`),\n value: requireString(value.value, `${at}.value`),\n }\n}\n\nfunction validateResumeEntry(value: unknown, index: number): AGUIResumeEntry {\n const at = `resume[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n const status = value.status\n if (status !== 'resolved' && status !== 'cancelled') {\n invalidBody(`${at}.status must be \"resolved\" or \"cancelled\"`)\n }\n const entry: AGUIResumeEntry = {\n interruptId: requireString(value.interruptId, `${at}.interruptId`),\n status,\n }\n // Omit the key entirely when absent, matching the optional-field shape the\n // schema produced.\n if (value.payload !== undefined) entry.payload = value.payload\n return entry\n}\n\n/**\n * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.\n *\n * Returns a spread-friendly object whose `messages` field is suitable for\n * passing directly to `chat({ messages })`. The existing\n * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and\n * reasoning/activity/developer-role normalization internally.\n *\n * Validated structurally against the AG-UI `RunAgentInput` contract without a\n * schema library, so this package pulls in no validation runtime of its own.\n *\n * @throws An error with a migration-pointing message when the body does\n * not conform to AG-UI `RunAgentInput`. Surface this as a\n * 400 Bad Request to the client.\n */\nexport async function chatParamsFromRequestBody(body: unknown): Promise<{\n messages: Array<UIMessage | ModelMessage>\n threadId: string\n runId: string\n parentRunId?: string\n tools: Array<{ name: string; description: string; parameters: JSONSchema }>\n forwardedProps: Record<string, unknown>\n state: unknown\n resume?: Array<RunAgentResumeItem>\n /**\n * @deprecated Use `aguiContext` instead. This alias will be removed in a\n * future release.\n */\n context: Array<AGUIContext>\n aguiContext: Array<AGUIContext>\n}> {\n if (!isRecord(body)) invalidBody('body must be a JSON object')\n\n const threadId = requireString(body.threadId, 'threadId')\n const runId = requireString(body.runId, 'runId')\n const parentRunId =\n body.parentRunId === undefined\n ? undefined\n : requireString(body.parentRunId, 'parentRunId')\n\n const messages = requireArray(body.messages, 'messages').map(validateMessage)\n const tools = requireArray(body.tools, 'tools').map(validateTool)\n const aguiContext = requireArray(body.context, 'context').map(validateContext)\n const resume =\n body.resume === undefined\n ? undefined\n : requireArray(body.resume, 'resume').map(validateResumeEntry)\n\n if (body.forwardedProps !== undefined && !isRecord(body.forwardedProps)) {\n invalidBody('forwardedProps must be an object')\n }\n\n return {\n // Unknown top-level fields (e.g. a legacy `cursor`) are dropped by\n // construction: only the fields below are copied onto the result.\n messages: messages as Array<UIMessage | ModelMessage>,\n threadId,\n runId,\n parentRunId,\n tools,\n forwardedProps: (body.forwardedProps ?? {}) as Record<string, unknown>,\n state: body.state,\n resume: resume as Array<RunAgentResumeItem> | undefined,\n context: aguiContext,\n aguiContext,\n }\n}\n\n/**\n * Read an HTTP `Request`, parse its JSON body, and validate it as an\n * AG-UI `RunAgentInput` — collapsing the standard `req.json()` +\n * `chatParamsFromRequestBody(...)` pair into a single call.\n *\n * On a malformed body or invalid AG-UI shape, this **throws a\n * `Response`** with status 400 and a migration-pointing message in the\n * body. Frameworks that natively handle thrown `Response` objects\n * (TanStack Start, SolidStart, Remix, React Router 7) will return the\n * 400 to the client automatically, so the handler reduces to:\n *\n * ```ts\n * export async function POST(req: Request) {\n * const params = await chatParamsFromRequest(req)\n * // ...use params\n * }\n * ```\n *\n * In frameworks that do not auto-handle thrown `Response` objects\n * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call\n * with try/catch and return the caught Response yourself, or use\n * `chatParamsFromRequestBody` directly with your own JSON-parsing.\n *\n * @throws {Response} 400 on malformed JSON or invalid AG-UI shape.\n */\nexport async function chatParamsFromRequest(\n req: Request,\n): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>> {\n let body: unknown\n try {\n body = await req.json()\n } catch (cause) {\n // Preserve the underlying error on the thrown Response for\n // server-side observability without leaking it to the client.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as { cause?: unknown }).cause = cause\n throw res\n }\n try {\n return await chatParamsFromRequestBody(body)\n } catch (cause) {\n // Generic public message — avoid echoing Zod paths (which can contain\n // user payload fragments) or internal validator strings to the client.\n // The original AGUIError is attached as `cause` so server logs can\n // surface it without exposing it to remote callers.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as { cause?: unknown }).cause = cause\n throw res\n }\n}\n\n/**\n * Client-declared tool stub (no execute). `name` is `string`, so arrays that\n * include these stubs intentionally widen `TypedStreamChunk` tool-name\n * discrimination — pass server tools alone when you need a closed name union.\n */\nexport type ClientToolDeclaration = {\n name: string\n description: string\n inputSchema: JSONSchema\n}\n\nexport type MergedAgentTools<TServerTools extends ReadonlyArray<AnyTool>> =\n ReadonlyArray<TServerTools[number] | ClientToolDeclaration>\n\n/**\n * Merge a server-side tool array with the AG-UI client-declared tools\n * received in the request body.\n *\n * Rules:\n * - Server tools win on name collision. The client's declaration is\n * ignored if the server already has a tool with that name. The client's\n * UI-side handler still fires when the streamed tool-result event comes\n * through (see `chat-client.ts` `onToolCall`), giving the\n * \"after server execution the client also handles\" semantic for free.\n * - Client-only tools (name not in `serverTools`) become no-execute\n * entries: the runtime's existing `ClientToolRequest` path handles\n * them — server emits a tool-call request, client executes via its\n * registered handler, client posts back the result.\n *\n * Typing:\n * - Empty `clientTools` preserves the server tuple (closed name union).\n * - Non-empty `clientTools` returns a widened array that honestly includes\n * client stubs, so `TypedStreamChunk` does not claim a closed server-only\n * name union.\n *\n * @param serverTools - The server's tool array (e.g. from\n * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.\n * @param clientTools - The `tools` array received from\n * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.\n * @returns A merged array suitable for `chat({ tools })`.\n */\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(serverTools: TServerTools, clientTools: readonly []): TServerTools\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(\n serverTools: TServerTools,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): MergedAgentTools<TServerTools>\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(\n serverTools: TServerTools,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): TServerTools | MergedAgentTools<TServerTools> {\n if (clientTools.length === 0) {\n return serverTools\n }\n const seen = new Set(serverTools.map((t) => t.name))\n const merged: Array<TServerTools[number] | ClientToolDeclaration> = [\n ...serverTools,\n ]\n for (const ct of clientTools) {\n if (seen.has(ct.name)) {\n // Server wins on name collision.\n continue\n }\n seen.add(ct.name)\n merged.push({\n name: ct.name,\n description: ct.description,\n inputSchema: ct.parameters,\n // No `execute` — runtime treats this as a client-side tool and\n // emits ClientToolRequest events.\n })\n }\n return merged\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"chat-params.js","names":[],"sources":["../../../src/utilities/chat-params.ts"],"sourcesContent":["import { AGUIError } from '@ag-ui/core'\nimport type {\n Context as AGUIContext,\n Message as AGUIMessage,\n Role as AGUIRole,\n} from '@ag-ui/core'\nimport type {\n AnyTool,\n JSONSchema,\n ModelMessage,\n RunAgentResumeItem,\n UIMessage,\n} from '../types'\n\nconst KNOWN_PART_TYPES = new Set([\n 'text',\n 'image',\n 'audio',\n 'video',\n 'document',\n 'tool-call',\n 'tool-result',\n 'thinking',\n 'structured-output',\n])\n\nfunction isValidParts(value: unknown): value is Array<{ type: string }> {\n if (!Array.isArray(value)) return false\n for (const p of value) {\n if (!p || typeof p !== 'object') return false\n const type = (p as { type?: unknown }).type\n if (typeof type !== 'string' || !KNOWN_PART_TYPES.has(type)) return false\n if (type === 'structured-output') {\n const raw = (p as { raw?: unknown }).raw\n if (raw !== undefined && typeof raw !== 'string') return false\n }\n }\n return true\n}\n\n/**\n * Keyed by `AGUIRole` so a role added upstream fails to compile here until it\n * is handled, rather than silently falling through as an unknown role.\n */\nconst AGUI_ROLES: Record<AGUIRole, true> = {\n developer: true,\n system: true,\n assistant: true,\n user: true,\n tool: true,\n activity: true,\n reasoning: true,\n}\n\nfunction isAGUIRole(value: unknown): value is AGUIRole {\n return typeof value === 'string' && value in AGUI_ROLES\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reject the request body, pointing at the migration guide. Mirrors the\n * message the previous `RunAgentInputSchema.safeParse` failure produced.\n */\nfunction invalidBody(reason: string): never {\n throw new AGUIError(\n `Request body is not a valid AG-UI RunAgentInput. ` +\n `If you're upgrading from a previous @tanstack/ai-client release, ` +\n `see docs/migration/ag-ui-compliance.md. ` +\n `Validation errors: ${reason}`,\n )\n}\n\nfunction requireString(value: unknown, at: string): string {\n if (typeof value !== 'string') invalidBody(`${at} must be a string`)\n return value\n}\n\nfunction requireArray(value: unknown, at: string): Array<unknown> {\n if (!Array.isArray(value)) invalidBody(`${at} must be an array`)\n return value\n}\n\n/**\n * Assert one AG-UI `Message`, discriminating on `role` exactly as the upstream\n * `MessageSchema` discriminated union does. The record view is retained on the\n * asserted type so callers can still inspect non-AG-UI extras like `parts`.\n */\nfunction assertAGUIMessage(\n value: Record<string, unknown>,\n at: string,\n): asserts value is Record<string, unknown> & AGUIMessage {\n requireString(value.id, `${at}.id`)\n\n const role = value.role\n if (!isAGUIRole(role)) {\n invalidBody(\n `${at}.role must be one of ${Object.keys(AGUI_ROLES).join(' | ')}`,\n )\n }\n\n switch (role) {\n case 'assistant':\n // Both optional: a tool-calling turn carries no text content.\n if (value.content !== undefined) {\n requireString(value.content, `${at}.content`)\n }\n if (value.toolCalls !== undefined) {\n requireArray(value.toolCalls, `${at}.toolCalls`)\n }\n break\n case 'user':\n if (typeof value.content !== 'string' && !Array.isArray(value.content)) {\n invalidBody(\n `${at}.content must be a string or an array of content parts`,\n )\n }\n break\n case 'tool':\n requireString(value.content, `${at}.content`)\n requireString(value.toolCallId, `${at}.toolCallId`)\n break\n case 'activity':\n requireString(value.activityType, `${at}.activityType`)\n if (!isRecord(value.content)) {\n invalidBody(`${at}.content must be an object`)\n }\n break\n case 'developer':\n case 'system':\n case 'reasoning':\n requireString(value.content, `${at}.content`)\n break\n }\n}\n\nfunction validateMessage(value: unknown, index: number): AGUIMessage {\n const at = `messages[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n assertAGUIMessage(value, at)\n\n // `parts` is TanStack's canonical extra, carried through so the UIMessage\n // path inside `chat()` can use it. Keep it only when it holds recognized\n // part types — the previous schema-based path dropped `parts` during parse\n // and re-attached it from the raw body behind this same check.\n if ('parts' in value && !isValidParts(value.parts)) {\n const withoutParts = { ...value }\n Reflect.deleteProperty(withoutParts, 'parts')\n return withoutParts\n }\n return value\n}\n\nfunction validateTool(\n value: unknown,\n index: number,\n): { name: string; description: string; parameters: JSONSchema } {\n const at = `tools[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n return {\n name: requireString(value.name, `${at}.name`),\n description: requireString(value.description, `${at}.description`),\n // Upstream `ToolSchema` types this as optional `any`; it reaches the\n // provider as a raw JSON Schema either way.\n parameters: value.parameters as JSONSchema,\n }\n}\n\nfunction validateContext(value: unknown, index: number): AGUIContext {\n const at = `context[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n return {\n description: requireString(value.description, `${at}.description`),\n value: requireString(value.value, `${at}.value`),\n }\n}\n\nfunction validateResumeEntry(\n value: unknown,\n index: number,\n): RunAgentResumeItem {\n const at = `resume[${index}]`\n if (!isRecord(value)) invalidBody(`${at} must be an object`)\n const status = value.status\n if (status !== 'resolved' && status !== 'cancelled') {\n invalidBody(`${at}.status must be \"resolved\" or \"cancelled\"`)\n }\n const entry: RunAgentResumeItem = {\n interruptId: requireString(value.interruptId, `${at}.interruptId`),\n status,\n }\n // Omit the key entirely when absent, matching the optional-field shape the\n // schema produced.\n if (value.payload !== undefined) entry.payload = value.payload\n if (value.metadata !== undefined) {\n if (!isRecord(value.metadata)) {\n invalidBody(`${at}.metadata must be an object`)\n }\n entry.metadata = value.metadata\n }\n return entry\n}\n\n/**\n * Parse and validate an HTTP request body as an AG-UI `RunAgentInput`.\n *\n * Returns a spread-friendly object whose `messages` field is suitable for\n * passing directly to `chat({ messages })`. The existing\n * `convertMessagesToModelMessages` handles AG-UI fan-out dedup and\n * reasoning/activity/developer-role normalization internally.\n *\n * Validated structurally against the AG-UI `RunAgentInput` contract without a\n * schema library, so this package pulls in no validation runtime of its own.\n *\n * @throws An error with a migration-pointing message when the body does\n * not conform to AG-UI `RunAgentInput`. Surface this as a\n * 400 Bad Request to the client.\n */\nexport async function chatParamsFromRequestBody(body: unknown): Promise<{\n messages: Array<UIMessage | ModelMessage>\n threadId: string\n runId: string\n parentRunId?: string\n tools: Array<{ name: string; description: string; parameters: JSONSchema }>\n forwardedProps: Record<string, unknown>\n state: unknown\n resume?: Array<RunAgentResumeItem>\n /**\n * @deprecated Use `aguiContext` instead. This alias will be removed in a\n * future release.\n */\n context: Array<AGUIContext>\n aguiContext: Array<AGUIContext>\n}> {\n if (!isRecord(body)) invalidBody('body must be a JSON object')\n\n const threadId = requireString(body.threadId, 'threadId')\n const runId = requireString(body.runId, 'runId')\n const parentRunId =\n body.parentRunId === undefined\n ? undefined\n : requireString(body.parentRunId, 'parentRunId')\n\n const messages = requireArray(body.messages, 'messages').map(validateMessage)\n const tools = requireArray(body.tools, 'tools').map(validateTool)\n const aguiContext = requireArray(body.context, 'context').map(validateContext)\n const resume =\n body.resume === undefined\n ? undefined\n : requireArray(body.resume, 'resume').map(validateResumeEntry)\n\n if (body.forwardedProps !== undefined && !isRecord(body.forwardedProps)) {\n invalidBody('forwardedProps must be an object')\n }\n\n return {\n // Unknown top-level fields (e.g. a legacy `cursor`) are dropped by\n // construction: only the fields below are copied onto the result.\n messages: messages as Array<UIMessage | ModelMessage>,\n threadId,\n runId,\n parentRunId,\n tools,\n forwardedProps: (body.forwardedProps ?? {}) as Record<string, unknown>,\n state: body.state,\n resume: resume as Array<RunAgentResumeItem> | undefined,\n context: aguiContext,\n aguiContext,\n }\n}\n\n/**\n * Read an HTTP `Request`, parse its JSON body, and validate it as an\n * AG-UI `RunAgentInput` — collapsing the standard `req.json()` +\n * `chatParamsFromRequestBody(...)` pair into a single call.\n *\n * On a malformed body or invalid AG-UI shape, this **throws a\n * `Response`** with status 400 and a migration-pointing message in the\n * body. Frameworks that natively handle thrown `Response` objects\n * (TanStack Start, SolidStart, Remix, React Router 7) will return the\n * 400 to the client automatically, so the handler reduces to:\n *\n * ```ts\n * export async function POST(req: Request) {\n * const params = await chatParamsFromRequest(req)\n * // ...use params\n * }\n * ```\n *\n * In frameworks that do not auto-handle thrown `Response` objects\n * (Next.js Route Handlers, SvelteKit, Hono, raw Node), wrap the call\n * with try/catch and return the caught Response yourself, or use\n * `chatParamsFromRequestBody` directly with your own JSON-parsing.\n *\n * @throws {Response} 400 on malformed JSON or invalid AG-UI shape.\n */\nexport async function chatParamsFromRequest(\n req: Request,\n): Promise<Awaited<ReturnType<typeof chatParamsFromRequestBody>>> {\n let body: unknown\n try {\n body = await req.json()\n } catch (cause) {\n // Preserve the underlying error on the thrown Response for\n // server-side observability without leaking it to the client.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as { cause?: unknown }).cause = cause\n throw res\n }\n try {\n return await chatParamsFromRequestBody(body)\n } catch (cause) {\n // Generic public message — avoid echoing Zod paths (which can contain\n // user payload fragments) or internal validator strings to the client.\n // The original AGUIError is attached as `cause` so server logs can\n // surface it without exposing it to remote callers.\n const res = new Response(\n 'Invalid AG-UI request body. See docs/migration/ag-ui-compliance.md.',\n { status: 400 },\n )\n ;(res as { cause?: unknown }).cause = cause\n throw res\n }\n}\n\n/**\n * Client-declared tool stub (no execute). `name` is `string`, so arrays that\n * include these stubs intentionally widen `TypedStreamChunk` tool-name\n * discrimination — pass server tools alone when you need a closed name union.\n */\nexport type ClientToolDeclaration = {\n name: string\n description: string\n inputSchema: JSONSchema\n}\n\nexport type MergedAgentTools<TServerTools extends ReadonlyArray<AnyTool>> =\n ReadonlyArray<TServerTools[number] | ClientToolDeclaration>\n\n/**\n * Merge a server-side tool array with the AG-UI client-declared tools\n * received in the request body.\n *\n * Rules:\n * - Server tools win on name collision. The client's declaration is\n * ignored if the server already has a tool with that name. The client's\n * UI-side handler still fires when the streamed tool-result event comes\n * through (see `chat-client.ts` `onToolCall`), giving the\n * \"after server execution the client also handles\" semantic for free.\n * - Client-only tools (name not in `serverTools`) become no-execute\n * entries: the runtime's existing `ClientToolRequest` path handles\n * them — server emits a tool-call request, client executes via its\n * registered handler, client posts back the result.\n *\n * Typing:\n * - Empty `clientTools` preserves the server tuple (closed name union).\n * - Non-empty `clientTools` returns a widened array that honestly includes\n * client stubs, so `TypedStreamChunk` does not claim a closed server-only\n * name union.\n *\n * @param serverTools - The server's tool array (e.g. from\n * `[myToolDef.server(...)]`). Pass directly to `chat({ tools })`.\n * @param clientTools - The `tools` array received from\n * `chatParamsFromRequest(...)` / `chatParamsFromRequestBody(...)`.\n * @returns A merged array suitable for `chat({ tools })`.\n */\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(serverTools: TServerTools, clientTools: readonly []): TServerTools\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(\n serverTools: TServerTools,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): MergedAgentTools<TServerTools>\nexport function mergeAgentTools<\n const TServerTools extends ReadonlyArray<AnyTool>,\n>(\n serverTools: TServerTools,\n clientTools: ReadonlyArray<{\n name: string\n description: string\n parameters: JSONSchema\n }>,\n): TServerTools | MergedAgentTools<TServerTools> {\n if (clientTools.length === 0) {\n return serverTools\n }\n const seen = new Set(serverTools.map((t) => t.name))\n const merged: Array<TServerTools[number] | ClientToolDeclaration> = [\n ...serverTools,\n ]\n for (const ct of clientTools) {\n if (seen.has(ct.name)) {\n // Server wins on name collision.\n continue\n }\n seen.add(ct.name)\n merged.push({\n name: ct.name,\n description: ct.description,\n inputSchema: ct.parameters,\n // No `execute` — runtime treats this as a client-side tool and\n // emits ClientToolRequest events.\n })\n }\n return merged\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai",
3
- "version": "0.45.1",
3
+ "version": "0.47.1",
4
4
  "description": "Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "@ag-ui/core": "0.1.1-canary.beta.0",
81
81
  "@standard-schema/spec": "^1.1.0",
82
82
  "partial-json": "^0.1.7",
83
- "@tanstack/ai-event-client": "^0.8.0",
83
+ "@tanstack/ai-event-client": "^0.9.0",
84
84
  "@tanstack/ai-utils": "^0.4.0"
85
85
  },
86
86
  "peerDependencies": {
@@ -561,11 +561,14 @@ durations 4/8/12s, single `input_reference` image prompt part), `grokVideo(...)`
561
561
  outputs inherit the source clip's properties, so `size`/`aspect_ratio`/`resolution`
562
562
  throw in both modes and `duration` throws in edit mode — pass none of them there;
563
563
  generation uses the aspect-ratio size template like `'16:9_720p'` (1080p is 1.5-only),
564
- integer durations 1-15s, reports `usage.unitsBilled` seconds and exact `usage.cost`), `byteplusVideo(...)` (Seedance —
564
+ integer durations 1-15s, reports `usage.billed` seconds ({ quantity, unit: 'seconds' }) and exact `usage.cost`), `byteplusVideo(...)` (Seedance —
565
565
  aspect-ratio size template like `'16:9_720p'`, durations 4-15s on the 2.0 family,
566
566
  4-12s on 1.5-pro, 2-12s on the 1.0-pro models; reads `ARK_API_KEY`),
567
567
  `openRouterVideo(...)` (OpenRouter's dedicated `POST /api/v1/videos` gateway),
568
- and `falVideo(...)` (hosted models, see cost tracking below).
568
+ and `falVideo(...)` (hosted models; `duration` typed from `@fal-ai/client`'s
569
+ `EndpointTypeMap` — `'5' | '10'` on Kling 2.6, `'3'`…`'15'` on Kling 3,
570
+ `'4s' | '6s' | '8s'` on Veo 3.1, `'5s' | '9s'` on Luma; `availableDurations()` /
571
+ `snapDuration()` on the curated set; see cost tracking below).
569
572
 
570
573
  > **Seedance option applicability is per model and enforced server-side** —
571
574
  > Ark returns a 400 for an inapplicable field rather than ignoring it.
@@ -620,10 +623,11 @@ const { generate, result, jobId, videoStatus, isLoading } = useGenerateVideo({
620
623
 
621
624
  fal bills media generation by usage-based units, not tokens. Every fal media
622
625
  adapter (`falImage`, `falAudio`, `falSpeech`, `falTranscription`, `falVideo`)
623
- surfaces the real billed quantity on the result as `usage.unitsBilled`, read
624
- from fal's `x-fal-billable-units` response header — no `fetch` interceptor
625
- needed. It rides on the canonical `TokenUsage` shape (token fields are `0` for
626
- media), mirroring how duration-billed transcription surfaces `durationSeconds`.
626
+ surfaces the real billed quantity on the result as `usage.billed`
627
+ ({ quantity, unit: 'units' }), read from fal's `x-fal-billable-units` response
628
+ header — no `fetch` interceptor needed. It rides on the canonical `TokenUsage`
629
+ shape (token fields are `0` for media), mirroring how duration-billed
630
+ transcription reports { quantity, unit: 'seconds' }.
627
631
 
628
632
  ```typescript
629
633
  import { generateImage } from '@tanstack/ai'
@@ -634,10 +638,10 @@ const result = await generateImage({
634
638
  prompt: 'a serene mountain lake',
635
639
  })
636
640
 
637
- // usage.unitsBilled is the priced quantity. Multiply by the endpoint unit
641
+ // usage.billed.quantity is the priced quantity. Multiply by the endpoint unit
638
642
  // price (GET https://api.fal.ai/v1/models/pricing?endpoint_id=…) for exact cost.
639
- if (result.usage?.unitsBilled != null) {
640
- const cost = result.usage.unitsBilled * unitPrice
643
+ if (result.usage?.billed) {
644
+ const cost = result.usage.billed.quantity * unitPrice
641
645
  }
642
646
  ```
643
647