@spekoai/sdk 0.4.3 → 0.5.2
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 +6 -0
- package/README.md +88 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/lib/client.d.ts +13 -1
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +22 -2
- package/dist/lib/http.d.ts +12 -4
- package/dist/lib/http.d.ts.map +1 -1
- package/dist/lib/http.js +31 -14
- package/dist/lib/resources/agents.d.ts +12 -2
- package/dist/lib/resources/agents.d.ts.map +1 -1
- package/dist/lib/resources/agents.js +12 -4
- package/dist/lib/resources/calls.d.ts +19 -1
- package/dist/lib/resources/calls.d.ts.map +1 -1
- package/dist/lib/resources/calls.js +22 -0
- package/dist/lib/resources/knowledge-bases.d.ts +1 -1
- package/dist/lib/resources/knowledge-bases.js +1 -1
- package/dist/lib/resources/phone-numbers.d.ts +2 -1
- package/dist/lib/resources/phone-numbers.d.ts.map +1 -1
- package/dist/lib/resources/phone-numbers.js +2 -1
- package/dist/lib/resources/realtime.d.ts +3 -5
- package/dist/lib/resources/realtime.d.ts.map +1 -1
- package/dist/lib/resources/realtime.js +829 -91
- package/dist/lib/resources/sessions.d.ts +53 -0
- package/dist/lib/resources/sessions.d.ts.map +1 -0
- package/dist/lib/resources/sessions.js +166 -0
- package/dist/lib/resources/sms.d.ts +80 -0
- package/dist/lib/resources/sms.d.ts.map +1 -0
- package/dist/lib/resources/sms.js +152 -0
- package/dist/lib/resources/synthesize.d.ts.map +1 -1
- package/dist/lib/resources/synthesize.js +12 -6
- package/dist/lib/resources/transcribe.d.ts.map +1 -1
- package/dist/lib/resources/transcribe.js +9 -2
- package/dist/lib/resources/voice.d.ts +283 -1
- package/dist/lib/resources/voice.d.ts.map +1 -1
- package/dist/lib/resources/voice.js +345 -0
- package/dist/lib/resources/webhooks.d.ts +25 -0
- package/dist/lib/resources/webhooks.d.ts.map +1 -0
- package/dist/lib/resources/webhooks.js +46 -0
- package/dist/lib/types/index.d.ts +998 -9
- package/dist/lib/types/index.d.ts.map +1 -1
- package/dist/lib/voice-contract.d.ts +280 -0
- package/dist/lib/voice-contract.d.ts.map +1 -0
- package/dist/lib/voice-contract.js +115 -0
- package/package.json +2 -1
- package/src/index.ts +212 -0
- package/src/lib/client.ts +169 -0
- package/src/lib/errors.ts +28 -0
- package/src/lib/http.ts +442 -0
- package/src/lib/resources/agents.ts +211 -0
- package/src/lib/resources/callbacks.ts +40 -0
- package/src/lib/resources/calls.ts +113 -0
- package/src/lib/resources/complete.ts +63 -0
- package/src/lib/resources/credits.ts +41 -0
- package/src/lib/resources/knowledge-bases.ts +199 -0
- package/src/lib/resources/phone-numbers.ts +109 -0
- package/src/lib/resources/realtime-globals.d.ts +31 -0
- package/src/lib/resources/realtime.spec.ts +565 -0
- package/src/lib/resources/realtime.ts +1169 -0
- package/src/lib/resources/sessions.ts +191 -0
- package/src/lib/resources/sms.ts +214 -0
- package/src/lib/resources/synthesize.ts +101 -0
- package/src/lib/resources/transcribe.ts +91 -0
- package/src/lib/resources/usage.ts +24 -0
- package/src/lib/resources/voice.ts +426 -0
- package/src/lib/resources/voices.ts +32 -0
- package/src/lib/resources/webhooks.ts +67 -0
- package/src/lib/types/index.ts +2409 -0
- package/src/lib/voice-contract.ts +358 -0
|
@@ -4,15 +4,15 @@ export class Realtime {
|
|
|
4
4
|
this.http = http;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* (OpenAI Realtime, Gemini Live, xAI Grok Voice, Inworld) so the client sees a
|
|
11
|
-
* single transport regardless of which backend is in use.
|
|
7
|
+
* Mint a scoped provider credential, then connect the browser directly to
|
|
8
|
+
* the selected provider. Speko remains on setup and metering paths only;
|
|
9
|
+
* realtime audio never traverses a Speko WebSocket or media proxy.
|
|
12
10
|
*/
|
|
13
11
|
async connect(params) {
|
|
12
|
+
const idempotencyKey = params.idempotencyKey?.trim() || globalThis.crypto.randomUUID();
|
|
14
13
|
const response = await this.http.post('/v1/sessions', {
|
|
15
14
|
mode: 's2s',
|
|
15
|
+
agentId: params.agentId,
|
|
16
16
|
s2s: {
|
|
17
17
|
provider: params.provider,
|
|
18
18
|
model: params.model,
|
|
@@ -23,176 +23,914 @@ export class Realtime {
|
|
|
23
23
|
outputSampleRate: params.outputSampleRate,
|
|
24
24
|
tools: params.tools,
|
|
25
25
|
},
|
|
26
|
+
webhookTags: params.webhookTags,
|
|
26
27
|
metadata: params.metadata,
|
|
27
28
|
ttlSeconds: params.ttlSeconds,
|
|
28
|
-
});
|
|
29
|
-
return
|
|
29
|
+
}, undefined, { 'Idempotency-Key': idempotencyKey });
|
|
30
|
+
return ProviderDirectRealtimeHandle.create(response);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
function browserRealtimeGlobals() {
|
|
34
|
+
return globalThis;
|
|
35
|
+
}
|
|
36
|
+
class ProviderDirectRealtimeHandle {
|
|
33
37
|
sessionId;
|
|
34
38
|
expiresAt;
|
|
35
39
|
inputSampleRate;
|
|
36
40
|
outputSampleRate;
|
|
37
|
-
ws;
|
|
41
|
+
ws = null;
|
|
42
|
+
peer = null;
|
|
43
|
+
dataChannel = null;
|
|
44
|
+
inputAudioContext = null;
|
|
45
|
+
inputDestination = null;
|
|
46
|
+
inputScheduledAt = 0;
|
|
47
|
+
outputAudioContext = null;
|
|
48
|
+
outputSource = null;
|
|
49
|
+
outputProcessor = null;
|
|
38
50
|
handlers = new Set();
|
|
51
|
+
response;
|
|
52
|
+
provider;
|
|
53
|
+
attemptId;
|
|
54
|
+
telemetry;
|
|
55
|
+
authorizedDurationMs;
|
|
56
|
+
leaseExpiresAt;
|
|
57
|
+
openedAtMs = null;
|
|
39
58
|
closed = false;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
telemetryFinished = false;
|
|
60
|
+
telemetryTimer = null;
|
|
61
|
+
telemetrySequence = 0;
|
|
62
|
+
googleToolNames = new Map();
|
|
63
|
+
googleResumptionHandle = null;
|
|
64
|
+
googleSocketGeneration = 0;
|
|
65
|
+
googleReady = false;
|
|
66
|
+
googleReadyEmitted = false;
|
|
67
|
+
renewalTimer = null;
|
|
68
|
+
pendingGoogleMessages = [];
|
|
69
|
+
static async create(response) {
|
|
70
|
+
const handle = new ProviderDirectRealtimeHandle(response);
|
|
71
|
+
await handle.initialize();
|
|
72
|
+
return handle;
|
|
73
|
+
}
|
|
74
|
+
constructor(response) {
|
|
75
|
+
const expectedAdapter = adapterForProvider(response.provider);
|
|
76
|
+
if (response.transport !== 'provider_direct' || response.adapter !== expectedAdapter) {
|
|
77
|
+
throw new Error('Unsupported realtime provider-direct session plan');
|
|
78
|
+
}
|
|
79
|
+
this.sessionId = response.sessionId;
|
|
80
|
+
this.expiresAt = response.expiresAt;
|
|
81
|
+
this.inputSampleRate = response.inputSampleRate;
|
|
82
|
+
this.outputSampleRate = response.outputSampleRate;
|
|
83
|
+
this.response = response;
|
|
84
|
+
this.provider = response.provider;
|
|
85
|
+
this.attemptId = response.attemptId;
|
|
86
|
+
this.telemetry = response.telemetry;
|
|
87
|
+
this.authorizedDurationMs = response.reservation.authorizedDurationSeconds * 1_000;
|
|
88
|
+
this.leaseExpiresAt = response.reservation.leaseExpiresAt;
|
|
89
|
+
const expectedInputRate = response.provider === 'google' ? 16000 : 24000;
|
|
90
|
+
if (this.inputSampleRate !== expectedInputRate || this.outputSampleRate !== 24000) {
|
|
91
|
+
throw new Error(`${response.provider} realtime requires ${expectedInputRate / 1000} kHz input and 24 kHz output mono PCM audio`);
|
|
92
|
+
}
|
|
93
|
+
if ((response.provider === 'openai' && response.providerTransport !== 'webrtc') ||
|
|
94
|
+
(response.provider !== 'openai' && response.providerTransport !== 'websocket')) {
|
|
95
|
+
throw new Error(`Invalid ${response.provider} realtime transport`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async initialize() {
|
|
99
|
+
if (this.provider === 'openai') {
|
|
100
|
+
await this.connectOpenAI();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.connectWebSocket(this.response.credential.value);
|
|
104
|
+
}
|
|
105
|
+
connectWebSocket(credential, resumptionHandle) {
|
|
106
|
+
const generation = ++this.googleSocketGeneration;
|
|
107
|
+
const url = providerRealtimeURL(this.response.endpoint, this.provider, this.response.model, credential);
|
|
108
|
+
// Browser WebSockets cannot set Authorization. Each provider's browser
|
|
109
|
+
// channel carries only the delegated credential as a subprotocol.
|
|
110
|
+
const socket = this.provider === 'google'
|
|
111
|
+
? new WebSocket(url)
|
|
112
|
+
: new WebSocket(url, [`xai-client-secret.${credential}`]);
|
|
113
|
+
this.ws = socket;
|
|
114
|
+
socket.addEventListener('open', () => {
|
|
115
|
+
if (this.closed || this.ws !== socket)
|
|
116
|
+
return;
|
|
117
|
+
this.openedAtMs ??= Date.now();
|
|
118
|
+
this.startTelemetry();
|
|
119
|
+
socket.send(JSON.stringify(providerSessionUpdate(this.response, resumptionHandle)));
|
|
120
|
+
});
|
|
121
|
+
socket.addEventListener('message', (event) => {
|
|
122
|
+
if (this.ws === socket)
|
|
123
|
+
this.dispatchIncoming(event.data);
|
|
52
124
|
});
|
|
53
|
-
|
|
125
|
+
socket.addEventListener('close', (event) => {
|
|
126
|
+
if (this.provider === 'google' && generation !== this.googleSocketGeneration)
|
|
127
|
+
return;
|
|
128
|
+
if (this.ws !== socket || this.closed)
|
|
129
|
+
return;
|
|
54
130
|
this.closed = true;
|
|
55
|
-
this.
|
|
131
|
+
this.finishTelemetry();
|
|
132
|
+
this.emit({ type: 'close', code: event.code, reason: event.reason });
|
|
133
|
+
});
|
|
134
|
+
socket.addEventListener('error', () => {
|
|
135
|
+
if (this.ws !== socket)
|
|
136
|
+
return;
|
|
137
|
+
this.emit({ type: 'error', code: 'WS_ERROR', message: 'Provider WebSocket transport error' });
|
|
56
138
|
});
|
|
57
|
-
|
|
139
|
+
}
|
|
140
|
+
async connectOpenAI() {
|
|
141
|
+
const endpoint = validatedOpenAIWebRTCEndpoint(this.response.endpoint);
|
|
142
|
+
const sidebandUrl = validatedSidebandURL(this.response.sidebandUrl, this.telemetry.endpoint, this.sessionId);
|
|
143
|
+
const browser = browserRealtimeGlobals();
|
|
144
|
+
const PeerConnection = browser.RTCPeerConnection;
|
|
145
|
+
const AudioContext = browser.AudioContext;
|
|
146
|
+
if (!PeerConnection || !AudioContext) {
|
|
147
|
+
throw new Error('OpenAI realtime WebRTC requires browser WebRTC and Web Audio support');
|
|
148
|
+
}
|
|
149
|
+
const peer = new PeerConnection();
|
|
150
|
+
this.peer = peer;
|
|
151
|
+
const inputContext = new AudioContext({ sampleRate: this.inputSampleRate });
|
|
152
|
+
const destination = inputContext.createMediaStreamDestination();
|
|
153
|
+
this.inputAudioContext = inputContext;
|
|
154
|
+
this.inputDestination = destination;
|
|
155
|
+
const inputTrack = destination.stream.getAudioTracks()[0];
|
|
156
|
+
if (!inputTrack)
|
|
157
|
+
throw new Error('Unable to create the OpenAI realtime audio track');
|
|
158
|
+
peer.addTrack(inputTrack, destination.stream);
|
|
159
|
+
const channel = peer.createDataChannel('oai-events');
|
|
160
|
+
this.dataChannel = channel;
|
|
161
|
+
channel.addEventListener('open', () => {
|
|
162
|
+
if (this.closed || this.dataChannel !== channel)
|
|
163
|
+
return;
|
|
164
|
+
this.openedAtMs = Date.now();
|
|
165
|
+
this.startTelemetry();
|
|
166
|
+
this.sendJson(providerSessionUpdate(this.response));
|
|
167
|
+
});
|
|
168
|
+
channel.addEventListener('message', (event) => this.dispatchIncoming(event.data));
|
|
169
|
+
channel.addEventListener('error', () => {
|
|
58
170
|
this.emit({
|
|
59
171
|
type: 'error',
|
|
60
|
-
code: '
|
|
61
|
-
message: '
|
|
172
|
+
code: 'WEBRTC_DATA_ERROR',
|
|
173
|
+
message: 'OpenAI control channel error',
|
|
62
174
|
});
|
|
63
175
|
});
|
|
176
|
+
peer.addEventListener('track', (event) => {
|
|
177
|
+
if (event.track)
|
|
178
|
+
this.attachOpenAIOutput(event.track);
|
|
179
|
+
});
|
|
180
|
+
peer.addEventListener('connectionstatechange', () => {
|
|
181
|
+
if (this.closed || (peer.connectionState !== 'failed' && peer.connectionState !== 'closed')) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.close(1011, `webrtc_${peer.connectionState}`);
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
const offer = await peer.createOffer();
|
|
188
|
+
await peer.setLocalDescription(offer);
|
|
189
|
+
const offerSdp = peer.localDescription?.sdp;
|
|
190
|
+
if (!offerSdp)
|
|
191
|
+
throw new Error('OpenAI WebRTC offer has no SDP');
|
|
192
|
+
const answer = await fetch(endpoint, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
redirect: 'error',
|
|
195
|
+
signal: AbortSignal.timeout(10_000),
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${this.response.credential.value}`,
|
|
198
|
+
'Content-Type': 'application/sdp',
|
|
199
|
+
},
|
|
200
|
+
body: offerSdp,
|
|
201
|
+
});
|
|
202
|
+
if (!answer.ok) {
|
|
203
|
+
throw new Error(`OpenAI WebRTC setup failed with HTTP ${answer.status}`);
|
|
204
|
+
}
|
|
205
|
+
const callId = openAICallID(answer.headers.get('Location'));
|
|
206
|
+
const answerSdp = await answer.text();
|
|
207
|
+
if (!answerSdp || answerSdp.length > 128 << 10) {
|
|
208
|
+
throw new Error('OpenAI WebRTC answer is invalid');
|
|
209
|
+
}
|
|
210
|
+
// Do not install the remote description (and therefore do not enable
|
|
211
|
+
// media) until Speko has attached a provider-authenticated sideband.
|
|
212
|
+
const bound = await fetch(sidebandUrl, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
redirect: 'error',
|
|
215
|
+
signal: AbortSignal.timeout(10_000),
|
|
216
|
+
headers: {
|
|
217
|
+
Authorization: `Bearer ${this.telemetry.token}`,
|
|
218
|
+
'Content-Type': 'application/json',
|
|
219
|
+
},
|
|
220
|
+
body: JSON.stringify({ attempt_id: this.attemptId, provider_session_id: callId }),
|
|
221
|
+
});
|
|
222
|
+
if (!bound.ok) {
|
|
223
|
+
throw new Error(`OpenAI billing sideband failed with HTTP ${bound.status}`);
|
|
224
|
+
}
|
|
225
|
+
await peer.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
this.disposeTransports();
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
attachOpenAIOutput(track) {
|
|
233
|
+
const browser = browserRealtimeGlobals();
|
|
234
|
+
const AudioContext = browser.AudioContext;
|
|
235
|
+
const MediaStream = browser.MediaStream;
|
|
236
|
+
if (this.closed || !AudioContext || !MediaStream)
|
|
237
|
+
return;
|
|
238
|
+
const context = new AudioContext({ sampleRate: this.outputSampleRate });
|
|
239
|
+
const source = context.createMediaStreamSource(new MediaStream([track]));
|
|
240
|
+
const processor = context.createScriptProcessor(2048, 1, 1);
|
|
241
|
+
processor.onaudioprocess = (event) => {
|
|
242
|
+
const samples = event.inputBuffer.getChannelData(0);
|
|
243
|
+
const pcm = floatPCMTo16Bit(samples, context.sampleRate, this.outputSampleRate);
|
|
244
|
+
if (pcm.byteLength > 0) {
|
|
245
|
+
this.emit({ type: 'audio', pcm, sampleRate: this.outputSampleRate });
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
source.connect(processor);
|
|
249
|
+
processor.connect(context.destination);
|
|
250
|
+
this.outputAudioContext = context;
|
|
251
|
+
this.outputSource = source;
|
|
252
|
+
this.outputProcessor = processor;
|
|
253
|
+
void context.resume().catch(() => undefined);
|
|
64
254
|
}
|
|
65
255
|
sendAudio(pcm) {
|
|
66
|
-
if (this.
|
|
256
|
+
if (this.provider === 'openai') {
|
|
257
|
+
this.sendOpenAIAudio(pcm);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (this.isGoogle()) {
|
|
261
|
+
this.sendJson({
|
|
262
|
+
realtimeInput: {
|
|
263
|
+
audio: { mimeType: 'audio/pcm;rate=16000', data: encodeBase64(pcm) },
|
|
264
|
+
},
|
|
265
|
+
});
|
|
67
266
|
return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const copy = new Uint8Array(pcm.byteLength);
|
|
71
|
-
copy.set(pcm);
|
|
72
|
-
this.ws.send(copy.buffer);
|
|
267
|
+
}
|
|
268
|
+
this.sendJson({ type: 'input_audio_buffer.append', audio: encodeBase64(pcm) });
|
|
73
269
|
}
|
|
74
270
|
commit() {
|
|
75
|
-
this.
|
|
271
|
+
if (this.isGoogle()) {
|
|
272
|
+
this.sendJson({ realtimeInput: { audioStreamEnd: true } });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.sendJson({ type: 'input_audio_buffer.commit' });
|
|
276
|
+
this.sendJson({ type: 'response.create' });
|
|
76
277
|
}
|
|
77
278
|
interrupt() {
|
|
78
|
-
this.
|
|
279
|
+
if (this.isGoogle()) {
|
|
280
|
+
this.emit({ type: 'interruption', at: 'assistant' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.sendJson({ type: 'response.cancel' });
|
|
79
284
|
}
|
|
80
285
|
sendToolResult(callId, output) {
|
|
81
|
-
this.
|
|
286
|
+
if (this.isGoogle()) {
|
|
287
|
+
const name = this.googleToolNames.get(callId);
|
|
288
|
+
this.googleToolNames.delete(callId);
|
|
289
|
+
let response = { result: output };
|
|
290
|
+
try {
|
|
291
|
+
response = JSON.parse(output);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Non-JSON tool results remain a string result.
|
|
295
|
+
}
|
|
296
|
+
this.sendJson({
|
|
297
|
+
toolResponse: {
|
|
298
|
+
functionResponses: [{ id: callId, ...(name ? { name } : {}), response }],
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.sendJson({
|
|
304
|
+
type: 'conversation.item.create',
|
|
305
|
+
item: { type: 'function_call_output', call_id: callId, output },
|
|
306
|
+
});
|
|
307
|
+
this.sendJson({ type: 'response.create' });
|
|
82
308
|
}
|
|
83
309
|
on(handler) {
|
|
84
310
|
this.handlers.add(handler);
|
|
85
|
-
return () =>
|
|
86
|
-
this.handlers.delete(handler);
|
|
87
|
-
};
|
|
311
|
+
return () => this.handlers.delete(handler);
|
|
88
312
|
}
|
|
89
313
|
close(code = 1000, reason = 'client_closed') {
|
|
90
314
|
if (this.closed)
|
|
91
315
|
return;
|
|
92
316
|
this.closed = true;
|
|
317
|
+
this.finishTelemetry();
|
|
318
|
+
this.disposeTransports(code, reason);
|
|
319
|
+
}
|
|
320
|
+
sendJson(payload) {
|
|
321
|
+
if (this.closed)
|
|
322
|
+
return;
|
|
323
|
+
const encoded = JSON.stringify(payload);
|
|
324
|
+
if (this.provider === 'openai') {
|
|
325
|
+
if (this.dataChannel?.readyState !== 'open')
|
|
326
|
+
return;
|
|
327
|
+
try {
|
|
328
|
+
this.dataChannel.send(encoded);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// A data-channel/peer state event is the authoritative signal.
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (this.provider === 'google' && (!this.googleReady || this.ws?.readyState !== 1)) {
|
|
336
|
+
// Keep rotation gaps media-lossless but bounded. At typical 20 ms chunks,
|
|
337
|
+
// 128 messages is under three seconds of audio.
|
|
338
|
+
if (this.pendingGoogleMessages.length < 128)
|
|
339
|
+
this.pendingGoogleMessages.push(encoded);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (this.ws?.readyState !== 1)
|
|
343
|
+
return;
|
|
93
344
|
try {
|
|
94
|
-
this.ws.
|
|
345
|
+
this.ws.send(encoded);
|
|
95
346
|
}
|
|
96
347
|
catch {
|
|
97
|
-
//
|
|
348
|
+
// A close/error event is the authoritative transport signal.
|
|
98
349
|
}
|
|
99
350
|
}
|
|
100
|
-
|
|
101
|
-
|
|
351
|
+
sendOpenAIAudio(pcm) {
|
|
352
|
+
const context = this.inputAudioContext;
|
|
353
|
+
const destination = this.inputDestination;
|
|
354
|
+
if (this.closed || !context || !destination || pcm.byteLength < 2)
|
|
355
|
+
return;
|
|
356
|
+
const sampleCount = Math.floor(pcm.byteLength / 2);
|
|
357
|
+
const buffer = context.createBuffer(1, sampleCount, this.inputSampleRate);
|
|
358
|
+
const samples = buffer.getChannelData(0);
|
|
359
|
+
const view = new DataView(pcm.buffer, pcm.byteOffset, sampleCount * 2);
|
|
360
|
+
for (let index = 0; index < sampleCount; index += 1) {
|
|
361
|
+
samples[index] = view.getInt16(index * 2, true) / 32768;
|
|
362
|
+
}
|
|
363
|
+
const source = context.createBufferSource();
|
|
364
|
+
source.buffer = buffer;
|
|
365
|
+
source.connect(destination);
|
|
366
|
+
const startAt = Math.max(context.currentTime + 0.005, this.inputScheduledAt);
|
|
367
|
+
source.start(startAt);
|
|
368
|
+
this.inputScheduledAt = startAt + buffer.duration;
|
|
369
|
+
source.addEventListener('ended', () => source.disconnect(), { once: true });
|
|
370
|
+
void context.resume().catch(() => undefined);
|
|
102
371
|
}
|
|
103
|
-
|
|
104
|
-
if (this.
|
|
372
|
+
startTelemetry() {
|
|
373
|
+
if (this.telemetryTimer !== null || this.telemetryFinished)
|
|
105
374
|
return;
|
|
375
|
+
this.telemetryTimer = setInterval(() => this.sendTelemetry(false), this.telemetry.flushIntervalMs);
|
|
376
|
+
}
|
|
377
|
+
disposeTransports(code = 1000, reason = 'client_closed') {
|
|
378
|
+
if (this.renewalTimer !== null)
|
|
379
|
+
clearTimeout(this.renewalTimer);
|
|
380
|
+
this.renewalTimer = null;
|
|
106
381
|
try {
|
|
107
|
-
this.ws
|
|
382
|
+
this.ws?.close(code, reason);
|
|
108
383
|
}
|
|
109
384
|
catch {
|
|
110
385
|
// ignore
|
|
111
386
|
}
|
|
387
|
+
try {
|
|
388
|
+
this.dataChannel?.close();
|
|
389
|
+
this.peer?.close();
|
|
390
|
+
this.outputSource?.disconnect();
|
|
391
|
+
this.outputProcessor?.disconnect();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// ignore
|
|
395
|
+
}
|
|
396
|
+
void this.inputAudioContext?.close().catch(() => undefined);
|
|
397
|
+
void this.outputAudioContext?.close().catch(() => undefined);
|
|
398
|
+
this.ws = null;
|
|
399
|
+
this.dataChannel = null;
|
|
400
|
+
this.peer = null;
|
|
112
401
|
}
|
|
113
402
|
dispatchIncoming(data) {
|
|
114
|
-
if (data instanceof ArrayBuffer) {
|
|
115
|
-
this.emit({
|
|
116
|
-
type: 'audio',
|
|
117
|
-
pcm: new Uint8Array(data),
|
|
118
|
-
sampleRate: 24000,
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
403
|
if (typeof data !== 'string')
|
|
123
404
|
return;
|
|
124
|
-
let
|
|
405
|
+
let event;
|
|
125
406
|
try {
|
|
126
|
-
|
|
407
|
+
event = JSON.parse(data);
|
|
127
408
|
}
|
|
128
409
|
catch {
|
|
129
410
|
return;
|
|
130
411
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
text: String(parsed['text'] ?? ''),
|
|
138
|
-
final: Boolean(parsed['final']),
|
|
139
|
-
});
|
|
140
|
-
break;
|
|
141
|
-
case 'ready':
|
|
412
|
+
if (this.isGoogle()) {
|
|
413
|
+
this.dispatchGoogle(event);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
switch (event['type']) {
|
|
417
|
+
case 'session.updated':
|
|
142
418
|
this.emit({
|
|
143
419
|
type: 'ready',
|
|
144
|
-
inputSampleRate:
|
|
145
|
-
outputSampleRate:
|
|
420
|
+
inputSampleRate: this.inputSampleRate,
|
|
421
|
+
outputSampleRate: this.outputSampleRate,
|
|
146
422
|
});
|
|
147
423
|
break;
|
|
148
|
-
case '
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
424
|
+
case 'response.output_audio.delta': {
|
|
425
|
+
const delta = event['delta'];
|
|
426
|
+
if (typeof delta === 'string' && delta) {
|
|
427
|
+
this.emit({ type: 'audio', pcm: decodeBase64(delta), sampleRate: this.outputSampleRate });
|
|
428
|
+
}
|
|
153
429
|
break;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
430
|
+
}
|
|
431
|
+
case 'conversation.item.input_audio_transcription.delta':
|
|
432
|
+
this.emitTranscript('user', event['delta'], false);
|
|
433
|
+
break;
|
|
434
|
+
case 'conversation.item.input_audio_transcription.updated':
|
|
435
|
+
// xAI publishes cumulative corrections rather than deltas.
|
|
436
|
+
this.emitTranscript('user', event['transcript'], false);
|
|
437
|
+
break;
|
|
438
|
+
case 'conversation.item.input_audio_transcription.completed':
|
|
439
|
+
this.emitTranscript('user', event['transcript'], true);
|
|
161
440
|
break;
|
|
162
|
-
case '
|
|
441
|
+
case 'response.output_audio_transcript.delta':
|
|
442
|
+
this.emitTranscript('assistant', event['delta'], false);
|
|
443
|
+
break;
|
|
444
|
+
case 'response.output_audio_transcript.done':
|
|
445
|
+
this.emitTranscript('assistant', event['transcript'], true);
|
|
446
|
+
break;
|
|
447
|
+
case 'response.function_call_arguments.done':
|
|
163
448
|
this.emit({
|
|
164
449
|
type: 'tool_call',
|
|
165
|
-
callId: String(
|
|
166
|
-
name: String(
|
|
167
|
-
arguments: String(
|
|
450
|
+
callId: String(event['call_id'] ?? ''),
|
|
451
|
+
name: String(event['name'] ?? ''),
|
|
452
|
+
arguments: String(event['arguments'] ?? ''),
|
|
168
453
|
});
|
|
169
454
|
break;
|
|
170
|
-
case '
|
|
455
|
+
case 'input_audio_buffer.speech_started':
|
|
456
|
+
this.emit({ type: 'interruption', at: 'user' });
|
|
457
|
+
break;
|
|
458
|
+
case 'response.done': {
|
|
459
|
+
const usage = asRecord(asRecord(event['response'])['usage']);
|
|
460
|
+
const inputDetails = asRecord(usage['input_token_details']);
|
|
461
|
+
const outputDetails = asRecord(usage['output_token_details']);
|
|
171
462
|
this.emit({
|
|
172
463
|
type: 'usage',
|
|
173
|
-
inputAudioTokens:
|
|
174
|
-
outputAudioTokens:
|
|
464
|
+
inputAudioTokens: finiteNumber(inputDetails['audio_tokens']),
|
|
465
|
+
outputAudioTokens: finiteNumber(outputDetails['audio_tokens']),
|
|
175
466
|
});
|
|
176
467
|
break;
|
|
177
|
-
|
|
468
|
+
}
|
|
469
|
+
case 'error': {
|
|
470
|
+
const error = asRecord(event['error']);
|
|
178
471
|
this.emit({
|
|
179
472
|
type: 'error',
|
|
180
|
-
code: String(
|
|
181
|
-
message: String(
|
|
473
|
+
code: String(error['code'] ?? 'PROVIDER_ERROR'),
|
|
474
|
+
message: String(error['message'] ?? 'Provider realtime error'),
|
|
182
475
|
});
|
|
183
476
|
break;
|
|
477
|
+
}
|
|
184
478
|
default:
|
|
185
479
|
break;
|
|
186
480
|
}
|
|
187
481
|
}
|
|
482
|
+
dispatchGoogle(event) {
|
|
483
|
+
if (event['setupComplete'] !== undefined) {
|
|
484
|
+
this.googleReady = true;
|
|
485
|
+
if (!this.googleReadyEmitted) {
|
|
486
|
+
this.googleReadyEmitted = true;
|
|
487
|
+
this.emit({
|
|
488
|
+
type: 'ready',
|
|
489
|
+
inputSampleRate: this.inputSampleRate,
|
|
490
|
+
outputSampleRate: this.outputSampleRate,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
const socket = this.ws;
|
|
494
|
+
if (socket?.readyState === 1) {
|
|
495
|
+
for (const message of this.pendingGoogleMessages.splice(0))
|
|
496
|
+
socket.send(message);
|
|
497
|
+
}
|
|
498
|
+
this.scheduleGoogleRenewal();
|
|
499
|
+
}
|
|
500
|
+
const resumption = asRecord(event['sessionResumptionUpdate']);
|
|
501
|
+
const newHandle = resumption['newHandle'];
|
|
502
|
+
if (typeof newHandle === 'string' && newHandle)
|
|
503
|
+
this.googleResumptionHandle = newHandle;
|
|
504
|
+
const content = asRecord(event['serverContent']);
|
|
505
|
+
const inputTranscription = asRecord(content['inputTranscription']);
|
|
506
|
+
this.emitTranscript('user', inputTranscription['text'], Boolean(content['turnComplete']));
|
|
507
|
+
const outputTranscription = asRecord(content['outputTranscription']);
|
|
508
|
+
this.emitTranscript('assistant', outputTranscription['text'], Boolean(content['turnComplete']));
|
|
509
|
+
const modelTurn = asRecord(content['modelTurn']);
|
|
510
|
+
const parts = Array.isArray(modelTurn['parts']) ? modelTurn['parts'] : [];
|
|
511
|
+
for (const value of parts) {
|
|
512
|
+
const part = asRecord(value);
|
|
513
|
+
const inlineData = asRecord(part['inlineData']);
|
|
514
|
+
const audio = inlineData['data'];
|
|
515
|
+
if (typeof audio === 'string' && audio) {
|
|
516
|
+
this.emit({ type: 'audio', pcm: decodeBase64(audio), sampleRate: this.outputSampleRate });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (content['interrupted'] === true) {
|
|
520
|
+
this.emit({ type: 'interruption', at: 'user' });
|
|
521
|
+
}
|
|
522
|
+
const toolCall = asRecord(event['toolCall']);
|
|
523
|
+
const functionCalls = Array.isArray(toolCall['functionCalls']) ? toolCall['functionCalls'] : [];
|
|
524
|
+
for (const value of functionCalls) {
|
|
525
|
+
const call = asRecord(value);
|
|
526
|
+
const callId = String(call['id'] ?? '');
|
|
527
|
+
const name = String(call['name'] ?? '');
|
|
528
|
+
if (callId && name)
|
|
529
|
+
this.googleToolNames.set(callId, name);
|
|
530
|
+
this.emit({
|
|
531
|
+
type: 'tool_call',
|
|
532
|
+
callId,
|
|
533
|
+
name,
|
|
534
|
+
arguments: typeof call['args'] === 'string' ? call['args'] : JSON.stringify(call['args'] ?? {}),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
const usage = asRecord(event['usageMetadata']);
|
|
538
|
+
if (Object.keys(usage).length > 0) {
|
|
539
|
+
this.emit({
|
|
540
|
+
type: 'usage',
|
|
541
|
+
inputAudioTokens: finiteNumber(usage['promptTokenCount']),
|
|
542
|
+
outputAudioTokens: finiteNumber(usage['responseTokenCount']),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const error = asRecord(event['error']);
|
|
546
|
+
if (Object.keys(error).length > 0) {
|
|
547
|
+
this.emit({
|
|
548
|
+
type: 'error',
|
|
549
|
+
code: String(error['status'] ?? error['code'] ?? 'PROVIDER_ERROR'),
|
|
550
|
+
message: String(error['message'] ?? 'Gemini Live error'),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
emitTranscript(role, value, final) {
|
|
555
|
+
if (typeof value === 'string' && value) {
|
|
556
|
+
this.emit({ type: 'transcript', role, text: value, final });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
188
559
|
emit(frame) {
|
|
189
560
|
for (const handler of this.handlers) {
|
|
190
561
|
try {
|
|
191
562
|
handler(frame);
|
|
192
563
|
}
|
|
193
564
|
catch {
|
|
194
|
-
//
|
|
565
|
+
// A consumer callback cannot break the provider event pump.
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
isGoogle() {
|
|
570
|
+
return this.provider === 'google';
|
|
571
|
+
}
|
|
572
|
+
scheduleGoogleRenewal() {
|
|
573
|
+
if (this.provider !== 'google' || this.closed)
|
|
574
|
+
return;
|
|
575
|
+
const renewalUrl = this.response.reservation.billing.renewalUrl;
|
|
576
|
+
const renewableUntil = this.response.reservation.billing.renewableUntil;
|
|
577
|
+
if (!renewalUrl || !renewableUntil)
|
|
578
|
+
return;
|
|
579
|
+
if (Date.parse(this.leaseExpiresAt) >= Date.parse(renewableUntil))
|
|
580
|
+
return;
|
|
581
|
+
if (this.renewalTimer !== null)
|
|
582
|
+
clearTimeout(this.renewalTimer);
|
|
583
|
+
const remainingMs = Math.max(0, Date.parse(this.leaseExpiresAt) - Date.now());
|
|
584
|
+
const leadMs = Math.min(15_000, Math.max(2_000, Math.floor(remainingMs / 5)));
|
|
585
|
+
this.renewalTimer = setTimeout(() => void this.renewGoogleEntitlement(0), Math.max(0, remainingMs - leadMs));
|
|
586
|
+
}
|
|
587
|
+
async renewGoogleEntitlement(attempt) {
|
|
588
|
+
if (this.closed || this.provider !== 'google')
|
|
589
|
+
return;
|
|
590
|
+
const previousExpiresAt = this.leaseExpiresAt;
|
|
591
|
+
try {
|
|
592
|
+
if (!this.googleResumptionHandle) {
|
|
593
|
+
throw new Error('Gemini Live did not provide a session-resumption handle');
|
|
594
|
+
}
|
|
595
|
+
const renewalUrl = validatedRenewalURL(this.response.reservation.billing.renewalUrl, this.telemetry.endpoint, this.sessionId);
|
|
596
|
+
const response = await fetch(renewalUrl, {
|
|
597
|
+
method: 'POST',
|
|
598
|
+
redirect: 'error',
|
|
599
|
+
signal: AbortSignal.timeout(10_000),
|
|
600
|
+
headers: {
|
|
601
|
+
Authorization: `Bearer ${this.telemetry.token}`,
|
|
602
|
+
'Content-Type': 'application/json',
|
|
603
|
+
'Idempotency-Key': `renew:${this.attemptId}:${previousExpiresAt}`,
|
|
604
|
+
},
|
|
605
|
+
body: JSON.stringify({ previous_expires_at: previousExpiresAt }),
|
|
606
|
+
});
|
|
607
|
+
if (!response.ok)
|
|
608
|
+
throw new Error(`entitlement renewal failed with HTTP ${response.status}`);
|
|
609
|
+
const renewal = (await response.json());
|
|
610
|
+
assertRenewalResponse(renewal, previousExpiresAt, this.response.reservation.billing.renewableUntil);
|
|
611
|
+
const oldSocket = this.ws;
|
|
612
|
+
this.googleReady = false;
|
|
613
|
+
this.googleSocketGeneration += 1;
|
|
614
|
+
this.ws = null;
|
|
615
|
+
try {
|
|
616
|
+
oldSocket?.close(1000, 'entitlement_rotation');
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// The generation guard already suppresses a stale close event.
|
|
195
620
|
}
|
|
621
|
+
this.authorizedDurationMs += renewal.authorized_units * 1_000;
|
|
622
|
+
this.leaseExpiresAt = renewal.lease_expires_at;
|
|
623
|
+
this.connectWebSocket(renewal.credential.value, this.googleResumptionHandle);
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
const remainingMs = Date.parse(previousExpiresAt) - Date.now();
|
|
627
|
+
if (attempt < 2 && remainingMs > 3_000) {
|
|
628
|
+
this.renewalTimer = setTimeout(() => void this.renewGoogleEntitlement(attempt + 1), Math.min(2_000, Math.max(250, remainingMs - 2_000)));
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
this.emit({
|
|
632
|
+
type: 'error',
|
|
633
|
+
code: 'ENTITLEMENT_RENEWAL_FAILED',
|
|
634
|
+
message: error instanceof Error ? error.message : 'Gemini Live entitlement renewal failed',
|
|
635
|
+
});
|
|
636
|
+
this.renewalTimer = setTimeout(() => this.close(4003, 'entitlement_expired'), Math.max(0, remainingMs));
|
|
196
637
|
}
|
|
197
638
|
}
|
|
639
|
+
finishTelemetry() {
|
|
640
|
+
if (this.telemetryFinished)
|
|
641
|
+
return;
|
|
642
|
+
this.telemetryFinished = true;
|
|
643
|
+
if (this.telemetryTimer !== null) {
|
|
644
|
+
clearInterval(this.telemetryTimer);
|
|
645
|
+
this.telemetryTimer = null;
|
|
646
|
+
}
|
|
647
|
+
this.sendTelemetry(true);
|
|
648
|
+
}
|
|
649
|
+
sendTelemetry(terminal) {
|
|
650
|
+
if (this.telemetryFinished && !terminal)
|
|
651
|
+
return;
|
|
652
|
+
const createdAtMs = Date.now();
|
|
653
|
+
const elapsed = this.openedAtMs === null ? 0 : Math.max(0, createdAtMs - this.openedAtMs);
|
|
654
|
+
const quantityMillis = Math.min(this.authorizedDurationMs, elapsed);
|
|
655
|
+
this.telemetrySequence += 1;
|
|
656
|
+
const common = {
|
|
657
|
+
session_id: this.sessionId,
|
|
658
|
+
attempt_id: this.attemptId,
|
|
659
|
+
created_at_ms: createdAtMs,
|
|
660
|
+
};
|
|
661
|
+
const events = [
|
|
662
|
+
{
|
|
663
|
+
...common,
|
|
664
|
+
type: 'usage.reported',
|
|
665
|
+
event_id: `${this.attemptId}:usage.reported:${this.telemetrySequence}`,
|
|
666
|
+
data: { unit: 'duration_seconds', quantity_millis: quantityMillis },
|
|
667
|
+
},
|
|
668
|
+
...(terminal
|
|
669
|
+
? [
|
|
670
|
+
{
|
|
671
|
+
...common,
|
|
672
|
+
type: 'session.closed',
|
|
673
|
+
event_id: `${this.attemptId}:session.closed`,
|
|
674
|
+
},
|
|
675
|
+
]
|
|
676
|
+
: []),
|
|
677
|
+
];
|
|
678
|
+
void fetch(this.telemetry.endpoint, {
|
|
679
|
+
method: 'POST',
|
|
680
|
+
keepalive: true,
|
|
681
|
+
headers: {
|
|
682
|
+
Authorization: `Bearer ${this.telemetry.token}`,
|
|
683
|
+
'Content-Type': 'application/json',
|
|
684
|
+
},
|
|
685
|
+
body: JSON.stringify({ events }),
|
|
686
|
+
}).catch(() => undefined);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function providerSessionUpdate(response, resumptionHandle) {
|
|
690
|
+
if (response.provider === 'google')
|
|
691
|
+
return googleSessionSetup(response, resumptionHandle);
|
|
692
|
+
if (response.provider === 'xai')
|
|
693
|
+
return xAISessionUpdate(response);
|
|
694
|
+
const session = {
|
|
695
|
+
type: 'realtime',
|
|
696
|
+
output_modalities: ['audio'],
|
|
697
|
+
audio: {
|
|
698
|
+
input: {
|
|
699
|
+
format: { type: 'audio/pcm', rate: 24000 },
|
|
700
|
+
transcription: { model: 'gpt-4o-mini-transcribe' },
|
|
701
|
+
// RealtimeSessionHandle.commit() is the explicit turn boundary.
|
|
702
|
+
turn_detection: null,
|
|
703
|
+
},
|
|
704
|
+
output: {
|
|
705
|
+
format: { type: 'audio/pcm', rate: 24000 },
|
|
706
|
+
...(response.session.voice ? { voice: response.session.voice } : {}),
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
if (response.session.instructions !== undefined) {
|
|
711
|
+
session['instructions'] = response.session.instructions;
|
|
712
|
+
}
|
|
713
|
+
if (response.session.tools) {
|
|
714
|
+
session['tools'] = response.session.tools.map((tool) => ({ type: 'function', ...tool }));
|
|
715
|
+
session['tool_choice'] = 'auto';
|
|
716
|
+
}
|
|
717
|
+
return { type: 'session.update', session };
|
|
718
|
+
}
|
|
719
|
+
function googleSessionSetup(response, resumptionHandle) {
|
|
720
|
+
const generationConfig = { responseModalities: ['AUDIO'] };
|
|
721
|
+
if (response.session.voice) {
|
|
722
|
+
generationConfig['speechConfig'] = {
|
|
723
|
+
voiceConfig: { prebuiltVoiceConfig: { voiceName: response.session.voice } },
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (response.session.temperature !== undefined) {
|
|
727
|
+
generationConfig['temperature'] = response.session.temperature;
|
|
728
|
+
}
|
|
729
|
+
const setup = {
|
|
730
|
+
model: `models/${response.model.replace(/^models\//, '')}`,
|
|
731
|
+
generationConfig,
|
|
732
|
+
inputAudioTranscription: {},
|
|
733
|
+
outputAudioTranscription: {},
|
|
734
|
+
sessionResumption: resumptionHandle ? { handle: resumptionHandle } : {},
|
|
735
|
+
};
|
|
736
|
+
if (response.session.instructions !== undefined) {
|
|
737
|
+
setup['systemInstruction'] = { parts: [{ text: response.session.instructions }] };
|
|
738
|
+
}
|
|
739
|
+
if (response.session.tools?.length) {
|
|
740
|
+
setup['tools'] = [
|
|
741
|
+
{
|
|
742
|
+
functionDeclarations: response.session.tools.map((tool) => ({
|
|
743
|
+
name: tool.name,
|
|
744
|
+
description: tool.description,
|
|
745
|
+
parameters: tool.parameters,
|
|
746
|
+
})),
|
|
747
|
+
},
|
|
748
|
+
];
|
|
749
|
+
}
|
|
750
|
+
return { setup };
|
|
751
|
+
}
|
|
752
|
+
function xAISessionUpdate(response) {
|
|
753
|
+
const session = {
|
|
754
|
+
type: 'realtime',
|
|
755
|
+
model: response.model,
|
|
756
|
+
output_modalities: ['audio'],
|
|
757
|
+
turn_detection: { type: 'server_vad' },
|
|
758
|
+
audio: {
|
|
759
|
+
input: {
|
|
760
|
+
format: { type: 'audio/pcm', rate: 24000 },
|
|
761
|
+
transcription: { model: 'grok-transcribe' },
|
|
762
|
+
},
|
|
763
|
+
output: { format: { type: 'audio/pcm', rate: 24000 } },
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
if (response.session.voice !== undefined)
|
|
767
|
+
session['voice'] = response.session.voice;
|
|
768
|
+
if (response.session.instructions !== undefined) {
|
|
769
|
+
session['instructions'] = response.session.instructions;
|
|
770
|
+
}
|
|
771
|
+
if (response.session.tools) {
|
|
772
|
+
session['tools'] = response.session.tools.map((tool) => ({ type: 'function', ...tool }));
|
|
773
|
+
session['tool_choice'] = 'auto';
|
|
774
|
+
}
|
|
775
|
+
return { type: 'session.update', session };
|
|
776
|
+
}
|
|
777
|
+
function providerRealtimeURL(endpoint, provider, model, credential) {
|
|
778
|
+
if (provider === 'openai')
|
|
779
|
+
throw new Error('OpenAI realtime requires WebRTC');
|
|
780
|
+
const url = new URL(endpoint);
|
|
781
|
+
const expectedHost = provider === 'xai' ? 'api.x.ai' : 'generativelanguage.googleapis.com';
|
|
782
|
+
const expectedPath = provider === 'google'
|
|
783
|
+
? '/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained'
|
|
784
|
+
: '/v1/realtime';
|
|
785
|
+
if (url.protocol !== 'wss:' ||
|
|
786
|
+
url.hostname !== expectedHost ||
|
|
787
|
+
(url.port && url.port !== '443') ||
|
|
788
|
+
url.pathname !== expectedPath ||
|
|
789
|
+
url.username ||
|
|
790
|
+
url.password ||
|
|
791
|
+
url.search ||
|
|
792
|
+
url.hash) {
|
|
793
|
+
throw new Error(`Invalid ${provider} realtime endpoint`);
|
|
794
|
+
}
|
|
795
|
+
if (provider === 'google') {
|
|
796
|
+
url.searchParams.set('access_token', credential);
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
url.searchParams.set('model', model);
|
|
800
|
+
}
|
|
801
|
+
return url.toString();
|
|
802
|
+
}
|
|
803
|
+
function validatedOpenAIWebRTCEndpoint(raw) {
|
|
804
|
+
const url = new URL(raw);
|
|
805
|
+
if (url.protocol !== 'https:' ||
|
|
806
|
+
url.hostname !== 'api.openai.com' ||
|
|
807
|
+
(url.port && url.port !== '443') ||
|
|
808
|
+
url.pathname !== '/v1/realtime/calls' ||
|
|
809
|
+
url.username ||
|
|
810
|
+
url.password ||
|
|
811
|
+
url.search ||
|
|
812
|
+
url.hash) {
|
|
813
|
+
throw new Error('Invalid openai realtime endpoint');
|
|
814
|
+
}
|
|
815
|
+
return url.toString();
|
|
816
|
+
}
|
|
817
|
+
function validatedSidebandURL(raw, telemetryEndpoint, sessionId) {
|
|
818
|
+
if (!raw)
|
|
819
|
+
throw new Error('OpenAI billing sideband URL is missing');
|
|
820
|
+
const url = new URL(raw);
|
|
821
|
+
const telemetry = new URL(telemetryEndpoint);
|
|
822
|
+
if (url.protocol !== 'https:' ||
|
|
823
|
+
url.origin !== telemetry.origin ||
|
|
824
|
+
url.pathname !== `/v1/sessions/${encodeURIComponent(sessionId)}/sidebands/openai` ||
|
|
825
|
+
url.username ||
|
|
826
|
+
url.password ||
|
|
827
|
+
url.search ||
|
|
828
|
+
url.hash) {
|
|
829
|
+
throw new Error('Invalid OpenAI billing sideband URL');
|
|
830
|
+
}
|
|
831
|
+
return url.toString();
|
|
832
|
+
}
|
|
833
|
+
function validatedRenewalURL(raw, telemetryEndpoint, sessionId) {
|
|
834
|
+
if (!raw)
|
|
835
|
+
throw new Error('Gemini Live entitlement renewal URL is missing');
|
|
836
|
+
const url = new URL(raw);
|
|
837
|
+
const telemetry = new URL(telemetryEndpoint);
|
|
838
|
+
if (url.protocol !== 'https:' ||
|
|
839
|
+
url.origin !== telemetry.origin ||
|
|
840
|
+
url.pathname !== `/v1/sessions/${encodeURIComponent(sessionId)}/entitlements/renew` ||
|
|
841
|
+
url.username ||
|
|
842
|
+
url.password ||
|
|
843
|
+
url.search ||
|
|
844
|
+
url.hash) {
|
|
845
|
+
throw new Error('Invalid Gemini Live entitlement renewal URL');
|
|
846
|
+
}
|
|
847
|
+
return url.toString();
|
|
848
|
+
}
|
|
849
|
+
function openAICallID(location) {
|
|
850
|
+
if (!location)
|
|
851
|
+
throw new Error('OpenAI WebRTC response did not include a call ID');
|
|
852
|
+
const url = new URL(location, 'https://api.openai.com');
|
|
853
|
+
if (url.origin !== 'https://api.openai.com' || url.search || url.hash) {
|
|
854
|
+
throw new Error('OpenAI WebRTC call location is invalid');
|
|
855
|
+
}
|
|
856
|
+
const match = /^\/v1\/realtime\/calls\/([^/]+)$/.exec(url.pathname);
|
|
857
|
+
const callId = match?.[1] ? decodeURIComponent(match[1]) : '';
|
|
858
|
+
if (!/^[A-Za-z0-9_-]{8,256}$/.test(callId)) {
|
|
859
|
+
throw new Error('OpenAI WebRTC call ID is invalid');
|
|
860
|
+
}
|
|
861
|
+
return callId;
|
|
862
|
+
}
|
|
863
|
+
function assertRenewalResponse(renewal, previousExpiresAt, renewableUntil) {
|
|
864
|
+
const previousExpiryMs = Date.parse(previousExpiresAt);
|
|
865
|
+
const leaseExpiryMs = Date.parse(renewal?.lease_expires_at);
|
|
866
|
+
const credentialExpiryMs = Date.parse(renewal?.credential?.expires_at);
|
|
867
|
+
const renewableUntilMs = Date.parse(renewableUntil ?? '');
|
|
868
|
+
if (!renewal ||
|
|
869
|
+
typeof renewal.entitlement_id !== 'string' ||
|
|
870
|
+
!renewal.entitlement_id ||
|
|
871
|
+
!Number.isInteger(renewal.sequence) ||
|
|
872
|
+
renewal.sequence <= 0 ||
|
|
873
|
+
!Number.isInteger(renewal.authorized_units) ||
|
|
874
|
+
renewal.authorized_units <= 0 ||
|
|
875
|
+
renewal.authorized_units > 300 ||
|
|
876
|
+
!Number.isSafeInteger(renewal.maximum_amount_micros) ||
|
|
877
|
+
renewal.maximum_amount_micros <= 0 ||
|
|
878
|
+
renewal.currency !== 'USD' ||
|
|
879
|
+
renewal.credential?.kind !== 'bearer' ||
|
|
880
|
+
!renewal.credential.value ||
|
|
881
|
+
!Number.isFinite(previousExpiryMs) ||
|
|
882
|
+
!Number.isFinite(leaseExpiryMs) ||
|
|
883
|
+
!Number.isFinite(credentialExpiryMs) ||
|
|
884
|
+
!Number.isFinite(renewableUntilMs) ||
|
|
885
|
+
leaseExpiryMs <= previousExpiryMs ||
|
|
886
|
+
leaseExpiryMs > renewableUntilMs ||
|
|
887
|
+
credentialExpiryMs < leaseExpiryMs) {
|
|
888
|
+
throw new Error('Gemini Live entitlement renewal response is invalid');
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function floatPCMTo16Bit(samples, inputSampleRate, outputSampleRate) {
|
|
892
|
+
const ratio = inputSampleRate / outputSampleRate;
|
|
893
|
+
const outputLength = Math.max(0, Math.floor(samples.length / Math.max(1, ratio)));
|
|
894
|
+
const bytes = new Uint8Array(outputLength * 2);
|
|
895
|
+
const view = new DataView(bytes.buffer);
|
|
896
|
+
for (let outputIndex = 0; outputIndex < outputLength; outputIndex += 1) {
|
|
897
|
+
const start = Math.floor(outputIndex * ratio);
|
|
898
|
+
const end = Math.max(start + 1, Math.min(samples.length, Math.floor((outputIndex + 1) * ratio)));
|
|
899
|
+
let total = 0;
|
|
900
|
+
for (let inputIndex = start; inputIndex < end; inputIndex += 1) {
|
|
901
|
+
total += samples[inputIndex] ?? 0;
|
|
902
|
+
}
|
|
903
|
+
const sample = Math.max(-1, Math.min(1, total / (end - start)));
|
|
904
|
+
view.setInt16(outputIndex * 2, sample < 0 ? sample * 32768 : sample * 32767, true);
|
|
905
|
+
}
|
|
906
|
+
return bytes;
|
|
907
|
+
}
|
|
908
|
+
function adapterForProvider(provider) {
|
|
909
|
+
if (provider === 'openai')
|
|
910
|
+
return 'openai.realtime.v1';
|
|
911
|
+
if (provider === 'xai')
|
|
912
|
+
return 'xai.realtime.v1';
|
|
913
|
+
return 'google.live.v1';
|
|
914
|
+
}
|
|
915
|
+
function encodeBase64(bytes) {
|
|
916
|
+
let binary = '';
|
|
917
|
+
const chunkSize = 0x8000;
|
|
918
|
+
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
|
|
919
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
|
920
|
+
}
|
|
921
|
+
return btoa(binary);
|
|
922
|
+
}
|
|
923
|
+
function decodeBase64(value) {
|
|
924
|
+
const binary = atob(value);
|
|
925
|
+
const bytes = new Uint8Array(binary.length);
|
|
926
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
927
|
+
bytes[index] = binary.charCodeAt(index);
|
|
928
|
+
}
|
|
929
|
+
return bytes;
|
|
930
|
+
}
|
|
931
|
+
function asRecord(value) {
|
|
932
|
+
return value && typeof value === 'object' ? value : {};
|
|
933
|
+
}
|
|
934
|
+
function finiteNumber(value) {
|
|
935
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
198
936
|
}
|