@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +88 -0
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/lib/client.d.ts +13 -1
  7. package/dist/lib/client.d.ts.map +1 -1
  8. package/dist/lib/client.js +22 -2
  9. package/dist/lib/http.d.ts +12 -4
  10. package/dist/lib/http.d.ts.map +1 -1
  11. package/dist/lib/http.js +31 -14
  12. package/dist/lib/resources/agents.d.ts +12 -2
  13. package/dist/lib/resources/agents.d.ts.map +1 -1
  14. package/dist/lib/resources/agents.js +12 -4
  15. package/dist/lib/resources/calls.d.ts +19 -1
  16. package/dist/lib/resources/calls.d.ts.map +1 -1
  17. package/dist/lib/resources/calls.js +22 -0
  18. package/dist/lib/resources/knowledge-bases.d.ts +1 -1
  19. package/dist/lib/resources/knowledge-bases.js +1 -1
  20. package/dist/lib/resources/phone-numbers.d.ts +2 -1
  21. package/dist/lib/resources/phone-numbers.d.ts.map +1 -1
  22. package/dist/lib/resources/phone-numbers.js +2 -1
  23. package/dist/lib/resources/realtime.d.ts +3 -5
  24. package/dist/lib/resources/realtime.d.ts.map +1 -1
  25. package/dist/lib/resources/realtime.js +829 -91
  26. package/dist/lib/resources/sessions.d.ts +53 -0
  27. package/dist/lib/resources/sessions.d.ts.map +1 -0
  28. package/dist/lib/resources/sessions.js +166 -0
  29. package/dist/lib/resources/sms.d.ts +80 -0
  30. package/dist/lib/resources/sms.d.ts.map +1 -0
  31. package/dist/lib/resources/sms.js +152 -0
  32. package/dist/lib/resources/synthesize.d.ts.map +1 -1
  33. package/dist/lib/resources/synthesize.js +12 -6
  34. package/dist/lib/resources/transcribe.d.ts.map +1 -1
  35. package/dist/lib/resources/transcribe.js +9 -2
  36. package/dist/lib/resources/voice.d.ts +283 -1
  37. package/dist/lib/resources/voice.d.ts.map +1 -1
  38. package/dist/lib/resources/voice.js +345 -0
  39. package/dist/lib/resources/webhooks.d.ts +25 -0
  40. package/dist/lib/resources/webhooks.d.ts.map +1 -0
  41. package/dist/lib/resources/webhooks.js +46 -0
  42. package/dist/lib/types/index.d.ts +998 -9
  43. package/dist/lib/types/index.d.ts.map +1 -1
  44. package/dist/lib/voice-contract.d.ts +280 -0
  45. package/dist/lib/voice-contract.d.ts.map +1 -0
  46. package/dist/lib/voice-contract.js +115 -0
  47. package/package.json +2 -1
  48. package/src/index.ts +212 -0
  49. package/src/lib/client.ts +169 -0
  50. package/src/lib/errors.ts +28 -0
  51. package/src/lib/http.ts +442 -0
  52. package/src/lib/resources/agents.ts +211 -0
  53. package/src/lib/resources/callbacks.ts +40 -0
  54. package/src/lib/resources/calls.ts +113 -0
  55. package/src/lib/resources/complete.ts +63 -0
  56. package/src/lib/resources/credits.ts +41 -0
  57. package/src/lib/resources/knowledge-bases.ts +199 -0
  58. package/src/lib/resources/phone-numbers.ts +109 -0
  59. package/src/lib/resources/realtime-globals.d.ts +31 -0
  60. package/src/lib/resources/realtime.spec.ts +565 -0
  61. package/src/lib/resources/realtime.ts +1169 -0
  62. package/src/lib/resources/sessions.ts +191 -0
  63. package/src/lib/resources/sms.ts +214 -0
  64. package/src/lib/resources/synthesize.ts +101 -0
  65. package/src/lib/resources/transcribe.ts +91 -0
  66. package/src/lib/resources/usage.ts +24 -0
  67. package/src/lib/resources/voice.ts +426 -0
  68. package/src/lib/resources/voices.ts +32 -0
  69. package/src/lib/resources/webhooks.ts +67 -0
  70. package/src/lib/types/index.ts +2409 -0
  71. 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
