@kuralle-syrinx/realtime 4.4.1 → 4.5.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/dist/base64.d.ts +3 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +16 -0
- package/dist/base64.js.map +1 -0
- package/dist/from-gemini-live.d.ts +67 -0
- package/dist/from-gemini-live.d.ts.map +1 -0
- package/dist/from-gemini-live.js +299 -0
- package/dist/from-gemini-live.js.map +1 -0
- package/dist/from-openai-realtime.d.ts +49 -0
- package/dist/from-openai-realtime.d.ts.map +1 -0
- package/dist/from-openai-realtime.js +80 -0
- package/dist/from-openai-realtime.js.map +1 -0
- package/dist/gemini-translate.d.ts +17 -0
- package/dist/gemini-translate.d.ts.map +1 -0
- package/dist/gemini-translate.js +89 -0
- package/dist/gemini-translate.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/openai-compatible-realtime.d.ts +37 -0
- package/dist/openai-compatible-realtime.d.ts.map +1 -0
- package/dist/openai-compatible-realtime.js +416 -0
- package/dist/openai-compatible-realtime.js.map +1 -0
- package/dist/realtime-adapter.d.ts +100 -0
- package/dist/realtime-adapter.d.ts.map +1 -0
- package/dist/realtime-adapter.js +3 -0
- package/dist/realtime-adapter.js.map +1 -0
- package/dist/realtime-bridge.d.ts +130 -0
- package/dist/realtime-bridge.d.ts.map +1 -0
- package/dist/realtime-bridge.js +659 -0
- package/dist/realtime-bridge.js.map +1 -0
- package/dist/realtime-event-stream.d.ts +16 -0
- package/dist/realtime-event-stream.d.ts.map +1 -0
- package/dist/realtime-event-stream.js +47 -0
- package/dist/realtime-event-stream.js.map +1 -0
- package/package.json +13 -6
- package/src/edge-safety.test.ts +173 -0
- package/src/from-gemini-live.test.ts +366 -0
- package/src/from-openai-realtime.test.ts +769 -0
- package/src/gemini-translate.test.ts +82 -0
- package/src/openai-compatible-realtime.test.ts +293 -0
- package/src/realtime-bridge.test.ts +1573 -0
- package/src/workers-seam.test.ts +143 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { LiveServerMessage } from "@google/genai";
|
|
6
|
+
|
|
7
|
+
import type { RealtimeEvent } from "./realtime-adapter.js";
|
|
8
|
+
import { bytesToBase64 } from "./base64.js";
|
|
9
|
+
import { fromGeminiLive } from "./from-gemini-live.js";
|
|
10
|
+
|
|
11
|
+
const sendRealtimeInput = vi.fn();
|
|
12
|
+
const sendToolResponse = vi.fn();
|
|
13
|
+
const sendClientContent = vi.fn();
|
|
14
|
+
const closeSession = vi.fn();
|
|
15
|
+
const GoogleGenAI = vi.fn();
|
|
16
|
+
|
|
17
|
+
let onopen: (() => void) | null = null;
|
|
18
|
+
let onmessage: ((msg: LiveServerMessage) => void) | null = null;
|
|
19
|
+
|
|
20
|
+
const liveConnect = vi.fn().mockImplementation(async ({ callbacks }: {
|
|
21
|
+
callbacks: {
|
|
22
|
+
onopen?: () => void;
|
|
23
|
+
onmessage?: (msg: LiveServerMessage) => void;
|
|
24
|
+
};
|
|
25
|
+
}) => {
|
|
26
|
+
onopen = callbacks.onopen ?? null;
|
|
27
|
+
onmessage = callbacks.onmessage ?? null;
|
|
28
|
+
queueMicrotask(() => callbacks.onopen?.());
|
|
29
|
+
return {
|
|
30
|
+
sendRealtimeInput,
|
|
31
|
+
sendToolResponse,
|
|
32
|
+
sendClientContent,
|
|
33
|
+
close: closeSession,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
vi.mock("@google/genai", () => ({
|
|
38
|
+
GoogleGenAI: GoogleGenAI.mockImplementation(() => ({
|
|
39
|
+
live: { connect: liveConnect },
|
|
40
|
+
})),
|
|
41
|
+
Modality: { AUDIO: "AUDIO" },
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
sendRealtimeInput.mockClear();
|
|
46
|
+
sendToolResponse.mockClear();
|
|
47
|
+
sendClientContent.mockClear();
|
|
48
|
+
closeSession.mockClear();
|
|
49
|
+
GoogleGenAI.mockClear();
|
|
50
|
+
liveConnect.mockClear();
|
|
51
|
+
onopen = null;
|
|
52
|
+
onmessage = null;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
async function collectEvents(
|
|
56
|
+
events: AsyncIterable<RealtimeEvent>,
|
|
57
|
+
max = 12,
|
|
58
|
+
): Promise<RealtimeEvent[]> {
|
|
59
|
+
const out: RealtimeEvent[] = [];
|
|
60
|
+
for await (const event of events) {
|
|
61
|
+
out.push(event);
|
|
62
|
+
if (out.length >= max) break;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inject(msg: Partial<LiveServerMessage> & Record<string, unknown>): void {
|
|
68
|
+
if (!onmessage) throw new Error("mock session onmessage not wired");
|
|
69
|
+
onmessage(msg as LiveServerMessage);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("fromGeminiLive", () => {
|
|
73
|
+
it("frames silent context as a user-role update because Gemini drops system history", async () => {
|
|
74
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
75
|
+
|
|
76
|
+
await adapter.open(new AbortController().signal);
|
|
77
|
+
adapter.injectContext!("Use the verified deadline.");
|
|
78
|
+
|
|
79
|
+
expect(sendClientContent).toHaveBeenCalledWith({
|
|
80
|
+
turns: [{ role: "user", parts: [{ text: "[Context-only instruction]\nUse the verified deadline." }] }],
|
|
81
|
+
turnComplete: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await adapter.close();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("emits client calls for open, audio, and tool result", async () => {
|
|
88
|
+
const adapter = fromGeminiLive({
|
|
89
|
+
apiKey: "test-key",
|
|
90
|
+
tools: [{
|
|
91
|
+
name: "consult_knowledge",
|
|
92
|
+
description: "Answer knowledge questions.",
|
|
93
|
+
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
|
94
|
+
}],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await adapter.open(new AbortController().signal);
|
|
98
|
+
|
|
99
|
+
expect(liveConnect).toHaveBeenCalledTimes(1);
|
|
100
|
+
const connectArg = liveConnect.mock.calls[0]![0] as {
|
|
101
|
+
model: string;
|
|
102
|
+
config: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
expect(connectArg.model).toBe("gemini-3.1-flash-live-preview");
|
|
105
|
+
expect(connectArg.config["tools"]).toEqual([{
|
|
106
|
+
functionDeclarations: [{
|
|
107
|
+
name: "consult_knowledge",
|
|
108
|
+
description: "Answer knowledge questions.",
|
|
109
|
+
parametersJsonSchema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: { query: { type: "string" } },
|
|
112
|
+
required: ["query"],
|
|
113
|
+
},
|
|
114
|
+
}],
|
|
115
|
+
}]);
|
|
116
|
+
// Both directions default ON. Input in particular must stay on: RealtimeBridge turns
|
|
117
|
+
// `role: "user"` transcripts into stt.result packets, so defaulting it off would silently
|
|
118
|
+
// remove all user-side text on the Gemini front. #32 asked for configurable, not disabled.
|
|
119
|
+
expect(connectArg.config["inputAudioTranscription"]).toEqual({});
|
|
120
|
+
expect(connectArg.config["outputAudioTranscription"]).toEqual({});
|
|
121
|
+
|
|
122
|
+
const pcm = new Uint8Array([0, 1, 2, 3]);
|
|
123
|
+
adapter.sendAudio(pcm);
|
|
124
|
+
expect(sendRealtimeInput).toHaveBeenCalledWith({
|
|
125
|
+
audio: {
|
|
126
|
+
data: bytesToBase64(pcm),
|
|
127
|
+
mimeType: "audio/pcm;rate=16000",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
inject({
|
|
132
|
+
toolCall: {
|
|
133
|
+
functionCalls: [{
|
|
134
|
+
id: "call_abc",
|
|
135
|
+
name: "consult_knowledge",
|
|
136
|
+
args: { query: "late add" },
|
|
137
|
+
}],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
adapter.injectToolResult("call_abc", "Late Add Petition required.");
|
|
141
|
+
expect(sendToolResponse).toHaveBeenCalledWith({
|
|
142
|
+
functionResponses: [{
|
|
143
|
+
id: "call_abc",
|
|
144
|
+
name: "consult_knowledge",
|
|
145
|
+
response: { result: "Late Add Petition required." },
|
|
146
|
+
}],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
adapter.cancelResponse(420);
|
|
150
|
+
expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("builds the documented transcription, speech, and API-version setup options", async () => {
|
|
154
|
+
const adapter = fromGeminiLive({
|
|
155
|
+
apiKey: "test-key",
|
|
156
|
+
transcription: {
|
|
157
|
+
input: true,
|
|
158
|
+
output: { languageCodes: ["en-US"] },
|
|
159
|
+
},
|
|
160
|
+
speechConfig: { voice: "Kore", languageCode: "en-US" },
|
|
161
|
+
apiVersion: "v1alpha",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await adapter.open(new AbortController().signal);
|
|
165
|
+
|
|
166
|
+
expect(GoogleGenAI).toHaveBeenCalledWith({
|
|
167
|
+
apiKey: "test-key",
|
|
168
|
+
httpOptions: { apiVersion: "v1alpha" },
|
|
169
|
+
});
|
|
170
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
171
|
+
expect(connectArg.config).toMatchObject({
|
|
172
|
+
inputAudioTranscription: {},
|
|
173
|
+
outputAudioTranscription: { languageCodes: ["en-US"] },
|
|
174
|
+
speechConfig: {
|
|
175
|
+
voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } },
|
|
176
|
+
languageCode: "en-US",
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("allows either transcription direction to be disabled", async () => {
|
|
182
|
+
const adapter = fromGeminiLive({
|
|
183
|
+
apiKey: "test-key",
|
|
184
|
+
transcription: { input: false, output: false },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await adapter.open(new AbortController().signal);
|
|
188
|
+
|
|
189
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
190
|
+
expect(connectArg.config["inputAudioTranscription"]).toBeUndefined();
|
|
191
|
+
expect(connectArg.config["outputAudioTranscription"]).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("G4/WBS-4: native resume — always enables sessionResumption, passes a prior handle through, surfaces new handles", async () => {
|
|
195
|
+
const adapter = fromGeminiLive({ apiKey: "test-key", sessionResumptionHandle: "handle-prev" });
|
|
196
|
+
expect(adapter.caps.supportsNativeResume).toBe(true);
|
|
197
|
+
|
|
198
|
+
const eventsTask = collectEvents(adapter.events, 1);
|
|
199
|
+
await adapter.open(new AbortController().signal);
|
|
200
|
+
|
|
201
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
202
|
+
// Handle passthrough — the server restores the conversation; nothing is replayed
|
|
203
|
+
// client-side (sendClientContent untouched — R6: no double-apply).
|
|
204
|
+
expect(connectArg.config["sessionResumption"]).toEqual({ handle: "handle-prev" });
|
|
205
|
+
expect(sendClientContent).not.toHaveBeenCalled();
|
|
206
|
+
|
|
207
|
+
inject({ sessionResumptionUpdate: { newHandle: "handle-next", resumable: true } });
|
|
208
|
+
// Non-resumable updates carry no usable handle and must be ignored.
|
|
209
|
+
inject({ sessionResumptionUpdate: { newHandle: "", resumable: false } });
|
|
210
|
+
|
|
211
|
+
expect(await eventsTask).toEqual([{ type: "resumption_handle", handle: "handle-next" }]);
|
|
212
|
+
await adapter.close();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("G4/WBS-4: enables handle issuance even without a prior handle", async () => {
|
|
216
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
217
|
+
await adapter.open(new AbortController().signal);
|
|
218
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
219
|
+
expect(connectArg.config["sessionResumption"]).toEqual({});
|
|
220
|
+
await adapter.close();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("cfg-flex: responseModalities, generationConfig, safety, sessionResumption, and connectConfig reach live.connect", async () => {
|
|
224
|
+
const adapter = fromGeminiLive({
|
|
225
|
+
apiKey: "test-key",
|
|
226
|
+
responseModalities: ["AUDIO", "TEXT"],
|
|
227
|
+
temperature: 0.4,
|
|
228
|
+
topP: 0.9,
|
|
229
|
+
topK: 32,
|
|
230
|
+
maxOutputTokens: 1024,
|
|
231
|
+
mediaResolution: "MEDIA_RESOLUTION_MEDIUM",
|
|
232
|
+
seed: 7,
|
|
233
|
+
generationConfig: { candidateCount: 1 },
|
|
234
|
+
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" }],
|
|
235
|
+
thinkingConfig: { includeThoughts: false },
|
|
236
|
+
enableAffectiveDialog: true,
|
|
237
|
+
realtimeInputConfig: { turnCoverage: "TURN_INCLUDES_ONLY_ACTIVITY" },
|
|
238
|
+
contextWindowCompression: { slidingWindow: {} },
|
|
239
|
+
proactivity: { proactiveAudio: true },
|
|
240
|
+
explicitVadSignal: true,
|
|
241
|
+
sessionResumption: { transparent: true },
|
|
242
|
+
sessionResumptionHandle: "handle-prev",
|
|
243
|
+
connectConfig: { avatarConfig: { enabled: false } },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await adapter.open(new AbortController().signal);
|
|
247
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
248
|
+
expect(connectArg.config["responseModalities"]).toEqual(["AUDIO", "TEXT"]);
|
|
249
|
+
expect(connectArg.config["temperature"]).toBe(0.4);
|
|
250
|
+
expect(connectArg.config["topP"]).toBe(0.9);
|
|
251
|
+
expect(connectArg.config["topK"]).toBe(32);
|
|
252
|
+
expect(connectArg.config["maxOutputTokens"]).toBe(1024);
|
|
253
|
+
expect(connectArg.config["mediaResolution"]).toBe("MEDIA_RESOLUTION_MEDIUM");
|
|
254
|
+
expect(connectArg.config["seed"]).toBe(7);
|
|
255
|
+
expect(connectArg.config["generationConfig"]).toEqual({ candidateCount: 1 });
|
|
256
|
+
expect(connectArg.config["safetySettings"]).toEqual([
|
|
257
|
+
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_NONE" },
|
|
258
|
+
]);
|
|
259
|
+
expect(connectArg.config["thinkingConfig"]).toEqual({ includeThoughts: false });
|
|
260
|
+
expect(connectArg.config["enableAffectiveDialog"]).toBe(true);
|
|
261
|
+
expect(connectArg.config["realtimeInputConfig"]).toEqual({
|
|
262
|
+
turnCoverage: "TURN_INCLUDES_ONLY_ACTIVITY",
|
|
263
|
+
});
|
|
264
|
+
expect(connectArg.config["contextWindowCompression"]).toEqual({ slidingWindow: {} });
|
|
265
|
+
expect(connectArg.config["proactivity"]).toEqual({ proactiveAudio: true });
|
|
266
|
+
expect(connectArg.config["explicitVadSignal"]).toBe(true);
|
|
267
|
+
expect(connectArg.config["sessionResumption"]).toEqual({
|
|
268
|
+
transparent: true,
|
|
269
|
+
handle: "handle-prev",
|
|
270
|
+
});
|
|
271
|
+
expect(connectArg.config["avatarConfig"]).toEqual({ enabled: false });
|
|
272
|
+
// Defaults still on.
|
|
273
|
+
expect(connectArg.config["inputAudioTranscription"]).toEqual({});
|
|
274
|
+
expect(connectArg.config["outputAudioTranscription"]).toEqual({});
|
|
275
|
+
await adapter.close();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("cfg-flex: sessionResumption false omits the field entirely", async () => {
|
|
279
|
+
const adapter = fromGeminiLive({ apiKey: "test-key", sessionResumption: false });
|
|
280
|
+
await adapter.open(new AbortController().signal);
|
|
281
|
+
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
282
|
+
expect(connectArg.config["sessionResumption"]).toBeUndefined();
|
|
283
|
+
await adapter.close();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("sends a typed user turn via sendClientContent with turnComplete", async () => {
|
|
287
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
288
|
+
await adapter.open(new AbortController().signal);
|
|
289
|
+
|
|
290
|
+
adapter.sendText!("when is the late-add deadline?");
|
|
291
|
+
expect(sendClientContent).toHaveBeenCalledWith({
|
|
292
|
+
turns: [{ role: "user", parts: [{ text: "when is the late-add deadline?" }] }],
|
|
293
|
+
turnComplete: true,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("normalizes provider server messages into RealtimeEvent", async () => {
|
|
298
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
299
|
+
const eventsTask = collectEvents(adapter.events, 7);
|
|
300
|
+
await adapter.open(new AbortController().signal);
|
|
301
|
+
|
|
302
|
+
const audioBytes = new Uint8Array([9, 10, 11, 12]);
|
|
303
|
+
inject({ setupComplete: {} });
|
|
304
|
+
inject({
|
|
305
|
+
serverContent: {
|
|
306
|
+
modelTurn: {
|
|
307
|
+
parts: [{
|
|
308
|
+
inlineData: {
|
|
309
|
+
data: bytesToBase64(audioBytes),
|
|
310
|
+
mimeType: "audio/pcm;rate=24000",
|
|
311
|
+
},
|
|
312
|
+
}],
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
inject({ serverContent: { interrupted: true } });
|
|
317
|
+
inject({
|
|
318
|
+
serverContent: {
|
|
319
|
+
inputTranscription: { text: "Can I add Biology?", finished: true },
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
inject({
|
|
323
|
+
serverContent: {
|
|
324
|
+
outputTranscription: { text: "Let me check that.", finished: true },
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
inject({
|
|
328
|
+
toolCall: {
|
|
329
|
+
functionCalls: [{
|
|
330
|
+
id: "call_123",
|
|
331
|
+
name: "consult_knowledge",
|
|
332
|
+
args: { query: "late add biology" },
|
|
333
|
+
}],
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
inject({ serverContent: { turnComplete: true } });
|
|
337
|
+
|
|
338
|
+
const events = await eventsTask;
|
|
339
|
+
expect(events.slice(0, 7)).toEqual([
|
|
340
|
+
{ type: "response_started" },
|
|
341
|
+
{ type: "audio", pcm16: audioBytes, sampleRateHz: 24000 },
|
|
342
|
+
{ type: "speech_started" },
|
|
343
|
+
{ type: "transcript", role: "user", text: "Can I add Biology?", final: true },
|
|
344
|
+
{ type: "transcript", role: "assistant", text: "Let me check that.", final: true },
|
|
345
|
+
{
|
|
346
|
+
type: "tool_call",
|
|
347
|
+
toolId: "call_123",
|
|
348
|
+
toolName: "consult_knowledge",
|
|
349
|
+
args: { query: "late add biology" },
|
|
350
|
+
},
|
|
351
|
+
{ type: "response_done" },
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("exposes Gemini Live capability flags", () => {
|
|
356
|
+
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
357
|
+
expect(adapter.caps).toEqual({
|
|
358
|
+
inputSampleRateHz: 16_000,
|
|
359
|
+
outputSampleRateHz: 24_000,
|
|
360
|
+
supportsNativeResume: true,
|
|
361
|
+
supportsConcurrentToolAudio: false,
|
|
362
|
+
supportsTruncate: false,
|
|
363
|
+
emitsServerSpeechStarted: true,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|