@tanstack/ai 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -1106,6 +1108,8 @@ export interface RunStartedEvent extends AGUIRunStartedEvent {
1106
1108
  // Re-export the canonical usage types (defined in `@tanstack/ai-event-client`)
1107
1109
  // so `@tanstack/ai` consumers keep importing them from here unchanged.
1108
1110
  export type {
1111
+ BilledUsage,
1112
+ BillingUnit,
1109
1113
  CompletionTokensDetails,
1110
1114
  PromptTokensDetails,
1111
1115
  ProviderUsageDetails,
@@ -2078,9 +2082,10 @@ export interface RerankResult<TDocument = string> {
2078
2082
  rerankedDocuments: Array<TDocument>
2079
2083
  /**
2080
2084
  * 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.
2085
+ * units" (`usage.billed = { quantity, unit: 'units' }`) rather than tokens.
2086
+ * Some providers (e.g. OpenRouter) may also report `totalTokens` and `cost`.
2087
+ * Cohere reports only search units and leaves the token counts at 0.
2088
+ * The deprecated `unitsBilled` field is still populated for compatibility.
2084
2089
  */
2085
2090
  usage: TokenUsage
2086
2091
  }
@@ -2463,8 +2468,8 @@ export interface VideoUrlResult {
2463
2468
  expiresAt?: Date
2464
2469
  /**
2465
2470
  * 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.
2471
+ * it. For usage-based providers (e.g. fal) this carries `billed` — the real
2472
+ * billed quantity paired with its unit — so consumers can compute exact cost.
2468
2473
  */
2469
2474
  usage?: TokenUsage
2470
2475
  /** Persisted artifact references for generated assets, when available */