@byokit/realtime 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +202 -0
- package/README.md +41 -0
- package/dist/adapter.d.ts +13 -0
- package/dist/adapter.js +1 -0
- package/dist/catalogue.d.ts +14 -0
- package/dist/catalogue.js +2 -0
- package/dist/catalogue.json +6 -0
- package/dist/chatgpt.d.ts +10 -0
- package/dist/chatgpt.js +146 -0
- package/dist/child.d.ts +1 -0
- package/dist/child.js +50 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +276 -0
- package/dist/frames.d.ts +107 -0
- package/dist/frames.js +188 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/node.d.ts +22 -0
- package/dist/node.js +163 -0
- package/dist/policy.d.ts +7 -0
- package/dist/policy.js +24 -0
- package/dist/prose.d.ts +7 -0
- package/dist/prose.js +42 -0
- package/dist/providers/gemini.d.ts +6 -0
- package/dist/providers/gemini.js +429 -0
- package/dist/providers/openai.d.ts +6 -0
- package/dist/providers/openai.js +313 -0
- package/dist/providers/xai.d.ts +6 -0
- package/dist/providers/xai.js +542 -0
- package/dist/rn-webrtc.d.ts +5 -0
- package/dist/rn-webrtc.js +12 -0
- package/dist/tools.d.ts +30 -0
- package/dist/tools.js +93 -0
- package/dist/types.d.ts +106 -0
- package/dist/types.js +12 -0
- package/dist/webrtc.d.ts +24 -0
- package/dist/webrtc.js +133 -0
- package/dist/words.d.ts +8 -0
- package/dist/words.js +5 -0
- package/dist/words.json +8 -0
- package/package.json +56 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { responseFence } from "../policy.js";
|
|
3
|
+
import { cleanProse, providerRefusal as refusal } from "../prose.js";
|
|
4
|
+
// Lifted wire behavior; this module is loaded only by the kit child process.
|
|
5
|
+
export function createAdapter(options) {
|
|
6
|
+
const MODEL = options.model ?? 'gemini-3.8-live';
|
|
7
|
+
const ENDPOINT = options.endpoint ?? 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
|
|
8
|
+
const INPUT_RATE = 16000;
|
|
9
|
+
const OUTPUT_RATE = 24000;
|
|
10
|
+
let endAfterResponse = false;
|
|
11
|
+
const providerTools = options.tools.map((tool) => ({ type: 'function', ...tool }));
|
|
12
|
+
const currentContext = '';
|
|
13
|
+
const isExplicitHangup = options.hangup ?? (() => false);
|
|
14
|
+
const cleanProviderProse = (value, fallback, max) => cleanProse(value, fallback, max, options.redact);
|
|
15
|
+
const providerRefusal = (status, body) => refusal(status, body, options.redact);
|
|
16
|
+
const tools = options.bridge;
|
|
17
|
+
const geminiSchema = (value) => {
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.map(geminiSchema);
|
|
20
|
+
if (value === null || typeof value !== 'object')
|
|
21
|
+
return value;
|
|
22
|
+
return Object.fromEntries(Object.entries(value)
|
|
23
|
+
.filter(([key]) => key !== 'additionalProperties')
|
|
24
|
+
.map(([key, entry]) => [
|
|
25
|
+
key,
|
|
26
|
+
key === 'type' && typeof entry === 'string' ? entry.toUpperCase() : geminiSchema(entry),
|
|
27
|
+
]));
|
|
28
|
+
};
|
|
29
|
+
const GEMINI_TOOLS = [{
|
|
30
|
+
functionDeclarations: providerTools.map(({ type: _type, ...tool }) => ({ ...tool, parameters: geminiSchema(tool.parameters) })),
|
|
31
|
+
}];
|
|
32
|
+
const OUTPUT_FRAME_MS = 20;
|
|
33
|
+
const AUDIO_CHUNK_SIZE = OUTPUT_RATE * 2 * OUTPUT_FRAME_MS / 1000 * 4 / 3;
|
|
34
|
+
const MAX_PENDING_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
35
|
+
const outputQueue = [];
|
|
36
|
+
let outputBytes = 0;
|
|
37
|
+
let outputBlocked = false;
|
|
38
|
+
let outputTimer;
|
|
39
|
+
let outputDeadline = 0;
|
|
40
|
+
let outputPausedByClient = false;
|
|
41
|
+
function syncProviderReadState() {
|
|
42
|
+
if (ws?.readyState !== WebSocket.OPEN)
|
|
43
|
+
return;
|
|
44
|
+
if (outputBlocked || outputQueue.length > 0 || outputPausedByClient)
|
|
45
|
+
ws.pause();
|
|
46
|
+
else
|
|
47
|
+
ws.resume();
|
|
48
|
+
}
|
|
49
|
+
function flushOutput() {
|
|
50
|
+
outputTimer = undefined;
|
|
51
|
+
if (outputBlocked || outputQueue.length === 0) {
|
|
52
|
+
if (outputQueue.length === 0)
|
|
53
|
+
outputDeadline = 0;
|
|
54
|
+
syncProviderReadState();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const index = outputPausedByClient ? outputQueue.findIndex((item) => item.force) : 0;
|
|
58
|
+
if (index === -1) {
|
|
59
|
+
syncProviderReadState();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const [item] = outputQueue.splice(index, 1);
|
|
63
|
+
outputBytes -= item.line.length;
|
|
64
|
+
outputBlocked = !process.stdout.write(item.line, item.done);
|
|
65
|
+
if (item.delayMs > 0) {
|
|
66
|
+
if (outputDeadline === 0)
|
|
67
|
+
outputDeadline = performance.now();
|
|
68
|
+
outputDeadline += item.delayMs;
|
|
69
|
+
}
|
|
70
|
+
const delayMs = item.delayMs > 0 ? Math.max(0, outputDeadline - performance.now()) : 0;
|
|
71
|
+
outputTimer = setTimeout(flushOutput, delayMs);
|
|
72
|
+
syncProviderReadState();
|
|
73
|
+
}
|
|
74
|
+
process.stdout.on('drain', () => {
|
|
75
|
+
outputBlocked = false;
|
|
76
|
+
outputDeadline = 0;
|
|
77
|
+
if (outputTimer === undefined)
|
|
78
|
+
flushOutput();
|
|
79
|
+
});
|
|
80
|
+
const emit = (frame, done, force = false) => {
|
|
81
|
+
const line = `${JSON.stringify(frame)}\n`;
|
|
82
|
+
if (!force && outputBytes + line.length > MAX_PENDING_OUTPUT_BYTES) {
|
|
83
|
+
stopped = true;
|
|
84
|
+
ws?.close();
|
|
85
|
+
outputQueue.length = 0;
|
|
86
|
+
outputBytes = 0;
|
|
87
|
+
outputDeadline = 0;
|
|
88
|
+
close('Voice output buffer overflowed.', true);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const delayMs = frame.type === 'realtime.audio'
|
|
92
|
+
? Math.max(1, Math.round(frame.data.length / 4 * 3 / (OUTPUT_RATE * 2) * 1000))
|
|
93
|
+
: 0;
|
|
94
|
+
outputQueue.push({ line, delayMs, done, force, audio: frame.type === 'realtime.audio' });
|
|
95
|
+
outputBytes += line.length;
|
|
96
|
+
if (outputTimer === undefined)
|
|
97
|
+
outputTimer = setTimeout(flushOutput, 0);
|
|
98
|
+
syncProviderReadState();
|
|
99
|
+
return true;
|
|
100
|
+
};
|
|
101
|
+
function clearQueuedAudio() {
|
|
102
|
+
for (let index = outputQueue.length - 1; index >= 0; index -= 1) {
|
|
103
|
+
if (!outputQueue[index].audio)
|
|
104
|
+
continue;
|
|
105
|
+
outputBytes -= outputQueue[index].line.length;
|
|
106
|
+
outputQueue.splice(index, 1);
|
|
107
|
+
}
|
|
108
|
+
if (!outputQueue.some((item) => item.audio))
|
|
109
|
+
outputDeadline = 0;
|
|
110
|
+
}
|
|
111
|
+
// Gemini can deliver several seconds in one provider event. Twenty-millisecond
|
|
112
|
+
// frames keep native admission smooth while the paced queue backpressures the
|
|
113
|
+
// provider WebSocket whenever the phone or app is not draining.
|
|
114
|
+
function chunkAudio(audio) {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
for (let offset = 0; offset < audio.length; offset += AUDIO_CHUNK_SIZE)
|
|
117
|
+
chunks.push(audio.slice(offset, offset + AUDIO_CHUNK_SIZE));
|
|
118
|
+
return chunks;
|
|
119
|
+
}
|
|
120
|
+
const emitAudio = (audio) => {
|
|
121
|
+
for (const data of chunkAudio(audio)) {
|
|
122
|
+
if (!emit({ type: 'realtime.audio', data }))
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const state = (value, detail) => tools.state(value, detail);
|
|
127
|
+
const PROMPT = options.instructions;
|
|
128
|
+
const fence = responseFence();
|
|
129
|
+
const interrupt = () => { fence.interrupt(); clearTimeout(finishTimer); clearQueuedAudio(); inputTranscript = ''; outputTranscript = ''; turnThinking = false; emit({ type: 'realtime.audio.clear' }, undefined, true); };
|
|
130
|
+
let closing = false;
|
|
131
|
+
const close = (reason, force = false) => {
|
|
132
|
+
if (closing)
|
|
133
|
+
return;
|
|
134
|
+
closing = true;
|
|
135
|
+
stopped = true;
|
|
136
|
+
fence.interrupt();
|
|
137
|
+
clearTimeout(reconnectTimer);
|
|
138
|
+
clearTimeout(stableTimer);
|
|
139
|
+
clearTimeout(finishTimer);
|
|
140
|
+
tools.close();
|
|
141
|
+
emit({ type: 'realtime.closed', reason }, () => process.exit(0), force);
|
|
142
|
+
};
|
|
143
|
+
let ws;
|
|
144
|
+
let stopped = false;
|
|
145
|
+
let providerReady = false;
|
|
146
|
+
let sessionHandle = '';
|
|
147
|
+
const cancelledToolCalls = new Set();
|
|
148
|
+
const activeToolCalls = new Map();
|
|
149
|
+
let providerReconnects = 0;
|
|
150
|
+
let reconnectTimer;
|
|
151
|
+
let stableTimer;
|
|
152
|
+
/** A provider link alive this long was healthy; forget its retries. */
|
|
153
|
+
const PROVIDER_STABLE_AFTER_MS = 30000;
|
|
154
|
+
const text = (value) => String(value ?? '').trim();
|
|
155
|
+
function providerError(error) {
|
|
156
|
+
const raw = typeof error === 'string' ? error : error?.message ?? error?.code ?? 'provider error';
|
|
157
|
+
const detail = cleanProviderProse(raw, 'provider error', 200);
|
|
158
|
+
return { detail, terminal: /api key|auth|credit|quota|billing|permission|forbidden|invalid json|invalid payload|unknown name|unsupported/i.test(detail) };
|
|
159
|
+
}
|
|
160
|
+
const runTool = (name, input, operationId, signal) => tools.run(name, input, operationId, signal);
|
|
161
|
+
let inputTranscript = '';
|
|
162
|
+
let outputTranscript = '';
|
|
163
|
+
let turnThinking = false;
|
|
164
|
+
let finishTimer;
|
|
165
|
+
function finishTurn() {
|
|
166
|
+
if (outputTranscript.trim())
|
|
167
|
+
tools.answered();
|
|
168
|
+
if (isExplicitHangup(inputTranscript))
|
|
169
|
+
endAfterResponse = true;
|
|
170
|
+
if (inputTranscript.trim())
|
|
171
|
+
emit({ type: 'realtime.transcript', role: 'user', text: inputTranscript.trim() });
|
|
172
|
+
if (outputTranscript.trim())
|
|
173
|
+
emit({ type: 'realtime.transcript', role: 'agent', text: outputTranscript.trim() });
|
|
174
|
+
inputTranscript = '';
|
|
175
|
+
outputTranscript = '';
|
|
176
|
+
turnThinking = false;
|
|
177
|
+
if (endAfterResponse) {
|
|
178
|
+
stopped = true;
|
|
179
|
+
ws?.close();
|
|
180
|
+
close('ended', true);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
state('connected');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function handleGeminiEvent(raw) {
|
|
187
|
+
let message;
|
|
188
|
+
try {
|
|
189
|
+
message = JSON.parse(raw);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (message.setupComplete) {
|
|
195
|
+
providerReady = true;
|
|
196
|
+
emit({ type: 'realtime.ready', inputRate: INPUT_RATE, outputRate: OUTPUT_RATE });
|
|
197
|
+
state('connected', providerReconnects === 0 ? undefined : 'Voice provider reconnected');
|
|
198
|
+
clearTimeout(stableTimer);
|
|
199
|
+
stableTimer = setTimeout(() => { providerReconnects = 0; }, PROVIDER_STABLE_AFTER_MS);
|
|
200
|
+
}
|
|
201
|
+
if (message.usageMetadata)
|
|
202
|
+
emit({ type: 'realtime.usage', usage: { seconds: 0, basis: 'tokens', inputTokens: message.usageMetadata.promptTokenCount ?? 0, outputTokens: message.usageMetadata.responseTokenCount ?? 0 } });
|
|
203
|
+
const content = message.serverContent;
|
|
204
|
+
if (content) {
|
|
205
|
+
if (content.interrupted === true) {
|
|
206
|
+
interrupt();
|
|
207
|
+
}
|
|
208
|
+
if (typeof content.inputTranscription?.text === 'string')
|
|
209
|
+
inputTranscript += content.inputTranscription.text;
|
|
210
|
+
if (fence.accepts() && typeof content.outputTranscription?.text === 'string')
|
|
211
|
+
outputTranscript += content.outputTranscription.text;
|
|
212
|
+
for (const part of fence.accepts() ? content.modelTurn?.parts ?? [] : []) {
|
|
213
|
+
const audio = part.inlineData?.data;
|
|
214
|
+
if (typeof audio === 'string' && audio) {
|
|
215
|
+
if (!turnThinking) {
|
|
216
|
+
state('thinking');
|
|
217
|
+
turnThinking = true;
|
|
218
|
+
}
|
|
219
|
+
emitAudio(audio);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (content.turnComplete === true) {
|
|
223
|
+
if (!fence.accepts()) {
|
|
224
|
+
fence.begin();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Transcription chunks are independent and have no final bit; give late chunks one short grace window.
|
|
228
|
+
clearTimeout(finishTimer);
|
|
229
|
+
finishTimer = setTimeout(finishTurn, 150);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (typeof message.sessionResumptionUpdate?.newHandle === 'string' && message.sessionResumptionUpdate.resumable === true) {
|
|
233
|
+
sessionHandle = message.sessionResumptionUpdate.newHandle;
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(message.toolCallCancellation?.ids)) {
|
|
236
|
+
// ponytail: completed Herdr side effects cannot be undone; cancellation aborts work still in flight.
|
|
237
|
+
for (const id of message.toolCallCancellation.ids) {
|
|
238
|
+
cancelledToolCalls.add(id);
|
|
239
|
+
activeToolCalls.get(id)?.abort();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (fence.accepts() && Array.isArray(message.toolCall?.functionCalls)) {
|
|
243
|
+
const signal = fence.signal, current = ws;
|
|
244
|
+
void (async () => {
|
|
245
|
+
const functionResponses = (await Promise.all(message.toolCall.functionCalls.map(async (call) => {
|
|
246
|
+
if (cancelledToolCalls.delete(call.id))
|
|
247
|
+
return undefined;
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
activeToolCalls.set(call.id, controller);
|
|
250
|
+
let output;
|
|
251
|
+
try {
|
|
252
|
+
output = await runTool(call.name, call.args, call.id, AbortSignal.any([controller.signal, signal]));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (cancelledToolCalls.delete(call.id) || error?.name === 'AbortError')
|
|
256
|
+
return undefined;
|
|
257
|
+
output = 'The request could not be completed. Please try again.';
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
activeToolCalls.delete(call.id);
|
|
261
|
+
}
|
|
262
|
+
if (cancelledToolCalls.delete(call.id))
|
|
263
|
+
return undefined;
|
|
264
|
+
return { id: call.id, name: call.name, response: { result: text(output).slice(0, 24000) } };
|
|
265
|
+
}))).filter(Boolean);
|
|
266
|
+
if (stopped || signal.aborted || ws !== current || ws?.readyState !== WebSocket.OPEN || functionResponses.length === 0)
|
|
267
|
+
return;
|
|
268
|
+
ws.send(JSON.stringify({ toolResponse: { functionResponses } }));
|
|
269
|
+
})();
|
|
270
|
+
}
|
|
271
|
+
if (message.goAway && ws?.readyState === WebSocket.OPEN) {
|
|
272
|
+
const current = ws;
|
|
273
|
+
const milliseconds = Math.max(0, (Number.parseFloat(message.goAway.timeLeft) || 1) * 1000 - 500);
|
|
274
|
+
setTimeout(() => {
|
|
275
|
+
if (!stopped && ws === current)
|
|
276
|
+
current.close(1000, 'Session rotation');
|
|
277
|
+
}, milliseconds);
|
|
278
|
+
}
|
|
279
|
+
if (message.error) {
|
|
280
|
+
const { detail, terminal } = providerError(message.error);
|
|
281
|
+
if (!providerReady || terminal) {
|
|
282
|
+
stopped = true;
|
|
283
|
+
ws?.close();
|
|
284
|
+
close(`Voice provider error: ${detail}`, true);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
state('connected', detail);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function handleClientFrame(frame) {
|
|
292
|
+
if (tools.receive(frame))
|
|
293
|
+
return;
|
|
294
|
+
if (frame.type === 'realtime.control') {
|
|
295
|
+
if (frame.action === 'stop') {
|
|
296
|
+
stopped = true;
|
|
297
|
+
ws?.close();
|
|
298
|
+
outputQueue.length = 0;
|
|
299
|
+
outputBytes = 0;
|
|
300
|
+
outputDeadline = 0;
|
|
301
|
+
close('ended', true);
|
|
302
|
+
}
|
|
303
|
+
else if (frame.action === 'interrupt') {
|
|
304
|
+
interrupt();
|
|
305
|
+
sessionHandle = '';
|
|
306
|
+
// Automatic VAD owns activity signals; rotate rather than invent a cancel packet.
|
|
307
|
+
ws?.resume();
|
|
308
|
+
ws?.close(1000, 'Interrupted');
|
|
309
|
+
}
|
|
310
|
+
else if (frame.action === 'pause_output') {
|
|
311
|
+
outputPausedByClient = true;
|
|
312
|
+
outputDeadline = 0;
|
|
313
|
+
syncProviderReadState();
|
|
314
|
+
}
|
|
315
|
+
else if (frame.action === 'resume_output') {
|
|
316
|
+
outputPausedByClient = false;
|
|
317
|
+
if (outputTimer === undefined)
|
|
318
|
+
outputTimer = setTimeout(flushOutput, 0);
|
|
319
|
+
syncProviderReadState();
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (ws?.readyState !== WebSocket.OPEN)
|
|
324
|
+
return;
|
|
325
|
+
if (frame.type === 'realtime.audio') {
|
|
326
|
+
ws.send(JSON.stringify({ realtimeInput: { audio: { data: frame.data, mimeType: `audio/pcm;rate=${INPUT_RATE}` } } }));
|
|
327
|
+
}
|
|
328
|
+
else if (frame.type === 'realtime.say') {
|
|
329
|
+
ws.send(JSON.stringify({
|
|
330
|
+
clientContent: {
|
|
331
|
+
turns: [{ role: 'user', parts: [{ text: frame.text }] }],
|
|
332
|
+
turnComplete: true,
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
// mute/unmute are enforced on the phone's capture side; nothing to forward.
|
|
337
|
+
}
|
|
338
|
+
function connectProvider(key) {
|
|
339
|
+
if (stopped)
|
|
340
|
+
return;
|
|
341
|
+
clearTimeout(reconnectTimer);
|
|
342
|
+
clearTimeout(stableTimer);
|
|
343
|
+
providerReady = false;
|
|
344
|
+
fence.begin();
|
|
345
|
+
state('connecting', providerReconnects === 0 ? undefined : 'Voice provider reconnecting');
|
|
346
|
+
const current = new WebSocket(`${ENDPOINT}?key=${encodeURIComponent(key)}`, { maxPayload: 4 * 1024 * 1024 });
|
|
347
|
+
ws = current;
|
|
348
|
+
let downHandled = false;
|
|
349
|
+
const onDown = (reason) => {
|
|
350
|
+
if (downHandled || stopped || ws !== current)
|
|
351
|
+
return;
|
|
352
|
+
downHandled = true;
|
|
353
|
+
providerReady = false;
|
|
354
|
+
clearTimeout(stableTimer);
|
|
355
|
+
interrupt();
|
|
356
|
+
state('connecting', 'Voice provider reconnecting');
|
|
357
|
+
if (providerReconnects >= 2) {
|
|
358
|
+
close(reason, true);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
providerReconnects += 1;
|
|
362
|
+
reconnectTimer = setTimeout(() => connectProvider(key), providerReconnects * 500);
|
|
363
|
+
};
|
|
364
|
+
current.on('open', () => {
|
|
365
|
+
if (stopped || ws !== current)
|
|
366
|
+
return;
|
|
367
|
+
current.send(JSON.stringify({
|
|
368
|
+
setup: {
|
|
369
|
+
model: `models/${MODEL}`,
|
|
370
|
+
generationConfig: {
|
|
371
|
+
responseModalities: ['AUDIO'],
|
|
372
|
+
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: options.voice ?? 'Kore' } } },
|
|
373
|
+
},
|
|
374
|
+
systemInstruction: { parts: [{ text: PROMPT + currentContext }] },
|
|
375
|
+
inputAudioTranscription: {},
|
|
376
|
+
outputAudioTranscription: {},
|
|
377
|
+
sessionResumption: sessionHandle ? { handle: sessionHandle } : {},
|
|
378
|
+
contextWindowCompression: { slidingWindow: {} },
|
|
379
|
+
tools: GEMINI_TOOLS,
|
|
380
|
+
},
|
|
381
|
+
}));
|
|
382
|
+
});
|
|
383
|
+
current.on('message', (data) => {
|
|
384
|
+
if (!stopped && !downHandled && ws === current)
|
|
385
|
+
handleGeminiEvent(String(data));
|
|
386
|
+
});
|
|
387
|
+
// ws suppresses 'error' and 'close' once this is handled, so the failure
|
|
388
|
+
// path is driven from here. Auth and permission refusals are terminal:
|
|
389
|
+
// retrying an out-of-credits account only delays the real message.
|
|
390
|
+
current.on('unexpected-response', (_request, response) => {
|
|
391
|
+
let body = '';
|
|
392
|
+
response.on('data', (chunk) => { if (Buffer.byteLength(body) + chunk.length > 16384) {
|
|
393
|
+
response.destroy();
|
|
394
|
+
current.terminate();
|
|
395
|
+
close('Voice provider refusal exceeded its limit.');
|
|
396
|
+
return;
|
|
397
|
+
} body += chunk; });
|
|
398
|
+
response.on('end', () => {
|
|
399
|
+
current.terminate();
|
|
400
|
+
if (stopped || ws !== current)
|
|
401
|
+
return;
|
|
402
|
+
const reason = providerRefusal(response.statusCode, body);
|
|
403
|
+
if (response.statusCode === 401 || response.statusCode === 403)
|
|
404
|
+
close(reason, true);
|
|
405
|
+
else
|
|
406
|
+
onDown(reason);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
current.on('close', (code, reasonBuffer) => {
|
|
410
|
+
if (stopped || ws !== current)
|
|
411
|
+
return;
|
|
412
|
+
const reason = cleanProviderProse(String(reasonBuffer), '', 160);
|
|
413
|
+
const detail = `The voice provider disconnected (${code})${reason ? `: ${reason}` : '.'}`;
|
|
414
|
+
if (providerError(reason).terminal) {
|
|
415
|
+
stopped = true;
|
|
416
|
+
close(detail, true);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
onDown(detail);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
current.on('error', (error) => {
|
|
423
|
+
if (!stopped && ws === current)
|
|
424
|
+
state('connecting', `Voice provider connection interrupted: ${cleanProviderProse(error.message, 'connection error', 160)}`);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
connectProvider(options.key);
|
|
428
|
+
return { receive: handleClientFrame, close: (reason = 'ended') => { stopped = true; ws?.close(); close(reason); } };
|
|
429
|
+
}
|