rebellm-bridge 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/dist/openai.js ADDED
@@ -0,0 +1,265 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { HttpError, clientGone, pathOf, readJson, sendJson } from './http.js';
3
+ /** Well inside the read timeouts of common clients (Node's fetch: 300 s). */
4
+ export const KEEPALIVE_MS = 15_000;
5
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
6
+ const isStr = (v) => typeof v === 'string';
7
+ /** OpenAI's error shape, which its SDKs turn into readable messages. */
8
+ export function sendError(res, status, message, type, code) {
9
+ sendJson(res, status, { error: { message, type, code: code ?? null } });
10
+ }
11
+ const ROLES = {
12
+ system: 'system',
13
+ developer: 'system',
14
+ user: 'user',
15
+ assistant: 'assistant',
16
+ tool: 'tool',
17
+ };
18
+ /** Text of a message's content; parts the tab cannot take become a note. */
19
+ function contentText(c) {
20
+ if (c === undefined || c === null)
21
+ return '';
22
+ if (isStr(c))
23
+ return c;
24
+ if (!Array.isArray(c))
25
+ return null;
26
+ const parts = [];
27
+ for (const p of c) {
28
+ if (!isObj(p) || !isStr(p.type))
29
+ return null;
30
+ parts.push(p.type === 'text' && isStr(p.text) ? p.text : `[${p.type} omitted]`);
31
+ }
32
+ return parts.join('\n');
33
+ }
34
+ function toolArgs(a) {
35
+ if (a === undefined || a === null || a === '')
36
+ return {};
37
+ if (isObj(a))
38
+ return a;
39
+ if (!isStr(a))
40
+ return null;
41
+ try {
42
+ const o = JSON.parse(a);
43
+ return isObj(o) ? o : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function toolSchemas(tools) {
50
+ if (!Array.isArray(tools))
51
+ return '`tools` must be an array';
52
+ const out = [];
53
+ for (const [i, t] of tools.entries()) {
54
+ const f = isObj(t) ? t.function : undefined;
55
+ if (!isObj(t) || t.type !== 'function' || !isObj(f) || !isStr(f.name) || !f.name)
56
+ return `tools[${i}] must be { type: 'function', function: { name, ... } }`;
57
+ if (f.description !== undefined && !isStr(f.description))
58
+ return `tools[${i}].function.description must be a string`;
59
+ if (f.parameters !== undefined && !isObj(f.parameters))
60
+ return `tools[${i}].function.parameters must be an object`;
61
+ out.push({
62
+ type: 'function',
63
+ function: {
64
+ name: f.name,
65
+ description: f.description ?? '',
66
+ parameters: f.parameters ?? { type: 'object', properties: {} },
67
+ },
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+ /** An OpenAI chat completion request as the tab's `chat`; the error says what is wrong with it. */
73
+ export function toChatInput(body) {
74
+ if (!isObj(body))
75
+ return { error: 'the body must be a JSON object' };
76
+ if (!Array.isArray(body.messages) || !body.messages.length)
77
+ return { error: '`messages` must be a non-empty array' };
78
+ // A tool result names the call it answers only by id; the tab wants the tool's name.
79
+ const callNames = new Map();
80
+ const messages = [];
81
+ for (const [i, m] of body.messages.entries()) {
82
+ if (!isObj(m))
83
+ return { error: `messages[${i}] must be an object` };
84
+ const role = isStr(m.role) ? ROLES[m.role] : undefined;
85
+ if (!role)
86
+ return { error: `messages[${i}].role ${JSON.stringify(m.role)} is not supported` };
87
+ const content = contentText(m.content);
88
+ if (content === null)
89
+ return { error: `messages[${i}].content must be a string or an array of parts` };
90
+ const out = { role, content };
91
+ if (role === 'assistant' && m.tool_calls !== undefined && m.tool_calls !== null) {
92
+ if (!Array.isArray(m.tool_calls))
93
+ return { error: `messages[${i}].tool_calls must be an array` };
94
+ const calls = [];
95
+ for (const c of m.tool_calls) {
96
+ const f = isObj(c) ? c.function : undefined;
97
+ const args = isObj(f) ? toolArgs(f.arguments) : null;
98
+ if (!isObj(c) || !isObj(f) || !isStr(f.name) || !args)
99
+ return { error: `messages[${i}].tool_calls needs a function name and JSON object arguments` };
100
+ if (isStr(c.id))
101
+ callNames.set(c.id, f.name);
102
+ calls.push({ ...(isStr(c.id) ? { id: c.id } : {}), function: { name: f.name, arguments: args } });
103
+ }
104
+ if (calls.length)
105
+ out.tool_calls = calls;
106
+ }
107
+ if (role === 'tool') {
108
+ const name = isStr(m.name) ? m.name : isStr(m.tool_call_id) ? callNames.get(m.tool_call_id) : undefined;
109
+ if (name)
110
+ out.name = name;
111
+ }
112
+ messages.push(out);
113
+ }
114
+ const input = { messages };
115
+ if (body.tools !== undefined && body.tools !== null && body.tool_choice !== 'none') {
116
+ const tools = toolSchemas(body.tools);
117
+ if (isStr(tools))
118
+ return { error: tools };
119
+ if (tools.length)
120
+ input.tools = tools;
121
+ }
122
+ const max = body.max_completion_tokens ?? body.max_tokens;
123
+ if (max !== undefined && max !== null) {
124
+ if (!Number.isInteger(max) || max < 1)
125
+ return { error: '`max_tokens` must be a positive integer' };
126
+ input.maxTokens = max;
127
+ }
128
+ const temp = body.temperature;
129
+ if (temp !== undefined && temp !== null) {
130
+ if (typeof temp !== 'number' || !Number.isFinite(temp) || temp < 0)
131
+ return { error: '`temperature` must be a number of at least 0' };
132
+ input.temperature = temp;
133
+ }
134
+ const includeUsage = isObj(body.stream_options) && body.stream_options.include_usage === true;
135
+ return { input, stream: body.stream === true, includeUsage };
136
+ }
137
+ const finishReason = (r) => r.calls.length || r.stop === 'tool_call' ? 'tool_calls' : r.stop === 'length' ? 'length' : 'stop';
138
+ const usage = (u) => ({
139
+ prompt_tokens: u.prompt,
140
+ completion_tokens: u.completion,
141
+ total_tokens: u.prompt + u.completion,
142
+ });
143
+ const newId = () => randomBytes(12).toString('hex');
144
+ const toolCall = (c) => ({
145
+ id: c.id ?? `call_${newId()}`,
146
+ type: 'function',
147
+ function: { name: c.function.name, arguments: JSON.stringify(c.function.arguments) },
148
+ });
149
+ /** A non-streamed answer in OpenAI's shape. */
150
+ export function completion(r, meta) {
151
+ const calls = r.calls.map(toolCall);
152
+ return {
153
+ ...meta,
154
+ object: 'chat.completion',
155
+ choices: [
156
+ {
157
+ index: 0,
158
+ message: {
159
+ role: 'assistant',
160
+ // OpenAI sends null content beside tool calls when there is no text.
161
+ content: calls.length && !r.text ? null : r.text,
162
+ ...(calls.length ? { tool_calls: calls } : {}),
163
+ },
164
+ finish_reason: finishReason(r),
165
+ },
166
+ ],
167
+ usage: usage(r.usage),
168
+ };
169
+ }
170
+ /** The HTTP routes for OpenAI-style clients: completions, models and health. */
171
+ export function openaiRoutes(tab, o) {
172
+ async function completions(req, res) {
173
+ let body;
174
+ try {
175
+ body = await readJson(req);
176
+ }
177
+ catch (e) {
178
+ return sendError(res, e instanceof HttpError ? e.status : 400, e.message, 'invalid_request_error');
179
+ }
180
+ const parsed = toChatInput(body);
181
+ if ('error' in parsed)
182
+ return sendError(res, 400, parsed.error, 'invalid_request_error');
183
+ const gone = clientGone(res);
184
+ const missing = await tab.waitReady(o.waitMs, gone);
185
+ if (gone.aborted)
186
+ return;
187
+ if (missing)
188
+ return sendError(res, 503, missing.message, 'service_unavailable', missing.code);
189
+ const meta = { id: `chatcmpl-${newId()}`, created: Math.floor(Date.now() / 1000), model: tab.modelName };
190
+ if (parsed.stream)
191
+ return stream(res, parsed.input, parsed.includeUsage, meta, gone);
192
+ try {
193
+ sendJson(res, 200, completion(await tab.chat(parsed.input, { signal: gone }), meta));
194
+ }
195
+ catch (e) {
196
+ if (gone.aborted)
197
+ return;
198
+ const err = e;
199
+ if (err.kind === 'no_tab')
200
+ return sendError(res, 503, err.message, 'service_unavailable', 'no_tab');
201
+ sendError(res, 502, err.message, 'api_error', err.kind === 'disconnected' ? 'tab_disconnected' : 'tab_error');
202
+ }
203
+ }
204
+ async function stream(res, input, includeUsage, meta, signal) {
205
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' });
206
+ res.socket?.setNoDelay(true);
207
+ const write = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
208
+ const chunk = (delta, finish = null) => write({ ...meta, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: finish }] });
209
+ chunk({ role: 'assistant', content: '' });
210
+ // The tab may take minutes before its first token; a comment keeps clients from giving up.
211
+ const beat = setInterval(() => res.write(': keep-alive\n\n'), o.keepAliveMs ?? KEEPALIVE_MS);
212
+ let calls = 0;
213
+ try {
214
+ const r = await tab.chat(input, {
215
+ signal,
216
+ onEvent: (e) => {
217
+ if (e.t === 'token')
218
+ chunk({ content: e.text });
219
+ else if (e.t === 'tool_call') {
220
+ chunk({ tool_calls: e.calls.map((c, i) => ({ index: calls + i, ...toolCall(c) })) });
221
+ calls += e.calls.length;
222
+ }
223
+ },
224
+ });
225
+ chunk({}, finishReason(r));
226
+ if (includeUsage)
227
+ write({ ...meta, object: 'chat.completion.chunk', choices: [], usage: usage(r.usage) });
228
+ res.end('data: [DONE]\n\n');
229
+ }
230
+ catch (e) {
231
+ if (signal.aborted)
232
+ return;
233
+ // OpenAI's SDKs raise an error for a data chunk that carries one.
234
+ write({ error: { message: e.message, type: 'api_error', code: null } });
235
+ res.end();
236
+ }
237
+ finally {
238
+ clearInterval(beat);
239
+ }
240
+ }
241
+ return function route(req, res) {
242
+ const path = pathOf(req);
243
+ const get = req.method === 'GET' || req.method === 'HEAD';
244
+ if (path === '/health' && get)
245
+ return sendJson(res, 200, { service: 'rebellm-bridge', ...tab.health() });
246
+ if (path === '/v1/models' && get) {
247
+ const model = tab.health().model;
248
+ return sendJson(res, 200, {
249
+ object: 'list',
250
+ data: model ? [{ id: model, object: 'model', created: 0, owned_by: 'rebellm' }] : [],
251
+ });
252
+ }
253
+ if (path === '/v1/chat/completions') {
254
+ if (req.method !== 'POST')
255
+ return sendError(res, 405, 'use POST', 'invalid_request_error');
256
+ return void completions(req, res).catch((e) => {
257
+ if (!res.headersSent)
258
+ sendError(res, 500, e.message, 'api_error');
259
+ else
260
+ res.end();
261
+ });
262
+ }
263
+ sendError(res, 404, `no route for ${req.method} ${path}`, 'invalid_request_error', 'not_found');
264
+ };
265
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Protocol v1 between the RebeLLM tab (client) and this bridge (server): JSON text frames
3
+ * over one WebSocket. The RebeLLM app's `local-bridge` spec owns it; this is the copy.
4
+ */
5
+ export declare const PROTOCOL_VERSION = 1;
6
+ export type Role = 'system' | 'user' | 'assistant' | 'tool';
7
+ export interface ToolCall {
8
+ id?: string;
9
+ function: {
10
+ name: string;
11
+ arguments: Record<string, unknown>;
12
+ };
13
+ }
14
+ export interface ChatMessage {
15
+ role: Role;
16
+ content: string;
17
+ name?: string;
18
+ tool_calls?: ToolCall[];
19
+ }
20
+ export interface ToolSchema {
21
+ type: 'function';
22
+ function: {
23
+ name: string;
24
+ description: string;
25
+ parameters: Record<string, unknown>;
26
+ };
27
+ }
28
+ export type StopReason = 'eos' | 'length' | 'tool_call' | 'abort';
29
+ export type ModelState = 'loading' | 'ready' | 'unavailable';
30
+ export interface Usage {
31
+ prompt: number;
32
+ completion: number;
33
+ tokensPerSec: number;
34
+ }
35
+ /** Tab → bridge. */
36
+ export type TabMessage = {
37
+ t: 'hello';
38
+ v: number;
39
+ token: string;
40
+ model: string;
41
+ contextTokens: number;
42
+ app: string;
43
+ } | {
44
+ t: 'token';
45
+ id: string;
46
+ text: string;
47
+ } | {
48
+ t: 'tool_call';
49
+ id: string;
50
+ calls: ToolCall[];
51
+ } | {
52
+ t: 'done';
53
+ id: string;
54
+ stop: StopReason;
55
+ usage: Usage;
56
+ } | {
57
+ t: 'error';
58
+ id?: string;
59
+ message: string;
60
+ } | {
61
+ t: 'queued';
62
+ id: string;
63
+ position: number;
64
+ } | {
65
+ t: 'status';
66
+ state: ModelState;
67
+ model?: string;
68
+ detail?: string;
69
+ } | {
70
+ t: 'ping';
71
+ } | {
72
+ t: 'pong';
73
+ };
74
+ export interface ChatRequest {
75
+ t: 'chat';
76
+ id: string;
77
+ messages: ChatMessage[];
78
+ tools?: ToolSchema[];
79
+ maxTokens?: number;
80
+ temperature?: number;
81
+ }
82
+ /** Bridge → tab. */
83
+ export type BridgeMessage = {
84
+ t: 'ok';
85
+ } | {
86
+ t: 'error';
87
+ code: 'auth' | 'version' | 'busy';
88
+ message?: string;
89
+ } | ChatRequest | {
90
+ t: 'abort';
91
+ id: string;
92
+ } | {
93
+ t: 'ping';
94
+ } | {
95
+ t: 'pong';
96
+ };
97
+ /** Parses one frame from the tab; null for anything that is not a v1 message. */
98
+ export declare function parseTabMessage(raw: string): TabMessage | null;
99
+ export declare const encode: (m: BridgeMessage) => string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Protocol v1 between the RebeLLM tab (client) and this bridge (server): JSON text frames
3
+ * over one WebSocket. The RebeLLM app's `local-bridge` spec owns it; this is the copy.
4
+ */
5
+ export const PROTOCOL_VERSION = 1;
6
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
7
+ const isStr = (v) => typeof v === 'string';
8
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
9
+ const optStr = (v) => v === undefined || isStr(v);
10
+ const STOPS = new Set(['eos', 'length', 'tool_call', 'abort']);
11
+ const STATES = new Set(['loading', 'ready', 'unavailable']);
12
+ const isCall = (c) => isObj(c) && optStr(c.id) && isObj(c.function) && isStr(c.function.name) && isObj(c.function.arguments);
13
+ const isUsage = (u) => isObj(u) && isNum(u.prompt) && isNum(u.completion) && isNum(u.tokensPerSec);
14
+ function valid(m) {
15
+ switch (m.t) {
16
+ // Any numeric version parses, so the bridge can answer `version` instead of dropping it.
17
+ case 'hello':
18
+ return isNum(m.v) && isStr(m.token) && optStr(m.model) && optStr(m.app);
19
+ case 'token':
20
+ return isStr(m.id) && isStr(m.text);
21
+ case 'tool_call':
22
+ return isStr(m.id) && Array.isArray(m.calls) && m.calls.every(isCall);
23
+ case 'done':
24
+ return isStr(m.id) && STOPS.has(m.stop) && isUsage(m.usage);
25
+ case 'error':
26
+ return optStr(m.id) && isStr(m.message);
27
+ case 'queued':
28
+ return isStr(m.id) && isNum(m.position);
29
+ case 'status':
30
+ return STATES.has(m.state) && optStr(m.model) && optStr(m.detail);
31
+ case 'ping':
32
+ case 'pong':
33
+ return true;
34
+ default:
35
+ return false;
36
+ }
37
+ }
38
+ /** Parses one frame from the tab; null for anything that is not a v1 message. */
39
+ export function parseTabMessage(raw) {
40
+ let o;
41
+ try {
42
+ o = JSON.parse(raw);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (!isObj(o) || !valid(o))
48
+ return null;
49
+ if (o.t === 'hello')
50
+ return { model: '', contextTokens: 0, app: '', ...o, t: 'hello' };
51
+ return o;
52
+ }
53
+ export const encode = (m) => JSON.stringify(m);
@@ -0,0 +1,24 @@
1
+ import { TabLink } from './tab.js';
2
+ export declare const DEFAULT_PORT = 7343;
3
+ export declare const DEFAULT_HOST = "127.0.0.1";
4
+ export interface ServerOptions {
5
+ host: string;
6
+ port: number;
7
+ token: string;
8
+ waitMs: number;
9
+ keepAliveMs?: number;
10
+ log?: (line: string) => void;
11
+ pingMs?: number;
12
+ silenceMs?: number;
13
+ }
14
+ export interface BridgeServer {
15
+ tab: TabLink;
16
+ host: string;
17
+ port: number;
18
+ close(): Promise<void>;
19
+ }
20
+ export declare const isLoopback: (host: string) => boolean;
21
+ /** The name in a Host header, without the port. */
22
+ export declare function hostName(header: string): string;
23
+ /** The tab (WebSocket upgrade) and the HTTP clients on one port. */
24
+ export declare function startServer(o: ServerOptions): Promise<BridgeServer>;
package/dist/server.js ADDED
@@ -0,0 +1,70 @@
1
+ import { createServer } from 'node:http';
2
+ import { WebSocketServer } from 'ws';
3
+ import { isMessagesPath, messagesRoutes, sendApiError } from './anthropic/routes.js';
4
+ import { pathOf } from './http.js';
5
+ import { openaiRoutes, sendError } from './openai.js';
6
+ import { TabLink } from './tab.js';
7
+ export const DEFAULT_PORT = 7343;
8
+ export const DEFAULT_HOST = '127.0.0.1';
9
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
10
+ export const isLoopback = (host) => LOOPBACK.has(host.replace(/^\[(.*)\]$/, '$1').toLowerCase());
11
+ /** The name in a Host header, without the port. */
12
+ export function hostName(header) {
13
+ if (header.startsWith('['))
14
+ return header.slice(1, header.indexOf(']'));
15
+ return header.split(':')[0] ?? '';
16
+ }
17
+ /** The tab (WebSocket upgrade) and the HTTP clients on one port. */
18
+ export async function startServer(o) {
19
+ const tab = new TabLink({
20
+ token: o.token,
21
+ ...(o.log ? { log: o.log } : {}),
22
+ ...(o.pingMs ? { pingMs: o.pingMs } : {}),
23
+ ...(o.silenceMs ? { silenceMs: o.silenceMs } : {}),
24
+ });
25
+ const routes = openaiRoutes(tab, { waitMs: o.waitMs, ...(o.keepAliveMs ? { keepAliveMs: o.keepAliveMs } : {}) });
26
+ const messages = messagesRoutes(tab, { waitMs: o.waitMs, ...(o.keepAliveMs ? { pingMs: o.keepAliveMs } : {}) });
27
+ // On loopback, a foreign Host means a DNS-rebinding page; bound wider, the user chose it.
28
+ const strictHost = isLoopback(o.host);
29
+ const hostOk = (req) => !strictHost || req.headers.host === undefined || isLoopback(hostName(req.headers.host));
30
+ const server = createServer((req, res) => {
31
+ // Anthropic clients read only Anthropic's error shape, OpenAI clients only OpenAI's.
32
+ const anthropic = isMessagesPath(pathOf(req));
33
+ // Web pages have no business here; local clients send no Origin.
34
+ if (req.headers.origin !== undefined || !hostOk(req)) {
35
+ const why = 'the bridge answers local clients only';
36
+ return anthropic ? sendApiError(res, 403, 'permission_error', why) : sendError(res, 403, why, 'forbidden');
37
+ }
38
+ if (anthropic)
39
+ return messages(req, res);
40
+ routes(req, res);
41
+ });
42
+ const wss = new WebSocketServer({ noServer: true });
43
+ server.on('upgrade', (req, socket, head) => {
44
+ if (!hostOk(req)) {
45
+ socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
46
+ return;
47
+ }
48
+ wss.handleUpgrade(req, socket, head, (ws) => tab.accept(ws));
49
+ });
50
+ await new Promise((resolve, reject) => {
51
+ server.once('error', reject);
52
+ server.listen(o.port, o.host, () => {
53
+ server.off('error', reject);
54
+ resolve();
55
+ });
56
+ });
57
+ return {
58
+ tab,
59
+ host: o.host,
60
+ port: server.address().port,
61
+ async close() {
62
+ tab.close();
63
+ for (const c of wss.clients)
64
+ c.terminate();
65
+ wss.close();
66
+ server.closeAllConnections();
67
+ await new Promise((resolve) => server.close(() => resolve()));
68
+ },
69
+ };
70
+ }
package/dist/tab.d.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { WebSocket } from 'ws';
3
+ import { type ChatMessage, type ModelState, type StopReason, type ToolCall, type ToolSchema, type Usage } from './protocol.js';
4
+ export declare const PING_MS = 20000;
5
+ export declare const SILENCE_MS = 50000;
6
+ /** Close codes for a refused `hello`, as the app's stand-in bridge uses them. */
7
+ export declare const CLOSE: {
8
+ readonly auth: 4000;
9
+ readonly version: 4001;
10
+ readonly busy: 4002;
11
+ readonly helloFirst: 4003;
12
+ };
13
+ export interface ChatInput {
14
+ messages: ChatMessage[];
15
+ tools?: ToolSchema[];
16
+ maxTokens?: number;
17
+ temperature?: number;
18
+ }
19
+ export type ChatEvent = {
20
+ t: 'token';
21
+ text: string;
22
+ } | {
23
+ t: 'queued';
24
+ position: number;
25
+ } | {
26
+ t: 'tool_call';
27
+ calls: ToolCall[];
28
+ };
29
+ export interface ChatResult {
30
+ id: string;
31
+ text: string;
32
+ calls: ToolCall[];
33
+ stop: StopReason;
34
+ usage: Usage;
35
+ }
36
+ export interface ChatOptions {
37
+ onEvent?: (e: ChatEvent) => void;
38
+ /** Aborting sends `abort` to the tab and rejects the chat. */
39
+ signal?: AbortSignal;
40
+ }
41
+ export type ChatErrorKind = 'no_tab' | 'tab' | 'disconnected' | 'aborted';
42
+ export declare class ChatError extends Error {
43
+ readonly kind: ChatErrorKind;
44
+ constructor(message: string, kind: ChatErrorKind);
45
+ }
46
+ /** What `/health` and the MCP `status` tool report. */
47
+ export interface Health {
48
+ tab: boolean;
49
+ state: ModelState | 'none';
50
+ model?: string;
51
+ detail?: string;
52
+ contextTokens?: number;
53
+ app?: string;
54
+ }
55
+ export interface Unavailable {
56
+ code: 'no_tab' | 'model_loading' | 'model_unavailable';
57
+ message: string;
58
+ }
59
+ export interface TabLinkOptions {
60
+ token: string;
61
+ pingMs?: number;
62
+ silenceMs?: number;
63
+ log?: (line: string) => void;
64
+ }
65
+ /** Constant-time comparison; hashing first makes the lengths equal. */
66
+ export declare const sameToken: (a: string, b: string) => boolean;
67
+ /**
68
+ * The one RebeLLM tab connected to this bridge: its `hello`, its model state, and the chats
69
+ * sent to it, each settled by the tab's `done` or `error` or by the connection closing.
70
+ * Emits `change` when a tab connects, disconnects or reports a new status.
71
+ */
72
+ export declare class TabLink extends EventEmitter {
73
+ private readonly token;
74
+ private readonly pingMs;
75
+ private readonly silenceMs;
76
+ private readonly log;
77
+ private ws;
78
+ private hello;
79
+ private status;
80
+ private pinger;
81
+ private pending;
82
+ private seq;
83
+ private readonly prefix;
84
+ constructor(o: TabLinkOptions);
85
+ get connected(): boolean;
86
+ /** Takes a new WebSocket; it becomes the tab after a valid `hello`. */
87
+ accept(ws: WebSocket): void;
88
+ health(): Health;
89
+ /** The tab's model name for responses. */
90
+ get modelName(): string;
91
+ /** Why a chat cannot start now, or null when the tab's model is ready. */
92
+ unavailable(): Unavailable | null;
93
+ /** Resolves null once the model is ready, or the reason it is not after `ms` or an abort. */
94
+ waitReady(ms: number, signal?: AbortSignal): Promise<Unavailable | null>;
95
+ /** Sends one chat to the tab; resolves with its answer when the tab says `done`. */
96
+ chat(input: ChatInput, opts?: ChatOptions): Promise<ChatResult>;
97
+ /** Drops the tab, failing its open chats. */
98
+ close(): void;
99
+ private attach;
100
+ private detach;
101
+ private receive;
102
+ }