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/LICENSE +223 -0
- package/README.md +115 -0
- package/dist/anthropic/map.d.ts +32 -0
- package/dist/anthropic/map.js +254 -0
- package/dist/anthropic/routes.d.ts +15 -0
- package/dist/anthropic/routes.js +140 -0
- package/dist/anthropic/sse.d.ts +24 -0
- package/dist/anthropic/sse.js +66 -0
- package/dist/anthropic/stop.d.ts +17 -0
- package/dist/anthropic/stop.js +51 -0
- package/dist/anthropic/types.d.ts +112 -0
- package/dist/anthropic/types.js +5 -0
- package/dist/cli.d.ts +57 -0
- package/dist/cli.js +200 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +46 -0
- package/dist/launcher.d.ts +37 -0
- package/dist/launcher.js +253 -0
- package/dist/mcp.d.ts +17 -0
- package/dist/mcp.js +169 -0
- package/dist/openai.d.ts +54 -0
- package/dist/openai.js +265 -0
- package/dist/protocol.d.ts +99 -0
- package/dist/protocol.js +53 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.js +70 -0
- package/dist/tab.d.ts +102 -0
- package/dist/tab.js +257 -0
- package/package.json +68 -0
package/dist/tab.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { PROTOCOL_VERSION, encode, parseTabMessage, } from './protocol.js';
|
|
4
|
+
export const PING_MS = 20_000;
|
|
5
|
+
export const SILENCE_MS = 50_000;
|
|
6
|
+
/** Close codes for a refused `hello`, as the app's stand-in bridge uses them. */
|
|
7
|
+
export const CLOSE = { auth: 4000, version: 4001, busy: 4002, helloFirst: 4003 };
|
|
8
|
+
export class ChatError extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
constructor(message, kind) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ChatError';
|
|
13
|
+
this.kind = kind;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
17
|
+
/** Constant-time comparison; hashing first makes the lengths equal. */
|
|
18
|
+
export const sameToken = (a, b) => timingSafeEqual(digest(a), digest(b));
|
|
19
|
+
const text = (d) => (Buffer.isBuffer(d) ? d : Array.isArray(d) ? Buffer.concat(d) : Buffer.from(d)).toString('utf8');
|
|
20
|
+
/**
|
|
21
|
+
* The one RebeLLM tab connected to this bridge: its `hello`, its model state, and the chats
|
|
22
|
+
* sent to it, each settled by the tab's `done` or `error` or by the connection closing.
|
|
23
|
+
* Emits `change` when a tab connects, disconnects or reports a new status.
|
|
24
|
+
*/
|
|
25
|
+
export class TabLink extends EventEmitter {
|
|
26
|
+
token;
|
|
27
|
+
pingMs;
|
|
28
|
+
silenceMs;
|
|
29
|
+
log;
|
|
30
|
+
ws = null;
|
|
31
|
+
hello = null;
|
|
32
|
+
status = null;
|
|
33
|
+
pinger = null;
|
|
34
|
+
pending = new Map();
|
|
35
|
+
seq = 0;
|
|
36
|
+
// Ids differ across restarts, so tool call ids in a client's history never repeat.
|
|
37
|
+
prefix = randomBytes(4).toString('hex');
|
|
38
|
+
constructor(o) {
|
|
39
|
+
super();
|
|
40
|
+
this.setMaxListeners(0);
|
|
41
|
+
this.token = o.token;
|
|
42
|
+
this.pingMs = o.pingMs ?? PING_MS;
|
|
43
|
+
this.silenceMs = o.silenceMs ?? SILENCE_MS;
|
|
44
|
+
this.log = o.log ?? (() => undefined);
|
|
45
|
+
}
|
|
46
|
+
get connected() {
|
|
47
|
+
return !!this.ws;
|
|
48
|
+
}
|
|
49
|
+
/** Takes a new WebSocket; it becomes the tab after a valid `hello`. */
|
|
50
|
+
accept(ws) {
|
|
51
|
+
let authed = false;
|
|
52
|
+
const silence = setTimeout(() => {
|
|
53
|
+
if (authed)
|
|
54
|
+
this.log('the RebeLLM tab stopped answering; dropped it');
|
|
55
|
+
ws.terminate();
|
|
56
|
+
}, this.silenceMs);
|
|
57
|
+
ws.on('message', (data, isBinary) => {
|
|
58
|
+
silence.refresh();
|
|
59
|
+
const m = isBinary ? null : parseTabMessage(text(data));
|
|
60
|
+
if (authed)
|
|
61
|
+
return m ? this.receive(m) : this.log('ignored a frame from the tab that is not protocol v1');
|
|
62
|
+
if (m?.t !== 'hello')
|
|
63
|
+
return ws.close(CLOSE.helloFirst, 'hello first');
|
|
64
|
+
const refuse = (code, message) => {
|
|
65
|
+
send(ws, { t: 'error', code, message });
|
|
66
|
+
ws.close(CLOSE[code], code);
|
|
67
|
+
this.log(`refused a tab: ${message}`);
|
|
68
|
+
};
|
|
69
|
+
if (m.v !== PROTOCOL_VERSION)
|
|
70
|
+
return refuse('version', `this bridge speaks protocol v${PROTOCOL_VERSION}, not v${m.v}`);
|
|
71
|
+
if (!sameToken(m.token, this.token))
|
|
72
|
+
return refuse('auth', 'wrong token');
|
|
73
|
+
if (this.ws)
|
|
74
|
+
return refuse('busy', 'another RebeLLM tab is connected');
|
|
75
|
+
authed = true;
|
|
76
|
+
this.attach(ws, m);
|
|
77
|
+
});
|
|
78
|
+
ws.on('close', () => {
|
|
79
|
+
clearTimeout(silence);
|
|
80
|
+
if (this.ws === ws)
|
|
81
|
+
this.detach();
|
|
82
|
+
});
|
|
83
|
+
// A close event follows every error.
|
|
84
|
+
ws.on('error', () => undefined);
|
|
85
|
+
}
|
|
86
|
+
health() {
|
|
87
|
+
if (!this.ws || !this.hello)
|
|
88
|
+
return { tab: false, state: 'none' };
|
|
89
|
+
const s = this.status;
|
|
90
|
+
const model = s?.model || this.hello.model;
|
|
91
|
+
return {
|
|
92
|
+
tab: true,
|
|
93
|
+
// The tab sends its status right after `ok`; until then its model counts as loading.
|
|
94
|
+
state: s?.state ?? 'loading',
|
|
95
|
+
...(model ? { model } : {}),
|
|
96
|
+
...(s?.detail ? { detail: s.detail } : {}),
|
|
97
|
+
contextTokens: this.hello.contextTokens,
|
|
98
|
+
app: this.hello.app,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** The tab's model name for responses. */
|
|
102
|
+
get modelName() {
|
|
103
|
+
return this.health().model ?? 'rebellm';
|
|
104
|
+
}
|
|
105
|
+
/** Why a chat cannot start now, or null when the tab's model is ready. */
|
|
106
|
+
unavailable() {
|
|
107
|
+
const h = this.health();
|
|
108
|
+
if (!h.tab)
|
|
109
|
+
return { code: 'no_tab', message: 'no RebeLLM tab connected' };
|
|
110
|
+
if (h.state === 'ready')
|
|
111
|
+
return null;
|
|
112
|
+
const [code, base] = h.state === 'unavailable'
|
|
113
|
+
? ['model_unavailable', 'model unavailable']
|
|
114
|
+
: ['model_loading', 'model loading'];
|
|
115
|
+
return { code, message: h.detail ? `${base}: ${h.detail}` : base };
|
|
116
|
+
}
|
|
117
|
+
/** Resolves null once the model is ready, or the reason it is not after `ms` or an abort. */
|
|
118
|
+
waitReady(ms, signal) {
|
|
119
|
+
const now = this.unavailable();
|
|
120
|
+
if (!now || ms <= 0 || signal?.aborted)
|
|
121
|
+
return Promise.resolve(now);
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const done = () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
this.off('change', check);
|
|
126
|
+
signal?.removeEventListener('abort', done);
|
|
127
|
+
resolve(this.unavailable());
|
|
128
|
+
};
|
|
129
|
+
const check = () => !this.unavailable() && done();
|
|
130
|
+
const timer = setTimeout(done, ms);
|
|
131
|
+
this.on('change', check);
|
|
132
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Sends one chat to the tab; resolves with its answer when the tab says `done`. */
|
|
136
|
+
chat(input, opts = {}) {
|
|
137
|
+
const ws = this.ws;
|
|
138
|
+
if (!ws)
|
|
139
|
+
return Promise.reject(new ChatError('no RebeLLM tab connected', 'no_tab'));
|
|
140
|
+
if (opts.signal?.aborted)
|
|
141
|
+
return Promise.reject(new ChatError('the request was cancelled', 'aborted'));
|
|
142
|
+
const id = `${this.prefix}-${++this.seq}`;
|
|
143
|
+
const { signal, onEvent } = opts;
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const onAbort = () => {
|
|
146
|
+
if (!this.pending.delete(id))
|
|
147
|
+
return;
|
|
148
|
+
send(ws, { t: 'abort', id });
|
|
149
|
+
reject(new ChatError('the request was cancelled', 'aborted'));
|
|
150
|
+
};
|
|
151
|
+
const cleanup = () => {
|
|
152
|
+
this.pending.delete(id);
|
|
153
|
+
signal?.removeEventListener('abort', onAbort);
|
|
154
|
+
};
|
|
155
|
+
this.pending.set(id, {
|
|
156
|
+
text: '',
|
|
157
|
+
calls: [],
|
|
158
|
+
...(onEvent ? { onEvent } : {}),
|
|
159
|
+
finish: (r) => (cleanup(), resolve(r)),
|
|
160
|
+
fail: (e) => (cleanup(), reject(e)),
|
|
161
|
+
});
|
|
162
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
163
|
+
send(ws, {
|
|
164
|
+
t: 'chat',
|
|
165
|
+
id,
|
|
166
|
+
messages: input.messages,
|
|
167
|
+
...(input.tools?.length ? { tools: input.tools } : {}),
|
|
168
|
+
...(input.maxTokens !== undefined ? { maxTokens: input.maxTokens } : {}),
|
|
169
|
+
...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** Drops the tab, failing its open chats. */
|
|
174
|
+
close() {
|
|
175
|
+
const ws = this.ws;
|
|
176
|
+
if (!ws)
|
|
177
|
+
return;
|
|
178
|
+
this.detach();
|
|
179
|
+
ws.terminate();
|
|
180
|
+
}
|
|
181
|
+
attach(ws, hello) {
|
|
182
|
+
this.ws = ws;
|
|
183
|
+
this.hello = hello;
|
|
184
|
+
this.status = null;
|
|
185
|
+
send(ws, { t: 'ok' });
|
|
186
|
+
this.pinger = setInterval(() => send(ws, { t: 'ping' }), this.pingMs);
|
|
187
|
+
const model = hello.model ? `, model ${hello.model}` : '';
|
|
188
|
+
this.log(`RebeLLM tab connected (app ${hello.app || 'unknown'}${model})`);
|
|
189
|
+
this.emit('change');
|
|
190
|
+
}
|
|
191
|
+
detach() {
|
|
192
|
+
if (this.pinger)
|
|
193
|
+
clearInterval(this.pinger);
|
|
194
|
+
this.pinger = null;
|
|
195
|
+
this.ws = null;
|
|
196
|
+
this.hello = null;
|
|
197
|
+
this.status = null;
|
|
198
|
+
const open = [...this.pending.values()];
|
|
199
|
+
this.pending.clear();
|
|
200
|
+
for (const p of open)
|
|
201
|
+
p.fail(new ChatError('the RebeLLM tab disconnected', 'disconnected'));
|
|
202
|
+
this.log('RebeLLM tab disconnected');
|
|
203
|
+
this.emit('change');
|
|
204
|
+
}
|
|
205
|
+
receive(m) {
|
|
206
|
+
switch (m.t) {
|
|
207
|
+
case 'ping':
|
|
208
|
+
if (this.ws)
|
|
209
|
+
send(this.ws, { t: 'pong' });
|
|
210
|
+
return;
|
|
211
|
+
case 'status': {
|
|
212
|
+
const prev = this.status?.state;
|
|
213
|
+
this.status = {
|
|
214
|
+
state: m.state,
|
|
215
|
+
...(m.model ? { model: m.model } : {}),
|
|
216
|
+
...(m.detail ? { detail: m.detail } : {}),
|
|
217
|
+
};
|
|
218
|
+
if (prev !== m.state)
|
|
219
|
+
this.log(`model ${m.state}${m.model ? ` (${m.model})` : ''}${m.detail ? `: ${m.detail}` : ''}`);
|
|
220
|
+
this.emit('change');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
case 'token':
|
|
224
|
+
case 'tool_call':
|
|
225
|
+
case 'queued':
|
|
226
|
+
case 'done':
|
|
227
|
+
case 'error': {
|
|
228
|
+
if (m.id === undefined)
|
|
229
|
+
return m.t === 'error' && this.log(`the tab reported: ${m.message}`);
|
|
230
|
+
// Frames of a chat that was aborted or never sent have no one to go to.
|
|
231
|
+
const p = this.pending.get(m.id);
|
|
232
|
+
if (p)
|
|
233
|
+
route(p, m, m.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function route(p, m, id) {
|
|
239
|
+
switch (m.t) {
|
|
240
|
+
case 'token':
|
|
241
|
+
p.text += m.text;
|
|
242
|
+
return p.onEvent?.({ t: 'token', text: m.text });
|
|
243
|
+
case 'tool_call':
|
|
244
|
+
p.calls.push(...m.calls);
|
|
245
|
+
return p.onEvent?.({ t: 'tool_call', calls: m.calls });
|
|
246
|
+
case 'queued':
|
|
247
|
+
return p.onEvent?.({ t: 'queued', position: m.position });
|
|
248
|
+
case 'done':
|
|
249
|
+
return p.finish({ id, text: p.text, calls: p.calls, stop: m.stop, usage: m.usage });
|
|
250
|
+
case 'error':
|
|
251
|
+
return p.fail(new ChatError(m.message, 'tab'));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function send(ws, m) {
|
|
255
|
+
if (ws.readyState === ws.OPEN)
|
|
256
|
+
ws.send(encode(m));
|
|
257
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rebellm-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use the model in your RebeLLM browser tab from the command line, OpenAI/Anthropic clients and Claude Code",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"rebellm",
|
|
7
|
+
"llm",
|
|
8
|
+
"bridge",
|
|
9
|
+
"mcp",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"openai-compatible",
|
|
12
|
+
"anthropic-compatible",
|
|
13
|
+
"webgpu"
|
|
14
|
+
],
|
|
15
|
+
"author": "Byteleap Oy",
|
|
16
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
17
|
+
"homepage": "https://rebellm.ai",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Byteleap-Oy/RebeLLM-Bridge.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/Byteleap-Oy/RebeLLM-Bridge/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"rebellm-bridge": "dist/cli.js",
|
|
31
|
+
"rebellm-claude": "dist/launcher.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"prepare": "git config core.hooksPath .githooks",
|
|
40
|
+
"prepublishOnly": "npm run check && npm run build",
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
43
|
+
"lint": "eslint .",
|
|
44
|
+
"format": "prettier --write .",
|
|
45
|
+
"format:check": "prettier --check .",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"check": "npm run typecheck && npm run lint && npm run format:check && npm test"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
51
|
+
"cross-spawn": "^7.0.6",
|
|
52
|
+
"ws": "^8.21.3",
|
|
53
|
+
"zod": "^4.6.5"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@anthropic-ai/sdk": "^0.128.0",
|
|
57
|
+
"@eslint/js": "^10.0.1",
|
|
58
|
+
"@types/cross-spawn": "^6.0.6",
|
|
59
|
+
"@types/node": "^24.0.0",
|
|
60
|
+
"@types/ws": "^8.18.1",
|
|
61
|
+
"eslint": "^10.11.0",
|
|
62
|
+
"globals": "^17.12.0",
|
|
63
|
+
"prettier": "^3.9.9",
|
|
64
|
+
"typescript": "~5.8.3",
|
|
65
|
+
"typescript-eslint": "^8.70.1",
|
|
66
|
+
"vitest": "^4.0.0"
|
|
67
|
+
}
|
|
68
|
+
}
|