@kuralle-syrinx/realtime 4.1.0 → 4.3.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/README.md +28 -0
- package/package.json +25 -5
- package/src/from-gemini-live.ts +147 -9
- package/src/from-openai-realtime.ts +30 -5
- package/src/index.ts +6 -1
- package/src/openai-compatible-realtime.ts +82 -3
- package/src/realtime-adapter.ts +28 -1
- package/src/realtime-bridge.ts +199 -10
- package/src/edge-safety.test.ts +0 -173
- package/src/from-gemini-live.test.ts +0 -241
- package/src/from-openai-realtime.test.ts +0 -675
- package/src/gemini-translate.test.ts +0 -82
- package/src/realtime-bridge.test.ts +0 -1147
- package/src/workers-seam.test.ts +0 -143
- package/tsconfig.json +0 -21
|
@@ -1,241 +0,0 @@
|
|
|
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
|
-
|
|
16
|
-
let onopen: (() => void) | null = null;
|
|
17
|
-
let onmessage: ((msg: LiveServerMessage) => void) | null = null;
|
|
18
|
-
|
|
19
|
-
const liveConnect = vi.fn().mockImplementation(async ({ callbacks }: {
|
|
20
|
-
callbacks: {
|
|
21
|
-
onopen?: () => void;
|
|
22
|
-
onmessage?: (msg: LiveServerMessage) => void;
|
|
23
|
-
};
|
|
24
|
-
}) => {
|
|
25
|
-
onopen = callbacks.onopen ?? null;
|
|
26
|
-
onmessage = callbacks.onmessage ?? null;
|
|
27
|
-
queueMicrotask(() => callbacks.onopen?.());
|
|
28
|
-
return {
|
|
29
|
-
sendRealtimeInput,
|
|
30
|
-
sendToolResponse,
|
|
31
|
-
sendClientContent,
|
|
32
|
-
close: closeSession,
|
|
33
|
-
};
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
vi.mock("@google/genai", () => ({
|
|
37
|
-
GoogleGenAI: vi.fn().mockImplementation(() => ({
|
|
38
|
-
live: { connect: liveConnect },
|
|
39
|
-
})),
|
|
40
|
-
Modality: { AUDIO: "AUDIO" },
|
|
41
|
-
}));
|
|
42
|
-
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
sendRealtimeInput.mockClear();
|
|
45
|
-
sendToolResponse.mockClear();
|
|
46
|
-
sendClientContent.mockClear();
|
|
47
|
-
closeSession.mockClear();
|
|
48
|
-
liveConnect.mockClear();
|
|
49
|
-
onopen = null;
|
|
50
|
-
onmessage = null;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
async function collectEvents(
|
|
54
|
-
events: AsyncIterable<RealtimeEvent>,
|
|
55
|
-
max = 12,
|
|
56
|
-
): Promise<RealtimeEvent[]> {
|
|
57
|
-
const out: RealtimeEvent[] = [];
|
|
58
|
-
for await (const event of events) {
|
|
59
|
-
out.push(event);
|
|
60
|
-
if (out.length >= max) break;
|
|
61
|
-
}
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function inject(msg: Partial<LiveServerMessage> & Record<string, unknown>): void {
|
|
66
|
-
if (!onmessage) throw new Error("mock session onmessage not wired");
|
|
67
|
-
onmessage(msg as LiveServerMessage);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
describe("fromGeminiLive", () => {
|
|
71
|
-
it("emits client calls for open, audio, and tool result", async () => {
|
|
72
|
-
const adapter = fromGeminiLive({
|
|
73
|
-
apiKey: "test-key",
|
|
74
|
-
tools: [{
|
|
75
|
-
name: "consult_knowledge",
|
|
76
|
-
description: "Answer knowledge questions.",
|
|
77
|
-
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
|
78
|
-
}],
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
await adapter.open(new AbortController().signal);
|
|
82
|
-
|
|
83
|
-
expect(liveConnect).toHaveBeenCalledTimes(1);
|
|
84
|
-
const connectArg = liveConnect.mock.calls[0]![0] as {
|
|
85
|
-
model: string;
|
|
86
|
-
config: Record<string, unknown>;
|
|
87
|
-
};
|
|
88
|
-
expect(connectArg.model).toBe("gemini-3.1-flash-live-preview");
|
|
89
|
-
expect(connectArg.config["tools"]).toEqual([{
|
|
90
|
-
functionDeclarations: [{
|
|
91
|
-
name: "consult_knowledge",
|
|
92
|
-
description: "Answer knowledge questions.",
|
|
93
|
-
parametersJsonSchema: {
|
|
94
|
-
type: "object",
|
|
95
|
-
properties: { query: { type: "string" } },
|
|
96
|
-
required: ["query"],
|
|
97
|
-
},
|
|
98
|
-
}],
|
|
99
|
-
}]);
|
|
100
|
-
|
|
101
|
-
const pcm = new Uint8Array([0, 1, 2, 3]);
|
|
102
|
-
adapter.sendAudio(pcm);
|
|
103
|
-
expect(sendRealtimeInput).toHaveBeenCalledWith({
|
|
104
|
-
audio: {
|
|
105
|
-
data: bytesToBase64(pcm),
|
|
106
|
-
mimeType: "audio/pcm;rate=16000",
|
|
107
|
-
},
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
inject({
|
|
111
|
-
toolCall: {
|
|
112
|
-
functionCalls: [{
|
|
113
|
-
id: "call_abc",
|
|
114
|
-
name: "consult_knowledge",
|
|
115
|
-
args: { query: "late add" },
|
|
116
|
-
}],
|
|
117
|
-
},
|
|
118
|
-
});
|
|
119
|
-
adapter.injectToolResult("call_abc", "Late Add Petition required.");
|
|
120
|
-
expect(sendToolResponse).toHaveBeenCalledWith({
|
|
121
|
-
functionResponses: [{
|
|
122
|
-
id: "call_abc",
|
|
123
|
-
name: "consult_knowledge",
|
|
124
|
-
response: { result: "Late Add Petition required." },
|
|
125
|
-
}],
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
adapter.cancelResponse(420);
|
|
129
|
-
expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("G4/WBS-4: native resume — always enables sessionResumption, passes a prior handle through, surfaces new handles", async () => {
|
|
133
|
-
const adapter = fromGeminiLive({ apiKey: "test-key", sessionResumptionHandle: "handle-prev" });
|
|
134
|
-
expect(adapter.caps.supportsNativeResume).toBe(true);
|
|
135
|
-
|
|
136
|
-
const eventsTask = collectEvents(adapter.events, 1);
|
|
137
|
-
await adapter.open(new AbortController().signal);
|
|
138
|
-
|
|
139
|
-
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
140
|
-
// Handle passthrough — the server restores the conversation; nothing is replayed
|
|
141
|
-
// client-side (sendClientContent untouched — R6: no double-apply).
|
|
142
|
-
expect(connectArg.config["sessionResumption"]).toEqual({ handle: "handle-prev" });
|
|
143
|
-
expect(sendClientContent).not.toHaveBeenCalled();
|
|
144
|
-
|
|
145
|
-
inject({ sessionResumptionUpdate: { newHandle: "handle-next", resumable: true } });
|
|
146
|
-
// Non-resumable updates carry no usable handle and must be ignored.
|
|
147
|
-
inject({ sessionResumptionUpdate: { newHandle: "", resumable: false } });
|
|
148
|
-
|
|
149
|
-
expect(await eventsTask).toEqual([{ type: "resumption_handle", handle: "handle-next" }]);
|
|
150
|
-
await adapter.close();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("G4/WBS-4: enables handle issuance even without a prior handle", async () => {
|
|
154
|
-
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
155
|
-
await adapter.open(new AbortController().signal);
|
|
156
|
-
const connectArg = liveConnect.mock.calls[0]![0] as { config: Record<string, unknown> };
|
|
157
|
-
expect(connectArg.config["sessionResumption"]).toEqual({});
|
|
158
|
-
await adapter.close();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("sends a typed user turn via sendClientContent with turnComplete", async () => {
|
|
162
|
-
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
163
|
-
await adapter.open(new AbortController().signal);
|
|
164
|
-
|
|
165
|
-
adapter.sendText!("when is the late-add deadline?");
|
|
166
|
-
expect(sendClientContent).toHaveBeenCalledWith({
|
|
167
|
-
turns: [{ role: "user", parts: [{ text: "when is the late-add deadline?" }] }],
|
|
168
|
-
turnComplete: true,
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("normalizes provider server messages into RealtimeEvent", async () => {
|
|
173
|
-
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
174
|
-
const eventsTask = collectEvents(adapter.events, 7);
|
|
175
|
-
await adapter.open(new AbortController().signal);
|
|
176
|
-
|
|
177
|
-
const audioBytes = new Uint8Array([9, 10, 11, 12]);
|
|
178
|
-
inject({ setupComplete: {} });
|
|
179
|
-
inject({
|
|
180
|
-
serverContent: {
|
|
181
|
-
modelTurn: {
|
|
182
|
-
parts: [{
|
|
183
|
-
inlineData: {
|
|
184
|
-
data: bytesToBase64(audioBytes),
|
|
185
|
-
mimeType: "audio/pcm;rate=24000",
|
|
186
|
-
},
|
|
187
|
-
}],
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
inject({ serverContent: { interrupted: true } });
|
|
192
|
-
inject({
|
|
193
|
-
serverContent: {
|
|
194
|
-
inputTranscription: { text: "Can I add Biology?", finished: true },
|
|
195
|
-
},
|
|
196
|
-
});
|
|
197
|
-
inject({
|
|
198
|
-
serverContent: {
|
|
199
|
-
outputTranscription: { text: "Let me check that.", finished: true },
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
inject({
|
|
203
|
-
toolCall: {
|
|
204
|
-
functionCalls: [{
|
|
205
|
-
id: "call_123",
|
|
206
|
-
name: "consult_knowledge",
|
|
207
|
-
args: { query: "late add biology" },
|
|
208
|
-
}],
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
inject({ serverContent: { turnComplete: true } });
|
|
212
|
-
|
|
213
|
-
const events = await eventsTask;
|
|
214
|
-
expect(events.slice(0, 7)).toEqual([
|
|
215
|
-
{ type: "response_started" },
|
|
216
|
-
{ type: "audio", pcm16: audioBytes, sampleRateHz: 24000 },
|
|
217
|
-
{ type: "speech_started" },
|
|
218
|
-
{ type: "transcript", role: "user", text: "Can I add Biology?", final: true },
|
|
219
|
-
{ type: "transcript", role: "assistant", text: "Let me check that.", final: true },
|
|
220
|
-
{
|
|
221
|
-
type: "tool_call",
|
|
222
|
-
toolId: "call_123",
|
|
223
|
-
toolName: "consult_knowledge",
|
|
224
|
-
args: { query: "late add biology" },
|
|
225
|
-
},
|
|
226
|
-
{ type: "response_done" },
|
|
227
|
-
]);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("exposes Gemini Live capability flags", () => {
|
|
231
|
-
const adapter = fromGeminiLive({ apiKey: "test-key" });
|
|
232
|
-
expect(adapter.caps).toEqual({
|
|
233
|
-
inputSampleRateHz: 16_000,
|
|
234
|
-
outputSampleRateHz: 24_000,
|
|
235
|
-
supportsNativeResume: true,
|
|
236
|
-
supportsConcurrentToolAudio: false,
|
|
237
|
-
supportsTruncate: false,
|
|
238
|
-
emitsServerSpeechStarted: true,
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
});
|