ai 7.0.101 → 7.0.102

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 (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.d.ts +82 -20
  3. package/dist/index.js +2010 -330
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/index.js +1 -1
  6. package/docs/03-agents/07-workflow-agent.mdx +1 -1
  7. package/docs/03-ai-sdk-core/36-realtime.mdx +41 -18
  8. package/docs/04-ai-sdk-ui/21-transport.mdx +1 -1
  9. package/docs/07-reference/02-ai-sdk-ui/05-use-realtime.mdx +364 -48
  10. package/docs/07-reference/04-ai-sdk-workflow/02-workflow-chat-transport.mdx +4 -4
  11. package/docs/07-reference/05-ai-sdk-errors/index.mdx +131 -36
  12. package/package.json +12 -12
  13. package/src/generate-text/execute-tools-from-stream.ts +7 -0
  14. package/src/generate-text/stream-text.ts +3 -1
  15. package/src/realtime/__fixtures__/fake-live-websocket.ts +71 -0
  16. package/src/realtime/__fixtures__/fake-realtime.ts +36 -0
  17. package/src/realtime/__fixtures__/fake-webrtc.ts +133 -0
  18. package/src/realtime/browser-realtime-audio.ts +107 -10
  19. package/src/realtime/browser-realtime-live-websocket.ts +247 -0
  20. package/src/realtime/browser-realtime-transport.ts +235 -69
  21. package/src/realtime/browser-realtime-webrtc.ts +582 -0
  22. package/src/realtime/encode-realtime-frame.ts +33 -0
  23. package/src/realtime/index.ts +1 -0
  24. package/src/realtime/realtime-attempt.ts +45 -0
  25. package/src/realtime/realtime-command-tracker.ts +81 -0
  26. package/src/realtime/realtime-event-channel.ts +170 -0
  27. package/src/realtime/realtime-event-reducer.ts +3 -0
  28. package/src/realtime/realtime-session-state.ts +65 -0
  29. package/src/realtime/realtime-session.ts +768 -218
  30. package/src/realtime/realtime-types.ts +1 -1
  31. package/src/realtime/validate-realtime-setup.ts +37 -0
@@ -1,4 +1,8 @@
1
- import { safeParseJSON } from '@ai-sdk/provider-utils';
1
+ import { RealtimeEventChannel } from './realtime-event-channel';
2
+ import {
3
+ assertRealtimeFrameBudget,
4
+ encodeRealtimeFrame,
5
+ } from './encode-realtime-frame';
2
6
  import type {
3
7
  RealtimeClientEvent,
4
8
  RealtimeModel,
@@ -9,135 +13,297 @@ export type BrowserRealtimeTransportOptions = {
9
13
  model: RealtimeModel;
10
14
  onServerEvent: (event: RealtimeServerEvent) => void | Promise<void>;
11
15
  onError: (error: Error) => void;
12
- onClose: () => void;
16
+ onFatalError?: (error: Error, drain?: Promise<void>) => void;
17
+ onClosing?: () => void;
18
+ onClose: (error?: Error) => void;
13
19
  };
14
20
 
21
+ function getCloseError(event: CloseEvent): Error | undefined {
22
+ if (event.code === 1000 && event.wasClean) return undefined;
23
+ return new Error(
24
+ `Realtime WebSocket closed unexpectedly (code ${event.code}${
25
+ event.reason === '' ? '' : `: ${event.reason}`
26
+ })`,
27
+ );
28
+ }
29
+
15
30
  export class BrowserRealtimeTransport {
16
31
  private readonly model: RealtimeModel;
32
+ private readonly continuous: boolean;
17
33
  private readonly onServerEvent: BrowserRealtimeTransportOptions['onServerEvent'];
18
34
  private readonly onError: BrowserRealtimeTransportOptions['onError'];
19
35
  private readonly onClose: BrowserRealtimeTransportOptions['onClose'];
20
36
  private ws: WebSocket | null = null;
21
- private sendQueue: Promise<void> = Promise.resolve();
37
+ private codec: RealtimeEventChannel | undefined;
38
+ private epoch = 0;
39
+ private drainTimer?: ReturnType<typeof setTimeout>;
40
+ private failing = false;
41
+ private closing = false;
22
42
 
23
- constructor(options: BrowserRealtimeTransportOptions) {
43
+ constructor(private readonly options: BrowserRealtimeTransportOptions) {
24
44
  this.model = options.model;
45
+ this.continuous = options.model.capabilities?.conversation === 'continuous';
25
46
  this.onServerEvent = options.onServerEvent;
26
47
  this.onError = options.onError;
27
48
  this.onClose = options.onClose;
28
49
  }
29
50
 
51
+ get isOpen(): boolean {
52
+ return (
53
+ !this.closing && !this.failing && this.ws?.readyState === WebSocket.OPEN
54
+ );
55
+ }
56
+
30
57
  connect({
58
+ mode,
31
59
  token,
32
60
  url,
33
61
  onOpen,
34
- }: {
35
- token: string;
62
+ protocols,
63
+ }: (
64
+ | { mode: 'client-secret'; token: string; protocols?: never }
65
+ | { mode: 'relay'; token?: never; protocols?: string[] }
66
+ ) & {
36
67
  url: string;
37
- onOpen: () => void;
68
+ onOpen: () => void | Promise<void>;
38
69
  }): void {
39
- this.ws?.close();
40
- this.ws = null;
70
+ this.disconnect();
41
71
 
42
- const wsConfig = this.model.getWebSocketConfig({ token, url });
43
- const ws = new WebSocket(wsConfig.url, wsConfig.protocols);
72
+ const epoch = this.epoch;
73
+ if (
74
+ mode !== 'relay' &&
75
+ (mode !== 'client-secret' ||
76
+ typeof token !== 'string' ||
77
+ token.trim() === '')
78
+ )
79
+ throw new Error(
80
+ 'Realtime client-secret connection requires a nonempty token',
81
+ );
82
+ const wsConfig =
83
+ mode === 'relay'
84
+ ? { url, protocols }
85
+ : this.model.getWebSocketConfig?.({ token, url });
86
+ if (this.epoch !== epoch) return;
87
+ if (wsConfig == null)
88
+ throw new Error('Model does not support client-secret WebSockets');
89
+ const finalUrl = wsConfig.url;
90
+ const finalProtocols = wsConfig.protocols;
91
+ if (this.epoch !== epoch) return;
92
+ try {
93
+ if (typeof finalUrl !== 'string' || !/^wss?:\/\//i.test(finalUrl))
94
+ throw new Error('Invalid realtime WebSocket URL');
95
+ const parsed = new URL(finalUrl);
96
+ if (
97
+ !['ws:', 'wss:'].includes(parsed.protocol) ||
98
+ parsed.hostname === '' ||
99
+ finalUrl.includes('#')
100
+ )
101
+ throw new Error('Invalid realtime WebSocket URL');
102
+ } catch {
103
+ throw new Error(
104
+ 'Invalid realtime WebSocket URL; expected an absolute ws:// or wss:// URL with a host and no fragment',
105
+ );
106
+ }
107
+ if (this.epoch !== epoch) return;
108
+ const ws = new WebSocket(finalUrl, finalProtocols);
44
109
 
45
110
  // Track the socket immediately (not just in `onopen`) so that calling
46
111
  // `disconnect()` while it is still connecting actually closes it. Otherwise
47
112
  // `close()` would be a no-op and the socket could open afterwards and fire
48
113
  // the `onOpen` (session-update) callback against a disconnected session.
49
114
  this.ws = ws;
115
+ let starting = false;
116
+ let connectionError: Error | undefined;
117
+ const codec = new RealtimeEventChannel({
118
+ model: this.model,
119
+ send: data => {
120
+ if (this.epoch !== epoch || this.ws !== ws || !this.isOpen)
121
+ throw new Error('Realtime WebSocket is not open');
122
+ this.sendRaw(data);
123
+ },
124
+ onEvent: this.onServerEvent,
125
+ onError: error => {
126
+ if (!starting) this.onError(error);
127
+ },
128
+ onFatalError: error => this.fail(error),
129
+ });
130
+ if (this.epoch !== epoch) {
131
+ codec.dispose();
132
+ return;
133
+ }
134
+ this.codec = codec;
50
135
 
51
136
  ws.onopen = () => {
52
137
  // Ignore a late open for a socket that has since been replaced/closed.
53
138
  if (this.ws !== ws) return;
54
- onOpen();
139
+ starting = true;
140
+ try {
141
+ void Promise.resolve(onOpen())
142
+ .catch(error => {
143
+ if (this.ws === ws)
144
+ this.fail(
145
+ error instanceof Error ? error : new Error(String(error)),
146
+ );
147
+ })
148
+ .finally(() => {
149
+ starting = false;
150
+ });
151
+ } catch (error) {
152
+ if (this.epoch === epoch)
153
+ this.fail(error instanceof Error ? error : new Error(String(error)));
154
+ }
55
155
  };
56
156
 
57
157
  ws.onmessage = messageEvent => {
58
- void this.handleMessage(messageEvent);
158
+ if (this.ws === ws) this.codec?.receive(messageEvent.data);
59
159
  };
60
160
 
61
161
  ws.onerror = () => {
62
- this.onError(new Error('WebSocket connection error'));
162
+ if (this.ws === ws) {
163
+ connectionError = new Error('WebSocket connection error');
164
+ this.failing = true;
165
+ }
63
166
  };
64
167
 
65
- ws.onclose = () => {
168
+ ws.onclose = event => {
66
169
  if (this.ws === ws) {
67
170
  this.ws = null;
68
- this.onClose();
171
+ const closeError = getCloseError(event) ?? connectionError;
172
+ codec.stopWriting();
173
+ this.notifyClosing();
174
+ if (this.epoch !== epoch) return;
175
+ const complete = () => {
176
+ if (this.epoch !== epoch) return;
177
+ clearTimeout(this.drainTimer);
178
+ this.epoch++;
179
+ codec.dispose();
180
+ try {
181
+ this.onClose(closeError);
182
+ } catch (error) {
183
+ this.reportCallbackError(error);
184
+ }
185
+ };
186
+ this.drainTimer = setTimeout(complete, 1_000);
187
+ void this.awaitDrain(codec.finish(), complete);
69
188
  }
70
189
  };
71
190
  }
72
191
 
73
192
  disconnect(): void {
74
- this.ws?.close();
193
+ this.epoch++;
194
+ clearTimeout(this.drainTimer);
195
+ const ws = this.ws;
75
196
  this.ws = null;
197
+ this.codec?.dispose();
198
+ this.codec = undefined;
199
+ this.failing = false;
200
+ this.closing = false;
201
+ ws?.close();
76
202
  }
77
203
 
78
- sendEvent(event: RealtimeClientEvent): void {
79
- this.sendQueue = this.sendQueue
80
- .then(async () => {
81
- const serialized = await this.model.serializeClientEvent(event);
82
- if (serialized != null) {
83
- this.sendRaw(serialized);
84
- }
85
- })
86
- .catch(error => {
87
- this.onError(
88
- error instanceof Error
89
- ? error
90
- : new Error(`Failed to send realtime event: ${String(error)}`),
91
- );
92
- });
93
- }
94
-
95
- sendRaw(data: unknown): void {
96
- if (this.ws?.readyState === WebSocket.OPEN) {
97
- if (
98
- typeof data === 'string' ||
99
- data instanceof ArrayBuffer ||
100
- ArrayBuffer.isView(data) ||
101
- data instanceof Blob
102
- ) {
103
- this.ws.send(data);
104
- } else {
105
- this.ws.send(JSON.stringify(data));
106
- }
204
+ sendEvent(
205
+ event: RealtimeClientEvent,
206
+ shouldSend?: () => boolean,
207
+ automaticAudio = false,
208
+ ): Promise<void> {
209
+ if (this.failing || this.closing)
210
+ throw new Error('Realtime connection is closed');
211
+ if (this.codec == null) return Promise.resolve();
212
+ const epoch = this.epoch;
213
+ const failed = (error: unknown) => {
214
+ if (automaticAudio && this.epoch === epoch && this.isOpen)
215
+ this.fail(error instanceof Error ? error : new Error(String(error)));
216
+ };
217
+ try {
218
+ const sent = this.codec.send(event, shouldSend);
219
+ void sent.catch(failed);
220
+ return sent;
221
+ } catch (error) {
222
+ failed(error);
223
+ throw error;
107
224
  }
108
225
  }
109
226
 
110
- dispose(): void {
111
- this.disconnect();
227
+ finish(): Promise<void> {
228
+ return this.codec?.finish() ?? Promise.resolve();
112
229
  }
113
230
 
114
- private async handleMessage(messageEvent: MessageEvent): Promise<void> {
115
- let text: string;
116
- if (typeof messageEvent.data === 'string') {
117
- text = messageEvent.data;
118
- } else if (messageEvent.data instanceof Blob) {
119
- text = await messageEvent.data.text();
120
- } else {
121
- text = new TextDecoder().decode(messageEvent.data);
231
+ /** Drain accepted events when media or protocol continuity is lost. */
232
+ fail(error: Error): void {
233
+ if (this.failing) return;
234
+ this.failing = true;
235
+ const epoch = this.epoch;
236
+ const ws = this.ws;
237
+ this.ws = null;
238
+ this.codec?.stopWriting();
239
+ ws?.close();
240
+ this.notifyClosing();
241
+ if (this.epoch !== epoch) return;
242
+ const drain = this.finish();
243
+ if (this.options.onFatalError != null)
244
+ this.options.onFatalError(error, drain);
245
+ else {
246
+ const complete = () => {
247
+ if (epoch !== this.epoch) return;
248
+ this.disconnect();
249
+ try {
250
+ this.onClose();
251
+ } catch (cause) {
252
+ this.reportCallbackError(cause);
253
+ }
254
+ };
255
+ this.drainTimer = setTimeout(complete, 1_000);
256
+ void this.awaitDrain(drain, complete);
257
+ this.reportCallbackError(error);
122
258
  }
259
+ }
123
260
 
124
- const parseResult = await safeParseJSON({ text });
125
- if (!parseResult.success) return;
261
+ private notifyClosing(): void {
262
+ if (this.closing) return;
263
+ this.closing = true;
264
+ try {
265
+ this.options.onClosing?.();
266
+ } catch (error) {
267
+ this.reportCallbackError(error);
268
+ }
269
+ }
126
270
 
127
- const rawEvent = parseResult.value;
271
+ private async awaitDrain(
272
+ drain: Promise<void>,
273
+ complete: () => void,
274
+ ): Promise<void> {
275
+ try {
276
+ await drain;
277
+ } catch {
278
+ /* Transport loss still requires finalization. */
279
+ }
280
+ try {
281
+ complete();
282
+ } catch (error) {
283
+ this.reportCallbackError(error);
284
+ }
285
+ }
128
286
 
129
- if (this.model.getHealthCheckResponse != null) {
130
- const autoResponse = this.model.getHealthCheckResponse(rawEvent);
131
- if (autoResponse != null) {
132
- this.sendRaw(autoResponse);
133
- }
287
+ private reportCallbackError(error: unknown): void {
288
+ try {
289
+ this.onError(error instanceof Error ? error : new Error(String(error)));
290
+ } catch {
291
+ /* Application callbacks cannot interrupt teardown. */
134
292
  }
293
+ }
135
294
 
136
- const result = this.model.parseServerEvent(rawEvent);
137
- const events = Array.isArray(result) ? result : [result];
295
+ sendRaw(data: unknown): void {
296
+ const ws = this.ws;
297
+ const epoch = this.epoch;
298
+ if (ws == null || !this.isOpen) return;
299
+ const frame = encodeRealtimeFrame(data);
300
+ if (this.epoch !== epoch || this.ws !== ws || !this.isOpen)
301
+ throw new Error('Realtime connection is closed');
302
+ if (this.continuous) assertRealtimeFrameBudget(frame, ws.bufferedAmount);
303
+ ws.send(frame.data);
304
+ }
138
305
 
139
- for (const event of events) {
140
- await this.onServerEvent(event);
141
- }
306
+ dispose(): void {
307
+ this.disconnect();
142
308
  }
143
309
  }