@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
package/dist/node.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { parseRealtimeClientFrame, parseRealtimeHostFrame } from "./frames.js";
|
|
5
|
+
import { RealtimeError } from "./types.js";
|
|
6
|
+
import { cleanProse } from "./prose.js";
|
|
7
|
+
export { toolBridge, appBridge } from "./tools.js";
|
|
8
|
+
/** Provider sockets and SDP signaling run in a kit-owned child, never in the app process. */
|
|
9
|
+
export function realtimeEngine(options) {
|
|
10
|
+
if (!['chatgpt', 'openai', 'gemini', 'xai'].includes(options.engine))
|
|
11
|
+
throw new RealtimeError('unsupported');
|
|
12
|
+
if (options.endpoint && !['127.0.0.1', '[::1]', 'localhost'].includes(new URL(options.endpoint).hostname))
|
|
13
|
+
throw new RealtimeError('unsupported');
|
|
14
|
+
if ((options.engine === 'chatgpt') !== (options.auth.kind === 'plan'))
|
|
15
|
+
throw new RealtimeError('unsupported');
|
|
16
|
+
let stopped = false, emittedClose = false;
|
|
17
|
+
const lifetime = new AbortController();
|
|
18
|
+
const start = Date.now();
|
|
19
|
+
let child;
|
|
20
|
+
let killTimer;
|
|
21
|
+
let queuedBytes = 0;
|
|
22
|
+
const pending = [];
|
|
23
|
+
const toolCalls = new Map();
|
|
24
|
+
const secrets = [];
|
|
25
|
+
const usage = { seconds: 0, basis: options.engine === 'chatgpt' ? 'subscription' : options.engine === 'xai' ? 'minutes' : 'tokens' };
|
|
26
|
+
const safe = (value) => { let result = value; for (const secret of secrets)
|
|
27
|
+
if (secret)
|
|
28
|
+
result = result.replaceAll(secret, '[hidden]'); return cleanProse(result, 'Voice ended.', 500, options.redact); };
|
|
29
|
+
const emit = (frame) => {
|
|
30
|
+
if (frame.type === 'realtime.state' && frame.detail)
|
|
31
|
+
frame = { ...frame, detail: safe(frame.detail) };
|
|
32
|
+
if (frame.type === 'realtime.closed' && frame.reason)
|
|
33
|
+
frame = { ...frame, reason: safe(frame.reason) };
|
|
34
|
+
if (frame.type === 'realtime.transcript')
|
|
35
|
+
frame = { ...frame, text: safe(frame.text) };
|
|
36
|
+
// Inspect every outbound string, including data-channel payloads, for exact credentials.
|
|
37
|
+
const serialized = JSON.stringify(frame);
|
|
38
|
+
if (secrets.some(secret => secret && serialized.includes(secret))) {
|
|
39
|
+
if (frame.type === 'realtime.webrtc.answer') {
|
|
40
|
+
finish('Voice answer included private credentials.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const secret of secrets)
|
|
44
|
+
if (secret)
|
|
45
|
+
frame = JSON.parse(JSON.stringify(frame).replaceAll(secret, '[hidden]'));
|
|
46
|
+
}
|
|
47
|
+
options.emit(frame);
|
|
48
|
+
};
|
|
49
|
+
const finish = (reason, retryable = false) => {
|
|
50
|
+
if (emittedClose)
|
|
51
|
+
return;
|
|
52
|
+
emittedClose = true;
|
|
53
|
+
stopped = true;
|
|
54
|
+
lifetime.abort();
|
|
55
|
+
options.bridge.close();
|
|
56
|
+
usage.seconds = (Date.now() - start) / 1000;
|
|
57
|
+
options.onUsage?.({ ...usage });
|
|
58
|
+
emit({ type: 'realtime.usage', usage: { ...usage } });
|
|
59
|
+
emit({ type: 'realtime.closed', ...(retryable ? { retryable: true } : {}), ...(reason ? { reason: safe(reason) } : {}) });
|
|
60
|
+
if (child && child.exitCode === null) {
|
|
61
|
+
child.kill('SIGTERM');
|
|
62
|
+
killTimer = setTimeout(() => child?.kill('SIGKILL'), 1000);
|
|
63
|
+
killTimer.unref();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const send = (line) => {
|
|
67
|
+
if (!child?.stdin || stopped || child.stdin.destroyed || child.stdin.writableLength + Buffer.byteLength(line) > 512 * 1024)
|
|
68
|
+
return false;
|
|
69
|
+
child.stdin.write(line);
|
|
70
|
+
return true;
|
|
71
|
+
};
|
|
72
|
+
const ready = (async () => {
|
|
73
|
+
const credential = options.auth.kind === 'key' ? { access: options.auth.key, accountId: '' } : await options.auth.access(lifetime.signal);
|
|
74
|
+
if (stopped)
|
|
75
|
+
return;
|
|
76
|
+
secrets.push(credential.access);
|
|
77
|
+
const entry = new URL(`./child.${import.meta.url.endsWith('.ts') ? 'ts' : 'js'}`, import.meta.url);
|
|
78
|
+
child = spawn(process.execPath, [fileURLToPath(entry)], { env: {}, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
79
|
+
child.stdin.on('error', () => finish('Voice input failed.'));
|
|
80
|
+
child.on('error', () => finish('Voice could not start.'));
|
|
81
|
+
child.on('exit', () => { clearTimeout(killTimer); finish('Voice provider disconnected.'); });
|
|
82
|
+
// Never forward stderr: providers may include credentials in URLs or errors.
|
|
83
|
+
child.stderr.resume();
|
|
84
|
+
let lineBytes = 0;
|
|
85
|
+
const startupTimer = setTimeout(() => finish('Voice setup timed out.'), 20000);
|
|
86
|
+
child.once('exit', () => clearTimeout(startupTimer));
|
|
87
|
+
child.stdout.on('data', (chunk) => { for (const byte of chunk) {
|
|
88
|
+
if (byte === 10)
|
|
89
|
+
lineBytes = 0;
|
|
90
|
+
else if (++lineBytes > 128 * 1024) {
|
|
91
|
+
finish('Voice frame exceeded its limit.');
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
} });
|
|
95
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
96
|
+
lines.on('line', line => {
|
|
97
|
+
if (stopped)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
const raw = JSON.parse(line);
|
|
101
|
+
if (raw.type === 'kit.tool.cancel') {
|
|
102
|
+
toolCalls.get(raw.id)?.abort();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (raw.type === 'kit.tool') {
|
|
106
|
+
const controller = toolCalls.get(raw.id) ?? new AbortController();
|
|
107
|
+
toolCalls.set(raw.id, controller);
|
|
108
|
+
void options.bridge.run(raw.name, raw.args, raw.id, AbortSignal.any([lifetime.signal, controller.signal])).then(output => { send(`${JSON.stringify({ type: 'kit.tool.result', id: raw.id, output })}\n`); }).catch(() => finish('Voice tool failed.')).finally(() => toolCalls.delete(raw.id));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const frame = parseRealtimeHostFrame(raw);
|
|
112
|
+
if (frame.type === 'realtime.ready' || frame.type === 'realtime.webrtc.start')
|
|
113
|
+
clearTimeout(startupTimer);
|
|
114
|
+
if (frame.type === 'realtime.closed') {
|
|
115
|
+
finish(frame.reason, frame.retryable);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (frame.type === 'realtime.usage') {
|
|
119
|
+
for (const key of ['inputTokens', 'outputTokens', 'audioInTokens', 'audioOutTokens', 'cachedTokens'])
|
|
120
|
+
if (frame.usage[key] !== undefined)
|
|
121
|
+
usage[key] = (usage[key] ?? 0) + frame.usage[key];
|
|
122
|
+
usage.seconds = (Date.now() - start) / 1000;
|
|
123
|
+
emit({ type: 'realtime.usage', usage: { ...usage } });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (frame.type === 'realtime.transcript' && frame.role === 'agent')
|
|
127
|
+
options.bridge.answered();
|
|
128
|
+
emit(frame);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
finish('Voice provider sent an invalid frame.');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
send(`${JSON.stringify({ engine: options.engine, key: credential.access, accountId: credential.accountId, instructions: options.instructions ?? '', tools: options.tools ?? [], model: options.model, voice: options.voice, endpoint: options.endpoint, redact: options.redact?.map(pattern => ({ source: pattern.source, flags: pattern.flags })) })}\n`);
|
|
135
|
+
for (const line of pending)
|
|
136
|
+
if (!send(line)) {
|
|
137
|
+
finish('Voice input overflowed.');
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
pending.length = 0;
|
|
141
|
+
queuedBytes = 0;
|
|
142
|
+
})().catch(() => finish('Voice sign-in is unavailable.'));
|
|
143
|
+
return {
|
|
144
|
+
ready, usage,
|
|
145
|
+
receive(raw) {
|
|
146
|
+
if (stopped)
|
|
147
|
+
return false;
|
|
148
|
+
const frame = parseRealtimeClientFrame(raw), line = `${JSON.stringify(frame)}\n`;
|
|
149
|
+
if (!child) {
|
|
150
|
+
const bytes = Buffer.byteLength(line);
|
|
151
|
+
if (queuedBytes + bytes > 128 * 1024) {
|
|
152
|
+
finish('Voice input overflowed.');
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
pending.push(line);
|
|
156
|
+
queuedBytes += bytes;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return send(line);
|
|
160
|
+
},
|
|
161
|
+
close(reason) { finish(reason); },
|
|
162
|
+
};
|
|
163
|
+
}
|
package/dist/policy.d.ts
ADDED
package/dist/policy.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** A new turn invalidates unfinished tools and output from the prior turn. */
|
|
2
|
+
export function responseFence() {
|
|
3
|
+
let controller = new AbortController(), id, fenced = false;
|
|
4
|
+
const retired = new Set();
|
|
5
|
+
const interrupt = () => { controller.abort(); if (id) {
|
|
6
|
+
retired.add(id);
|
|
7
|
+
if (retired.size > 128)
|
|
8
|
+
retired.delete(retired.values().next().value);
|
|
9
|
+
} fenced = true; };
|
|
10
|
+
return {
|
|
11
|
+
interrupt,
|
|
12
|
+
begin(next) {
|
|
13
|
+
if (next && (retired.has(next) || next === id && !fenced))
|
|
14
|
+
return false;
|
|
15
|
+
interrupt();
|
|
16
|
+
controller = new AbortController();
|
|
17
|
+
id = next;
|
|
18
|
+
fenced = false;
|
|
19
|
+
return true;
|
|
20
|
+
},
|
|
21
|
+
accepts(next) { return !fenced && (!next || !id || next === id) && (!next || !retired.has(next)); },
|
|
22
|
+
get signal() { return controller.signal; },
|
|
23
|
+
};
|
|
24
|
+
}
|
package/dist/prose.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const cleanProse: (value: unknown, fallback: string, max: number, redact?: RegExp[]) => string;
|
|
2
|
+
/**
|
|
3
|
+
* The provider explains a refusal in the HTTP body; the close code does not.
|
|
4
|
+
* An out-of-credits 403 is otherwise indistinguishable from a dropped network,
|
|
5
|
+
* and reporting only the code costs a debugging session to rediscover.
|
|
6
|
+
*/
|
|
7
|
+
export declare function providerRefusal(status: number | undefined, body: string, redact?: RegExp[]): string;
|
package/dist/prose.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const redactCredentials = (value) => String(value ?? '')
|
|
2
|
+
.normalize('NFKC')
|
|
3
|
+
.replace(/["'](?:[A-Za-z][A-Za-z0-9]*_)*(?:api[_-]?key|access[_-]?token|token|secret|password)["']\s*[:=]\s*["']?[^"'{}\s,;]+["']?/gi, '[credential redacted]')
|
|
4
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/-]{12,}/gi, '$1 [redacted]')
|
|
5
|
+
.replace(/\b(?:[A-Za-z][A-Za-z0-9]*_)+(?:api_key|token|secret|password)\s*[:=]\s*[^\s,;]+/gi, '[credential redacted]')
|
|
6
|
+
.replace(/\b((?:api[_-]?)?key|token|secret|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
7
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/gi, '[credential redacted]')
|
|
8
|
+
.replace(/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gi, '[credential redacted]');
|
|
9
|
+
export const cleanProse = (value, fallback, max, redact = []) => {
|
|
10
|
+
const clean = redact.reduce((text, pattern) => text.replace(pattern, '[hidden]'), redactCredentials(value))
|
|
11
|
+
.replace(/(?<![A-Za-z0-9_/])\/(?!\/)(?:[^\s\/<>"']+\/)+[^\s\/<>"']+/gm, '[path hidden]')
|
|
12
|
+
.replace(/\b[A-Za-z]:\\(?:[^\s\\]+\\)+[^\s,;]*/g, '[path hidden]')
|
|
13
|
+
.replace(/[\u0000-\u001F\u007F<>`{}\\/]/g, ' ')
|
|
14
|
+
.replace(/\s+/g, ' ').trim().slice(0, max);
|
|
15
|
+
return clean || fallback;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The provider explains a refusal in the HTTP body; the close code does not.
|
|
19
|
+
* An out-of-credits 403 is otherwise indistinguishable from a dropped network,
|
|
20
|
+
* and reporting only the code costs a debugging session to rediscover.
|
|
21
|
+
*/
|
|
22
|
+
export function providerRefusal(status, body, redact = []) {
|
|
23
|
+
let detail = '';
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(body);
|
|
26
|
+
if (typeof parsed?.error === 'string')
|
|
27
|
+
detail = parsed.error;
|
|
28
|
+
else if (typeof parsed?.error?.message === 'string')
|
|
29
|
+
detail = parsed.error.message;
|
|
30
|
+
else if (typeof parsed?.error?.status === 'string')
|
|
31
|
+
detail = parsed.error.status;
|
|
32
|
+
else if (typeof parsed?.code === 'string')
|
|
33
|
+
detail = parsed.code;
|
|
34
|
+
}
|
|
35
|
+
catch { /* not JSON: fall back to the raw body */ }
|
|
36
|
+
if (detail === '')
|
|
37
|
+
detail = body.trim();
|
|
38
|
+
const safe = cleanProse(detail, '', 300, redact);
|
|
39
|
+
return safe === ''
|
|
40
|
+
? `Voice provider refused the connection (HTTP ${status}).`
|
|
41
|
+
: `Voice provider refused the connection (HTTP ${status}): ${safe}`;
|
|
42
|
+
}
|