@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
@@ -0,0 +1,565 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { HttpClient } from '../http.js';
3
+ import type { RealtimeFrame } from '../types/index.js';
4
+ import { Realtime } from './realtime.js';
5
+
6
+ type Listener = (event: unknown) => void;
7
+
8
+ class FakeWebSocket {
9
+ static readonly OPEN = 1;
10
+ static instances: FakeWebSocket[] = [];
11
+
12
+ readonly url: string;
13
+ readonly protocols: string[];
14
+ readonly sent: string[] = [];
15
+ readyState: number = FakeWebSocket.OPEN;
16
+ private readonly listeners = new Map<string, Listener[]>();
17
+
18
+ constructor(url: string | URL, protocols?: string | string[]) {
19
+ this.url = String(url);
20
+ this.protocols = typeof protocols === 'string' ? [protocols] : (protocols ?? []);
21
+ FakeWebSocket.instances.push(this);
22
+ }
23
+
24
+ addEventListener(type: string, listener: Listener): void {
25
+ const listeners = this.listeners.get(type) ?? [];
26
+ listeners.push(listener);
27
+ this.listeners.set(type, listeners);
28
+ }
29
+
30
+ send(value: string): void {
31
+ this.sent.push(value);
32
+ }
33
+
34
+ close(code = 1000, reason = ''): void {
35
+ this.readyState = 3;
36
+ this.emit('close', { code, reason });
37
+ }
38
+
39
+ emit(type: string, event: unknown): void {
40
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
41
+ }
42
+ }
43
+
44
+ class FakeRTCDataChannel {
45
+ readonly sent: string[] = [];
46
+ readyState: RTCDataChannelState = 'open';
47
+ private readonly listeners = new Map<string, Listener[]>();
48
+
49
+ addEventListener(type: string, listener: Listener): void {
50
+ const listeners = this.listeners.get(type) ?? [];
51
+ listeners.push(listener);
52
+ this.listeners.set(type, listeners);
53
+ }
54
+
55
+ send(value: string): void {
56
+ this.sent.push(value);
57
+ }
58
+
59
+ close(): void {
60
+ this.readyState = 'closed';
61
+ }
62
+
63
+ emit(type: string, event: unknown): void {
64
+ for (const listener of this.listeners.get(type) ?? []) listener(event);
65
+ }
66
+ }
67
+
68
+ class FakeRTCPeerConnection {
69
+ static instances: FakeRTCPeerConnection[] = [];
70
+ readonly channel = new FakeRTCDataChannel();
71
+ connectionState: RTCPeerConnectionState = 'new';
72
+ localDescription: RTCSessionDescriptionInit | null = null;
73
+ remoteDescription: RTCSessionDescriptionInit | null = null;
74
+ private readonly listeners = new Map<string, Listener[]>();
75
+
76
+ constructor() {
77
+ FakeRTCPeerConnection.instances.push(this);
78
+ }
79
+
80
+ addTrack(): void {}
81
+
82
+ createDataChannel(): RTCDataChannel {
83
+ return this.channel as unknown as RTCDataChannel;
84
+ }
85
+
86
+ async createOffer(): Promise<RTCSessionDescriptionInit> {
87
+ return { type: 'offer', sdp: 'offer-sdp' };
88
+ }
89
+
90
+ async setLocalDescription(description: RTCSessionDescriptionInit): Promise<void> {
91
+ this.localDescription = description;
92
+ }
93
+
94
+ async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
95
+ this.remoteDescription = description;
96
+ }
97
+
98
+ addEventListener(type: string, listener: Listener): void {
99
+ const listeners = this.listeners.get(type) ?? [];
100
+ listeners.push(listener);
101
+ this.listeners.set(type, listeners);
102
+ }
103
+
104
+ close(): void {
105
+ this.connectionState = 'closed';
106
+ }
107
+ }
108
+
109
+ class FakeAudioContext {
110
+ readonly sampleRate: number;
111
+ readonly currentTime = 0;
112
+ readonly destination = {} as AudioDestinationNode;
113
+ scheduledSources = 0;
114
+
115
+ constructor(options?: AudioContextOptions) {
116
+ this.sampleRate = options?.sampleRate ?? 48_000;
117
+ }
118
+
119
+ createMediaStreamDestination(): MediaStreamAudioDestinationNode {
120
+ return {
121
+ stream: { getAudioTracks: () => [{ kind: 'audio' }] },
122
+ } as unknown as MediaStreamAudioDestinationNode;
123
+ }
124
+
125
+ createBuffer(_channels: number, length: number, sampleRate: number): AudioBuffer {
126
+ const samples = new Float32Array(length);
127
+ return {
128
+ duration: length / sampleRate,
129
+ getChannelData: () => samples,
130
+ } as unknown as AudioBuffer;
131
+ }
132
+
133
+ createBufferSource(): AudioBufferSourceNode {
134
+ return {
135
+ buffer: null,
136
+ connect: () => undefined,
137
+ disconnect: () => undefined,
138
+ start: () => {
139
+ this.scheduledSources += 1;
140
+ },
141
+ addEventListener: () => undefined,
142
+ } as unknown as AudioBufferSourceNode;
143
+ }
144
+
145
+ async resume(): Promise<void> {}
146
+ async close(): Promise<void> {}
147
+ }
148
+
149
+ function bootstrapResponse() {
150
+ return {
151
+ mode: 's2s',
152
+ transport: 'provider_direct',
153
+ sessionId: 'session-1',
154
+ planId: 'plan-1',
155
+ attemptId: 'attempt-1',
156
+ provider: 'openai',
157
+ model: 'gpt-realtime',
158
+ adapter: 'openai.realtime.v1',
159
+ providerTransport: 'webrtc',
160
+ endpoint: 'https://api.openai.com/v1/realtime/calls',
161
+ credential: { kind: 'bearer', value: 'ek-short-lived', expiresAt: '2100-01-01T00:05:00Z' },
162
+ telemetry: {
163
+ endpoint: 'https://control.speko.test/v1/runtime-events',
164
+ token: 'telemetry-token',
165
+ flushIntervalMs: 5000,
166
+ },
167
+ reservation: {
168
+ id: 'reservation-1',
169
+ authorizedDurationSeconds: 1800,
170
+ leaseExpiresAt: '2100-01-01T00:05:00Z',
171
+ billing: {
172
+ mode: 'direct_entitlement',
173
+ state: 'estimated',
174
+ maximumAmountMicros: '180000',
175
+ currency: 'USD',
176
+ },
177
+ },
178
+ sidebandUrl: 'https://control.speko.test/v1/sessions/session-1/sidebands/openai',
179
+ session: {
180
+ voice: 'marin',
181
+ instructions: 'Keep the answer short.',
182
+ tools: [{ name: 'lookup', description: 'Look up a record.', parameters: { type: 'object' } }],
183
+ },
184
+ inputSampleRate: 24000,
185
+ outputSampleRate: 24000,
186
+ expiresAt: '2100-01-01T00:05:00Z',
187
+ };
188
+ }
189
+
190
+ afterEach(() => {
191
+ vi.useRealTimers();
192
+ vi.unstubAllGlobals();
193
+ FakeWebSocket.instances = [];
194
+ FakeRTCPeerConnection.instances = [];
195
+ });
196
+
197
+ describe('Realtime provider-direct transport', () => {
198
+ it('connects OpenAI over WebRTC only after its provider-authenticated sideband binds', async () => {
199
+ const fetchMock = vi
200
+ .fn<typeof fetch>()
201
+ .mockResolvedValueOnce(Response.json(bootstrapResponse(), { status: 201 }))
202
+ .mockResolvedValueOnce(
203
+ new Response('answer-sdp', {
204
+ status: 201,
205
+ headers: { Location: '/v1/realtime/calls/call_12345678' },
206
+ }),
207
+ )
208
+ .mockResolvedValueOnce(Response.json({ status: 'bound' }, { status: 201 }))
209
+ .mockResolvedValueOnce(Response.json({ accepted: 2, deduplicated: 0 }, { status: 202 }));
210
+ vi.stubGlobal('fetch', fetchMock);
211
+ vi.stubGlobal('RTCPeerConnection', FakeRTCPeerConnection);
212
+ vi.stubGlobal('AudioContext', FakeAudioContext);
213
+ const realtime = new Realtime(
214
+ new HttpClient({ baseUrl: 'https://api.speko.test', apiKey: 'sk-customer', timeout: 5000 }),
215
+ );
216
+
217
+ const handle = await realtime.connect({
218
+ provider: 'openai',
219
+ model: 'gpt-realtime',
220
+ idempotencyKey: 'sdk-test-key',
221
+ });
222
+ expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({
223
+ 'Idempotency-Key': 'sdk-test-key',
224
+ });
225
+ const peer = FakeRTCPeerConnection.instances[0];
226
+ if (!peer) throw new Error('provider peer was not created');
227
+ const providerCall = fetchMock.mock.calls[1];
228
+ expect(providerCall?.[0]).toBe('https://api.openai.com/v1/realtime/calls');
229
+ expect(providerCall?.[1]).toMatchObject({
230
+ method: 'POST',
231
+ headers: {
232
+ Authorization: 'Bearer ek-short-lived',
233
+ 'Content-Type': 'application/sdp',
234
+ },
235
+ body: 'offer-sdp',
236
+ });
237
+ const sidebandCall = fetchMock.mock.calls[2];
238
+ expect(sidebandCall?.[0]).toBe(
239
+ 'https://control.speko.test/v1/sessions/session-1/sidebands/openai',
240
+ );
241
+ expect(JSON.parse(String(sidebandCall?.[1]?.body))).toEqual({
242
+ attempt_id: 'attempt-1',
243
+ provider_session_id: 'call_12345678',
244
+ });
245
+ expect(peer.remoteDescription).toEqual({ type: 'answer', sdp: 'answer-sdp' });
246
+
247
+ peer.channel.emit('open', {});
248
+ expect(JSON.parse(peer.channel.sent[0] ?? '{}')).toMatchObject({
249
+ type: 'session.update',
250
+ session: {
251
+ instructions: 'Keep the answer short.',
252
+ audio: { input: { turn_detection: null }, output: { voice: 'marin' } },
253
+ tools: [{ type: 'function', name: 'lookup' }],
254
+ },
255
+ });
256
+
257
+ handle.sendAudio(new Uint8Array([1, 2, 3]));
258
+ handle.commit();
259
+ handle.sendToolResult('call-1', '{"ok":true}');
260
+ expect(peer.channel.sent.map((item) => JSON.parse(item)['type'])).toEqual([
261
+ 'session.update',
262
+ 'input_audio_buffer.commit',
263
+ 'response.create',
264
+ 'conversation.item.create',
265
+ 'response.create',
266
+ ]);
267
+
268
+ const frames: RealtimeFrame[] = [];
269
+ handle.on((frame) => frames.push(frame));
270
+ peer.channel.emit('message', { data: JSON.stringify({ type: 'session.updated' }) });
271
+ peer.channel.emit('message', {
272
+ data: JSON.stringify({ type: 'response.output_audio.delta', delta: 'BAUG' }),
273
+ });
274
+ peer.channel.emit('message', {
275
+ data: JSON.stringify({
276
+ type: 'response.done',
277
+ response: {
278
+ usage: {
279
+ input_token_details: { audio_tokens: 12 },
280
+ output_token_details: { audio_tokens: 7 },
281
+ },
282
+ },
283
+ }),
284
+ });
285
+ expect(frames).toEqual([
286
+ { type: 'ready', inputSampleRate: 24000, outputSampleRate: 24000 },
287
+ { type: 'audio', pcm: new Uint8Array([4, 5, 6]), sampleRate: 24000 },
288
+ { type: 'usage', inputAudioTokens: 12, outputAudioTokens: 7 },
289
+ ]);
290
+
291
+ handle.close();
292
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
293
+ const telemetryCall = fetchMock.mock.calls[3];
294
+ if (!telemetryCall) throw new Error('telemetry was not posted');
295
+ expect(telemetryCall[0]).toBe('https://control.speko.test/v1/runtime-events');
296
+ expect((telemetryCall[1]?.headers as Record<string, string>).Authorization).toBe(
297
+ 'Bearer telemetry-token',
298
+ );
299
+ });
300
+
301
+ it('connects directly to xAI with its short-lived client-secret subprotocol', async () => {
302
+ const response = {
303
+ ...bootstrapResponse(),
304
+ provider: 'xai',
305
+ model: 'grok-voice-latest',
306
+ adapter: 'xai.realtime.v1',
307
+ providerTransport: 'websocket',
308
+ endpoint: 'wss://api.x.ai/v1/realtime',
309
+ };
310
+ vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(Response.json(response)));
311
+ vi.stubGlobal('WebSocket', FakeWebSocket);
312
+ const realtime = new Realtime(
313
+ new HttpClient({ baseUrl: 'https://api.speko.test', apiKey: 'sk-customer', timeout: 5000 }),
314
+ );
315
+
316
+ const handle = await realtime.connect({ provider: 'xai', model: 'grok-voice-latest' });
317
+ const socket = FakeWebSocket.instances[0];
318
+ if (!socket) throw new Error('provider socket was not created');
319
+ expect(socket.url).toBe('wss://api.x.ai/v1/realtime?model=grok-voice-latest');
320
+ expect(socket.protocols).toEqual(['xai-client-secret.ek-short-lived']);
321
+ expect(socket.url).not.toContain('ek-short-lived');
322
+
323
+ socket.emit('open', {});
324
+ expect(JSON.parse(socket.sent[0] ?? '{}')).toMatchObject({
325
+ type: 'session.update',
326
+ session: {
327
+ model: 'grok-voice-latest',
328
+ instructions: 'Keep the answer short.',
329
+ voice: 'marin',
330
+ turn_detection: { type: 'server_vad' },
331
+ audio: { input: { transcription: { model: 'grok-transcribe' } } },
332
+ },
333
+ });
334
+
335
+ const frames: RealtimeFrame[] = [];
336
+ handle.on((frame) => frames.push(frame));
337
+ socket.emit('message', {
338
+ data: JSON.stringify({
339
+ type: 'conversation.item.input_audio_transcription.updated',
340
+ item_id: 'item-1',
341
+ transcript: 'hello from xAI',
342
+ }),
343
+ });
344
+ expect(frames).toContainEqual({
345
+ type: 'transcript',
346
+ role: 'user',
347
+ text: 'hello from xAI',
348
+ final: false,
349
+ });
350
+ });
351
+
352
+ it('connects directly to Gemini Live with a constrained ephemeral token', async () => {
353
+ const response = {
354
+ ...bootstrapResponse(),
355
+ provider: 'google',
356
+ model: 'gemini-3.1-flash-live-preview',
357
+ adapter: 'google.live.v1',
358
+ providerTransport: 'websocket',
359
+ endpoint:
360
+ 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained',
361
+ inputSampleRate: 16000,
362
+ outputSampleRate: 24000,
363
+ session: {
364
+ ...bootstrapResponse().session,
365
+ voice: 'Puck',
366
+ temperature: 0.7,
367
+ },
368
+ };
369
+ vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(Response.json(response)));
370
+ vi.stubGlobal('WebSocket', FakeWebSocket);
371
+ const realtime = new Realtime(
372
+ new HttpClient({ baseUrl: 'https://api.speko.test', apiKey: 'sk-customer', timeout: 5000 }),
373
+ );
374
+
375
+ const handle = await realtime.connect({
376
+ provider: 'google',
377
+ model: 'gemini-3.1-flash-live-preview',
378
+ });
379
+ const socket = FakeWebSocket.instances[0];
380
+ if (!socket) throw new Error('provider socket was not created');
381
+ expect(socket.url).toBe(
382
+ 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained?access_token=ek-short-lived',
383
+ );
384
+ expect(socket.protocols).toEqual([]);
385
+
386
+ socket.emit('open', {});
387
+ expect(JSON.parse(socket.sent[0] ?? '{}')).toMatchObject({
388
+ setup: {
389
+ model: 'models/gemini-3.1-flash-live-preview',
390
+ generationConfig: {
391
+ responseModalities: ['AUDIO'],
392
+ temperature: 0.7,
393
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Puck' } } },
394
+ },
395
+ systemInstruction: { parts: [{ text: 'Keep the answer short.' }] },
396
+ tools: [{ functionDeclarations: [{ name: 'lookup' }] }],
397
+ },
398
+ });
399
+ handle.sendAudio(new Uint8Array([1, 2, 3]));
400
+ handle.commit();
401
+ expect(socket.sent).toHaveLength(1);
402
+
403
+ const frames: RealtimeFrame[] = [];
404
+ handle.on((frame) => frames.push(frame));
405
+ socket.emit('message', { data: JSON.stringify({ setupComplete: {} }) });
406
+ expect(JSON.parse(socket.sent[1] ?? '{}')).toEqual({
407
+ realtimeInput: { audio: { mimeType: 'audio/pcm;rate=16000', data: 'AQID' } },
408
+ });
409
+ expect(JSON.parse(socket.sent[2] ?? '{}')).toEqual({
410
+ realtimeInput: { audioStreamEnd: true },
411
+ });
412
+
413
+ socket.emit('message', {
414
+ data: JSON.stringify({
415
+ serverContent: {
416
+ modelTurn: {
417
+ parts: [{ inlineData: { data: 'BAUG', mimeType: 'audio/pcm;rate=24000' } }],
418
+ },
419
+ outputTranscription: { text: 'hello' },
420
+ turnComplete: true,
421
+ },
422
+ usageMetadata: { promptTokenCount: 4, responseTokenCount: 6 },
423
+ }),
424
+ });
425
+ socket.emit('message', {
426
+ data: JSON.stringify({
427
+ toolCall: { functionCalls: [{ id: 'google-call-1', name: 'lookup', args: { id: 7 } }] },
428
+ }),
429
+ });
430
+ expect(frames).toEqual([
431
+ { type: 'ready', inputSampleRate: 16000, outputSampleRate: 24000 },
432
+ { type: 'transcript', role: 'assistant', text: 'hello', final: true },
433
+ { type: 'audio', pcm: new Uint8Array([4, 5, 6]), sampleRate: 24000 },
434
+ { type: 'usage', inputAudioTokens: 4, outputAudioTokens: 6 },
435
+ {
436
+ type: 'tool_call',
437
+ callId: 'google-call-1',
438
+ name: 'lookup',
439
+ arguments: '{"id":7}',
440
+ },
441
+ ]);
442
+ handle.sendToolResult('google-call-1', '{"ok":true}');
443
+ expect(JSON.parse(socket.sent[3] ?? '{}')).toEqual({
444
+ toolResponse: {
445
+ functionResponses: [{ id: 'google-call-1', name: 'lookup', response: { ok: true } }],
446
+ },
447
+ });
448
+ });
449
+
450
+ it('renews Gemini in prepaid five-minute slices and resumes on a fresh direct socket', async () => {
451
+ vi.useFakeTimers();
452
+ const now = new Date('2026-08-28T12:00:00.000Z');
453
+ vi.setSystemTime(now);
454
+ const initialExpiry = new Date(now.getTime() + 20_000).toISOString();
455
+ const renewedExpiry = new Date(now.getTime() + 320_000).toISOString();
456
+ const renewableUntil = new Date(now.getTime() + 620_000).toISOString();
457
+ const response = {
458
+ ...bootstrapResponse(),
459
+ provider: 'google' as const,
460
+ model: 'gemini-3.1-flash-live-preview',
461
+ adapter: 'google.live.v1' as const,
462
+ providerTransport: 'websocket' as const,
463
+ endpoint:
464
+ 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained',
465
+ inputSampleRate: 16000 as const,
466
+ credential: {
467
+ kind: 'bearer' as const,
468
+ value: 'google-initial-token',
469
+ expiresAt: initialExpiry,
470
+ },
471
+ telemetry: {
472
+ ...bootstrapResponse().telemetry,
473
+ flushIntervalMs: 60_000,
474
+ },
475
+ reservation: {
476
+ ...bootstrapResponse().reservation,
477
+ authorizedDurationSeconds: 20,
478
+ leaseExpiresAt: initialExpiry,
479
+ billing: {
480
+ ...bootstrapResponse().reservation.billing,
481
+ renewalUrl: 'https://control.speko.test/v1/sessions/session-1/entitlements/renew',
482
+ renewableUntil,
483
+ },
484
+ },
485
+ };
486
+ const fetchMock = vi
487
+ .fn<typeof fetch>()
488
+ .mockResolvedValue(Response.json({ accepted: 2, deduplicated: 0 }, { status: 202 }))
489
+ .mockResolvedValueOnce(Response.json(response, { status: 201 }))
490
+ .mockResolvedValueOnce(
491
+ Response.json({
492
+ entitlement_id: 'entitlement-2',
493
+ sequence: 2,
494
+ lease_expires_at: renewedExpiry,
495
+ authorized_units: 300,
496
+ maximum_amount_micros: 30_000,
497
+ currency: 'USD',
498
+ credential: {
499
+ kind: 'bearer',
500
+ value: 'google-renewed-token',
501
+ expires_at: renewedExpiry,
502
+ },
503
+ }),
504
+ );
505
+ vi.stubGlobal('fetch', fetchMock);
506
+ vi.stubGlobal('WebSocket', FakeWebSocket);
507
+ const realtime = new Realtime(
508
+ new HttpClient({ baseUrl: 'https://api.speko.test', apiKey: 'sk-customer', timeout: 5000 }),
509
+ );
510
+
511
+ const handle = await realtime.connect({
512
+ provider: 'google',
513
+ model: 'gemini-3.1-flash-live-preview',
514
+ });
515
+ const firstSocket = FakeWebSocket.instances[0];
516
+ if (!firstSocket) throw new Error('initial Gemini socket was not created');
517
+ firstSocket.emit('open', {});
518
+ firstSocket.emit('message', {
519
+ data: JSON.stringify({
520
+ setupComplete: {},
521
+ sessionResumptionUpdate: { newHandle: 'resume-handle-1' },
522
+ }),
523
+ });
524
+
525
+ await vi.advanceTimersByTimeAsync(16_000);
526
+ expect(fetchMock).toHaveBeenCalledTimes(2);
527
+ const renewalCall = fetchMock.mock.calls[1];
528
+ expect(renewalCall?.[0]).toBe(
529
+ 'https://control.speko.test/v1/sessions/session-1/entitlements/renew',
530
+ );
531
+ expect(renewalCall?.[1]).toMatchObject({
532
+ method: 'POST',
533
+ headers: {
534
+ Authorization: 'Bearer telemetry-token',
535
+ 'Content-Type': 'application/json',
536
+ 'Idempotency-Key': `renew:attempt-1:${initialExpiry}`,
537
+ },
538
+ body: JSON.stringify({ previous_expires_at: initialExpiry }),
539
+ });
540
+ expect(firstSocket.readyState).toBe(3);
541
+ const secondSocket = FakeWebSocket.instances[1];
542
+ if (!secondSocket) throw new Error('renewed Gemini socket was not created');
543
+ expect(secondSocket.url).toContain('access_token=google-renewed-token');
544
+ secondSocket.emit('open', {});
545
+ expect(JSON.parse(secondSocket.sent[0] ?? '{}')).toMatchObject({
546
+ setup: { sessionResumption: { handle: 'resume-handle-1' } },
547
+ });
548
+ handle.close();
549
+ });
550
+
551
+ it('rejects a routed or attacker-controlled endpoint before using the credential', async () => {
552
+ const response = bootstrapResponse();
553
+ response.endpoint = 'https://router.speko.dev/v1/realtime/calls';
554
+ vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(Response.json(response)));
555
+ vi.stubGlobal('RTCPeerConnection', FakeRTCPeerConnection);
556
+ vi.stubGlobal('AudioContext', FakeAudioContext);
557
+ const realtime = new Realtime(
558
+ new HttpClient({ baseUrl: 'https://api.speko.test', apiKey: 'sk-customer', timeout: 5000 }),
559
+ );
560
+ await expect(realtime.connect({ provider: 'openai', model: 'gpt-realtime' })).rejects.toThrow(
561
+ 'Invalid openai realtime endpoint',
562
+ );
563
+ expect(FakeWebSocket.instances).toHaveLength(0);
564
+ });
565
+ });