realtime-voice-agents 2.0.0
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/LICENSE +21 -0
- package/README.md +298 -0
- package/assets/elevator-jazz.ulaw +1 -0
- package/assets/keyboard-typing.ulaw +1 -0
- package/assets/lofi.ulaw +1 -0
- package/assets/ringing.ulaw +1 -0
- package/assets/thinking-hum.ulaw +1 -0
- package/dist/BackgroundAudioPlayer-iMcivjis.mjs +282 -0
- package/dist/BackgroundAudioPlayer-jfRULWKC.cjs +313 -0
- package/dist/BaseRealtimeProvider-BQigr5mB.mjs +62 -0
- package/dist/BaseRealtimeProvider-BehPNT1r.d.cts +239 -0
- package/dist/BaseRealtimeProvider-ClP8Wx1X.d.mts +239 -0
- package/dist/BaseRealtimeProvider-DI4pKtOb.cjs +91 -0
- package/dist/GeminiLiveProvider-DvkTgzjG.d.cts +101 -0
- package/dist/GeminiLiveProvider-x2nyx5aO.d.mts +101 -0
- package/dist/InMemorySessionStore-B0_i-DOU.cjs +34 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.cts +83 -0
- package/dist/InMemorySessionStore-B5_rq61L.d.mts +83 -0
- package/dist/InMemorySessionStore-DYParOJO.mjs +29 -0
- package/dist/OpenAICompatibleProvider-Bdtl-UXH.mjs +402 -0
- package/dist/OpenAICompatibleProvider-NS4cKVQj.cjs +409 -0
- package/dist/audio.cjs +21 -0
- package/dist/audio.d.cts +165 -0
- package/dist/audio.d.mts +165 -0
- package/dist/audio.mjs +4 -0
- package/dist/env-DSnGaERV.cjs +19 -0
- package/dist/env-DUwUWTsg.mjs +14 -0
- package/dist/events-BUMYdETO.d.cts +24 -0
- package/dist/events-BUMYdETO.d.mts +24 -0
- package/dist/events-BxDTIKKq.cjs +48 -0
- package/dist/events-BylBSBW-.mjs +43 -0
- package/dist/gemini.cjs +401 -0
- package/dist/gemini.d.cts +41 -0
- package/dist/gemini.d.mts +41 -0
- package/dist/gemini.mjs +395 -0
- package/dist/index.cjs +2142 -0
- package/dist/index.d.cts +920 -0
- package/dist/index.d.mts +920 -0
- package/dist/index.mjs +2113 -0
- package/dist/mulaw--cwU2c9L.mjs +64 -0
- package/dist/mulaw-DLUObjdP.cjs +117 -0
- package/dist/openai.cjs +61 -0
- package/dist/openai.d.cts +121 -0
- package/dist/openai.d.mts +121 -0
- package/dist/openai.mjs +53 -0
- package/dist/presets-Bf75YXs5.d.cts +24 -0
- package/dist/presets-Bf75YXs5.d.mts +24 -0
- package/dist/rest-BYqiVOhe.mjs +265 -0
- package/dist/rest-BvUKut_k.cjs +300 -0
- package/dist/rolldown-runtime-VH7oDXx4.cjs +28 -0
- package/dist/session-config-BVLl7-ha.mjs +74 -0
- package/dist/session-config-c8sOw1XL.cjs +85 -0
- package/dist/store.cjs +3 -0
- package/dist/store.d.cts +2 -0
- package/dist/store.d.mts +2 -0
- package/dist/store.mjs +2 -0
- package/dist/testing.cjs +544 -0
- package/dist/testing.d.cts +191 -0
- package/dist/testing.d.mts +191 -0
- package/dist/testing.mjs +538 -0
- package/dist/transcode-C9aJG7_W.cjs +215 -0
- package/dist/transcode-CsAp97G9.mjs +198 -0
- package/dist/transport-B_PJFIVd.d.cts +155 -0
- package/dist/transport-CEaLFV4E.d.mts +155 -0
- package/dist/twilio.cjs +8 -0
- package/dist/twilio.d.cts +3 -0
- package/dist/twilio.d.mts +3 -0
- package/dist/twilio.mjs +2 -0
- package/dist/twiml-z9LjoF4_.d.cts +67 -0
- package/dist/twiml-z9LjoF4_.d.mts +67 -0
- package/dist/xai.cjs +92 -0
- package/dist/xai.d.cts +35 -0
- package/dist/xai.d.mts +35 -0
- package/dist/xai.mjs +86 -0
- package/package.json +183 -0
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { i as base64ByteLength } from "./mulaw--cwU2c9L.mjs";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
|
+
//#region src/testing/FakeTwilioMediaStream.ts
|
|
5
|
+
/**
|
|
6
|
+
* FakeTwilioMediaStream — a deterministic stand-in for Twilio's side of a
|
|
7
|
+
* Media Stream WebSocket.
|
|
8
|
+
*
|
|
9
|
+
* It exposes the ws-shaped surface the bridge consumes (send/close/on), and a
|
|
10
|
+
* test-driver surface for the "caller": connect(), sendMedia(), stop(), plus a
|
|
11
|
+
* manual playout clock (`advancePlayback`) that emulates Twilio's buffer-and-
|
|
12
|
+
* play-in-order behavior:
|
|
13
|
+
*
|
|
14
|
+
* - outbound `media` frames queue with duration = μ-law bytes / 8 per ms
|
|
15
|
+
* - outbound `mark` frames queue in order and echo back once playout reaches
|
|
16
|
+
* them
|
|
17
|
+
* - outbound `clear` drops everything buffered and immediately echoes every
|
|
18
|
+
* pending mark (exactly what real Twilio does with interrupted marks)
|
|
19
|
+
*
|
|
20
|
+
* No wall-clock timers: tests advance playback explicitly, so scenarios are
|
|
21
|
+
* fully deterministic.
|
|
22
|
+
*/
|
|
23
|
+
/** Base64 μ-law silence of the given duration (whole milliseconds at 8 kHz). */
|
|
24
|
+
function mulawSilenceBase64(ms) {
|
|
25
|
+
return Buffer.alloc(Math.round(ms * 8), 255).toString("base64");
|
|
26
|
+
}
|
|
27
|
+
var FakeTwilioMediaStream = class {
|
|
28
|
+
readyState = 1;
|
|
29
|
+
callSid;
|
|
30
|
+
streamSid;
|
|
31
|
+
/** Everything the bridge sent, parsed, in order. */
|
|
32
|
+
outbound = [];
|
|
33
|
+
/** Base64 payloads of outbound media frames, in order. */
|
|
34
|
+
sentMediaPayloads = [];
|
|
35
|
+
/** Names of marks echoed back to the bridge (played or discarded). */
|
|
36
|
+
echoedMarks = [];
|
|
37
|
+
/** Number of `clear` frames received from the bridge. */
|
|
38
|
+
clearCount = 0;
|
|
39
|
+
messageListeners = [];
|
|
40
|
+
closeListeners = [];
|
|
41
|
+
errorListeners = [];
|
|
42
|
+
playoutQueue = [];
|
|
43
|
+
playedMsTotal = 0;
|
|
44
|
+
callerClockMs = 0;
|
|
45
|
+
mediaSequence = 0;
|
|
46
|
+
closedByBridge = false;
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
this.callSid = options.callSid ?? "CA" + "f".repeat(32);
|
|
49
|
+
this.streamSid = options.streamSid ?? "MZ" + "f".repeat(32);
|
|
50
|
+
}
|
|
51
|
+
send(data) {
|
|
52
|
+
let frame;
|
|
53
|
+
try {
|
|
54
|
+
frame = JSON.parse(data);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.outbound.push(frame);
|
|
59
|
+
switch (frame.event) {
|
|
60
|
+
case "media": {
|
|
61
|
+
const payload = frame.media?.payload ?? "";
|
|
62
|
+
this.sentMediaPayloads.push(payload);
|
|
63
|
+
const ms = base64ByteLength(payload) / 8;
|
|
64
|
+
this.playoutQueue.push({
|
|
65
|
+
kind: "media",
|
|
66
|
+
remainingMs: ms,
|
|
67
|
+
totalMs: ms
|
|
68
|
+
});
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "mark":
|
|
72
|
+
this.playoutQueue.push({
|
|
73
|
+
kind: "mark",
|
|
74
|
+
name: frame.mark?.name ?? ""
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
case "clear": {
|
|
78
|
+
this.clearCount++;
|
|
79
|
+
const pendingMarks = this.playoutQueue.filter((i) => i.kind === "mark");
|
|
80
|
+
this.playoutQueue.length = 0;
|
|
81
|
+
for (const mark of pendingMarks) this.echoMark(mark.name);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
close(code, reason) {
|
|
87
|
+
if (this.readyState === 3) return;
|
|
88
|
+
this.readyState = 3;
|
|
89
|
+
this.closedByBridge = true;
|
|
90
|
+
for (const listener of this.closeListeners) listener(code ?? 1e3, reason);
|
|
91
|
+
}
|
|
92
|
+
on(event, listener) {
|
|
93
|
+
if (event === "message") this.messageListeners.push(listener);
|
|
94
|
+
else if (event === "close") this.closeListeners.push(listener);
|
|
95
|
+
else if (event === "error") this.errorListeners.push(listener);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
/** Send the `connected` preamble and `start` frame. */
|
|
99
|
+
connect(options = {}) {
|
|
100
|
+
if (options.sendConnectedFrame !== false) this.deliver({
|
|
101
|
+
event: "connected",
|
|
102
|
+
protocol: "Call",
|
|
103
|
+
version: "1.0.0"
|
|
104
|
+
});
|
|
105
|
+
this.deliver({
|
|
106
|
+
event: "start",
|
|
107
|
+
sequenceNumber: "1",
|
|
108
|
+
streamSid: options.streamSid ?? this.streamSid,
|
|
109
|
+
start: {
|
|
110
|
+
streamSid: options.streamSid ?? this.streamSid,
|
|
111
|
+
accountSid: options.accountSid ?? "AC" + "f".repeat(32),
|
|
112
|
+
callSid: options.callSid ?? this.callSid,
|
|
113
|
+
tracks: ["inbound"],
|
|
114
|
+
mediaFormat: {
|
|
115
|
+
encoding: "audio/x-mulaw",
|
|
116
|
+
sampleRate: 8e3,
|
|
117
|
+
channels: 1
|
|
118
|
+
},
|
|
119
|
+
customParameters: options.customParameters ?? {}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** Deliver one caller media frame (base64 μ-law) to the bridge. */
|
|
124
|
+
sendMedia(payload) {
|
|
125
|
+
this.mediaSequence++;
|
|
126
|
+
const ms = base64ByteLength(payload) / 8;
|
|
127
|
+
this.deliver({
|
|
128
|
+
event: "media",
|
|
129
|
+
sequenceNumber: String(this.mediaSequence + 1),
|
|
130
|
+
streamSid: this.streamSid,
|
|
131
|
+
media: {
|
|
132
|
+
track: "inbound",
|
|
133
|
+
chunk: String(this.mediaSequence),
|
|
134
|
+
timestamp: String(Math.round(this.callerClockMs)),
|
|
135
|
+
payload
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
this.callerClockMs += ms;
|
|
139
|
+
}
|
|
140
|
+
/** Deliver `ms` of caller silence as 20 ms μ-law frames. */
|
|
141
|
+
sendSilence(ms) {
|
|
142
|
+
let remaining = ms;
|
|
143
|
+
while (remaining > 0) {
|
|
144
|
+
const frameMs = Math.min(20, remaining);
|
|
145
|
+
this.sendMedia(mulawSilenceBase64(frameMs));
|
|
146
|
+
remaining -= frameMs;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
sendDtmf(digit) {
|
|
150
|
+
this.deliver({
|
|
151
|
+
event: "dtmf",
|
|
152
|
+
streamSid: this.streamSid,
|
|
153
|
+
dtmf: {
|
|
154
|
+
track: "inbound_track",
|
|
155
|
+
digit
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Deliver the `stop` frame (Twilio ended the stream). */
|
|
160
|
+
stop() {
|
|
161
|
+
this.deliver({
|
|
162
|
+
event: "stop",
|
|
163
|
+
streamSid: this.streamSid,
|
|
164
|
+
stop: { callSid: this.callSid }
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/** Simulate the socket dropping from Twilio's side. */
|
|
168
|
+
disconnect(code = 1006, reason = "connection lost") {
|
|
169
|
+
if (this.readyState === 3) return;
|
|
170
|
+
this.readyState = 3;
|
|
171
|
+
for (const listener of this.closeListeners) listener(code, reason);
|
|
172
|
+
}
|
|
173
|
+
/** Total milliseconds of agent audio actually "played" to the caller. */
|
|
174
|
+
get playedMs() {
|
|
175
|
+
return this.playedMsTotal;
|
|
176
|
+
}
|
|
177
|
+
/** Milliseconds of agent audio still buffered. */
|
|
178
|
+
get queuedMs() {
|
|
179
|
+
return this.playoutQueue.reduce((sum, item) => sum + (item.kind === "media" ? item.remainingMs : 0), 0);
|
|
180
|
+
}
|
|
181
|
+
get wasClosedByBridge() {
|
|
182
|
+
return this.closedByBridge;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Advance the playout clock: consume buffered media in order and echo each
|
|
186
|
+
* mark as playout reaches it.
|
|
187
|
+
*/
|
|
188
|
+
advancePlayback(ms) {
|
|
189
|
+
let budget = ms;
|
|
190
|
+
while (this.playoutQueue.length > 0) {
|
|
191
|
+
const head = this.playoutQueue[0];
|
|
192
|
+
if (head.kind === "mark") {
|
|
193
|
+
this.playoutQueue.shift();
|
|
194
|
+
this.echoMark(head.name);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (budget <= 0) break;
|
|
198
|
+
const consumed = Math.min(budget, head.remainingMs);
|
|
199
|
+
head.remainingMs -= consumed;
|
|
200
|
+
budget -= consumed;
|
|
201
|
+
this.playedMsTotal += consumed;
|
|
202
|
+
if (head.remainingMs <= 1e-9) this.playoutQueue.shift();
|
|
203
|
+
}
|
|
204
|
+
while (this.playoutQueue[0]?.kind === "mark") {
|
|
205
|
+
const mark = this.playoutQueue.shift();
|
|
206
|
+
this.echoMark(mark.name);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Play out everything currently buffered. */
|
|
210
|
+
playAll() {
|
|
211
|
+
this.advancePlayback(Number.MAX_SAFE_INTEGER);
|
|
212
|
+
}
|
|
213
|
+
echoMark(name) {
|
|
214
|
+
this.echoedMarks.push(name);
|
|
215
|
+
this.deliver({
|
|
216
|
+
event: "mark",
|
|
217
|
+
streamSid: this.streamSid,
|
|
218
|
+
mark: { name }
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
deliver(frame) {
|
|
222
|
+
if (this.readyState === 3) return;
|
|
223
|
+
const data = JSON.stringify(frame);
|
|
224
|
+
for (const listener of this.messageListeners) listener(data);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/testing/FakeOpenAIServer.ts
|
|
229
|
+
let responseCounter = 0;
|
|
230
|
+
var FakeOpenAIConnection = class {
|
|
231
|
+
socket;
|
|
232
|
+
/** Every parsed client → server frame, in order. */
|
|
233
|
+
received = [];
|
|
234
|
+
/** HTTP headers of the client's upgrade request (auth, extensions…). */
|
|
235
|
+
upgradeHeaders;
|
|
236
|
+
server;
|
|
237
|
+
constructor(server, socket, upgradeHeaders = {}) {
|
|
238
|
+
this.server = server;
|
|
239
|
+
this.socket = socket;
|
|
240
|
+
this.upgradeHeaders = upgradeHeaders;
|
|
241
|
+
}
|
|
242
|
+
/** Base64 payloads from input_audio_buffer.append frames. */
|
|
243
|
+
get appendedAudio() {
|
|
244
|
+
return this.received.filter((f) => f.type === "input_audio_buffer.append").map((f) => f.audio);
|
|
245
|
+
}
|
|
246
|
+
eventsOfType(type) {
|
|
247
|
+
return this.received.filter((f) => f.type === type);
|
|
248
|
+
}
|
|
249
|
+
async waitForEvent(predicate, timeoutMs = 2e3) {
|
|
250
|
+
const matches = typeof predicate === "string" ? (frame) => frame.type === predicate : predicate;
|
|
251
|
+
const deadline = Date.now() + timeoutMs;
|
|
252
|
+
let seen = 0;
|
|
253
|
+
while (Date.now() < deadline) {
|
|
254
|
+
for (; seen < this.received.length; seen++) {
|
|
255
|
+
const frame = this.received[seen];
|
|
256
|
+
if (matches(frame)) return frame;
|
|
257
|
+
}
|
|
258
|
+
await setTimeout(5);
|
|
259
|
+
}
|
|
260
|
+
throw new Error(`timed out waiting for client event; got: ${this.received.map((f) => f.type).join(", ")}`);
|
|
261
|
+
}
|
|
262
|
+
send(frame) {
|
|
263
|
+
this.socket.send(JSON.stringify(frame));
|
|
264
|
+
}
|
|
265
|
+
/** Script a complete assistant audio response (created → deltas → done). */
|
|
266
|
+
sendAudioResponse(options) {
|
|
267
|
+
const responseId = options.responseId ?? `resp_${++responseCounter}`;
|
|
268
|
+
const itemId = options.itemId ?? `item_${responseCounter}`;
|
|
269
|
+
this.send({
|
|
270
|
+
type: "response.created",
|
|
271
|
+
response: { id: responseId }
|
|
272
|
+
});
|
|
273
|
+
this.send({
|
|
274
|
+
type: "response.output_item.added",
|
|
275
|
+
response_id: responseId,
|
|
276
|
+
item: {
|
|
277
|
+
id: itemId,
|
|
278
|
+
type: "message"
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
for (const chunk of options.chunks) this.send({
|
|
282
|
+
type: "response.output_audio.delta",
|
|
283
|
+
response_id: responseId,
|
|
284
|
+
item_id: itemId,
|
|
285
|
+
delta: chunk
|
|
286
|
+
});
|
|
287
|
+
if (options.transcript) {
|
|
288
|
+
this.send({
|
|
289
|
+
type: "response.output_audio_transcript.delta",
|
|
290
|
+
response_id: responseId,
|
|
291
|
+
delta: options.transcript
|
|
292
|
+
});
|
|
293
|
+
this.send({
|
|
294
|
+
type: "response.output_audio_transcript.done",
|
|
295
|
+
response_id: responseId,
|
|
296
|
+
transcript: options.transcript
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (options.complete !== false) this.send({
|
|
300
|
+
type: "response.done",
|
|
301
|
+
response: {
|
|
302
|
+
id: responseId,
|
|
303
|
+
status: "completed",
|
|
304
|
+
usage: options.usage
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
responseId,
|
|
309
|
+
itemId
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
sendToolCall(options) {
|
|
313
|
+
const callId = options.callId ?? `call_${++responseCounter}`;
|
|
314
|
+
const responseId = options.responseId ?? `resp_${responseCounter}`;
|
|
315
|
+
this.send({
|
|
316
|
+
type: "response.created",
|
|
317
|
+
response: { id: responseId }
|
|
318
|
+
});
|
|
319
|
+
this.send({
|
|
320
|
+
type: "response.function_call_arguments.done",
|
|
321
|
+
response_id: responseId,
|
|
322
|
+
item_id: `item_${responseCounter}`,
|
|
323
|
+
call_id: callId,
|
|
324
|
+
name: options.name,
|
|
325
|
+
arguments: options.argumentsJson ?? "{}"
|
|
326
|
+
});
|
|
327
|
+
this.send({
|
|
328
|
+
type: "response.done",
|
|
329
|
+
response: {
|
|
330
|
+
id: responseId,
|
|
331
|
+
status: "completed"
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return callId;
|
|
335
|
+
}
|
|
336
|
+
sendSpeechStarted() {
|
|
337
|
+
this.send({ type: "input_audio_buffer.speech_started" });
|
|
338
|
+
}
|
|
339
|
+
sendSpeechStopped() {
|
|
340
|
+
this.send({ type: "input_audio_buffer.speech_stopped" });
|
|
341
|
+
}
|
|
342
|
+
sendUserTranscript(text) {
|
|
343
|
+
this.send({
|
|
344
|
+
type: "conversation.item.input_audio_transcription.completed",
|
|
345
|
+
transcript: text
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
sendError(error) {
|
|
349
|
+
this.send({
|
|
350
|
+
type: "error",
|
|
351
|
+
error
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
drop(code = 1011, reason = "fake server drop") {
|
|
355
|
+
this.socket.close(code, reason);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var FakeOpenAIServer = class FakeOpenAIServer {
|
|
359
|
+
connections = [];
|
|
360
|
+
wss;
|
|
361
|
+
options;
|
|
362
|
+
url;
|
|
363
|
+
constructor(wss, url, options) {
|
|
364
|
+
this.wss = wss;
|
|
365
|
+
this.url = url;
|
|
366
|
+
this.options = options;
|
|
367
|
+
wss.on("connection", (socket, request) => {
|
|
368
|
+
const connection = new FakeOpenAIConnection(this, socket, request.headers);
|
|
369
|
+
this.connections.push(connection);
|
|
370
|
+
socket.on("message", (raw) => {
|
|
371
|
+
let frame;
|
|
372
|
+
try {
|
|
373
|
+
frame = JSON.parse(raw.toString());
|
|
374
|
+
} catch {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
connection.received.push(frame);
|
|
378
|
+
if (frame.type === "session.update" && this.options.autoAckSessionUpdate !== false) connection.send({
|
|
379
|
+
type: "session.updated",
|
|
380
|
+
session: frame.session
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
socket.send(JSON.stringify({
|
|
384
|
+
type: "session.created",
|
|
385
|
+
session: {}
|
|
386
|
+
}));
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
static async start(options = {}) {
|
|
390
|
+
const wss = new WebSocketServer({
|
|
391
|
+
port: 0,
|
|
392
|
+
host: "127.0.0.1"
|
|
393
|
+
});
|
|
394
|
+
await new Promise((resolve, reject) => {
|
|
395
|
+
wss.once("listening", resolve);
|
|
396
|
+
wss.once("error", reject);
|
|
397
|
+
});
|
|
398
|
+
const address = wss.address();
|
|
399
|
+
if (typeof address === "string" || address === null) throw new Error("no server address");
|
|
400
|
+
return new FakeOpenAIServer(wss, `ws://127.0.0.1:${address.port}`, options);
|
|
401
|
+
}
|
|
402
|
+
get latest() {
|
|
403
|
+
const connection = this.connections[this.connections.length - 1];
|
|
404
|
+
if (!connection) throw new Error("no connections yet");
|
|
405
|
+
return connection;
|
|
406
|
+
}
|
|
407
|
+
async waitForConnection(timeoutMs = 2e3) {
|
|
408
|
+
const count = this.connections.length;
|
|
409
|
+
const deadline = Date.now() + timeoutMs;
|
|
410
|
+
while (Date.now() < deadline) {
|
|
411
|
+
if (this.connections.length > count) return this.connections[this.connections.length - 1];
|
|
412
|
+
await setTimeout(5);
|
|
413
|
+
}
|
|
414
|
+
throw new Error("timed out waiting for a provider connection");
|
|
415
|
+
}
|
|
416
|
+
async close() {
|
|
417
|
+
for (const connection of this.connections) try {
|
|
418
|
+
connection.socket.terminate();
|
|
419
|
+
} catch {}
|
|
420
|
+
await new Promise((resolve) => this.wss.close(() => resolve()));
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/testing/FakeGeminiLive.ts
|
|
425
|
+
/**
|
|
426
|
+
* FakeGeminiLive — a scripted stand-in for @google/genai's live connection.
|
|
427
|
+
*
|
|
428
|
+
* Instead of a WebSocket server, this fakes the SDK seam: pass
|
|
429
|
+
* `fake.connector` as the Gemini provider's `connector`. Each connect yields a
|
|
430
|
+
* FakeGeminiSession that records client messages and lets tests push server
|
|
431
|
+
* messages (audio parts, tool calls, transcriptions, resumption updates,
|
|
432
|
+
* goAway) through the provider's callbacks.
|
|
433
|
+
*/
|
|
434
|
+
var FakeGeminiSession = class {
|
|
435
|
+
params;
|
|
436
|
+
realtimeInputs = [];
|
|
437
|
+
clientContents = [];
|
|
438
|
+
toolResponses = [];
|
|
439
|
+
closed = false;
|
|
440
|
+
constructor(params) {
|
|
441
|
+
this.params = params;
|
|
442
|
+
}
|
|
443
|
+
sendRealtimeInput(input) {
|
|
444
|
+
this.realtimeInputs.push(input.audio);
|
|
445
|
+
}
|
|
446
|
+
sendClientContent(content) {
|
|
447
|
+
this.clientContents.push(content);
|
|
448
|
+
}
|
|
449
|
+
sendToolResponse(response) {
|
|
450
|
+
this.toolResponses.push(response);
|
|
451
|
+
}
|
|
452
|
+
close() {
|
|
453
|
+
if (this.closed) return;
|
|
454
|
+
this.closed = true;
|
|
455
|
+
this.params.callbacks.onclose?.({
|
|
456
|
+
code: 1e3,
|
|
457
|
+
reason: "client close"
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
serverMessage(message) {
|
|
461
|
+
this.params.callbacks.onmessage(message);
|
|
462
|
+
}
|
|
463
|
+
completeSetup() {
|
|
464
|
+
this.serverMessage({ setupComplete: {} });
|
|
465
|
+
}
|
|
466
|
+
/** Stream a model turn: PCM24k audio parts (+ transcript), then turnComplete. */
|
|
467
|
+
sendAudioTurn(options) {
|
|
468
|
+
for (const chunk of options.pcm24kBase64Chunks) this.serverMessage({ serverContent: { modelTurn: { parts: [{ inlineData: {
|
|
469
|
+
data: chunk,
|
|
470
|
+
mimeType: "audio/pcm;rate=24000"
|
|
471
|
+
} }] } } });
|
|
472
|
+
if (options.transcript) this.serverMessage({ serverContent: { outputTranscription: { text: options.transcript } } });
|
|
473
|
+
this.serverMessage({ serverContent: { turnComplete: true } });
|
|
474
|
+
}
|
|
475
|
+
sendToolCall(options) {
|
|
476
|
+
const id = options.id ?? `fn_${Math.random().toString(36).slice(2, 8)}`;
|
|
477
|
+
this.serverMessage({ toolCall: { functionCalls: [{
|
|
478
|
+
id,
|
|
479
|
+
name: options.name,
|
|
480
|
+
args: options.args ?? {}
|
|
481
|
+
}] } });
|
|
482
|
+
return id;
|
|
483
|
+
}
|
|
484
|
+
sendInterrupted() {
|
|
485
|
+
this.serverMessage({ serverContent: { interrupted: true } });
|
|
486
|
+
}
|
|
487
|
+
sendInputTranscription(text) {
|
|
488
|
+
this.serverMessage({ serverContent: { inputTranscription: { text } } });
|
|
489
|
+
}
|
|
490
|
+
sendResumptionUpdate(handle) {
|
|
491
|
+
this.serverMessage({ sessionResumptionUpdate: {
|
|
492
|
+
resumable: true,
|
|
493
|
+
newHandle: handle
|
|
494
|
+
} });
|
|
495
|
+
}
|
|
496
|
+
sendGoAway(timeLeft) {
|
|
497
|
+
this.serverMessage({ goAway: { timeLeft } });
|
|
498
|
+
}
|
|
499
|
+
sendUsage(usage) {
|
|
500
|
+
this.serverMessage({ usageMetadata: usage });
|
|
501
|
+
}
|
|
502
|
+
/** Drop the connection from the server side. */
|
|
503
|
+
drop(code = 1011, reason = "fake drop") {
|
|
504
|
+
if (this.closed) return;
|
|
505
|
+
this.closed = true;
|
|
506
|
+
this.params.callbacks.onclose?.({
|
|
507
|
+
code,
|
|
508
|
+
reason
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
var FakeGeminiLive = class {
|
|
513
|
+
sessions = [];
|
|
514
|
+
/** Auto-send setupComplete on connect. Default true. */
|
|
515
|
+
autoCompleteSetup = true;
|
|
516
|
+
connector = async (params) => {
|
|
517
|
+
const session = new FakeGeminiSession(params);
|
|
518
|
+
this.sessions.push(session);
|
|
519
|
+
params.callbacks.onopen?.();
|
|
520
|
+
if (this.autoCompleteSetup) queueMicrotask(() => session.completeSetup());
|
|
521
|
+
return session;
|
|
522
|
+
};
|
|
523
|
+
get latest() {
|
|
524
|
+
const session = this.sessions[this.sessions.length - 1];
|
|
525
|
+
if (!session) throw new Error("no gemini sessions yet");
|
|
526
|
+
return session;
|
|
527
|
+
}
|
|
528
|
+
async waitForSessions(count, timeoutMs = 2e3) {
|
|
529
|
+
const deadline = Date.now() + timeoutMs;
|
|
530
|
+
while (Date.now() < deadline) {
|
|
531
|
+
if (this.sessions.length >= count) return this.sessions[count - 1];
|
|
532
|
+
await setTimeout(5);
|
|
533
|
+
}
|
|
534
|
+
throw new Error(`timed out waiting for ${count} gemini sessions (have ${this.sessions.length})`);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
//#endregion
|
|
538
|
+
export { FakeGeminiLive, FakeGeminiSession, FakeOpenAIConnection, FakeOpenAIServer, FakeTwilioMediaStream, mulawSilenceBase64 };
|