@ai-ide-bridge/cli 1.0.5 → 1.1.1
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/.turbo/turbo-build.log +1 -1
- package/dist/commands/configure.js +78 -10
- package/dist/commands/daemon.d.ts +1 -0
- package/dist/commands/daemon.js +107 -13
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/init.js +70 -5
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +62 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/start.js +4 -4
- package/dist/core/config.d.ts +4 -0
- package/dist/core/config.js +43 -0
- package/dist/core/daemon-session.d.ts +14 -0
- package/dist/core/daemon-session.js +179 -0
- package/dist/core/daemon.d.ts +16 -0
- package/dist/core/daemon.js +168 -0
- package/dist/core/formatter.d.ts +3 -0
- package/dist/core/formatter.js +44 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.js +9 -0
- package/dist/core/parser.d.ts +164 -0
- package/dist/core/parser.js +37 -0
- package/dist/core/registry.d.ts +16 -0
- package/dist/core/registry.js +53 -0
- package/dist/core/server.d.ts +19 -0
- package/dist/core/server.js +185 -0
- package/dist/core/session.d.ts +11 -0
- package/dist/core/session.js +39 -0
- package/dist/core/types.d.ts +166 -0
- package/dist/core/types.js +44 -0
- package/dist/index.js +22 -5
- package/dist/oauth/device-flow.d.ts +12 -0
- package/dist/oauth/device-flow.js +93 -0
- package/dist/oauth/flow.d.ts +11 -0
- package/dist/oauth/flow.js +75 -0
- package/dist/oauth/index.d.ts +6 -0
- package/dist/oauth/index.js +5 -0
- package/dist/oauth/lifecycle.d.ts +13 -0
- package/dist/oauth/lifecycle.js +56 -0
- package/dist/oauth/providers.d.ts +2 -0
- package/dist/oauth/providers.js +19 -0
- package/dist/oauth/storage-file.d.ts +2 -0
- package/dist/oauth/storage-file.js +68 -0
- package/dist/oauth/storage.d.ts +2 -0
- package/dist/oauth/storage.js +4 -0
- package/dist/oauth/types.d.ts +44 -0
- package/dist/oauth/types.js +1 -0
- package/dist/plugins/copilot/auth.d.ts +7 -0
- package/dist/plugins/copilot/auth.js +30 -0
- package/dist/plugins/copilot/index.d.ts +5 -0
- package/dist/plugins/copilot/index.js +4 -0
- package/dist/plugins/copilot/plugin.d.ts +8 -0
- package/dist/plugins/copilot/plugin.js +29 -0
- package/dist/plugins/copilot/session.d.ts +8 -0
- package/dist/plugins/copilot/session.js +115 -0
- package/dist/plugins/copilot/tools.d.ts +10 -0
- package/dist/plugins/copilot/tools.js +10 -0
- package/dist/plugins/copilot/types.d.ts +15 -0
- package/dist/plugins/copilot/types.js +27 -0
- package/dist/plugins/cursor/index.d.ts +2 -0
- package/dist/plugins/cursor/index.js +2 -0
- package/dist/plugins/cursor/plugin.d.ts +8 -0
- package/dist/plugins/cursor/plugin.js +36 -0
- package/dist/plugins/cursor/session.d.ts +11 -0
- package/dist/plugins/cursor/session.js +69 -0
- package/dist/plugins/cursor/tools.d.ts +11 -0
- package/dist/plugins/cursor/tools.js +13 -0
- package/dist/plugins/windsurf/auth.d.ts +3 -0
- package/dist/plugins/windsurf/auth.js +20 -0
- package/dist/plugins/windsurf/daemon.d.ts +6 -0
- package/dist/plugins/windsurf/daemon.js +16 -0
- package/dist/plugins/windsurf/index.d.ts +5 -0
- package/dist/plugins/windsurf/index.js +4 -0
- package/dist/plugins/windsurf/models.d.ts +2 -0
- package/dist/plugins/windsurf/models.js +42 -0
- package/dist/plugins/windsurf/plugin.d.ts +8 -0
- package/dist/plugins/windsurf/plugin.js +31 -0
- package/dist/plugins/windsurf/session.d.ts +5 -0
- package/dist/plugins/windsurf/session.js +6 -0
- package/dist/plugins/windsurf/tools.d.ts +3 -0
- package/dist/plugins/windsurf/tools.js +10 -0
- package/dist/plugins/windsurf/types.d.ts +22 -0
- package/dist/plugins/windsurf/types.js +1 -0
- package/dist/utils/config.d.ts +1 -1
- package/dist/utils/config.js +1 -1
- package/dist/utils/opencode.d.ts +3 -1
- package/dist/utils/opencode.js +3 -3
- package/dist/utils/platform.d.ts +1 -0
- package/dist/utils/platform.js +3 -0
- package/package.json +3 -5
- package/src/commands/configure.ts +107 -12
- package/src/commands/daemon.ts +112 -13
- package/src/commands/doctor.ts +1 -1
- package/src/commands/init.ts +72 -5
- package/src/commands/login.ts +98 -0
- package/src/commands/logout.ts +15 -0
- package/src/commands/start.ts +4 -4
- package/src/core/config.ts +45 -0
- package/src/core/daemon-session.ts +199 -0
- package/src/core/daemon.ts +206 -0
- package/src/core/formatter.ts +56 -0
- package/src/core/index.ts +9 -0
- package/src/core/parser.ts +47 -0
- package/src/core/registry.ts +62 -0
- package/src/core/server.ts +211 -0
- package/src/core/session.ts +54 -0
- package/src/core/types.ts +100 -0
- package/src/index.ts +22 -4
- package/src/oauth/device-flow.ts +111 -0
- package/src/oauth/flow.ts +94 -0
- package/src/oauth/index.ts +6 -0
- package/src/oauth/lifecycle.ts +77 -0
- package/src/oauth/providers.ts +21 -0
- package/src/oauth/storage-file.ts +77 -0
- package/src/oauth/storage.ts +6 -0
- package/src/oauth/types.ts +50 -0
- package/src/plugins/copilot/auth.ts +39 -0
- package/src/plugins/copilot/index.ts +5 -0
- package/src/plugins/copilot/plugin.ts +31 -0
- package/src/plugins/copilot/session.ts +130 -0
- package/src/plugins/copilot/tools.ts +21 -0
- package/src/plugins/copilot/types.ts +43 -0
- package/src/plugins/cursor/index.ts +2 -0
- package/src/plugins/cursor/plugin.ts +37 -0
- package/src/plugins/cursor/session.ts +78 -0
- package/src/plugins/cursor/tools.ts +25 -0
- package/src/plugins/windsurf/auth.ts +23 -0
- package/src/plugins/windsurf/daemon.ts +24 -0
- package/src/plugins/windsurf/index.ts +5 -0
- package/src/plugins/windsurf/models.ts +44 -0
- package/src/plugins/windsurf/plugin.ts +34 -0
- package/src/plugins/windsurf/session.ts +8 -0
- package/src/plugins/windsurf/tools.ts +13 -0
- package/src/plugins/windsurf/types.ts +24 -0
- package/src/utils/config.ts +1 -1
- package/src/utils/opencode.ts +4 -3
- package/src/utils/platform.ts +3 -0
- package/test/configure.test.ts +19 -4
- package/test/daemon.test.ts +224 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
accessSync,
|
|
4
|
+
chmodSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { homedir, platform, arch } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { IncomingMessage, get as httpGet, request as httpRequest } from 'node:http';
|
|
15
|
+
import { request as httpsRequest } from 'node:https';
|
|
16
|
+
import { URL } from 'node:url';
|
|
17
|
+
|
|
18
|
+
export interface DaemonManager {
|
|
19
|
+
binaryName: string;
|
|
20
|
+
locate(): Promise<string | null>;
|
|
21
|
+
download(): Promise<string>;
|
|
22
|
+
spawn(binaryPath: string, args: string[]): ChildProcess;
|
|
23
|
+
healthCheck(port?: number): Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createDaemonManager(config: {
|
|
27
|
+
binaryName: string;
|
|
28
|
+
downloadUrl: string;
|
|
29
|
+
checksum: string;
|
|
30
|
+
knownPaths: string[];
|
|
31
|
+
envVar?: string;
|
|
32
|
+
daemonsDir?: string;
|
|
33
|
+
}): DaemonManager {
|
|
34
|
+
const defaultDaemonsDir = () => join(homedir(), '.llm-bridge', 'daemons');
|
|
35
|
+
const getDaemonsDir = () => config.daemonsDir ?? defaultDaemonsDir();
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
binaryName: config.binaryName,
|
|
39
|
+
|
|
40
|
+
async locate(): Promise<string | null> {
|
|
41
|
+
if (config.envVar && process.env[config.envVar]) {
|
|
42
|
+
const envPath = process.env[config.envVar]!;
|
|
43
|
+
if (existsSync(envPath)) {
|
|
44
|
+
try {
|
|
45
|
+
accessSync(envPath, constants.X_OK);
|
|
46
|
+
return envPath;
|
|
47
|
+
} catch {
|
|
48
|
+
// Not executable, skip
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const p of config.knownPaths) {
|
|
54
|
+
if (existsSync(p)) {
|
|
55
|
+
try {
|
|
56
|
+
accessSync(p, constants.X_OK);
|
|
57
|
+
return p;
|
|
58
|
+
} catch {
|
|
59
|
+
// Not executable, skip
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const daemonsDir = getDaemonsDir();
|
|
65
|
+
const managedPath = join(daemonsDir, config.binaryName);
|
|
66
|
+
if (existsSync(managedPath)) {
|
|
67
|
+
try {
|
|
68
|
+
accessSync(managedPath, constants.X_OK);
|
|
69
|
+
return managedPath;
|
|
70
|
+
} catch {
|
|
71
|
+
// Not executable, skip
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async download(): Promise<string> {
|
|
79
|
+
const daemonsDir = getDaemonsDir();
|
|
80
|
+
mkdirSync(daemonsDir, { recursive: true });
|
|
81
|
+
const destPath = join(daemonsDir, config.binaryName);
|
|
82
|
+
|
|
83
|
+
const url = config.downloadUrl.replace('{platform}', platform()).replace('{arch}', arch());
|
|
84
|
+
|
|
85
|
+
const MAX_REDIRECTS = 5;
|
|
86
|
+
const MAX_DOWNLOAD_SIZE = 500 * 1024 * 1024;
|
|
87
|
+
let currentReq: ReturnType<typeof httpRequest> | null = null;
|
|
88
|
+
|
|
89
|
+
const followRedirect = (currentUrl: string, redirectCount: number): Promise<Buffer> =>
|
|
90
|
+
new Promise((resolve, reject) => {
|
|
91
|
+
const parsed = new URL(currentUrl);
|
|
92
|
+
const requestFn = parsed.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
93
|
+
|
|
94
|
+
currentReq = requestFn(currentUrl, (res: IncomingMessage) => {
|
|
95
|
+
if (
|
|
96
|
+
res.statusCode === 301 ||
|
|
97
|
+
res.statusCode === 302 ||
|
|
98
|
+
res.statusCode === 307 ||
|
|
99
|
+
res.statusCode === 308
|
|
100
|
+
) {
|
|
101
|
+
if (redirectCount >= MAX_REDIRECTS) {
|
|
102
|
+
reject(new Error('Too many redirects'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const location = res.headers.location;
|
|
106
|
+
if (location) {
|
|
107
|
+
const redirectUrl = new URL(location, currentUrl);
|
|
108
|
+
if (redirectUrl.protocol !== 'http:' && redirectUrl.protocol !== 'https:') {
|
|
109
|
+
reject(new Error(`Redirect to unsupported protocol: ${redirectUrl.protocol}`));
|
|
110
|
+
res.resume();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.resume();
|
|
114
|
+
followRedirect(redirectUrl.href, redirectCount + 1)
|
|
115
|
+
.then(resolve)
|
|
116
|
+
.catch(reject);
|
|
117
|
+
} else {
|
|
118
|
+
reject(new Error('Redirect with no location'));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (res.statusCode !== 200) {
|
|
124
|
+
reject(new Error(`Download failed: ${res.statusCode}`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const chunks: Buffer[] = [];
|
|
129
|
+
let totalSize = 0;
|
|
130
|
+
res.on('data', (chunk: Buffer) => {
|
|
131
|
+
totalSize += chunk.length;
|
|
132
|
+
if (totalSize > MAX_DOWNLOAD_SIZE) {
|
|
133
|
+
reject(new Error(`Download exceeds maximum size (${MAX_DOWNLOAD_SIZE} bytes)`));
|
|
134
|
+
res.destroy();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
chunks.push(chunk);
|
|
138
|
+
});
|
|
139
|
+
res.on('end', () => {
|
|
140
|
+
resolve(Buffer.concat(chunks));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
currentReq.on('error', reject);
|
|
145
|
+
currentReq.end();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const downloadPromise = followRedirect(url, 0);
|
|
149
|
+
|
|
150
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
151
|
+
setTimeout(() => {
|
|
152
|
+
currentReq?.destroy();
|
|
153
|
+
reject(new Error('Download timeout (30s)'));
|
|
154
|
+
}, 30000);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return Promise.race([downloadPromise, timeoutPromise]).then((data) => {
|
|
158
|
+
try {
|
|
159
|
+
writeFileSync(destPath, data);
|
|
160
|
+
const hash = createHash('sha256').update(data).digest('hex');
|
|
161
|
+
if (hash !== config.checksum) {
|
|
162
|
+
throw new Error(`Checksum mismatch: expected ${config.checksum}, got ${hash}`);
|
|
163
|
+
}
|
|
164
|
+
chmodSync(destPath, 0o755);
|
|
165
|
+
return destPath;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
try {
|
|
168
|
+
unlinkSync(destPath);
|
|
169
|
+
} catch {}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
spawn(binaryPath: string, args: string[]): ChildProcess {
|
|
176
|
+
return spawn(binaryPath, args, {
|
|
177
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async healthCheck(port?: number): Promise<boolean> {
|
|
182
|
+
const path = await this.locate();
|
|
183
|
+
if (!path) return false;
|
|
184
|
+
if (!existsSync(path)) return false;
|
|
185
|
+
|
|
186
|
+
if (port) {
|
|
187
|
+
try {
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
const req = httpGet(`http://127.0.0.1:${port}/health`, (res) => {
|
|
190
|
+
resolve(res.statusCode === 200);
|
|
191
|
+
});
|
|
192
|
+
req.on('error', () => resolve(false));
|
|
193
|
+
req.setTimeout(5000, () => {
|
|
194
|
+
req.destroy();
|
|
195
|
+
resolve(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return true;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { StreamChunk } from './types.js';
|
|
2
|
+
|
|
3
|
+
export function formatStreamChunk(chunk: StreamChunk, model: string, completionId: string): string {
|
|
4
|
+
const delta: Record<string, unknown> = {};
|
|
5
|
+
let finishReason: string | null = null;
|
|
6
|
+
|
|
7
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
8
|
+
delta.content = chunk.content;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (chunk.type === 'tool_call' && chunk.toolCall) {
|
|
12
|
+
delta.tool_calls = [
|
|
13
|
+
{
|
|
14
|
+
index: 0,
|
|
15
|
+
id: chunk.toolCall.id,
|
|
16
|
+
type: 'function',
|
|
17
|
+
function: { name: chunk.toolCall.name, arguments: chunk.toolCall.arguments },
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (chunk.finishReason) {
|
|
23
|
+
finishReason = chunk.finishReason;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const payload = {
|
|
27
|
+
id: completionId,
|
|
28
|
+
object: 'chat.completion.chunk',
|
|
29
|
+
created: Math.floor(Date.now() / 1000),
|
|
30
|
+
model,
|
|
31
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatCompletion(
|
|
38
|
+
content: string,
|
|
39
|
+
model: string,
|
|
40
|
+
completionId: string,
|
|
41
|
+
): Record<string, unknown> {
|
|
42
|
+
return {
|
|
43
|
+
id: completionId,
|
|
44
|
+
object: 'chat.completion',
|
|
45
|
+
created: Math.floor(Date.now() / 1000),
|
|
46
|
+
model,
|
|
47
|
+
choices: [
|
|
48
|
+
{
|
|
49
|
+
index: 0,
|
|
50
|
+
message: { role: 'assistant', content },
|
|
51
|
+
finish_reason: 'stop',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { BridgeServer } from './server.js';
|
|
3
|
+
export { SessionStore } from './session.js';
|
|
4
|
+
export { parseChatRequest, parseModelsRequest } from './parser.js';
|
|
5
|
+
export { formatStreamChunk, formatCompletion } from './formatter.js';
|
|
6
|
+
export { PluginRegistry } from './registry.js';
|
|
7
|
+
export { loadConfig, saveConfig, configPath } from './config.js';
|
|
8
|
+
export { createDaemonManager, type DaemonManager } from './daemon.js';
|
|
9
|
+
export { DaemonBridgeSession } from './daemon-session.js';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { MessageSchema, ToolDefinitionSchema } from './types.js';
|
|
4
|
+
|
|
5
|
+
const ChatRequestSchema = z.object({
|
|
6
|
+
model: z.string().min(1, 'model is required'),
|
|
7
|
+
messages: z.array(MessageSchema).min(1, 'messages must have at least one message'),
|
|
8
|
+
stream: z.boolean().optional().default(false),
|
|
9
|
+
tools: z.array(ToolDefinitionSchema).optional(),
|
|
10
|
+
tool_choice: z.unknown().optional(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type ParsedChatRequest = z.infer<typeof ChatRequestSchema>;
|
|
14
|
+
|
|
15
|
+
export async function parseChatRequest(
|
|
16
|
+
req: http.IncomingMessage,
|
|
17
|
+
): Promise<{ success: true; data: ParsedChatRequest } | { success: false; error: string }> {
|
|
18
|
+
try {
|
|
19
|
+
const body = await readBody(req);
|
|
20
|
+
const json = JSON.parse(body);
|
|
21
|
+
const result = ChatRequestSchema.safeParse(json);
|
|
22
|
+
if (!result.success) {
|
|
23
|
+
return { success: false, error: result.error.errors[0].message };
|
|
24
|
+
}
|
|
25
|
+
return { success: true, data: result.data };
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof SyntaxError) {
|
|
28
|
+
return { success: false, error: 'Invalid JSON body' };
|
|
29
|
+
}
|
|
30
|
+
return { success: false, error: e instanceof Error ? e.message : 'Unknown error' };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseModelsRequest(
|
|
35
|
+
_req: http.IncomingMessage,
|
|
36
|
+
): { success: true } | { success: false; error: string } {
|
|
37
|
+
return { success: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const chunks: Buffer[] = [];
|
|
43
|
+
req.on('data', (c) => chunks.push(c as Buffer));
|
|
44
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { BridgePlugin, PluginHealth } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class PluginRegistry {
|
|
4
|
+
private plugins: Map<string, BridgePlugin> = new Map();
|
|
5
|
+
private activePluginName: string | null = null;
|
|
6
|
+
private defaultPluginName: string | null = null;
|
|
7
|
+
private health: Map<string, PluginHealth> = new Map();
|
|
8
|
+
|
|
9
|
+
register(plugin: BridgePlugin): void {
|
|
10
|
+
this.plugins.set(plugin.name, plugin);
|
|
11
|
+
this.health.set(plugin.name, {
|
|
12
|
+
name: plugin.name,
|
|
13
|
+
healthy: true,
|
|
14
|
+
lastChecked: new Date(),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getPlugin(name: string): BridgePlugin | undefined {
|
|
19
|
+
return this.plugins.get(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
setActive(name: string): void {
|
|
23
|
+
if (!this.plugins.has(name)) {
|
|
24
|
+
throw new Error(`Plugin "${name}" is not registered`);
|
|
25
|
+
}
|
|
26
|
+
this.activePluginName = name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setDefault(name: string): void {
|
|
30
|
+
if (!this.plugins.has(name)) {
|
|
31
|
+
throw new Error(`Plugin "${name}" is not registered`);
|
|
32
|
+
}
|
|
33
|
+
this.defaultPluginName = name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getActivePlugin(): BridgePlugin | null {
|
|
37
|
+
if (!this.activePluginName) return null;
|
|
38
|
+
return this.plugins.get(this.activePluginName) ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getDefaultPlugin(): BridgePlugin | null {
|
|
42
|
+
if (!this.defaultPluginName) return null;
|
|
43
|
+
return this.plugins.get(this.defaultPluginName) ?? null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
listPlugins(): BridgePlugin[] {
|
|
47
|
+
return Array.from(this.plugins.values());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
markUnhealthy(name: string, error: string): void {
|
|
51
|
+
this.health.set(name, {
|
|
52
|
+
name,
|
|
53
|
+
healthy: false,
|
|
54
|
+
lastChecked: new Date(),
|
|
55
|
+
error,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getHealth(name: string): PluginHealth | undefined {
|
|
60
|
+
return this.health.get(name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { BridgeConfig, DefaultConfig, BridgePlugin } from './types.js';
|
|
4
|
+
import { parseChatRequest } from './parser.js';
|
|
5
|
+
import { PluginRegistry } from './registry.js';
|
|
6
|
+
import { SessionStore } from './session.js';
|
|
7
|
+
import { formatStreamChunk, formatCompletion } from './formatter.js';
|
|
8
|
+
|
|
9
|
+
export class BridgeServer {
|
|
10
|
+
private server: http.Server | null = null;
|
|
11
|
+
private registry: PluginRegistry;
|
|
12
|
+
private sessions: SessionStore;
|
|
13
|
+
private config: BridgeConfig;
|
|
14
|
+
|
|
15
|
+
constructor(config: Partial<BridgeConfig> = {}) {
|
|
16
|
+
this.config = { ...DefaultConfig, ...config };
|
|
17
|
+
this.registry = new PluginRegistry();
|
|
18
|
+
this.sessions = new SessionStore(this.config.sessionTTL);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async start(): Promise<void> {
|
|
22
|
+
this.server = http.createServer((req, res) => {
|
|
23
|
+
this.handleRequest(req, res).catch((err) => {
|
|
24
|
+
console.error('[llm-bridge] unhandled error:', err);
|
|
25
|
+
if (!res.headersSent) {
|
|
26
|
+
this.jsonResponse(res, 500, { error: { message: 'internal error', type: 'internal' } });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
this.server!.listen(this.config.port, this.config.host, () => {
|
|
33
|
+
const address = this.server!.address();
|
|
34
|
+
const port = typeof address === 'object' ? address?.port : this.config.port;
|
|
35
|
+
console.error(`[llm-bridge] listening on http://${this.config.host}:${port}`);
|
|
36
|
+
resolve();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async stop(): Promise<void> {
|
|
42
|
+
await this.sessions.disposeAll();
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
this.server?.close(() => resolve());
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
address(): import('net').AddressInfo | string | null {
|
|
49
|
+
return this.server?.address() ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async handleRequest(req: IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
53
|
+
const url = new URL(req.url ?? '/', `http://${this.config.host}`);
|
|
54
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
55
|
+
|
|
56
|
+
if (req.method === 'GET' && path === '/health') {
|
|
57
|
+
this.jsonResponse(res, 200, { ok: true, service: 'llm-bridge' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (req.method === 'GET' && path === '/v1/models') {
|
|
62
|
+
await this.handleModels(req, res);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (req.method === 'POST' && path === '/v1/chat/completions') {
|
|
67
|
+
await this.handleChatCompletions(req, res);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.jsonResponse(res, 404, { error: { message: `Not found: ${path}`, type: 'not_found' } });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async handleModels(_req: IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
75
|
+
const allPlugins = this.registry.listPlugins();
|
|
76
|
+
if (allPlugins.length === 0) {
|
|
77
|
+
this.jsonResponse(res, 503, {
|
|
78
|
+
error: { message: 'No plugins configured', type: 'configuration_error' },
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const models: { id: string; object: string; created: number; owned_by: string }[] = [];
|
|
85
|
+
const now = Math.floor(Date.now() / 1000);
|
|
86
|
+
for (const plugin of allPlugins) {
|
|
87
|
+
const config = this.config.plugins[plugin.name] ?? {};
|
|
88
|
+
const pluginModels = await plugin.listModels(config);
|
|
89
|
+
for (const m of pluginModels) {
|
|
90
|
+
models.push({
|
|
91
|
+
id: `${plugin.name}/${m.id}`,
|
|
92
|
+
object: 'model',
|
|
93
|
+
created: now,
|
|
94
|
+
owned_by: plugin.name,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
this.jsonResponse(res, 200, {
|
|
99
|
+
object: 'list',
|
|
100
|
+
data: models,
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
104
|
+
this.jsonResponse(res, 502, { error: { message: msg, type: 'provider_error' } });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private resolvePlugin(model: string): { plugin: BridgePlugin | null; error: string | null } {
|
|
109
|
+
const slashIndex = model.indexOf('/');
|
|
110
|
+
if (slashIndex !== -1) {
|
|
111
|
+
const prefix = model.slice(0, slashIndex);
|
|
112
|
+
const plugin = this.registry.getPlugin(prefix);
|
|
113
|
+
if (plugin) return { plugin, error: null };
|
|
114
|
+
return { plugin: null, error: `Unknown plugin: "${prefix}"` };
|
|
115
|
+
}
|
|
116
|
+
return { plugin: this.registry.getDefaultPlugin(), error: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async handleChatCompletions(
|
|
120
|
+
req: IncomingMessage,
|
|
121
|
+
res: http.ServerResponse,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const parsed = await parseChatRequest(req);
|
|
124
|
+
if (!parsed.success) {
|
|
125
|
+
this.jsonResponse(res, 400, {
|
|
126
|
+
error: { message: parsed.error, type: 'invalid_request_error' },
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { messages, model, stream, tools } = parsed.data;
|
|
132
|
+
const { plugin, error } = this.resolvePlugin(model);
|
|
133
|
+
|
|
134
|
+
if (error) {
|
|
135
|
+
this.jsonResponse(res, 400, {
|
|
136
|
+
error: { message: error, type: 'invalid_request_error' },
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!plugin) {
|
|
142
|
+
this.jsonResponse(res, 503, {
|
|
143
|
+
error: { message: 'No default plugin configured', type: 'configuration_error' },
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const config = this.config.plugins[plugin.name] ?? {};
|
|
150
|
+
const modelId = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
|
|
151
|
+
const session = await plugin.createSession(config, modelId);
|
|
152
|
+
const sessionId = req.headers['x-session-id'] as string | undefined;
|
|
153
|
+
this.sessions.set(sessionId ?? crypto.randomUUID(), session);
|
|
154
|
+
|
|
155
|
+
res.writeHead(200, {
|
|
156
|
+
'Content-Type': stream ? 'text/event-stream; charset=utf-8' : 'application/json',
|
|
157
|
+
'Cache-Control': 'no-cache',
|
|
158
|
+
Connection: 'keep-alive',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const chunks: string[] = [];
|
|
162
|
+
for await (const chunk of session.send(messages, tools)) {
|
|
163
|
+
if (stream) {
|
|
164
|
+
res.write(formatStreamChunk(chunk, model, `chatcmpl-${crypto.randomUUID()}`));
|
|
165
|
+
} else {
|
|
166
|
+
if (chunk.type === 'text' && chunk.content) {
|
|
167
|
+
chunks.push(chunk.content);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!stream) {
|
|
173
|
+
const completionId = `chatcmpl-${crypto.randomUUID()}`;
|
|
174
|
+
const completion = formatCompletion(chunks.join(''), model, completionId);
|
|
175
|
+
res.end(JSON.stringify(completion));
|
|
176
|
+
} else {
|
|
177
|
+
res.write(`data: [DONE]\n\n`);
|
|
178
|
+
res.end();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await session.dispose();
|
|
182
|
+
} catch (e) {
|
|
183
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
184
|
+
if (stream && !res.writableEnded) {
|
|
185
|
+
res.write(
|
|
186
|
+
`data: ${JSON.stringify({ error: { message: msg, type: 'provider_error' } })}\n\n`,
|
|
187
|
+
);
|
|
188
|
+
res.end();
|
|
189
|
+
} else if (!res.headersSent) {
|
|
190
|
+
this.jsonResponse(res, 502, { error: { message: msg, type: 'provider_error' } });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private jsonResponse(res: http.ServerResponse, status: number, body: unknown): void {
|
|
196
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
197
|
+
res.end(JSON.stringify(body));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
registerPlugin(plugin: BridgePlugin): void {
|
|
201
|
+
this.registry.register(plugin);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setActivePlugin(name: string): void {
|
|
205
|
+
this.registry.setActive(name);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setDefaultPlugin(name: string): void {
|
|
209
|
+
this.registry.setDefault(name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BridgeSession } from './types.js';
|
|
2
|
+
|
|
3
|
+
interface SessionEntry {
|
|
4
|
+
session: BridgeSession;
|
|
5
|
+
lastActive: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class SessionStore {
|
|
9
|
+
private sessions: Map<string, SessionEntry> = new Map();
|
|
10
|
+
private ttlMs: number;
|
|
11
|
+
|
|
12
|
+
constructor(ttlSeconds: number = 1800) {
|
|
13
|
+
this.ttlMs = ttlSeconds * 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
set(id: string, session: BridgeSession): void {
|
|
17
|
+
this.sessions.set(id, { session, lastActive: Date.now() });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get(id: string): BridgeSession | undefined {
|
|
21
|
+
const entry = this.sessions.get(id);
|
|
22
|
+
if (entry) {
|
|
23
|
+
entry.lastActive = Date.now();
|
|
24
|
+
return entry.session;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
cleanup(): void {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [id, entry] of this.sessions.entries()) {
|
|
32
|
+
if (now - entry.lastActive > this.ttlMs) {
|
|
33
|
+
this.sessions.delete(id);
|
|
34
|
+
entry.session.dispose().catch((err) => {
|
|
35
|
+
console.error(`[session] dispose error for ${id}:`, err);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async disposeAll(): Promise<void> {
|
|
42
|
+
const disposals = Array.from(this.sessions.values()).map((entry) =>
|
|
43
|
+
entry.session.dispose().catch((err) => {
|
|
44
|
+
console.error('[session] disposeAll error:', err);
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
this.sessions.clear();
|
|
48
|
+
await Promise.all(disposals);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get size(): number {
|
|
52
|
+
return this.sessions.size;
|
|
53
|
+
}
|
|
54
|
+
}
|