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,3 +1,4 @@
1
+ import { UnsupportedFunctionalityError } from '@ai-sdk/provider';
1
2
  import type {
2
3
  RealtimeClientEvent,
3
4
  RealtimeModel,
@@ -6,24 +7,48 @@ import type {
6
7
  } from '../types/realtime-model';
7
8
  import { BrowserRealtimeAudio } from './browser-realtime-audio';
8
9
  import { BrowserRealtimeTransport } from './browser-realtime-transport';
10
+ import { BrowserRealtimeLiveWebSocket } from './browser-realtime-live-websocket';
11
+ import { BrowserRealtimeWebRTC } from './browser-realtime-webrtc';
12
+ import { RealtimeAttempt } from './realtime-attempt';
13
+ import { RealtimeCommandTracker } from './realtime-command-tracker';
14
+ import { validateRealtimeSetup } from './validate-realtime-setup';
15
+ import {
16
+ createSessionState,
17
+ reduceSessionState,
18
+ } from './realtime-session-state';
9
19
  import {
10
20
  createInitialRealtimeState,
11
21
  RealtimeEventReducer,
12
22
  type RealtimeReducerEffect,
13
23
  type RealtimeState,
14
- type RealtimeStatus,
15
24
  } from './realtime-event-reducer';
16
25
 
17
- export type { RealtimeState, RealtimeStatus };
26
+ export type { RealtimeSessionState } from './realtime-session-state';
27
+ export type { RealtimeState, RealtimeStatus } from './realtime-event-reducer';
18
28
 
19
29
  export type RealtimeSessionOptions = {
20
30
  model: RealtimeModel;
21
- api: {
22
- token: string;
23
- };
31
+ /** websocket uses a raw-protocol relay; session exchanges SDP; token fetches client-secret setup. */
32
+ api:
33
+ | { token: string; session?: never; websocket?: never; protocols?: never }
34
+ | { session: string; token?: never; websocket?: never; protocols?: never }
35
+ | {
36
+ websocket: string;
37
+ protocols?: string[];
38
+ token?: never;
39
+ session?: never;
40
+ };
24
41
  sessionConfig?: Partial<RealtimeSessionConfig>;
25
42
  sampleRate?: number;
26
43
  maxEvents?: number;
44
+ /** Maximum time to establish transport and receive readiness. Default: 30s. */
45
+ startupTimeoutMs?: number;
46
+ /** Maximum wait for final usage after close(). Default: 15s. */
47
+ closeTimeoutMs?: number;
48
+ /** WebRTC peer disconnect recovery grace period. Default: 5s. */
49
+ rtcDisconnectTimeoutMs?: number;
50
+ /** Continuous PCM only: pause at this budget until resumePlayback(). Default: 2s. */
51
+ maxPlaybackBufferSeconds?: number;
27
52
  onToolCall?: (args: {
28
53
  toolCall: { toolCallId: string; toolName: string; args: unknown };
29
54
  }) => Promise<unknown> | unknown | undefined;
@@ -34,23 +59,23 @@ export type RealtimeSessionOptions = {
34
59
  export abstract class AbstractRealtimeSession {
35
60
  protected state: RealtimeState = createInitialRealtimeState();
36
61
  protected maxEvents: number;
37
-
38
62
  onToolCall: RealtimeSessionOptions['onToolCall'];
39
- onEvent: ((event: RealtimeServerEvent) => void) | undefined;
40
- onError: ((error: Error) => void) | undefined;
41
-
42
- private readonly model: RealtimeModel;
43
- private readonly api: RealtimeSessionOptions['api'];
44
- private readonly sessionConfig: Partial<RealtimeSessionConfig> | undefined;
45
- private readonly reducer: RealtimeEventReducer;
46
- private readonly transport: BrowserRealtimeTransport;
47
- private readonly audio: BrowserRealtimeAudio;
63
+ onEvent: RealtimeSessionOptions['onEvent'];
64
+ onError: RealtimeSessionOptions['onError'];
65
+ private reducer: RealtimeEventReducer;
66
+ private readonly sessionLifecycle: boolean;
67
+ private readonly continuous: boolean;
68
+ private attempt?: RealtimeAttempt;
69
+ private commands?: RealtimeCommandTracker;
70
+ private publication = 0;
71
+ private transport?: BrowserRealtimeTransport;
72
+ private audio?: BrowserRealtimeAudio;
73
+ private pcm?: BrowserRealtimeLiveWebSocket;
74
+ private rtc?: BrowserRealtimeWebRTC;
75
+ private suppliedStream?: MediaStream;
76
+ private captureGeneration = 0;
77
+ private captureRequested = false;
48
78
  private currentResponseItemId: string | null = null;
49
-
50
- // Tool calls requested by the current (tool-bearing) response, the outputs
51
- // that have been submitted for them, and whether that response has finished
52
- // delivering its tool calls. Used to request a single response only once
53
- // every tool output for the turn has been submitted.
54
79
  private readonly toolCallsInResponse = new Set<string>();
55
80
  private readonly submittedToolOutputs = new Set<string>();
56
81
  private responseToolCallsClosed = false;
@@ -60,103 +85,442 @@ export abstract class AbstractRealtimeSession {
60
85
  value: RealtimeState[K],
61
86
  ): void;
62
87
 
63
- constructor(options: RealtimeSessionOptions) {
64
- this.model = options.model;
65
- this.api = options.api;
66
- this.sessionConfig = options.sessionConfig;
88
+ constructor(private readonly options: RealtimeSessionOptions) {
89
+ const capabilities = options.model.capabilities;
90
+ this.sessionLifecycle =
91
+ capabilities?.startup === 'session-start' ||
92
+ capabilities?.finalization === 'session-close';
93
+ this.continuous = capabilities?.conversation === 'continuous';
67
94
  this.maxEvents = options.maxEvents ?? 500;
95
+ if (!Number.isSafeInteger(this.maxEvents) || this.maxEvents < 1)
96
+ throw new Error('maxEvents must be a positive integer');
97
+ for (const timeout of [
98
+ options.startupTimeoutMs ?? 30_000,
99
+ options.closeTimeoutMs ?? 15_000,
100
+ options.rtcDisconnectTimeoutMs ?? 5_000,
101
+ ]) {
102
+ if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 2_147_483_647)
103
+ throw new Error(
104
+ 'Realtime timeouts must be positive finite timer durations',
105
+ );
106
+ }
107
+ const budget = options.maxPlaybackBufferSeconds ?? 2;
108
+ if (!Number.isFinite(budget) || budget <= 0)
109
+ throw new Error('maxPlaybackBufferSeconds must be positive and finite');
68
110
  this.reducer = new RealtimeEventReducer(this.maxEvents);
69
111
  this.onToolCall = options.onToolCall;
70
112
  this.onEvent = options.onEvent;
71
113
  this.onError = options.onError;
72
-
73
- const sampleRate = options.sampleRate ?? 24000;
74
- const captureSampleRate =
75
- options.sessionConfig?.inputAudioFormat?.rate ?? sampleRate;
76
- const playbackSampleRate =
77
- options.sessionConfig?.outputAudioFormat?.rate ?? sampleRate;
78
-
79
- this.transport = new BrowserRealtimeTransport({
80
- model: this.model,
81
- onServerEvent: event => this.handleServerEvent(event),
82
- onError: error => {
83
- this.applyState(this.reducer.setStatus(this.state, 'error'));
84
- this.onError?.(error);
85
- },
86
- onClose: () => {
87
- this.applyState(this.reducer.setStatus(this.state, 'disconnected'));
88
- },
89
- });
90
-
91
- this.audio = new BrowserRealtimeAudio({
92
- captureSampleRate,
93
- playbackSampleRate,
94
- onAudio: audio => this.sendAudio(audio),
95
- onCapturingChange: isCapturing => {
96
- this.applyState(this.reducer.setCapturing(this.state, isCapturing));
97
- },
98
- onPlayingChange: isPlaying => {
99
- this.applyState(this.reducer.setPlaying(this.state, isPlaying));
100
- },
101
- });
102
114
  }
103
115
 
104
- // ── Connection ─────────────────────────────────────────────────────
105
-
106
- async connect(): Promise<void> {
107
- this.applyState(this.reducer.setStatus(this.state, 'connecting'));
116
+ private validateConnection(): void {
117
+ const { model, api } = this.options;
118
+ if (
119
+ (!this.continuous || api.session != null) &&
120
+ this.options.maxPlaybackBufferSeconds != null
121
+ )
122
+ throw new Error(
123
+ 'maxPlaybackBufferSeconds is supported only for continuous PCM sessions',
124
+ );
125
+ const connection =
126
+ api.session != null
127
+ ? 'webrtc'
128
+ : api.token != null
129
+ ? 'client-secret-websocket'
130
+ : 'server-websocket';
131
+ if (
132
+ !(
133
+ model.capabilities?.connections ?? ['client-secret-websocket']
134
+ ).includes(connection) ||
135
+ (model.capabilities?.transports != null &&
136
+ !model.capabilities.transports.includes(
137
+ api.session != null ? 'webrtc' : 'websocket',
138
+ ))
139
+ )
140
+ throw new Error(`Realtime model does not support ${connection}`);
141
+ if (api.session != null) {
142
+ if (model.getWebRTCConfig == null)
143
+ throw new Error('Realtime model does not support WebRTC configuration');
144
+ return;
145
+ }
146
+ if (this.continuous && api.websocket == null)
147
+ throw new Error(
148
+ 'Continuous PCM sessions require an application WebSocket relay',
149
+ );
150
+ if (api.token != null && model.getWebSocketConfig == null)
151
+ throw new Error(
152
+ 'Realtime model does not support client-secret WebSocket configuration',
153
+ );
154
+ }
108
155
 
156
+ connect(): Promise<void>;
157
+ connect(options: { stream?: MediaStream; capture?: boolean }): Promise<void>;
158
+ async connect(connectOptions?: {
159
+ stream?: MediaStream;
160
+ capture?: boolean;
161
+ }): Promise<void> {
162
+ if (this.attempt?.active)
163
+ throw new Error('Realtime session is already active');
164
+ const attempt = new RealtimeAttempt();
165
+ this.attempt = attempt;
166
+ const current = () => this.attempt === attempt && attempt.active;
109
167
  try {
110
- const response = await fetch(this.api.token, {
111
- method: 'POST',
112
- headers: { 'Content-Type': 'application/json' },
113
- body: JSON.stringify({ sessionConfig: this.sessionConfig }),
168
+ this.applyState({ ...this.state, status: 'connecting' });
169
+ if (!current()) return;
170
+ this.validateConnection();
171
+ const { model, api, sessionConfig } = this.options;
172
+ if (connectOptions?.capture === false) this.stopAudioCapture();
173
+ if (!current()) return;
174
+ if (
175
+ connectOptions?.stream != null &&
176
+ connectOptions.stream !== this.suppliedStream
177
+ ) {
178
+ if (
179
+ this.captureRequested &&
180
+ !this.sessionLifecycle &&
181
+ !this.continuous &&
182
+ api.session == null
183
+ )
184
+ this.startAudioCapture(connectOptions.stream);
185
+ else this.suppliedStream = connectOptions.stream;
186
+ }
187
+ if (!current()) return;
188
+ this.reducer = new RealtimeEventReducer(this.maxEvents);
189
+ this.currentResponseItemId = null;
190
+ this.toolCallsInResponse.clear();
191
+ this.submittedToolOutputs.clear();
192
+ this.responseToolCallsClosed = false;
193
+ if (this.sessionLifecycle)
194
+ this.applyState({ ...this.state, session: createSessionState() });
195
+ if (!current()) return;
196
+ this.commands = new RealtimeCommandTracker(event => {
197
+ const writable = () =>
198
+ current() &&
199
+ !attempt.transportClosing &&
200
+ attempt.cause == null &&
201
+ (!attempt.closing || event.type === 'session-close');
202
+ if (!writable()) throw new Error('Realtime connection is closed');
203
+ return this.sendTransport(event, writable);
204
+ });
205
+ attempt.timer('startup', this.options.startupTimeoutMs ?? 30_000, () => {
206
+ if (current())
207
+ this.fail(new Error('Realtime session startup timed out'));
114
208
  });
115
- if (!response.ok) {
116
- throw new Error(`Failed to fetch realtime setup: ${response.status}`);
209
+ const callbacks = {
210
+ model,
211
+ onEvent: async (event: RealtimeServerEvent) => {
212
+ if (!current()) return;
213
+ try {
214
+ await this.handleServerEvent(event, attempt);
215
+ } catch (error) {
216
+ if (current())
217
+ this.fail(
218
+ error,
219
+ (this.rtc ?? this.pcm ?? this.transport)?.finish(),
220
+ );
221
+ }
222
+ },
223
+ onError: (error: Error) => {
224
+ if (current()) void this.reportError(error, attempt);
225
+ },
226
+ onFatalError: (error: Error, drain?: Promise<void>) => {
227
+ if (current()) this.fail(error, drain);
228
+ },
229
+ onClosing: () => {
230
+ if (!current()) return;
231
+ attempt.beginClose();
232
+ attempt.transportClosing = true;
233
+ attempt.clearTimer('startup');
234
+ attempt.clearTimer('close');
235
+ this.stopAudioCapture();
236
+ if (!current()) return;
237
+ this.applyState({
238
+ ...this.state,
239
+ status: attempt.cause == null ? 'closing' : 'error',
240
+ });
241
+ },
242
+ onClose: (error?: Error) => {
243
+ if (!current()) return;
244
+ const finalizationConfirmed =
245
+ model.capabilities?.finalization === 'session-close' &&
246
+ this.state.session?.finalization === 'confirmed';
247
+ if (error != null && !finalizationConfirmed) this.fail(error);
248
+ else if (!attempt.ready && !finalizationConfirmed)
249
+ this.fail(
250
+ new Error('Realtime connection closed before becoming ready'),
251
+ );
252
+ else this.disconnect();
253
+ },
254
+ onCapturing: (isCapturing: boolean) => {
255
+ if (current()) this.applyState({ ...this.state, isCapturing });
256
+ },
257
+ onPlaying: (isPlaying: boolean) => {
258
+ if (current()) this.applyState({ ...this.state, isPlaying });
259
+ },
260
+ };
261
+ if (api.session != null) {
262
+ this.rtc = new BrowserRealtimeWebRTC({
263
+ ...callbacks,
264
+ disconnectTimeoutMs: this.options.rtcDisconnectTimeoutMs,
265
+ });
266
+ await this.rtc.connect({
267
+ api: api.session,
268
+ sessionConfig,
269
+ stream: connectOptions?.stream,
270
+ capture: connectOptions?.capture,
271
+ timeoutMs: this.options.startupTimeoutMs ?? 30_000,
272
+ });
273
+ } else if (
274
+ api.websocket != null &&
275
+ model.capabilities?.conversation === 'continuous'
276
+ ) {
277
+ this.pcm = new BrowserRealtimeLiveWebSocket({
278
+ ...callbacks,
279
+ sessionConfig,
280
+ sampleRate: this.options.sampleRate,
281
+ maxPlaybackBufferSeconds: this.options.maxPlaybackBufferSeconds,
282
+ });
283
+ this.pcm.connect({
284
+ url: api.websocket,
285
+ protocols: api.protocols,
286
+ stream: connectOptions?.stream,
287
+ capture: connectOptions?.capture,
288
+ });
289
+ } else {
290
+ this.ensureAudio();
291
+ let config: RealtimeSessionConfig = sessionConfig ?? {};
292
+ let token: string | undefined;
293
+ let url = api.websocket;
294
+ if (api.token != null) {
295
+ const response = await fetch(api.token, {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify({ sessionConfig }),
299
+ signal: attempt.abort.signal,
300
+ });
301
+ if (!current()) return;
302
+ if (!response.ok)
303
+ throw new Error(
304
+ `Failed to fetch realtime setup: ${response.status}`,
305
+ );
306
+ const payload: unknown = await response.json().catch(() => {
307
+ throw new Error('Invalid realtime setup response');
308
+ });
309
+ if (!current() || attempt.closing) return;
310
+ const setup = validateRealtimeSetup(payload);
311
+ token = setup.token;
312
+ url = setup.url;
313
+ config = {
314
+ ...sessionConfig,
315
+ ...(setup.tools == null ? {} : { tools: setup.tools }),
316
+ };
317
+ }
318
+ if (url == null) throw new Error('Realtime WebSocket URL is missing');
319
+ if (api.token != null && token == null)
320
+ throw new Error('Realtime client-secret connection requires a token');
321
+ this.ensureAudio().ensurePlaybackContext();
322
+ if (!current()) return;
323
+ this.transport = new BrowserRealtimeTransport({
324
+ ...callbacks,
325
+ onServerEvent: callbacks.onEvent,
326
+ });
327
+ this.transport.connect({
328
+ ...(token != null
329
+ ? { mode: 'client-secret' as const, token }
330
+ : { mode: 'relay' as const, protocols: api.protocols }),
331
+ url,
332
+ onOpen: () => {
333
+ const writable = () =>
334
+ current() && !attempt.closing && attempt.cause == null;
335
+ if (!writable()) return;
336
+ return this.sendTransport(
337
+ {
338
+ type: model.capabilities?.startup ?? 'session-update',
339
+ config,
340
+ },
341
+ writable,
342
+ );
343
+ },
344
+ });
117
345
  }
346
+ } catch (error) {
347
+ if (current()) this.fail(error);
348
+ }
349
+ }
118
350
 
119
- const setupData = await response.json();
120
- const { token, url, tools: toolDefinitions } = setupData;
351
+ private fail(error: unknown, drain?: Promise<void>): void {
352
+ const attempt = this.attempt;
353
+ if (attempt == null || !attempt.active || attempt.cause != null) return;
354
+ attempt.cause = error instanceof Error ? error : new Error(String(error));
355
+ if (drain != null) attempt.beginClose();
356
+ this.applyState({ ...this.state, status: 'error' });
357
+ if (this.attempt !== attempt || !attempt.active) return;
358
+ this.stopAudioCapture();
359
+ if (this.attempt !== attempt || !attempt.active) return;
360
+ if (drain == null) this.disconnect();
361
+ else void this.drainAttempt(attempt, drain);
362
+ void this.reportError(attempt.cause, attempt);
363
+ }
121
364
 
122
- const config: RealtimeSessionConfig = {
123
- ...this.sessionConfig,
124
- tools: toolDefinitions as RealtimeSessionConfig['tools'],
125
- };
365
+ private async drainAttempt(
366
+ attempt: RealtimeAttempt,
367
+ drain?: Promise<void>,
368
+ ): Promise<void> {
369
+ if (this.attempt === attempt && attempt.active)
370
+ attempt.timer('drain', 1_000, () => this.finishAttempt(attempt));
371
+ try {
372
+ await drain;
373
+ } catch {
374
+ /* Finalize unconfirmed when draining fails. */
375
+ }
376
+ this.finishAttempt(attempt);
377
+ }
126
378
 
127
- this.audio.ensurePlaybackContext();
128
- this.transport.connect({
129
- token,
130
- url,
131
- onOpen: () => {
132
- this.sendEvent({
133
- type: 'session-update',
134
- config,
135
- });
136
- },
137
- });
379
+ private finishAttempt(attempt: RealtimeAttempt): void {
380
+ if (this.attempt !== attempt || !attempt.active) return;
381
+ try {
382
+ this.disconnect();
138
383
  } catch (error) {
139
- this.applyState(this.reducer.setStatus(this.state, 'error'));
140
- this.onError?.(
141
- error instanceof Error
142
- ? error
143
- : new Error(`Connection failed: ${String(error)}`),
144
- );
384
+ void this.reportError(error, attempt);
145
385
  }
146
386
  }
147
387
 
388
+ private closeFailed(attempt: RealtimeAttempt, error: unknown): void {
389
+ if (this.attempt !== attempt || !attempt.active || attempt.transportClosing)
390
+ return;
391
+ attempt.clearTimer('close');
392
+ const transport = this.rtc ?? this.pcm ?? this.transport;
393
+ void this.drainAttempt(attempt, transport?.finish());
394
+ void this.reportError(error, attempt);
395
+ }
396
+
148
397
  disconnect(): void {
149
- this.transport.disconnect();
150
- this.applyState(this.reducer.setStatus(this.state, 'disconnected'));
398
+ const attempt = this.attempt;
399
+ this.captureGeneration++;
400
+ this.captureRequested = false;
401
+ this.suppliedStream = undefined;
402
+ const transport = this.transport;
403
+ const audio = this.audio;
404
+ const pcm = this.pcm;
405
+ const rtc = this.rtc;
406
+ this.transport = undefined;
407
+ this.audio = undefined;
408
+ this.pcm = undefined;
409
+ this.rtc = undefined;
410
+ this.commands = undefined;
411
+ attempt?.retire();
412
+ transport?.dispose();
413
+ audio?.dispose();
414
+ pcm?.dispose();
415
+ rtc?.dispose();
416
+ if (this.attempt !== attempt) return;
417
+ const session = this.state.session;
418
+ this.applyState({
419
+ ...this.state,
420
+ status: attempt?.cause != null ? 'error' : 'disconnected',
421
+ isCapturing: false,
422
+ isPlaying: false,
423
+ ...(session != null
424
+ ? {
425
+ session:
426
+ session.finalization === 'confirmed'
427
+ ? session
428
+ : { ...session, finalization: 'unconfirmed' },
429
+ }
430
+ : {}),
431
+ });
432
+ }
433
+
434
+ /** Wait for final usage when the model supports a session-close acknowledgement. */
435
+ close(options?: { eventId?: string }): Promise<void> {
436
+ const attempt = this.attempt;
437
+ if (attempt?.closePromise != null) return attempt.closePromise;
438
+ if (
439
+ this.state.status !== 'connected' ||
440
+ this.options.model.capabilities?.finalization !== 'session-close' ||
441
+ attempt == null
442
+ ) {
443
+ this.disconnect();
444
+ return Promise.resolve();
445
+ }
446
+ if (options?.eventId != null) this.commands?.validateId(options.eventId);
447
+ const promise = attempt.beginClose();
448
+ this.applyState({ ...this.state, status: 'closing' });
449
+ if (this.attempt !== attempt || !attempt.active || attempt.transportClosing)
450
+ return promise;
451
+ this.stopAudioCapture();
452
+ if (this.attempt !== attempt || !attempt.active || attempt.transportClosing)
453
+ return promise;
454
+ attempt.timer('close', this.options.closeTimeoutMs ?? 15_000, () =>
455
+ this.finishAttempt(attempt),
456
+ );
457
+ const failed = (error: unknown) => this.closeFailed(attempt, error);
458
+ try {
459
+ void this.commands
460
+ ?.send({ type: 'session-close', eventId: options?.eventId })
461
+ .catch(failed);
462
+ } catch (error) {
463
+ failed(error);
464
+ }
465
+ return promise;
151
466
  }
152
467
 
153
- // ── Sending events ─────────────────────────────────────────────────
468
+ private sendTransport(
469
+ event: RealtimeClientEvent,
470
+ guard?: () => boolean,
471
+ automaticAudio = false,
472
+ ): Promise<void> {
473
+ const transport = this.rtc ?? this.pcm ?? this.transport;
474
+ if (transport == null) throw new Error('Realtime connection is not open');
475
+ if (transport === this.transport)
476
+ return this.transport.sendEvent(event, guard, automaticAudio);
477
+ return transport.sendEvent(event, guard);
478
+ }
154
479
 
155
- sendEvent(event: RealtimeClientEvent): void {
156
- this.transport.sendEvent(event);
480
+ sendEvent(event: RealtimeClientEvent): Promise<void> {
481
+ if (
482
+ this.state.status === 'error' ||
483
+ this.state.status === 'closing' ||
484
+ this.attempt?.closing ||
485
+ !this.attempt?.active
486
+ )
487
+ throw new Error('Realtime session is not accepting submissions');
488
+ if (this.sessionLifecycle && this.state.status !== 'connected')
489
+ throw new Error('Realtime session is not accepting submissions');
490
+ if (
491
+ (this.rtc != null && event.type === 'input-audio-append') ||
492
+ ((this.continuous || this.rtc != null) &&
493
+ (event.type === 'input-audio-commit' ||
494
+ event.type === 'input-audio-clear'))
495
+ )
496
+ throw new UnsupportedFunctionalityError({
497
+ functionality:
498
+ 'JSON audio commands unsupported for this session transport',
499
+ });
500
+ if (event.type === 'session-close')
501
+ return this.close({ eventId: event.eventId });
502
+ if (event.type === 'session-start' && this.sessionLifecycle)
503
+ throw new Error('Realtime session has already started');
504
+ if (!this.sessionLifecycle && this.state.session == null) {
505
+ const attempt = this.attempt;
506
+ return this.sendTransport(
507
+ event,
508
+ () =>
509
+ this.attempt === attempt &&
510
+ attempt.active &&
511
+ !attempt.closing &&
512
+ attempt.cause == null,
513
+ );
514
+ }
515
+ return this.commands?.send(event) ?? this.sendTransport(event);
157
516
  }
158
517
 
159
518
  sendTextMessage(text: string): void {
519
+ if (this.continuous)
520
+ throw new UnsupportedFunctionalityError({
521
+ functionality:
522
+ 'sendTextMessage for continuous sessions; client-delegation text is application-owned, use context-append for context',
523
+ });
160
524
  this.sendEvent({
161
525
  type: 'conversation-item-create',
162
526
  item: { type: 'text-message', role: 'user', text },
@@ -165,218 +529,404 @@ export abstract class AbstractRealtimeSession {
165
529
  this.applyState(this.reducer.addUserTextMessage(this.state, text));
166
530
  }
167
531
 
168
- sendAudio(base64Audio: string): void {
169
- this.sendEvent({ type: 'input-audio-append', audio: base64Audio });
532
+ sendAudio(audio: string): void {
533
+ const attempt = this.attempt;
534
+ void this.sendEvent({ type: 'input-audio-append', audio }).catch(error => {
535
+ if (attempt?.active && this.attempt === attempt)
536
+ void this.reportError(error, attempt);
537
+ });
170
538
  }
171
-
172
539
  commitAudio(): void {
173
540
  this.sendEvent({ type: 'input-audio-commit' });
174
541
  }
175
-
176
542
  clearAudioBuffer(): void {
177
543
  this.sendEvent({ type: 'input-audio-clear' });
178
544
  }
179
-
180
545
  requestResponse(options?: { modalities?: string[] }): void {
181
546
  this.sendEvent({
182
547
  type: 'response-create',
183
548
  ...(options != null ? { options } : {}),
184
549
  });
185
550
  }
186
-
187
551
  cancelResponse(): void {
188
552
  this.sendEvent({ type: 'response-cancel' });
189
553
  }
190
554
 
191
- // ── Tool output ───────────────────────────────────────────────────
555
+ private sendAutomaticAudio(audio: string): void {
556
+ if (
557
+ this.state.status !== 'connected' ||
558
+ !this.attempt?.active ||
559
+ this.attempt.closing
560
+ )
561
+ return;
562
+ const attempt = this.attempt;
563
+ const failed = (error: unknown) => {
564
+ if (attempt?.active && this.attempt === attempt && !attempt.closing)
565
+ this.fail(error);
566
+ };
567
+ try {
568
+ void this.sendTransport(
569
+ { type: 'input-audio-append', audio },
570
+ () => this.attempt === attempt && attempt.active && !attempt.closing,
571
+ true,
572
+ ).catch(failed);
573
+ } catch (error) {
574
+ failed(error);
575
+ }
576
+ }
192
577
 
193
578
  addToolOutput(callId: string, result: unknown): void {
579
+ if (this.continuous)
580
+ throw new UnsupportedFunctionalityError({
581
+ functionality:
582
+ 'addToolOutput for continuous sessions; client delegation is application-owned',
583
+ });
584
+ const attempt = this.attempt;
585
+ if (!attempt?.active || attempt.closing || attempt.cause != null)
586
+ throw new Error('Realtime session is not accepting submissions');
194
587
  const { state, output } = this.reducer.addToolOutput(
195
588
  this.state,
196
589
  callId,
197
590
  result,
198
591
  );
199
592
  this.applyState(state);
200
-
593
+ if (this.attempt !== attempt || !attempt?.active) return;
201
594
  this.sendEvent({
202
595
  type: 'conversation-item-create',
203
- item: {
204
- type: 'function-call-output',
205
- callId: output.callId,
206
- name: output.name,
207
- output: output.output,
208
- },
596
+ item: { type: 'function-call-output', ...output },
209
597
  });
210
-
211
598
  this.submittedToolOutputs.add(callId);
212
599
  this.maybeRequestToolResponse();
213
600
  }
214
601
 
215
- /**
216
- * Requests a single response once the tool-bearing response has finished
217
- * delivering its tool calls and every one of them has an output. Requesting a
218
- * response after each individual output can cause the model to continue
219
- * without the full tool context on multi-tool turns.
220
- */
221
602
  private maybeRequestToolResponse(): void {
222
- if (!this.responseToolCallsClosed) return;
223
- if (this.toolCallsInResponse.size === 0) return;
224
-
225
- for (const callId of this.toolCallsInResponse) {
226
- if (!this.submittedToolOutputs.has(callId)) return;
227
- }
228
-
603
+ if (
604
+ this.state.status !== 'connected' ||
605
+ this.attempt?.closing ||
606
+ !this.responseToolCallsClosed ||
607
+ this.toolCallsInResponse.size === 0 ||
608
+ [...this.toolCallsInResponse].some(
609
+ id => !this.submittedToolOutputs.has(id),
610
+ )
611
+ )
612
+ return;
229
613
  this.sendEvent({ type: 'response-create' });
230
614
  this.toolCallsInResponse.clear();
231
615
  this.submittedToolOutputs.clear();
232
616
  this.responseToolCallsClosed = false;
233
617
  }
234
618
 
235
- // ── Audio capture ──────────────────────────────────────────────────
236
-
237
619
  startAudioCapture(stream: MediaStream): void {
238
- this.audio.startCapture(stream);
620
+ const legacy =
621
+ !this.sessionLifecycle &&
622
+ !this.continuous &&
623
+ this.options.api.session == null;
624
+ if (!legacy && this.state.status !== 'connected')
625
+ throw new Error('Realtime session is not accepting capture');
626
+ if (
627
+ this.attempt?.active &&
628
+ (this.attempt.closing || this.attempt.cause != null)
629
+ )
630
+ throw new Error('Realtime session is not accepting capture');
631
+ this.suppliedStream = stream;
632
+ if (legacy) {
633
+ this.captureGeneration++;
634
+ this.captureRequested = true;
635
+ this.ensureAudio().startCapture(stream);
636
+ return;
637
+ }
638
+ const attempt = this.attempt;
639
+ void this.resumeAudioCapture().catch(error => {
640
+ if (attempt?.active) void this.reportError(error, attempt);
641
+ });
239
642
  }
240
643
 
241
- stopAudioCapture(): void {
242
- this.audio.stopCapture();
644
+ async resumeAudioCapture(): Promise<void> {
645
+ const accepting = () =>
646
+ !this.attempt?.closing &&
647
+ this.attempt?.cause == null &&
648
+ (this.state.status === 'connected' ||
649
+ (!this.sessionLifecycle && this.state.status === 'connecting'));
650
+ if (!accepting())
651
+ throw new Error('Realtime session is not accepting capture');
652
+ this.captureRequested = true;
653
+ if (this.rtc != null) return this.rtc.startCapture(this.suppliedStream);
654
+ if (this.pcm != null) return this.pcm.resumeCapture(this.suppliedStream);
655
+ const audio = this.audio;
656
+ if (audio == null)
657
+ throw new Error('Realtime capture transport is not ready');
658
+ const captureGeneration = ++this.captureGeneration;
659
+ const attempt = this.attempt;
660
+ const supplied = this.suppliedStream;
661
+ const stream =
662
+ supplied ?? (await navigator.mediaDevices.getUserMedia({ audio: true }));
663
+ if (
664
+ !attempt?.active ||
665
+ this.attempt !== attempt ||
666
+ this.audio !== audio ||
667
+ !accepting() ||
668
+ captureGeneration !== this.captureGeneration
669
+ ) {
670
+ if (supplied == null) stream.getTracks().forEach(track => track.stop());
671
+ return;
672
+ }
673
+ audio.startCapture(stream, {
674
+ ownsStream:
675
+ supplied == null ||
676
+ !this.sessionLifecycle ||
677
+ this.options.api.token != null,
678
+ });
243
679
  }
244
680
 
245
- // ── Playback ───────────────────────────────────────────────────────
246
-
681
+ stopAudioCapture(): void {
682
+ this.captureGeneration++;
683
+ this.captureRequested = false;
684
+ if (
685
+ this.audio != null &&
686
+ (!this.sessionLifecycle || this.options.api.token != null)
687
+ )
688
+ this.suppliedStream = undefined;
689
+ const pcm = this.pcm;
690
+ const audio = this.audio;
691
+ const rtc = this.rtc;
692
+ const attempt = this.attempt;
693
+ if (rtc != null)
694
+ void rtc.stopCapture().catch(error => {
695
+ if (attempt?.active && this.attempt === attempt)
696
+ void this.reportError(error, attempt);
697
+ });
698
+ pcm?.stopCapture();
699
+ audio?.stopCapture();
700
+ }
247
701
  stopPlayback(): void {
248
- this.audio.stopPlayback();
702
+ (this.rtc ?? this.pcm ?? this.audio)?.stopPlayback();
703
+ }
704
+ async resumePlayback(): Promise<void> {
705
+ await (this.rtc ?? this.pcm ?? this.audio)?.resumePlayback();
249
706
  }
250
-
251
- // ── Cleanup ────────────────────────────────────────────────────────
252
-
253
707
  dispose(): void {
254
- this.transport.dispose();
255
- this.audio.dispose();
256
- this.applyState(
257
- this.reducer.setStatus(
258
- this.reducer.setPlaying(
259
- this.reducer.setCapturing(this.state, false),
260
- false,
261
- ),
262
- 'disconnected',
263
- ),
264
- );
708
+ this.disconnect();
265
709
  }
266
710
 
267
- // ── Private helpers ────────────────────────────────────────────────
711
+ private ensureAudio(): BrowserRealtimeAudio {
712
+ if (this.audio != null) return this.audio;
713
+ const { sessionConfig, sampleRate } = this.options;
714
+ const audio = new BrowserRealtimeAudio({
715
+ captureSampleRate:
716
+ sessionConfig?.inputAudioFormat?.rate ?? sampleRate ?? 24000,
717
+ playbackSampleRate:
718
+ sessionConfig?.outputAudioFormat?.rate ?? sampleRate ?? 24000,
719
+ onAudio: value => {
720
+ if (this.audio === audio) this.sendAutomaticAudio(value);
721
+ },
722
+ onError: error => {
723
+ if (this.audio !== audio) return;
724
+ void this.reportError(error, this.attempt);
725
+ },
726
+ onCapturingChange: isCapturing => {
727
+ if (this.audio === audio)
728
+ this.applyState({ ...this.state, isCapturing });
729
+ },
730
+ onPlayingChange: isPlaying => {
731
+ if (this.audio === audio) this.applyState({ ...this.state, isPlaying });
732
+ },
733
+ });
734
+ this.audio = audio;
735
+ return audio;
736
+ }
268
737
 
269
738
  private applyState(nextState: RealtimeState): void {
270
- const previousState = this.state;
739
+ const publication = ++this.publication;
740
+ const previous = this.state;
271
741
  this.state = nextState;
272
-
273
- if (previousState.status !== nextState.status) {
274
- this.setState('status', nextState.status);
275
- }
276
- if (previousState.messages !== nextState.messages) {
277
- this.setState('messages', nextState.messages);
278
- }
279
- if (previousState.events !== nextState.events) {
280
- this.setState('events', nextState.events);
281
- }
282
- if (previousState.isCapturing !== nextState.isCapturing) {
283
- this.setState('isCapturing', nextState.isCapturing);
284
- }
285
- if (previousState.isPlaying !== nextState.isPlaying) {
286
- this.setState('isPlaying', nextState.isPlaying);
287
- }
742
+ const update = <K extends keyof RealtimeState>(key: K) => {
743
+ if (publication === this.publication && previous[key] !== nextState[key])
744
+ this.setState(key, nextState[key]);
745
+ };
746
+ update('status');
747
+ update('messages');
748
+ update('events');
749
+ update('isCapturing');
750
+ update('isPlaying');
751
+ update('session');
288
752
  }
289
753
 
290
- private async executeToolCall({
291
- name,
292
- args,
293
- callId,
294
- }: {
295
- name: string;
296
- args: Record<string, unknown>;
297
- callId: string;
298
- }): Promise<void> {
299
- if (this.onToolCall == null) {
300
- this.onError?.(new Error(`No handler provided for tool "${name}"`));
754
+ private async executeTool(
755
+ callId: string,
756
+ name: string,
757
+ args: unknown,
758
+ attempt: RealtimeAttempt,
759
+ ): Promise<void> {
760
+ if (
761
+ this.continuous ||
762
+ this.attempt !== attempt ||
763
+ !attempt.active ||
764
+ attempt.closing ||
765
+ attempt.cause != null
766
+ )
301
767
  return;
302
- }
303
-
304
768
  try {
769
+ if (this.onToolCall == null) {
770
+ void this.reportError(
771
+ new Error(`No handler provided for tool "${name}"`),
772
+ attempt,
773
+ );
774
+ return;
775
+ }
305
776
  const result = await this.onToolCall({
306
777
  toolCall: { toolCallId: callId, toolName: name, args },
307
778
  });
308
-
309
- // Returning `undefined` is the documented human-in-the-loop pattern:
310
- // the application submits the output later via `addToolOutput`. Only an
311
- // explicitly returned value is submitted automatically here.
312
- if (result !== undefined) {
779
+ if (
780
+ result !== undefined &&
781
+ this.attempt === attempt &&
782
+ attempt?.active &&
783
+ this.state.status === 'connected'
784
+ )
313
785
  this.addToolOutput(callId, result);
314
- }
315
786
  } catch (error) {
316
- this.onError?.(
317
- error instanceof Error
318
- ? error
319
- : new Error(`Client tool execution failed: ${String(error)}`),
320
- );
787
+ if (attempt.active) void this.reportError(error, attempt);
321
788
  }
322
789
  }
323
790
 
324
- private async handleServerEvent(event: RealtimeServerEvent): Promise<void> {
791
+ private async handleServerEvent(
792
+ event: RealtimeServerEvent,
793
+ attempt: RealtimeAttempt,
794
+ ): Promise<void> {
795
+ const current = () => this.attempt === attempt && attempt.active;
796
+ if (!current()) return;
797
+ if (event.type === 'session-started' && event.delegationMode === 'provider')
798
+ throw new UnsupportedFunctionalityError({
799
+ functionality:
800
+ 'Provider delegation mode; this realtime runtime supports client delegation only',
801
+ });
802
+ if (this.continuous && event.type === 'audio-delta')
803
+ throw new UnsupportedFunctionalityError({
804
+ functionality:
805
+ 'Turn-based audio-delta in a continuous PCM session; use audio-chunk',
806
+ });
807
+ const command = this.commands?.receive(event);
325
808
  const result = await this.reducer.reduceServerEvent(this.state, event);
326
- this.applyState(result.state);
327
- this.onEvent?.(event);
328
-
809
+ if (!current()) return;
810
+ const session =
811
+ this.state.session ??
812
+ (event.type === 'session-started' ? createSessionState() : undefined);
813
+ this.applyState({
814
+ ...result.state,
815
+ status: this.state.status,
816
+ ...(session == null
817
+ ? {}
818
+ : {
819
+ session: reduceSessionState(
820
+ session,
821
+ event,
822
+ this.maxEvents,
823
+ command?.muted,
824
+ ),
825
+ }),
826
+ });
827
+ if (!current()) return;
329
828
  for (const effect of result.effects) {
330
- this.handleReducerEffect(effect);
829
+ this.handleReducerEffect(effect, attempt);
830
+ if (!current()) return;
331
831
  }
332
-
333
- // `response-done` for a response that requested tool calls marks the point
334
- // where no further tool calls will arrive for that turn, so we can request
335
- // the follow-up response once every output is in.
832
+ if (event.type === 'audio-chunk')
833
+ (this.pcm ?? this.audio)?.playAudio(event.delta);
834
+ if (!current()) return;
336
835
  if (event.type === 'response-done' && this.toolCallsInResponse.size > 0) {
337
836
  this.responseToolCallsClosed = true;
338
837
  this.maybeRequestToolResponse();
339
838
  }
839
+ if (!current()) return;
840
+ if (event.type === 'session-closed' && session != null) {
841
+ this.disconnect();
842
+ this.notifyEvent(event, attempt, true);
843
+ return;
844
+ }
845
+ if (
846
+ (event.type === 'session-started' ||
847
+ event.type === 'session-created' ||
848
+ event.type === 'session-updated') &&
849
+ attempt.active &&
850
+ !attempt.closing &&
851
+ attempt.cause == null
852
+ ) {
853
+ const ready =
854
+ this.options.model.capabilities?.startup === 'session-start'
855
+ ? event.type === 'session-started'
856
+ : event.type !== 'session-started';
857
+ if (ready) {
858
+ attempt.ready = true;
859
+ attempt.clearTimer('startup');
860
+ this.applyState({ ...this.state, status: 'connected' });
861
+ if (!current() || attempt.closing) return;
862
+ this.pcm?.startCapture();
863
+ }
864
+ }
865
+ if (current()) this.notifyEvent(event, attempt);
340
866
  }
341
867
 
342
- private handleReducerEffect(effect: RealtimeReducerEffect): void {
868
+ private handleReducerEffect(
869
+ effect: RealtimeReducerEffect,
870
+ attempt: RealtimeAttempt,
871
+ ): void {
343
872
  switch (effect.type) {
344
- case 'play-audio': {
873
+ case 'play-audio':
345
874
  this.currentResponseItemId = effect.itemId;
346
- this.audio.playAudio(effect.delta);
875
+ this.audio?.playAudio(effect.delta);
347
876
  break;
348
- }
349
- case 'speech-started': {
350
- if (this.state.isPlaying) {
351
- const playedMs = this.audio.getPlaybackOffsetMs();
352
- this.audio.stopPlayback();
353
-
354
- if (this.currentResponseItemId != null) {
877
+ case 'speech-started':
878
+ if (!this.continuous && this.state.isPlaying) {
879
+ const playedMs = this.audio?.getPlaybackOffsetMs() ?? 0;
880
+ const itemId = this.currentResponseItemId;
881
+ this.audio?.stopPlayback();
882
+ if (this.attempt !== attempt || !attempt.active) return;
883
+ if (itemId != null && !attempt.closing && attempt.cause == null)
355
884
  this.sendEvent({
356
885
  type: 'conversation-item-truncate',
357
- itemId: this.currentResponseItemId,
886
+ itemId,
358
887
  contentIndex: 0,
359
888
  audioEndMs: Math.round(playedMs),
360
889
  });
361
- }
362
890
  }
363
891
  break;
364
- }
365
- case 'tool-call': {
366
- // Track every tool call in the response so a multi-tool turn only
367
- // triggers a single `response-create` once all outputs are submitted.
892
+ case 'tool-call':
893
+ if (this.continuous) break;
368
894
  this.toolCallsInResponse.add(effect.callId);
369
- void this.executeToolCall({
370
- name: effect.name,
371
- args: effect.args,
372
- callId: effect.callId,
373
- });
895
+ void this.executeTool(effect.callId, effect.name, effect.args, attempt);
374
896
  break;
375
- }
376
- case 'error': {
377
- this.onError?.(effect.error);
897
+ case 'error':
898
+ void this.reportError(effect.error, attempt);
378
899
  break;
379
- }
900
+ }
901
+ }
902
+
903
+ private async reportError(
904
+ error: unknown,
905
+ attempt?: RealtimeAttempt,
906
+ ): Promise<void> {
907
+ if (this.attempt !== attempt) return;
908
+ try {
909
+ await this.onError?.(
910
+ error instanceof Error ? error : new Error(String(error)),
911
+ );
912
+ } catch {
913
+ /* Application callbacks cannot interrupt cleanup. */
914
+ }
915
+ }
916
+
917
+ private notifyEvent(
918
+ event: RealtimeServerEvent,
919
+ attempt: RealtimeAttempt,
920
+ terminal = false,
921
+ ): void {
922
+ if (this.attempt !== attempt || (!attempt.active && !terminal)) return;
923
+ const report = (error: unknown) => {
924
+ if (attempt.active || terminal) void this.reportError(error, attempt);
925
+ };
926
+ try {
927
+ void Promise.resolve(this.onEvent?.(event)).catch(report);
928
+ } catch (error) {
929
+ report(error);
380
930
  }
381
931
  }
382
932
  }