- * Open a speech-to-speech session. Posts `/v1/sessions` with `mode: 's2s'`
8
- * to mint a short-lived WebSocket token, then opens a direct WS to the
9
- * server's S2S proxy. The proxy bridges to the underlying provider
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 new BrowserRealtimeHandle(response.sessionId, response.wsUrl, response.wsToken, response.expiresAt, response.inputSampleRate, response.outputSampleRate);
29
+ }, undefined, { 'Idempotency-Key': idempotencyKey });
30
+ return ProviderDirectRealtimeHandle.create(response);
30
31
  }
31
32
  }
32
- class BrowserRealtimeHandle {
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
- constructor(sessionId, wsUrl, wsToken, expiresAt, inputSampleRate, outputSampleRate) {
41
- this.sessionId = sessionId;
42
- this.expiresAt = expiresAt;
43
- this.inputSampleRate = inputSampleRate;
44
- this.outputSampleRate = outputSampleRate;
45
- // Pass the token as the first subprotocol — browsers can't set headers
46
- // on `new WebSocket()`, so subprotocol is the only auth carrier that
47
- // doesn't leak through URL params.
48
- this.ws = new WebSocket(wsUrl, [wsToken]);
49
- this.ws.binaryType = 'arraybuffer';
50
- this.ws.addEventListener('message', (evt) => {
51
- this.dispatchIncoming(evt.data);
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
- this.ws.addEventListener('close', (evt) => {
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.emit({ type: 'close', code: evt.code, reason: evt.reason });
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
- this.ws.addEventListener('error', () => {
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: 'WS_ERROR',
61
- message: 'WebSocket transport error',
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.closed || this.ws.readyState !== 1)
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
- // Copy to ensure we send a plain ArrayBuffer, not a typed-array view that
69
- // might alias a SharedArrayBuffer.
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.sendControl('commit');
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.sendJson({ t: 'interrupt' });
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.sendJson({ t: 'tool_result', callId, output });
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.close(code, reason);
345
+ this.ws.send(encoded);
95
346
  }
96
347
  catch {
97
- // ignore
348
+ // A close/error event is the authoritative transport signal.
98
349
  }
99
350
  }
100
- sendControl(action) {
101
- this.sendJson({ t: 'control', action });
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
- sendJson(payload) {
104
- if (this.closed || this.ws.readyState !== 1)
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.send(JSON.stringify(payload));
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 parsed;
405
+ let event;
125
406
  try {
126
- parsed = JSON.parse(data);
407
+ event = JSON.parse(data);
127
408
  }
128
409
  catch {
129
410
  return;
130
411
  }
131
- const t = parsed['t'];
132
- switch (t) {
133
- case 'transcript':
134
- this.emit({
135
- type: 'transcript',
136
- role: parsed['role'],
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: Number(parsed['inputSampleRate'] ?? this.inputSampleRate),
145
- outputSampleRate: Number(parsed['outputSampleRate'] ?? this.outputSampleRate),
420
+ inputSampleRate: this.inputSampleRate,
421
+ outputSampleRate: this.outputSampleRate,
146
422
  });
147
423
  break;
148
- case 'interruption':
149
- this.emit({
150
- type: 'interruption',
151
- at: parsed['at'] === 'assistant' ? 'assistant' : 'user',
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
- case 'server_tool_call':
155
- this.emit({
156
- type: 'server_tool_call',
157
- id: String(parsed['id'] ?? ''),
158
- name: String(parsed['name'] ?? ''),
159
- status: String(parsed['status'] ?? 'started'),
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 'tool_call':
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(parsed['callId'] ?? ''),
166
- name: String(parsed['name'] ?? ''),
167
- arguments: String(parsed['arguments'] ?? ''),
450
+ callId: String(event['call_id'] ?? ''),
451
+ name: String(event['name'] ?? ''),
452
+ arguments: String(event['arguments'] ?? ''),
168
453
  });
169
454
  break;
170
- case 'usage':
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: Number(parsed['inputAudioTokens'] ?? 0),
174
- outputAudioTokens: Number(parsed['outputAudioTokens'] ?? 0),
464
+ inputAudioTokens: finiteNumber(inputDetails['audio_tokens']),
465
+ outputAudioTokens: finiteNumber(outputDetails['audio_tokens']),
175
466
  });
176
467
  break;
177
- case 'error':
468
+ }
469
+ case 'error': {
470
+ const error = asRecord(event['error']);
178
471
  this.emit({
179
472
  type: 'error',
180
- code: String(parsed['code'] ?? 'UNKNOWN'),
181
- message: String(parsed['message'] ?? ''),
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
- // swallow a misbehaving handler shouldn't break the pump
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
  }