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.
@@ -0,0 +1,140 @@
1
+ import { HttpError, clientGone, pathOf, readJson, sendJson } from '../http.js';
2
+ import { ChatError } from '../tab.js';
3
+ import { content, estimateTokens, message, newId, stopReason, tabError, tabUsage, toChatInput, tooLong, toolUse, usage, } from './map.js';
4
+ import { EventWriter } from './sse.js';
5
+ import { StopMatcher } from './stop.js';
6
+ /** The Messages API sends a ping about this often; clients expect something within a minute. */
7
+ export const PING_MS = 10_000;
8
+ /** Anthropic's error shape, the only one its SDKs (and Claude Code) read. */
9
+ export function sendApiError(res, status, type, message) {
10
+ sendJson(res, status, { type: 'error', error: { type, message } });
11
+ }
12
+ export const isMessagesPath = (path) => path === '/v1/messages' || path.startsWith('/v1/messages/');
13
+ /**
14
+ * Runs one chat on the tab, passing text through the stop sequences. A match aborts the chat;
15
+ * its usage is then the bridge's own count, since the tab's `done` is not awaited.
16
+ */
17
+ async function answer(tab, input, stops, estimate, signal, sink) {
18
+ const matcher = new StopMatcher(stops);
19
+ const halt = new AbortController();
20
+ let frames = 0;
21
+ try {
22
+ const r = await tab.chat(input, {
23
+ signal: AbortSignal.any([signal, halt.signal]),
24
+ onEvent: (e) => {
25
+ if (e.t === 'token') {
26
+ frames++;
27
+ sink.text(matcher.push(e.text));
28
+ if (matcher.matched !== null)
29
+ halt.abort();
30
+ }
31
+ else if (e.t === 'tool_call')
32
+ for (const c of e.calls)
33
+ sink.toolUse(toolUse(c));
34
+ },
35
+ });
36
+ sink.text(matcher.flush());
37
+ return { reason: stopReason(r.stop, r.calls.length), sequence: null, usage: tabUsage(r.usage) };
38
+ }
39
+ catch (e) {
40
+ if (matcher.matched === null || signal.aborted)
41
+ throw e;
42
+ return { reason: 'stop_sequence', sequence: matcher.matched, usage: usage(estimate, frames) };
43
+ }
44
+ }
45
+ /** `POST /v1/messages` and `POST /v1/messages/count_tokens` for Anthropic clients such as Claude Code. */
46
+ export function messagesRoutes(tab, o) {
47
+ async function body(req, res) {
48
+ try {
49
+ return await readJson(req);
50
+ }
51
+ catch (e) {
52
+ const status = e instanceof HttpError ? e.status : 400;
53
+ sendApiError(res, status, status === 413 ? 'request_too_large' : 'invalid_request_error', e.message);
54
+ return undefined;
55
+ }
56
+ }
57
+ async function messages(req, res) {
58
+ const raw = await body(req, res);
59
+ if (raw === undefined)
60
+ return;
61
+ const parsed = toChatInput(raw);
62
+ if ('error' in parsed)
63
+ return sendApiError(res, 400, 'invalid_request_error', parsed.error);
64
+ const gone = clientGone(res);
65
+ const missing = await tab.waitReady(o.waitMs, gone);
66
+ if (gone.aborted)
67
+ return;
68
+ if (missing)
69
+ return sendApiError(res, 503, 'api_error', missing.message);
70
+ const estimate = estimateTokens(parsed.input);
71
+ const context = tab.health().contextTokens;
72
+ if (context && estimate >= context)
73
+ return sendApiError(res, 400, 'invalid_request_error', tooLong(estimate, context - 1));
74
+ const meta = { id: `msg_${newId()}`, model: tab.modelName };
75
+ const { input, stopSequences } = parsed;
76
+ if (parsed.stream) {
77
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' });
78
+ res.socket?.setNoDelay(true);
79
+ const w = new EventWriter(res);
80
+ w.start(message(meta, [], { reason: null, sequence: null }, usage(estimate, 0)));
81
+ const beat = setInterval(() => w.ping(), o.pingMs ?? PING_MS);
82
+ try {
83
+ const out = await answer(tab, input, stopSequences, estimate, gone, {
84
+ text: (t) => w.text(t),
85
+ toolUse: (b) => w.toolUse(b),
86
+ });
87
+ w.finish(out.reason, out.sequence, out.usage);
88
+ }
89
+ catch (e) {
90
+ if (!gone.aborted)
91
+ w.error(tabError(e.message));
92
+ }
93
+ finally {
94
+ clearInterval(beat);
95
+ }
96
+ return;
97
+ }
98
+ let text = '';
99
+ const calls = [];
100
+ try {
101
+ const out = await answer(tab, input, stopSequences, estimate, gone, {
102
+ text: (t) => (text += t),
103
+ toolUse: (b) => calls.push(b),
104
+ });
105
+ sendJson(res, 200, message(meta, content(text, calls), { reason: out.reason, sequence: out.sequence }, out.usage));
106
+ }
107
+ catch (e) {
108
+ if (gone.aborted)
109
+ return;
110
+ const err = e;
111
+ if (err instanceof ChatError && err.kind === 'no_tab')
112
+ return sendApiError(res, 503, 'api_error', err.message);
113
+ const api = tabError(err.message);
114
+ sendApiError(res, api.type === 'invalid_request_error' ? 400 : 502, api.type, api.message);
115
+ }
116
+ }
117
+ async function countTokens(req, res) {
118
+ const raw = await body(req, res);
119
+ if (raw === undefined)
120
+ return;
121
+ const parsed = toChatInput(raw);
122
+ if ('error' in parsed)
123
+ return sendApiError(res, 400, 'invalid_request_error', parsed.error);
124
+ sendJson(res, 200, { input_tokens: estimateTokens(parsed.input) });
125
+ }
126
+ return function route(req, res) {
127
+ const path = pathOf(req);
128
+ const handler = path === '/v1/messages' ? messages : path === '/v1/messages/count_tokens' ? countTokens : undefined;
129
+ if (!handler)
130
+ return sendApiError(res, 404, 'not_found_error', `no route for ${req.method} ${path}`);
131
+ if (req.method !== 'POST')
132
+ return sendApiError(res, 405, 'invalid_request_error', 'use POST');
133
+ void handler(req, res).catch((e) => {
134
+ if (!res.headersSent)
135
+ sendApiError(res, 500, 'api_error', e.message);
136
+ else
137
+ res.end();
138
+ });
139
+ };
140
+ }
@@ -0,0 +1,24 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import type { ApiError, Message, StopReason, ToolUseBlock, Usage } from './types.js';
3
+ /**
4
+ * Writes one streamed message as the Messages API does: `message_start`, the content blocks
5
+ * (a text block opened by the first text, one block per tool call), `message_delta`,
6
+ * `message_stop`.
7
+ */
8
+ export declare class EventWriter {
9
+ private readonly res;
10
+ private blocks;
11
+ private textOpen;
12
+ constructor(res: ServerResponse);
13
+ private send;
14
+ start(message: Message): void;
15
+ text(text: string): void;
16
+ toolUse(block: ToolUseBlock): void;
17
+ /** Ends the message; an answer with no content still gets one empty text block. */
18
+ finish(reason: StopReason, sequence: string | null, usage: Usage): void;
19
+ /** Keeps clients' read timeouts from cutting a slow first token. */
20
+ ping(): void;
21
+ /** Ends the stream with an error; Anthropic's SDKs raise it as an API error. */
22
+ error(error: ApiError): void;
23
+ private closeText;
24
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Writes one streamed message as the Messages API does: `message_start`, the content blocks
3
+ * (a text block opened by the first text, one block per tool call), `message_delta`,
4
+ * `message_stop`.
5
+ */
6
+ export class EventWriter {
7
+ res;
8
+ blocks = 0;
9
+ textOpen = false;
10
+ constructor(res) {
11
+ this.res = res;
12
+ }
13
+ send(e) {
14
+ this.res.write(`event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`);
15
+ }
16
+ start(message) {
17
+ this.send({ type: 'message_start', message });
18
+ }
19
+ text(text) {
20
+ if (!text)
21
+ return;
22
+ if (!this.textOpen) {
23
+ this.send({ type: 'content_block_start', index: this.blocks, content_block: { type: 'text', text: '' } });
24
+ this.textOpen = true;
25
+ }
26
+ this.send({ type: 'content_block_delta', index: this.blocks, delta: { type: 'text_delta', text } });
27
+ }
28
+ toolUse(block) {
29
+ this.closeText();
30
+ const index = this.blocks++;
31
+ this.send({ type: 'content_block_start', index, content_block: { ...block, input: {} } });
32
+ const partial_json = JSON.stringify(block.input);
33
+ this.send({ type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json } });
34
+ this.send({ type: 'content_block_stop', index });
35
+ }
36
+ /** Ends the message; an answer with no content still gets one empty text block. */
37
+ finish(reason, sequence, usage) {
38
+ if (!this.blocks && !this.textOpen) {
39
+ this.send({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
40
+ this.textOpen = true;
41
+ }
42
+ this.closeText();
43
+ this.send({
44
+ type: 'message_delta',
45
+ delta: { stop_reason: reason, stop_sequence: sequence },
46
+ usage: { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens },
47
+ });
48
+ this.send({ type: 'message_stop' });
49
+ this.res.end();
50
+ }
51
+ /** Keeps clients' read timeouts from cutting a slow first token. */
52
+ ping() {
53
+ this.send({ type: 'ping' });
54
+ }
55
+ /** Ends the stream with an error; Anthropic's SDKs raise it as an API error. */
56
+ error(error) {
57
+ this.send({ type: 'error', error });
58
+ this.res.end();
59
+ }
60
+ closeText() {
61
+ if (!this.textOpen)
62
+ return;
63
+ this.send({ type: 'content_block_stop', index: this.blocks++ });
64
+ this.textOpen = false;
65
+ }
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ends an answer at the first stop sequence. Text arrives in pieces that may split a
3
+ * sequence, so the end of the text is held back while it could still become one.
4
+ */
5
+ export declare class StopMatcher {
6
+ private readonly stops;
7
+ private held;
8
+ /** The sequence that ended the answer, once one has. */
9
+ matched: string | null;
10
+ constructor(stops: string[]);
11
+ /** The text safe to pass on now; after a match, only what came before it. */
12
+ push(text: string): string;
13
+ /** What is still held back when the answer ends without a match. */
14
+ flush(): string;
15
+ /** Length of the longest end of `buf` that begins some stop sequence. */
16
+ private partial;
17
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ends an answer at the first stop sequence. Text arrives in pieces that may split a
3
+ * sequence, so the end of the text is held back while it could still become one.
4
+ */
5
+ export class StopMatcher {
6
+ stops;
7
+ held = '';
8
+ /** The sequence that ended the answer, once one has. */
9
+ matched = null;
10
+ constructor(stops) {
11
+ this.stops = stops.filter((s) => s.length > 0);
12
+ }
13
+ /** The text safe to pass on now; after a match, only what came before it. */
14
+ push(text) {
15
+ if (this.matched !== null)
16
+ return '';
17
+ const buf = this.held + text;
18
+ let at = -1;
19
+ for (const s of this.stops) {
20
+ const i = buf.indexOf(s);
21
+ if (i >= 0 && (at < 0 || i < at)) {
22
+ at = i;
23
+ this.matched = s;
24
+ }
25
+ }
26
+ if (this.matched !== null) {
27
+ this.held = '';
28
+ return buf.slice(0, at);
29
+ }
30
+ const keep = this.partial(buf);
31
+ this.held = buf.slice(buf.length - keep);
32
+ return buf.slice(0, buf.length - keep);
33
+ }
34
+ /** What is still held back when the answer ends without a match. */
35
+ flush() {
36
+ const rest = this.held;
37
+ this.held = '';
38
+ return rest;
39
+ }
40
+ /** Length of the longest end of `buf` that begins some stop sequence. */
41
+ partial(buf) {
42
+ let best = 0;
43
+ for (const s of this.stops)
44
+ for (let n = Math.min(s.length - 1, buf.length); n > best; n--)
45
+ if (buf.endsWith(s.slice(0, n))) {
46
+ best = n;
47
+ break;
48
+ }
49
+ return best;
50
+ }
51
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The parts of the Anthropic Messages API the bridge reads and writes. Requests arrive as
3
+ * unknown JSON and are checked in `map.ts`; these are the shapes it accepts and answers with.
4
+ */
5
+ export interface TextBlock {
6
+ type: 'text';
7
+ text: string;
8
+ }
9
+ export interface ToolUseBlock {
10
+ type: 'tool_use';
11
+ id: string;
12
+ name: string;
13
+ input: Record<string, unknown>;
14
+ }
15
+ export type ContentBlock = TextBlock | ToolUseBlock;
16
+ /** Request content blocks the mapping looks into; any other type is degraded or dropped. */
17
+ export interface ToolResultBlockParam {
18
+ type: 'tool_result';
19
+ tool_use_id: string;
20
+ content?: string | {
21
+ type: string;
22
+ text?: string;
23
+ }[];
24
+ is_error?: boolean;
25
+ }
26
+ export interface MessageParam {
27
+ role: 'user' | 'assistant' | 'system';
28
+ content: string | ({
29
+ type: string;
30
+ } & Record<string, unknown>)[];
31
+ }
32
+ export interface ToolParam {
33
+ type?: 'custom';
34
+ name: string;
35
+ description?: string;
36
+ input_schema?: Record<string, unknown>;
37
+ }
38
+ export interface MessagesRequest {
39
+ model?: string;
40
+ system?: string | TextBlock[];
41
+ messages: MessageParam[];
42
+ tools?: ToolParam[];
43
+ tool_choice?: {
44
+ type: 'auto' | 'any' | 'tool' | 'none';
45
+ };
46
+ max_tokens?: number;
47
+ temperature?: number;
48
+ stop_sequences?: string[];
49
+ stream?: boolean;
50
+ }
51
+ export type StopReason = 'end_turn' | 'max_tokens' | 'stop_sequence' | 'tool_use';
52
+ export interface Usage {
53
+ input_tokens: number;
54
+ output_tokens: number;
55
+ cache_creation_input_tokens: number;
56
+ cache_read_input_tokens: number;
57
+ }
58
+ export interface Message {
59
+ id: string;
60
+ type: 'message';
61
+ role: 'assistant';
62
+ model: string;
63
+ content: ContentBlock[];
64
+ stop_reason: StopReason | null;
65
+ stop_sequence: string | null;
66
+ usage: Usage;
67
+ }
68
+ /** Server-sent events of a streamed message, in the order the API sends them. */
69
+ export type StreamEvent = {
70
+ type: 'message_start';
71
+ message: Message;
72
+ } | {
73
+ type: 'content_block_start';
74
+ index: number;
75
+ content_block: ContentBlock;
76
+ } | {
77
+ type: 'content_block_delta';
78
+ index: number;
79
+ delta: {
80
+ type: 'text_delta';
81
+ text: string;
82
+ } | {
83
+ type: 'input_json_delta';
84
+ partial_json: string;
85
+ };
86
+ } | {
87
+ type: 'content_block_stop';
88
+ index: number;
89
+ } | {
90
+ type: 'message_delta';
91
+ delta: {
92
+ stop_reason: StopReason;
93
+ stop_sequence: string | null;
94
+ };
95
+ usage: Omit<Usage, 'cache_creation_input_tokens' | 'cache_read_input_tokens'>;
96
+ } | {
97
+ type: 'message_stop';
98
+ } | {
99
+ type: 'ping';
100
+ } | {
101
+ type: 'error';
102
+ error: ApiError;
103
+ };
104
+ export type ErrorType = 'invalid_request_error' | 'permission_error' | 'not_found_error' | 'request_too_large' | 'api_error';
105
+ export interface ApiError {
106
+ type: ErrorType;
107
+ message: string;
108
+ }
109
+ export interface ErrorBody {
110
+ type: 'error';
111
+ error: ApiError;
112
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The parts of the Anthropic Messages API the bridge reads and writes. Requests arrive as
3
+ * unknown JSON and are checked in `map.ts`; these are the shapes it accepts and answers with.
4
+ */
5
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import type { Readable, Writable } from 'node:stream';
3
+ import { type ChatBackend } from './mcp.js';
4
+ import { type BridgeServer } from './server.js';
5
+ import type { Health } from './tab.js';
6
+ export declare const VERSION: string;
7
+ export declare const TOKEN_ENV = "REBELLM_BRIDGE_TOKEN";
8
+ export declare const USAGE = "Usage: rebellm-bridge [options]\n\nLets Claude Code (MCP or rebellm-claude) and OpenAI-style clients use the model in your RebeLLM tab.\n\n --port <n> port for the tab and the HTTP API (default 7343)\n --host <addr> address to listen on (default 127.0.0.1; anything else exposes the model)\n --token <t> token the tab must present (default: REBELLM_BRIDGE_TOKEN, else ~/.rebellm-bridge/token)\n --wait <s> seconds a request waits for the tab and its model (default 120)\n --mcp serve MCP over stdio as well (Claude Code starts the bridge this way)\n --version print the version\n --help print this help";
9
+ export interface CliOptions {
10
+ port: number;
11
+ host: string;
12
+ token?: string;
13
+ waitSec: number;
14
+ mcp: boolean;
15
+ help: boolean;
16
+ version: boolean;
17
+ }
18
+ export declare function parseCli(argv: string[]): CliOptions | {
19
+ error: string;
20
+ };
21
+ export declare const tokenFile: (home?: string) => string;
22
+ export interface TokenChoice {
23
+ token: string;
24
+ source: 'flag' | 'env' | 'file';
25
+ /** Generated by this start; the only time it is printed. */
26
+ created: boolean;
27
+ file: string;
28
+ }
29
+ /** `--token`, else the environment, else the token file, created on first use. */
30
+ export declare function resolveToken(o: {
31
+ flag?: string;
32
+ env?: string;
33
+ home?: string;
34
+ }): TokenChoice;
35
+ export interface Io {
36
+ stdout: Writable;
37
+ stderr: Writable;
38
+ stdin: Readable;
39
+ env: NodeJS.ProcessEnv;
40
+ home?: string;
41
+ }
42
+ export interface Running {
43
+ /** Null when `--mcp` found another bridge on the port and uses that one. */
44
+ server: BridgeServer | null;
45
+ backend: ChatBackend | null;
46
+ /** Resolves when the MCP client closes stdin; never without `--mcp`. */
47
+ done: Promise<void>;
48
+ close(): Promise<void>;
49
+ }
50
+ /** The `/health` of a bridge of ours at `base`; null when nothing or something else answers. */
51
+ export declare function bridgeHealth(base: string): Promise<Health | null>;
52
+ /** Anything but loopback lets other machines use the model; the HTTP API has no token. */
53
+ export declare const hostWarning: (host: string) => string | null;
54
+ /** Starts the bridge; throws a readable message when it cannot. */
55
+ export declare function run(o: CliOptions, io: Io): Promise<Running>;
56
+ /** The command line; resolves with the exit code once the bridge has stopped. */
57
+ export declare function main(argv: string[], io: Io): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parseArgs } from 'node:util';
9
+ import { createMcpServer, httpBackend, serveStdio, tabBackend } from './mcp.js';
10
+ import { DEFAULT_HOST, DEFAULT_PORT, isLoopback, startServer } from './server.js';
11
+ export const VERSION = createRequire(import.meta.url)('../package.json').version;
12
+ export const TOKEN_ENV = 'REBELLM_BRIDGE_TOKEN';
13
+ export const USAGE = `Usage: rebellm-bridge [options]
14
+
15
+ Lets Claude Code (MCP or rebellm-claude) and OpenAI-style clients use the model in your RebeLLM tab.
16
+
17
+ --port <n> port for the tab and the HTTP API (default ${DEFAULT_PORT})
18
+ --host <addr> address to listen on (default ${DEFAULT_HOST}; anything else exposes the model)
19
+ --token <t> token the tab must present (default: ${TOKEN_ENV}, else ~/.rebellm-bridge/token)
20
+ --wait <s> seconds a request waits for the tab and its model (default 120)
21
+ --mcp serve MCP over stdio as well (Claude Code starts the bridge this way)
22
+ --version print the version
23
+ --help print this help`;
24
+ export function parseCli(argv) {
25
+ let v;
26
+ try {
27
+ v = parseArgs({
28
+ args: argv,
29
+ strict: true,
30
+ options: {
31
+ port: { type: 'string' },
32
+ host: { type: 'string' },
33
+ token: { type: 'string' },
34
+ wait: { type: 'string' },
35
+ mcp: { type: 'boolean' },
36
+ help: { type: 'boolean', short: 'h' },
37
+ version: { type: 'boolean', short: 'v' },
38
+ },
39
+ }).values;
40
+ }
41
+ catch (e) {
42
+ return { error: e.message };
43
+ }
44
+ const port = Number(v.port ?? DEFAULT_PORT);
45
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
46
+ return { error: `--port ${v.port} is not a port number` };
47
+ const waitSec = Number(v.wait ?? 120);
48
+ if (!Number.isFinite(waitSec) || waitSec < 0)
49
+ return { error: `--wait ${v.wait} is not a number of seconds` };
50
+ const host = v.host ?? DEFAULT_HOST;
51
+ if (!host)
52
+ return { error: '--host needs an address' };
53
+ if (v.token !== undefined && !v.token.trim())
54
+ return { error: '--token needs a value' };
55
+ return {
56
+ port,
57
+ host,
58
+ ...(v.token !== undefined ? { token: v.token.trim() } : {}),
59
+ waitSec,
60
+ mcp: !!v.mcp,
61
+ help: !!v.help,
62
+ version: !!v.version,
63
+ };
64
+ }
65
+ export const tokenFile = (home = homedir()) => join(home, '.rebellm-bridge', 'token');
66
+ /** `--token`, else the environment, else the token file, created on first use. */
67
+ export function resolveToken(o) {
68
+ const file = tokenFile(o.home);
69
+ if (o.flag)
70
+ return { token: o.flag, source: 'flag', created: false, file };
71
+ if (o.env?.trim())
72
+ return { token: o.env.trim(), source: 'env', created: false, file };
73
+ try {
74
+ const stored = readFileSync(file, 'utf8').trim();
75
+ if (stored)
76
+ return { token: stored, source: 'file', created: false, file };
77
+ }
78
+ catch (e) {
79
+ if (e.code !== 'ENOENT')
80
+ throw e;
81
+ }
82
+ const token = randomBytes(24).toString('base64url');
83
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
84
+ writeFileSync(file, `${token}\n`, { mode: 0o600 });
85
+ // An empty file left from before keeps its old mode otherwise.
86
+ chmodSync(file, 0o600);
87
+ return { token, source: 'file', created: true, file };
88
+ }
89
+ /** The `/health` of a bridge of ours at `base`; null when nothing or something else answers. */
90
+ export async function bridgeHealth(base) {
91
+ try {
92
+ const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(2000) });
93
+ const body = (await r.json());
94
+ return body.service === 'rebellm-bridge' ? body : null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ /** Anything but loopback lets other machines use the model; the HTTP API has no token. */
101
+ export const hostWarning = (host) => isLoopback(host)
102
+ ? null
103
+ : `warning: listening on ${host} lets other machines use your model; the HTTP API has no token`;
104
+ const urlHost = (host) => (host.includes(':') ? `[${host}]` : host);
105
+ /** Starts the bridge; throws a readable message when it cannot. */
106
+ export async function run(o, io) {
107
+ // With --mcp, stdout is the MCP channel.
108
+ const out = o.mcp ? io.stderr : io.stdout;
109
+ const say = (line) => void out.write(`${line}\n`);
110
+ const log = (line) => say(`rebellm-bridge: ${line}`);
111
+ const tok = resolveToken({
112
+ ...(o.token ? { flag: o.token } : {}),
113
+ ...(io.env[TOKEN_ENV] ? { env: io.env[TOKEN_ENV] } : {}),
114
+ ...(io.home ? { home: io.home } : {}),
115
+ });
116
+ if (tok.created)
117
+ say(`\nNew bridge token (stored in ${tok.file}):\n\n ${tok.token}\n\n` +
118
+ 'Paste it into RebeLLM → Settings → Local bridge and turn the switch on. It is printed only this once.\n');
119
+ const warning = hostWarning(o.host);
120
+ if (warning)
121
+ log(warning);
122
+ let server = null;
123
+ let backend = null;
124
+ try {
125
+ server = await startServer({ host: o.host, port: o.port, token: tok.token, waitMs: o.waitSec * 1000, log });
126
+ if (o.mcp)
127
+ backend = tabBackend(server.tab, o.waitSec * 1000);
128
+ }
129
+ catch (e) {
130
+ if (e.code !== 'EADDRINUSE')
131
+ throw e;
132
+ const probe = `http://${urlHost(isLoopback(o.host) ? o.host : DEFAULT_HOST)}:${o.port}`;
133
+ if (!o.mcp || !(await bridgeHealth(probe)))
134
+ throw new Error(`port ${o.port} on ${o.host} is in use${o.mcp ? '' : ' (another rebellm-bridge?)'}`, {
135
+ cause: e,
136
+ });
137
+ backend = httpBackend(probe);
138
+ log(`using the bridge already running on ${probe}`);
139
+ }
140
+ if (server) {
141
+ const where = `${urlHost(o.host)}:${server.port}`;
142
+ log(`${VERSION} listening on ${where}`);
143
+ say(` tab: ws://${where} (RebeLLM → Settings → Local bridge)`);
144
+ say(` OpenAI: http://${where}/v1`);
145
+ say(` Claude: http://${where} (Anthropic API; rebellm-claude runs Claude Code on it)`);
146
+ say(` token: ${tok.source === 'flag' ? 'from --token' : tok.source === 'env' ? `from ${TOKEN_ENV}` : `in ${tok.file}`}`);
147
+ log('waiting for the RebeLLM tab');
148
+ }
149
+ let done = new Promise(() => undefined);
150
+ if (o.mcp && backend) {
151
+ done = serveStdio(createMcpServer(backend, VERSION), io.stdin, io.stdout);
152
+ }
153
+ return {
154
+ server,
155
+ backend,
156
+ done,
157
+ close: async () => {
158
+ await server?.close();
159
+ },
160
+ };
161
+ }
162
+ /** The command line; resolves with the exit code once the bridge has stopped. */
163
+ export async function main(argv, io) {
164
+ const o = parseCli(argv);
165
+ if ('error' in o) {
166
+ io.stderr.write(`rebellm-bridge: ${o.error}\n\n${USAGE}\n`);
167
+ return 2;
168
+ }
169
+ if (o.help || o.version) {
170
+ io.stdout.write(`${o.help ? USAGE : VERSION}\n`);
171
+ return 0;
172
+ }
173
+ let running;
174
+ try {
175
+ running = await run(o, io);
176
+ }
177
+ catch (e) {
178
+ io.stderr.write(`rebellm-bridge: ${e.message}\n`);
179
+ return 1;
180
+ }
181
+ const stop = new Promise((resolve) => {
182
+ process.once('SIGINT', resolve);
183
+ process.once('SIGTERM', resolve);
184
+ });
185
+ await Promise.race([running.done, stop]);
186
+ await running.close();
187
+ return 0;
188
+ }
189
+ function invokedDirectly() {
190
+ try {
191
+ return !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ if (invokedDirectly()) {
198
+ const io = { stdout: process.stdout, stderr: process.stderr, stdin: process.stdin, env: process.env };
199
+ void main(process.argv.slice(2), io).then((code) => process.exit(code));
200
+ }