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.
- package/CHANGELOG.md +30 -0
- package/dist/index.d.ts +82 -20
- package/dist/index.js +2010 -330
- package/dist/index.js.map +1 -1
- package/dist/internal/index.js +1 -1
- package/docs/03-agents/07-workflow-agent.mdx +1 -1
- package/docs/03-ai-sdk-core/36-realtime.mdx +41 -18
- package/docs/04-ai-sdk-ui/21-transport.mdx +1 -1
- package/docs/07-reference/02-ai-sdk-ui/05-use-realtime.mdx +364 -48
- package/docs/07-reference/04-ai-sdk-workflow/02-workflow-chat-transport.mdx +4 -4
- package/docs/07-reference/05-ai-sdk-errors/index.mdx +131 -36
- package/package.json +12 -12
- package/src/generate-text/execute-tools-from-stream.ts +7 -0
- package/src/generate-text/stream-text.ts +3 -1
- package/src/realtime/__fixtures__/fake-live-websocket.ts +71 -0
- package/src/realtime/__fixtures__/fake-realtime.ts +36 -0
- package/src/realtime/__fixtures__/fake-webrtc.ts +133 -0
- package/src/realtime/browser-realtime-audio.ts +107 -10
- package/src/realtime/browser-realtime-live-websocket.ts +247 -0
- package/src/realtime/browser-realtime-transport.ts +235 -69
- package/src/realtime/browser-realtime-webrtc.ts +582 -0
- package/src/realtime/encode-realtime-frame.ts +33 -0
- package/src/realtime/index.ts +1 -0
- package/src/realtime/realtime-attempt.ts +45 -0
- package/src/realtime/realtime-command-tracker.ts +81 -0
- package/src/realtime/realtime-event-channel.ts +170 -0
- package/src/realtime/realtime-event-reducer.ts +3 -0
- package/src/realtime/realtime-session-state.ts +65 -0
- package/src/realtime/realtime-session.ts +768 -218
- package/src/realtime/realtime-types.ts +1 -1
- 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 {
|
|
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
|
-
|
|
22
|
-
|
|
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:
|
|
40
|
-
onError:
|
|
41
|
-
|
|
42
|
-
private readonly
|
|
43
|
-
private readonly
|
|
44
|
-
private
|
|
45
|
-
private
|
|
46
|
-
private
|
|
47
|
-
private
|
|
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
|
-
|
|
65
|
-
this.
|
|
66
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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.
|
|
150
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
169
|
-
|
|
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
|
-
|
|
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 (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
739
|
+
const publication = ++this.publication;
|
|
740
|
+
const previous = this.state;
|
|
271
741
|
this.state = nextState;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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(
|
|
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
|
|
875
|
+
this.audio?.playAudio(effect.delta);
|
|
347
876
|
break;
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
this.audio
|
|
353
|
-
|
|
354
|
-
if (
|
|
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
|
|
886
|
+
itemId,
|
|
358
887
|
contentIndex: 0,
|
|
359
888
|
audioEndMs: Math.round(playedMs),
|
|
360
889
|
});
|
|
361
|
-
}
|
|
362
890
|
}
|
|
363
891
|
break;
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|