@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.
- package/README.md +10 -1
- package/dist/esm/activities/chat/index.js +55 -5
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/tools/unique-tool-names.d.ts +20 -0
- package/dist/esm/activities/chat/tools/unique-tool-names.js +57 -0
- package/dist/esm/activities/chat/tools/unique-tool-names.js.map +1 -0
- package/dist/esm/adapter-internals.d.ts +1 -0
- package/dist/esm/adapter-internals.js +2 -1
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +4 -2
- package/dist/esm/middlewares/usage-attributes.d.ts +2 -2
- package/dist/esm/middlewares/usage-attributes.js +9 -2
- package/dist/esm/middlewares/usage-attributes.js.map +1 -1
- package/dist/esm/stream-to-response.d.ts +26 -0
- package/dist/esm/stream-to-response.js +1 -1
- package/dist/esm/stream-to-response.js.map +1 -1
- package/dist/esm/stream-to-websocket.d.ts +123 -0
- package/dist/esm/stream-to-websocket.js +249 -0
- package/dist/esm/stream-to-websocket.js.map +1 -0
- package/dist/esm/types.d.ts +8 -7
- package/package.json +2 -2
- package/skills/ai-core/media-generation/SKILL.md +9 -8
- package/src/activities/chat/index.ts +96 -13
- package/src/activities/chat/tools/unique-tool-names.ts +73 -0
- package/src/adapter-internals.ts +4 -0
- package/src/index.ts +17 -0
- package/src/middlewares/usage-attributes.ts +12 -2
- package/src/stream-to-response.ts +2 -2
- package/src/stream-to-websocket.ts +418 -0
- package/src/types.ts +10 -5
|
@@ -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.
|
|
2082
|
-
* OpenRouter) may also report `totalTokens` and `cost
|
|
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 `
|
|
2467
|
-
*
|
|
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 */
|