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/http.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ /** Long conversations are large, but not this large. */
3
+ export declare const MAX_BODY: number;
4
+ export declare class HttpError extends Error {
5
+ readonly status: number;
6
+ constructor(status: number, message: string);
7
+ }
8
+ export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
9
+ /** The request body as JSON; rejects with an HttpError (413 or 400). */
10
+ export declare function readJson(req: IncomingMessage): Promise<unknown>;
11
+ /** The path of a request URL, without the query (Claude Code adds `?beta=true`). */
12
+ export declare const pathOf: (req: IncomingMessage) => string;
13
+ /** Aborts when the client goes away before the response is finished. */
14
+ export declare function clientGone(res: ServerResponse): AbortSignal;
package/dist/http.js ADDED
@@ -0,0 +1,46 @@
1
+ /** Long conversations are large, but not this large. */
2
+ export const MAX_BODY = 8 << 20;
3
+ export class HttpError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export function sendJson(res, status, body) {
11
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
12
+ res.end(JSON.stringify(body));
13
+ }
14
+ /** The request body as JSON; rejects with an HttpError (413 or 400). */
15
+ export function readJson(req) {
16
+ return new Promise((resolve, reject) => {
17
+ const chunks = [];
18
+ let size = 0;
19
+ req.on('data', (c) => {
20
+ size += c.length;
21
+ if (size > MAX_BODY) {
22
+ reject(new HttpError(413, 'the request body is too large'));
23
+ req.destroy();
24
+ }
25
+ else
26
+ chunks.push(c);
27
+ });
28
+ req.on('end', () => {
29
+ try {
30
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
31
+ }
32
+ catch {
33
+ reject(new HttpError(400, 'the body is not JSON'));
34
+ }
35
+ });
36
+ req.on('error', reject);
37
+ });
38
+ }
39
+ /** The path of a request URL, without the query (Claude Code adds `?beta=true`). */
40
+ export const pathOf = (req) => (req.url ?? '/').split('?')[0] ?? '/';
41
+ /** Aborts when the client goes away before the response is finished. */
42
+ export function clientGone(res) {
43
+ const gone = new AbortController();
44
+ res.on('close', () => !res.writableFinished && gone.abort());
45
+ return gone.signal;
46
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import type { Writable } from 'node:stream';
3
+ export declare const USAGE = "Usage: rebellm-claude [--claude <path>] [--shared-config] [--port <n>] [claude arguments]\n\nRuns Claude Code on the model in your RebeLLM tab, through rebellm-bridge.\n\n --claude <path> the claude executable (default: claude on PATH)\n --shared-config use your normal Claude Code config instead of ~/.rebellm-bridge/claude\n --port <n> the bridge's port (default 7343); a bridge already there is reused\n\nEverything else, and everything after --, goes to claude.";
4
+ export declare const MISSING = "claude was not found on PATH. Install Claude Code (https://claude.com/claude-code), or pass --claude <path>.";
5
+ /** The name Claude Code shows; the bridge answers every model name with the tab's model. */
6
+ export declare const MODEL = "rebellm";
7
+ export interface LauncherOptions {
8
+ claude?: string;
9
+ sharedConfig: boolean;
10
+ port: number;
11
+ /** For claude, in order. */
12
+ args: string[];
13
+ }
14
+ /** Takes the launcher's own flags out of `argv`; everything else passes to claude. */
15
+ export declare function parseLauncher(argv: string[]): LauncherOptions | {
16
+ error: string;
17
+ };
18
+ /** A command as the shell would find it: a path as given, else on PATH (with PATHEXT on Windows). */
19
+ export declare function findCommand(name: string, env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | null;
20
+ /** What sends every model request of claude to the bridge. */
21
+ export declare function bridgeEnv(base: string, contextTokens?: number): Record<string, string>;
22
+ /** The parent's environment with the bridge's on top; a real API key is removed, never sent to the bridge. */
23
+ export declare function childEnv(parent: NodeJS.ProcessEnv, ours: Record<string, string>, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
24
+ export declare const configDir: (home: string) => string;
25
+ export declare const logFile: (home: string) => string;
26
+ /** Puts `env` into the config dir's `settings.json`, keeping the rest; a warning when it cannot. */
27
+ export declare function writeSettings(dir: string, env: Record<string, string>): string | null;
28
+ export interface LaunchIo {
29
+ stderr: Writable;
30
+ env: NodeJS.ProcessEnv;
31
+ home?: string;
32
+ /** How often to ask the bridge whether the tab is there. */
33
+ pollMs?: number;
34
+ platform?: NodeJS.Platform;
35
+ }
36
+ /** The launcher; resolves with claude's exit code once claude and any bridge it started have stopped. */
37
+ export declare function launch(argv: string[], io: LaunchIo): Promise<number>;
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env node
2
+ import { accessSync, constants, createWriteStream, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
3
+ import { homedir, constants as os } from 'node:os';
4
+ import { delimiter, dirname, extname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import spawn from 'cross-spawn';
7
+ import { TOKEN_ENV, VERSION, bridgeHealth, resolveToken, tokenFile } from './cli.js';
8
+ import { DEFAULT_PORT, startServer } from './server.js';
9
+ export const USAGE = `Usage: rebellm-claude [--claude <path>] [--shared-config] [--port <n>] [claude arguments]
10
+
11
+ Runs Claude Code on the model in your RebeLLM tab, through rebellm-bridge.
12
+
13
+ --claude <path> the claude executable (default: claude on PATH)
14
+ --shared-config use your normal Claude Code config instead of ~/.rebellm-bridge/claude
15
+ --port <n> the bridge's port (default ${DEFAULT_PORT}); a bridge already there is reused
16
+
17
+ Everything else, and everything after --, goes to claude.`;
18
+ export const MISSING = 'claude was not found on PATH. Install Claude Code (https://claude.com/claude-code), or pass --claude <path>.';
19
+ /** The name Claude Code shows; the bridge answers every model name with the tab's model. */
20
+ export const MODEL = 'rebellm';
21
+ /** How long a request waits for the tab's model, as the bridge's `--wait` default. */
22
+ const WAIT_MS = 120_000;
23
+ /** Takes the launcher's own flags out of `argv`; everything else passes to claude. */
24
+ export function parseLauncher(argv) {
25
+ const o = { sharedConfig: false, port: DEFAULT_PORT, args: [] };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const arg = argv[i];
28
+ if (arg === '--') {
29
+ o.args.push(...argv.slice(i + 1));
30
+ break;
31
+ }
32
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
33
+ const flag = eq > 0 ? arg.slice(0, eq) : arg;
34
+ if (flag === '--shared-config' && eq < 0) {
35
+ o.sharedConfig = true;
36
+ }
37
+ else if (flag === '--claude' || flag === '--port') {
38
+ const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
39
+ if (!value)
40
+ return { error: `${flag} needs a value` };
41
+ if (flag === '--claude')
42
+ o.claude = value;
43
+ else {
44
+ const port = Number(value);
45
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
46
+ return { error: `--port ${value} is not a port number` };
47
+ o.port = port;
48
+ }
49
+ }
50
+ else
51
+ o.args.push(arg);
52
+ }
53
+ return o;
54
+ }
55
+ /** The key of `name` in `env`; Windows treats `Path` and `PATH` as one. */
56
+ const envKey = (env, name, win) => (win ? Object.keys(env).find((k) => k.toUpperCase() === name) : undefined) ?? name;
57
+ function runnable(file, win) {
58
+ try {
59
+ if (!statSync(file).isFile())
60
+ return false;
61
+ if (!win)
62
+ accessSync(file, constants.X_OK);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /** A command as the shell would find it: a path as given, else on PATH (with PATHEXT on Windows). */
70
+ export function findCommand(name, env, platform = process.platform) {
71
+ const win = platform === 'win32';
72
+ // Windows runs only files with an executable extension; npm installs claude as claude.cmd.
73
+ const exts = win && !extname(name)
74
+ ? (env[envKey(env, 'PATHEXT', win)] ?? '.COM;.EXE;.BAT;.CMD')
75
+ .split(';')
76
+ .filter(Boolean)
77
+ .map((e) => e.toLowerCase())
78
+ : [''];
79
+ const withExts = (base) => exts.map((e) => base + e);
80
+ if (/[\\/]/.test(name))
81
+ return withExts(name).find((f) => runnable(f, win)) ?? null;
82
+ const dirs = (env[envKey(env, 'PATH', win)] ?? '').split(win ? ';' : delimiter).filter(Boolean);
83
+ for (const dir of dirs) {
84
+ const hit = withExts(join(dir, name)).find((f) => runnable(f, win));
85
+ if (hit)
86
+ return hit;
87
+ }
88
+ return null;
89
+ }
90
+ /** What sends every model request of claude to the bridge. */
91
+ export function bridgeEnv(base, contextTokens) {
92
+ return {
93
+ ANTHROPIC_BASE_URL: base,
94
+ // The bridge checks no key; a Bearer token needs no approval prompt in Claude Code.
95
+ ANTHROPIC_AUTH_TOKEN: 'rebellm-bridge-needs-no-key',
96
+ ANTHROPIC_MODEL: MODEL,
97
+ ANTHROPIC_SMALL_FAST_MODEL: MODEL,
98
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: MODEL,
99
+ // A local model can take minutes before its first token.
100
+ API_TIMEOUT_MS: '600000',
101
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
102
+ // Claude Code assumes 200k for a model it does not know; it should compact within the tab's.
103
+ ...(contextTokens ? { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(contextTokens) } : {}),
104
+ };
105
+ }
106
+ /** The parent's environment with the bridge's on top; a real API key is removed, never sent to the bridge. */
107
+ export function childEnv(parent, ours, platform = process.platform) {
108
+ const win = platform === 'win32';
109
+ const drop = new Set(['ANTHROPIC_API_KEY', ...Object.keys(ours)]);
110
+ const env = {};
111
+ for (const [k, v] of Object.entries(parent))
112
+ if (!drop.has(win ? k.toUpperCase() : k))
113
+ env[k] = v;
114
+ return { ...env, ...ours };
115
+ }
116
+ export const configDir = (home) => join(home, '.rebellm-bridge', 'claude');
117
+ export const logFile = (home) => join(home, '.rebellm-bridge', 'bridge.log');
118
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
119
+ /** Puts `env` into the config dir's `settings.json`, keeping the rest; a warning when it cannot. */
120
+ export function writeSettings(dir, env) {
121
+ const file = join(dir, 'settings.json');
122
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
123
+ let settings = {};
124
+ try {
125
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
126
+ if (!isObj(parsed))
127
+ return `${file} is not a JSON object; left it as it is`;
128
+ settings = parsed;
129
+ }
130
+ catch (e) {
131
+ if (e.code !== 'ENOENT')
132
+ return `could not read ${file}; left it as it is`;
133
+ }
134
+ settings.env = { ...(isObj(settings.env) ? settings.env : {}), ...env };
135
+ writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
136
+ return null;
137
+ }
138
+ const sleep = (ms, signal) => new Promise((resolve) => {
139
+ const t = setTimeout(resolve, ms);
140
+ signal.addEventListener('abort', () => (clearTimeout(t), resolve()), { once: true });
141
+ });
142
+ /** The launcher; resolves with claude's exit code once claude and any bridge it started have stopped. */
143
+ export async function launch(argv, io) {
144
+ const say = (line) => void io.stderr.write(`rebellm-claude: ${line}\n`);
145
+ const o = parseLauncher(argv);
146
+ if ('error' in o) {
147
+ say(o.error);
148
+ io.stderr.write(`\n${USAGE}\n`);
149
+ return 2;
150
+ }
151
+ const platform = io.platform ?? process.platform;
152
+ const home = io.home ?? homedir();
153
+ const claude = findCommand(o.claude ?? 'claude', io.env, platform);
154
+ if (!claude) {
155
+ say(o.claude ? `${o.claude} was not found` : MISSING);
156
+ return 1;
157
+ }
158
+ // Ctrl+C quits the wait; while claude runs, claude handles it.
159
+ const quit = new AbortController();
160
+ const onSigint = () => quit.abort();
161
+ process.on('SIGINT', onSigint);
162
+ let server = null;
163
+ let log = null;
164
+ try {
165
+ let base = `http://127.0.0.1:${o.port}`;
166
+ let token = `the token in ${tokenFile(home)}`;
167
+ let health = await bridgeHealth(base);
168
+ if (health)
169
+ say(`using the bridge already running on ${base}`);
170
+ else {
171
+ const tok = resolveToken({ ...(io.env[TOKEN_ENV] ? { env: io.env[TOKEN_ENV] } : {}), home });
172
+ const file = logFile(home);
173
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
174
+ const stream = createWriteStream(file, { flags: 'a', mode: 0o600 });
175
+ log = stream;
176
+ const write = (line) => void stream.write(`${new Date().toISOString()} ${line}\n`);
177
+ try {
178
+ server = await startServer({ host: '127.0.0.1', port: o.port, token: tok.token, waitMs: WAIT_MS, log: write });
179
+ }
180
+ catch (e) {
181
+ if (e.code !== 'EADDRINUSE')
182
+ throw e;
183
+ // Another launcher may have started one meanwhile.
184
+ health = await bridgeHealth(base);
185
+ if (!health)
186
+ throw new Error(`port ${o.port} on 127.0.0.1 is in use by something that is not a rebellm-bridge`, {
187
+ cause: e,
188
+ });
189
+ }
190
+ if (server) {
191
+ base = `http://127.0.0.1:${server.port}`;
192
+ write(`${VERSION} listening on 127.0.0.1:${server.port} for rebellm-claude`);
193
+ say(`started the bridge on ${base} (log: ${file})`);
194
+ if (tok.created)
195
+ token = `this new token (stored in ${tok.file}):\n\n ${tok.token}\n`;
196
+ else if (tok.source === 'env')
197
+ token = `the token in ${TOKEN_ENV}`;
198
+ health = await bridgeHealth(base);
199
+ }
200
+ }
201
+ if (!health?.tab) {
202
+ say(`waiting for the RebeLLM tab. In RebeLLM → Settings → Local bridge, connect to ` +
203
+ `${base.replace('http:', 'ws:')} with ${token}\nCtrl+C quits.`);
204
+ while (!health?.tab) {
205
+ if (quit.signal.aborted)
206
+ return 130;
207
+ await sleep(io.pollMs ?? 1000, quit.signal);
208
+ health = await bridgeHealth(base);
209
+ }
210
+ }
211
+ const model = health.model ? ` (model ${health.model}, ${health.state})` : '';
212
+ say(`the RebeLLM tab is connected${model}`);
213
+ const dir = o.sharedConfig ? null : configDir(home);
214
+ const ours = bridgeEnv(base, health.contextTokens);
215
+ if (dir) {
216
+ const warning = writeSettings(dir, ours);
217
+ if (warning)
218
+ say(warning);
219
+ }
220
+ say(`starting ${claude} with ${dir ? `the config in ${dir}` : 'your own Claude Code config'}`);
221
+ const env = childEnv(io.env, { ...ours, ...(dir ? { CLAUDE_CONFIG_DIR: dir } : {}) }, platform);
222
+ const child = spawn(claude, o.args, { stdio: 'inherit', env });
223
+ return await new Promise((resolve) => {
224
+ child.on('error', (e) => {
225
+ say(`could not start ${claude}: ${e.message}`);
226
+ resolve(1);
227
+ });
228
+ child.on('exit', (code, signal) => resolve(code ?? 128 + ((signal && os.signals[signal]) || 0)));
229
+ });
230
+ }
231
+ catch (e) {
232
+ say(e.message);
233
+ return 1;
234
+ }
235
+ finally {
236
+ process.off('SIGINT', onSigint);
237
+ await server?.close();
238
+ const stream = log;
239
+ if (stream)
240
+ await new Promise((resolve) => stream.end(resolve));
241
+ }
242
+ }
243
+ function invokedDirectly() {
244
+ try {
245
+ return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ if (invokedDirectly()) {
252
+ void launch(process.argv.slice(2), { stderr: process.stderr, env: process.env }).then((code) => process.exit(code));
253
+ }
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type { ChatInput, ChatOptions, ChatResult, Health, TabLink } from './tab.js';
4
+ /** How the MCP face reaches a tab: in this process, or through a bridge already running. */
5
+ export interface ChatBackend {
6
+ health(): Promise<Health>;
7
+ /** Waits for a ready model like the HTTP face does; rejects with the reason when there is none. */
8
+ chat(input: ChatInput, opts: ChatOptions): Promise<ChatResult>;
9
+ }
10
+ export declare function tabBackend(tab: TabLink, waitMs: number): ChatBackend;
11
+ /** A bridge already serving the port, used through its OpenAI endpoint. */
12
+ export declare function httpBackend(base: string): ChatBackend;
13
+ /** The `status` tool's text; never the token, which would end up in the client's context. */
14
+ export declare function describeHealth(h: Health): string;
15
+ export declare function createMcpServer(backend: ChatBackend, version: string): McpServer;
16
+ /** Serves MCP over stdio until the client closes its end. */
17
+ export declare function serveStdio(server: McpServer, stdin: Readable, stdout: Writable): Promise<void>;
package/dist/mcp.js ADDED
@@ -0,0 +1,169 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import * as z from 'zod';
4
+ export function tabBackend(tab, waitMs) {
5
+ return {
6
+ health: async () => tab.health(),
7
+ async chat(input, opts) {
8
+ const missing = await tab.waitReady(waitMs, opts.signal);
9
+ if (missing)
10
+ throw new Error(missing.message);
11
+ return tab.chat(input, opts);
12
+ },
13
+ };
14
+ }
15
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
+ async function errorMessage(r) {
17
+ const body = await r.json().catch(() => null);
18
+ const err = isObj(body) ? body.error : undefined;
19
+ return isObj(err) && typeof err.message === 'string' ? err.message : `the bridge answered ${r.status}`;
20
+ }
21
+ /** The `data:` payloads of a server-sent event stream. */
22
+ async function* sseData(body) {
23
+ const decoder = new TextDecoder();
24
+ let buf = '';
25
+ for await (const bytes of body) {
26
+ buf += decoder.decode(bytes, { stream: true });
27
+ let end;
28
+ while ((end = buf.indexOf('\n\n')) >= 0) {
29
+ const event = buf.slice(0, end);
30
+ buf = buf.slice(end + 2);
31
+ const data = event
32
+ .split('\n')
33
+ .filter((l) => l.startsWith('data:'))
34
+ .map((l) => l.slice(5).trimStart());
35
+ if (data.length)
36
+ yield data.join('\n');
37
+ }
38
+ }
39
+ }
40
+ /** A bridge already serving the port, used through its OpenAI endpoint. */
41
+ export function httpBackend(base) {
42
+ return {
43
+ async health() {
44
+ const r = await fetch(`${base}/health`);
45
+ if (!r.ok)
46
+ throw new Error(await errorMessage(r));
47
+ return (await r.json());
48
+ },
49
+ async chat(input, opts) {
50
+ const r = await fetch(`${base}/v1/chat/completions`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({
54
+ messages: input.messages,
55
+ ...(input.maxTokens !== undefined ? { max_tokens: input.maxTokens } : {}),
56
+ ...(input.temperature !== undefined ? { temperature: input.temperature } : {}),
57
+ stream: true,
58
+ stream_options: { include_usage: true },
59
+ }),
60
+ ...(opts.signal ? { signal: opts.signal } : {}),
61
+ });
62
+ if (!r.ok || !r.body)
63
+ throw new Error(await errorMessage(r));
64
+ let text = '';
65
+ let stop = 'eos';
66
+ let usage = { prompt: 0, completion: 0, tokensPerSec: 0 };
67
+ for await (const data of sseData(r.body)) {
68
+ if (data === '[DONE]')
69
+ break;
70
+ const o = JSON.parse(data);
71
+ if (isObj(o.error))
72
+ throw new Error(String(o.error.message ?? 'the bridge failed'));
73
+ const choice = Array.isArray(o.choices) && isObj(o.choices[0]) ? o.choices[0] : undefined;
74
+ const piece = isObj(choice?.delta) ? choice.delta.content : undefined;
75
+ if (typeof piece === 'string' && piece) {
76
+ text += piece;
77
+ opts.onEvent?.({ t: 'token', text: piece });
78
+ }
79
+ if (choice?.finish_reason === 'length')
80
+ stop = 'length';
81
+ if (isObj(o.usage))
82
+ usage = {
83
+ prompt: Number(o.usage.prompt_tokens),
84
+ completion: Number(o.usage.completion_tokens),
85
+ tokensPerSec: 0,
86
+ };
87
+ }
88
+ return { id: '', text, calls: [], stop, usage };
89
+ },
90
+ };
91
+ }
92
+ /** The `status` tool's text; never the token, which would end up in the client's context. */
93
+ export function describeHealth(h) {
94
+ if (!h.tab)
95
+ return ('No RebeLLM tab is connected. In RebeLLM → Settings → Local bridge, paste the bridge token ' +
96
+ '(printed at its first start, stored in ~/.rebellm-bridge/token) and turn the switch on.');
97
+ const tab = `A RebeLLM tab is connected${h.app ? ` (app ${h.app})` : ''}.`;
98
+ const model = h.model ? `The model ${h.model}` : 'The model';
99
+ if (h.state === 'ready')
100
+ return `${tab} ${model} is ready${h.contextTokens ? `, with a context of ${h.contextTokens} tokens` : ''}.`;
101
+ if (h.state === 'loading')
102
+ return `${tab} ${model} is loading${h.detail ? ` (${h.detail})` : ''}.`;
103
+ return `${tab} ${model} is not available${h.detail ? `: ${h.detail}` : ''}.`;
104
+ }
105
+ const message = z.object({ role: z.enum(['system', 'user', 'assistant']), content: z.string() });
106
+ export function createMcpServer(backend, version) {
107
+ const server = new McpServer({ name: 'rebellm-bridge', version });
108
+ server.registerTool('chat', {
109
+ title: 'Ask the RebeLLM model',
110
+ description: "Sends a conversation to the model running in the user's RebeLLM browser tab (a local model on this " +
111
+ "machine's GPU) and returns its answer. Waits for the tab when it is not connected or its model is loading.",
112
+ inputSchema: {
113
+ messages: z.array(message).min(1).describe('The conversation; the last message is usually from the user'),
114
+ max_tokens: z.number().int().positive().optional().describe('Most tokens in the answer'),
115
+ temperature: z.number().min(0).optional(),
116
+ },
117
+ }, async ({ messages, max_tokens, temperature }, extra) => {
118
+ const token = extra._meta?.progressToken;
119
+ let n = 0;
120
+ const progress = (message) => token !== undefined &&
121
+ void extra
122
+ .sendNotification({
123
+ method: 'notifications/progress',
124
+ params: { progressToken: token, progress: ++n, message },
125
+ })
126
+ .catch(() => undefined);
127
+ try {
128
+ const r = await backend.chat({
129
+ messages,
130
+ ...(max_tokens !== undefined ? { maxTokens: max_tokens } : {}),
131
+ ...(temperature !== undefined ? { temperature } : {}),
132
+ }, {
133
+ signal: extra.signal,
134
+ onEvent: (e) => {
135
+ if (e.t === 'token')
136
+ progress(e.text);
137
+ else if (e.t === 'queued')
138
+ progress(`waiting in the tab's queue, position ${e.position}`);
139
+ },
140
+ });
141
+ return { content: [{ type: 'text', text: r.text }] };
142
+ }
143
+ catch (e) {
144
+ return { isError: true, content: [{ type: 'text', text: `RebeLLM: ${e.message}` }] };
145
+ }
146
+ });
147
+ server.registerTool('status', {
148
+ title: 'RebeLLM tab status',
149
+ description: 'Tells whether a RebeLLM tab is connected to the bridge and whether its model is ready.',
150
+ }, async () => {
151
+ try {
152
+ return { content: [{ type: 'text', text: describeHealth(await backend.health()) }] };
153
+ }
154
+ catch (e) {
155
+ return { isError: true, content: [{ type: 'text', text: `RebeLLM: ${e.message}` }] };
156
+ }
157
+ });
158
+ return server;
159
+ }
160
+ /** Serves MCP over stdio until the client closes its end. */
161
+ export async function serveStdio(server, stdin, stdout) {
162
+ const closed = new Promise((resolve) => {
163
+ stdin.once('end', resolve);
164
+ stdin.once('close', resolve);
165
+ });
166
+ await server.connect(new StdioServerTransport(stdin, stdout));
167
+ await closed;
168
+ await server.close();
169
+ }
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type ChatInput, type ChatResult, type TabLink } from './tab.js';
3
+ /** Well inside the read timeouts of common clients (Node's fetch: 300 s). */
4
+ export declare const KEEPALIVE_MS = 15000;
5
+ export interface RouteOptions {
6
+ /** How long a request waits for a tab with a ready model. */
7
+ waitMs: number;
8
+ keepAliveMs?: number;
9
+ }
10
+ /** OpenAI's error shape, which its SDKs turn into readable messages. */
11
+ export declare function sendError(res: ServerResponse, status: number, message: string, type: string, code?: string): void;
12
+ export type ParsedRequest = {
13
+ input: ChatInput;
14
+ stream: boolean;
15
+ includeUsage: boolean;
16
+ } | {
17
+ error: string;
18
+ };
19
+ /** An OpenAI chat completion request as the tab's `chat`; the error says what is wrong with it. */
20
+ export declare function toChatInput(body: unknown): ParsedRequest;
21
+ /** A non-streamed answer in OpenAI's shape. */
22
+ export declare function completion(r: ChatResult, meta: {
23
+ id: string;
24
+ created: number;
25
+ model: string;
26
+ }): {
27
+ object: string;
28
+ choices: {
29
+ index: number;
30
+ message: {
31
+ tool_calls?: {
32
+ id: string;
33
+ type: "function";
34
+ function: {
35
+ name: string;
36
+ arguments: string;
37
+ };
38
+ }[] | undefined;
39
+ role: string;
40
+ content: string | null;
41
+ };
42
+ finish_reason: string;
43
+ }[];
44
+ usage: {
45
+ prompt_tokens: number;
46
+ completion_tokens: number;
47
+ total_tokens: number;
48
+ };
49
+ id: string;
50
+ created: number;
51
+ model: string;
52
+ };
53
+ /** The HTTP routes for OpenAI-style clients: completions, models and health. */
54
+ export declare function openaiRoutes(tab: TabLink, o: RouteOptions): (req: IncomingMessage, res: ServerResponse) => void;