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