@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.
@@ -0,0 +1,313 @@
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 ?? 'gpt-realtime-2.1';
7
+ const RATE = 24000;
8
+ const PROVIDER_URL = options.endpoint ?? `wss://api.openai.com/v1/realtime?model=${MODEL}`;
9
+ let endAfterResponse = false;
10
+ const providerTools = options.tools.map((tool) => ({ type: 'function', ...tool }));
11
+ const currentContext = '';
12
+ const isExplicitHangup = options.hangup ?? (() => false);
13
+ const cleanProviderProse = (value, fallback, max) => cleanProse(value, fallback, max, options.redact);
14
+ const providerRefusal = (status, body) => refusal(status, body, options.redact);
15
+ const tools = options.bridge;
16
+ const emit = (frame) => process.stdout.write(`${JSON.stringify(frame)}\n`);
17
+ // Provider deltas may exceed the public frame bound after tool calls. Base64 is
18
+ // independently decodable when split on four-character boundaries.
19
+ function chunkAudio(audio) {
20
+ const chunks = [];
21
+ const chunkSize = 64 * 1024;
22
+ for (let offset = 0; offset < audio.length; offset += chunkSize)
23
+ chunks.push(audio.slice(offset, offset + chunkSize));
24
+ return chunks;
25
+ }
26
+ const emitAudio = (audio) => {
27
+ for (const data of chunkAudio(audio))
28
+ emit({ type: 'realtime.audio', data });
29
+ };
30
+ const state = (value, detail) => tools.state(value, detail);
31
+ const PROMPT = options.instructions;
32
+ const fence = responseFence();
33
+ let reconnectTimer, stableTimer;
34
+ let providerReconnects = 0, responseActive = false;
35
+ const interrupt = () => { fence.interrupt(); responseActive = false; pendingToolCalls.clear(); emit({ type: 'realtime.audio.clear' }); };
36
+ let closing = false;
37
+ const close = (reason) => {
38
+ if (closing)
39
+ return;
40
+ closing = true;
41
+ stopped = true;
42
+ fence.interrupt();
43
+ clearTimeout(reconnectTimer);
44
+ clearTimeout(stableTimer);
45
+ tools.close();
46
+ process.stdout.write(`${JSON.stringify({ type: 'realtime.closed', reason })}\n`, () => process.exit(0));
47
+ };
48
+ let ws;
49
+ let stopped = false;
50
+ let providerReady = false;
51
+ const text = (value) => String(value ?? '').trim();
52
+ function providerError(error) {
53
+ const raw = typeof error === 'string' ? error : error?.message ?? error?.code ?? 'provider error';
54
+ const detail = cleanProviderProse(raw, 'provider error', 200);
55
+ return { detail, terminal: /api key|auth|credit|quota|billing|permission|forbidden|invalid json|invalid payload|unknown name|unsupported/i.test(detail) };
56
+ }
57
+ const runTool = (name, input, operationId, signal) => tools.run(name, input, operationId, signal);
58
+ const pendingToolCalls = new Map();
59
+ function handleOpenAiEvent(raw) {
60
+ let message;
61
+ try {
62
+ message = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ switch (message.type) {
68
+ case 'session.updated':
69
+ providerReady = true;
70
+ emit({ type: 'realtime.ready', inputRate: RATE, outputRate: RATE });
71
+ clearTimeout(stableTimer);
72
+ stableTimer = setTimeout(() => { providerReconnects = 0; }, 30000);
73
+ state('connected');
74
+ break;
75
+ case 'input_audio_buffer.speech_started':
76
+ interrupt();
77
+ break;
78
+ case 'response.created':
79
+ if (!fence.begin(message.response?.id))
80
+ break;
81
+ responseActive = true;
82
+ state('thinking');
83
+ break;
84
+ case 'response.output_audio.delta':
85
+ case 'response.audio.delta': {
86
+ const audio = typeof message.delta === 'string' ? message.delta : typeof message.audio === 'string' ? message.audio : '';
87
+ if (audio && fence.accepts(message.response_id))
88
+ emitAudio(audio);
89
+ break;
90
+ }
91
+ case 'response.done': {
92
+ if (message.response?.usage)
93
+ emit({ type: 'realtime.usage', usage: { seconds: 0, basis: 'tokens', inputTokens: message.response.usage.input_tokens ?? 0, outputTokens: message.response.usage.output_tokens ?? 0, audioInTokens: message.response.usage.input_token_details?.audio_tokens ?? 0, audioOutTokens: message.response.usage.output_token_details?.audio_tokens ?? 0, cachedTokens: message.response.usage.input_token_details?.cached_tokens ?? 0 } });
94
+ if (!fence.accepts(message.response?.id))
95
+ break;
96
+ responseActive = false;
97
+ const responseId = message.response?.id;
98
+ const calls = typeof responseId === 'string' ? pendingToolCalls.get(responseId) ?? [] : [];
99
+ if (typeof responseId === 'string')
100
+ pendingToolCalls.delete(responseId);
101
+ if (message.response?.status === 'completed' && calls.length > 0) {
102
+ const signal = fence.signal, current = ws;
103
+ void (async () => {
104
+ const outputs = [];
105
+ for (const call of calls) {
106
+ if (signal.aborted)
107
+ return;
108
+ let output;
109
+ try {
110
+ output = await runTool(call.name, call.args, call.callId, signal);
111
+ }
112
+ catch {
113
+ output = 'The request could not be completed. Please try again.';
114
+ }
115
+ outputs.push({ callId: call.callId, output });
116
+ }
117
+ if (stopped || signal.aborted || ws !== current || ws?.readyState !== WebSocket.OPEN)
118
+ return;
119
+ for (const output of outputs) {
120
+ ws.send(JSON.stringify({
121
+ type: 'conversation.item.create',
122
+ item: { type: 'function_call_output', call_id: output.callId, output: text(output.output).slice(0, 24000) },
123
+ }));
124
+ }
125
+ ws.send(JSON.stringify({ type: 'response.create' }));
126
+ })();
127
+ }
128
+ else if (message.response?.status === 'completed' && endAfterResponse) {
129
+ stopped = true;
130
+ ws?.close();
131
+ close('ended');
132
+ }
133
+ else {
134
+ state('connected');
135
+ }
136
+ break;
137
+ }
138
+ case 'conversation.item.input_audio_transcription.completed':
139
+ if (typeof message.transcript === 'string' && message.transcript.trim() !== '') {
140
+ if (isExplicitHangup(message.transcript))
141
+ endAfterResponse = true;
142
+ emit({ type: 'realtime.transcript', role: 'user', text: message.transcript });
143
+ }
144
+ break;
145
+ case 'response.output_audio_transcript.done':
146
+ if (!fence.accepts(message.response_id))
147
+ break;
148
+ if (typeof message.transcript === 'string' && message.transcript.trim() !== '') {
149
+ tools.answered();
150
+ emit({ type: 'realtime.transcript', role: 'agent', text: message.transcript });
151
+ }
152
+ break;
153
+ case 'response.function_call_arguments.done': {
154
+ if (!fence.accepts(message.response_id))
155
+ break;
156
+ if (typeof message.response_id !== 'string')
157
+ break;
158
+ let args = {};
159
+ try {
160
+ args = JSON.parse(message.arguments || '{}');
161
+ }
162
+ catch { /* interrupted arguments */ }
163
+ const calls = pendingToolCalls.get(message.response_id) ?? [];
164
+ calls.push({ name: message.name, callId: message.call_id, args });
165
+ pendingToolCalls.set(message.response_id, calls);
166
+ break;
167
+ }
168
+ case 'error': {
169
+ const { detail, terminal } = providerError(message.error);
170
+ if (!providerReady || terminal) {
171
+ stopped = true;
172
+ ws?.close();
173
+ close(`Voice provider error: ${detail}`);
174
+ }
175
+ else {
176
+ state('connected', detail);
177
+ }
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ function handleClientFrame(frame) {
183
+ if (tools.receive(frame))
184
+ return;
185
+ if (frame.type === 'realtime.control' && frame.action === 'stop') {
186
+ stopped = true;
187
+ ws?.close();
188
+ close('ended');
189
+ return;
190
+ }
191
+ if (frame.type === 'realtime.control' && frame.action === 'interrupt') {
192
+ const cancel = responseActive;
193
+ interrupt();
194
+ if (cancel && ws?.readyState === WebSocket.OPEN)
195
+ ws.send(JSON.stringify({ type: 'response.cancel' }));
196
+ return;
197
+ }
198
+ if (ws?.readyState !== WebSocket.OPEN)
199
+ return;
200
+ if (frame.type === 'realtime.audio') {
201
+ ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: frame.data }));
202
+ }
203
+ else if (frame.type === 'realtime.say') {
204
+ ws.send(JSON.stringify({
205
+ type: 'conversation.item.create',
206
+ item: { type: 'message', role: 'user', content: [{ type: 'input_text', text: frame.text }] },
207
+ }));
208
+ ws.send(JSON.stringify({ type: 'response.create' }));
209
+ }
210
+ // mute/unmute are enforced on the phone's capture side; nothing to forward.
211
+ }
212
+ function connectProvider(key) {
213
+ if (stopped)
214
+ return;
215
+ providerReady = false;
216
+ state('connecting');
217
+ const current = new WebSocket(PROVIDER_URL, {
218
+ headers: { Authorization: `Bearer ${key}` },
219
+ maxPayload: 4 * 1024 * 1024,
220
+ });
221
+ ws = current;
222
+ let downHandled = false;
223
+ const onDown = (reason) => {
224
+ if (downHandled || stopped || ws !== current)
225
+ return;
226
+ downHandled = true;
227
+ providerReady = false;
228
+ clearTimeout(stableTimer);
229
+ interrupt();
230
+ if (providerReconnects >= 2) {
231
+ close(reason);
232
+ return;
233
+ }
234
+ providerReconnects++;
235
+ state('connecting', 'Voice provider reconnecting');
236
+ reconnectTimer = setTimeout(() => connectProvider(key), providerReconnects * 500);
237
+ };
238
+ current.on('open', () => {
239
+ if (stopped || ws !== current)
240
+ return;
241
+ current.send(JSON.stringify({
242
+ type: 'session.update',
243
+ session: {
244
+ type: 'realtime',
245
+ model: MODEL,
246
+ instructions: PROMPT + currentContext,
247
+ output_modalities: ['audio'],
248
+ audio: {
249
+ input: {
250
+ format: { type: 'audio/pcm', rate: RATE },
251
+ transcription: { model: 'gpt-4o-mini-transcribe' },
252
+ turn_detection: {
253
+ type: 'server_vad',
254
+ threshold: 0.9,
255
+ silence_duration_ms: 700,
256
+ prefix_padding_ms: 300,
257
+ create_response: true,
258
+ interrupt_response: true,
259
+ },
260
+ },
261
+ output: { format: { type: 'audio/pcm' }, voice: options.voice ?? 'marin' },
262
+ },
263
+ tools: providerTools,
264
+ },
265
+ }));
266
+ });
267
+ current.on('message', (data) => {
268
+ if (!stopped && !downHandled && ws === current)
269
+ handleOpenAiEvent(String(data));
270
+ });
271
+ // ws suppresses 'error' and 'close' once this is handled, so the failure
272
+ // path is driven from here. Auth and permission refusals are terminal:
273
+ // retrying an out-of-credits account only delays the real message.
274
+ current.on('unexpected-response', (_request, response) => {
275
+ let body = '';
276
+ response.on('data', (chunk) => { if (Buffer.byteLength(body) + chunk.length > 16384) {
277
+ response.destroy();
278
+ current.terminate();
279
+ close('Voice provider refusal exceeded its limit.');
280
+ return;
281
+ } body += chunk; });
282
+ response.on('end', () => {
283
+ current.terminate();
284
+ if (stopped || ws !== current)
285
+ return;
286
+ const reason = providerRefusal(response.statusCode, body);
287
+ if (response.statusCode === 401 || response.statusCode === 403)
288
+ close(reason);
289
+ else
290
+ onDown(reason);
291
+ });
292
+ });
293
+ current.on('close', (code, reasonBuffer) => {
294
+ if (stopped || ws !== current)
295
+ return;
296
+ const reason = cleanProviderProse(String(reasonBuffer), '', 160);
297
+ const detail = `OpenAI session ended (${code})${reason ? `: ${reason}` : '.'}`;
298
+ if (providerError(reason).terminal) {
299
+ stopped = true;
300
+ close(detail);
301
+ }
302
+ else {
303
+ onDown(detail);
304
+ }
305
+ });
306
+ current.on('error', (error) => {
307
+ if (!stopped && ws === current)
308
+ state('connecting', `Voice provider connection interrupted: ${cleanProviderProse(error.message, 'connection error', 160)}`);
309
+ });
310
+ }
311
+ connectProvider(options.key);
312
+ return { receive: handleClientFrame, close: (reason = 'ended') => { stopped = true; ws?.close(); close(reason); } };
313
+ }
@@ -0,0 +1,6 @@
1
+ import type { AdapterOptions } from '../adapter.ts';
2
+ import type { RealtimeClientFrame } from '../frames.ts';
3
+ export declare function createAdapter(options: AdapterOptions): {
4
+ receive(frame: RealtimeClientFrame): void;
5
+ close(reason?: string): void;
6
+ };