@ai-ide-bridge/cli 1.0.4 → 1.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/.turbo/turbo-build.log +1 -1
- 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 +30 -4
- 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/platform.d.ts +1 -0
- package/dist/utils/platform.js +3 -0
- package/package.json +3 -5
- package/src/commands/daemon.ts +112 -13
- package/src/commands/doctor.ts +1 -1
- package/src/commands/init.ts +29 -4
- 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/platform.ts +3 -0
- package/test/daemon.test.ts +224 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { createTokenStore, DeviceFlow, providers } from '../oauth/index.js';
|
|
3
|
+
|
|
4
|
+
const SUPPORTED_PROVIDERS = ['copilot', 'cursor'] as const;
|
|
5
|
+
type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];
|
|
6
|
+
|
|
7
|
+
export async function loginCommand(provider?: string): Promise<void> {
|
|
8
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9
|
+
const ask = (q: string) => new Promise<string>((resolve) => rl.question(q, resolve));
|
|
10
|
+
|
|
11
|
+
let providerId: SupportedProvider;
|
|
12
|
+
|
|
13
|
+
if (provider && SUPPORTED_PROVIDERS.includes(provider as SupportedProvider)) {
|
|
14
|
+
providerId = provider as SupportedProvider;
|
|
15
|
+
} else {
|
|
16
|
+
const input = await ask(`Provider to login (${SUPPORTED_PROVIDERS.join(', ')}): `);
|
|
17
|
+
if (!SUPPORTED_PROVIDERS.includes(input as SupportedProvider)) {
|
|
18
|
+
console.error(`Unknown provider: ${input}`);
|
|
19
|
+
rl.close();
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
providerId = input as SupportedProvider;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const oauthProvider = providers[providerId];
|
|
26
|
+
if (!oauthProvider) {
|
|
27
|
+
console.error(`OAuth not configured for provider: ${providerId}`);
|
|
28
|
+
rl.close();
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const store = createTokenStore();
|
|
33
|
+
|
|
34
|
+
if (oauthProvider.deviceFlow) {
|
|
35
|
+
await loginWithDeviceFlow(rl, oauthProvider, store);
|
|
36
|
+
} else {
|
|
37
|
+
await loginWithPKCE(rl, oauthProvider, store);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
rl.close();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loginWithDeviceFlow(
|
|
44
|
+
rl: ReturnType<typeof createInterface>,
|
|
45
|
+
provider: {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
authUrl: string;
|
|
49
|
+
tokenUrl: string;
|
|
50
|
+
scopes: string[];
|
|
51
|
+
clientId: string;
|
|
52
|
+
},
|
|
53
|
+
store: ReturnType<typeof createTokenStore>,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const config = {
|
|
56
|
+
provider,
|
|
57
|
+
store,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const deviceFlow = new DeviceFlow(config);
|
|
61
|
+
|
|
62
|
+
console.log(`\nAuthenticating with ${provider.name}...`);
|
|
63
|
+
console.log('Requesting device code...');
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const deviceCode = await deviceFlow.start();
|
|
67
|
+
|
|
68
|
+
console.log(`\nOpen this URL in your browser: ${deviceCode.verificationUri}`);
|
|
69
|
+
console.log(`Enter this code: ${deviceCode.userCode}\n`);
|
|
70
|
+
console.log('Waiting for authorization...');
|
|
71
|
+
|
|
72
|
+
const token = await deviceFlow.poll();
|
|
73
|
+
|
|
74
|
+
const expiresIn = Math.round((token.expiresAt - Date.now()) / 1000);
|
|
75
|
+
console.log(`\nAuthentication successful!`);
|
|
76
|
+
console.log(`Token stored securely. Expires in ${expiresIn}s.`);
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
console.error(`\nAuthentication failed: ${err.message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loginWithPKCE(
|
|
84
|
+
rl: ReturnType<typeof createInterface>,
|
|
85
|
+
provider: {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
authUrl: string;
|
|
89
|
+
tokenUrl: string;
|
|
90
|
+
scopes: string[];
|
|
91
|
+
clientId: string;
|
|
92
|
+
},
|
|
93
|
+
store: ReturnType<typeof createTokenStore>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
console.log(`\nOAuth with PKCE is not yet supported for ${provider.name}.`);
|
|
96
|
+
console.log('Use a personal access token instead: llm-bridge init');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createTokenStore } from '../oauth/index.js';
|
|
2
|
+
|
|
3
|
+
export async function logoutCommand(provider?: string): Promise<void> {
|
|
4
|
+
const store = createTokenStore();
|
|
5
|
+
|
|
6
|
+
if (provider) {
|
|
7
|
+
await store.delete(provider);
|
|
8
|
+
console.log(`Logged out from ${provider}.`);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await store.delete('copilot');
|
|
13
|
+
await store.delete('cursor');
|
|
14
|
+
console.log('Logged out from all providers.');
|
|
15
|
+
}
|
package/src/commands/start.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { BridgeServer, loadConfig } from '
|
|
2
|
-
import { CursorBridgePlugin } from '
|
|
3
|
-
import { CopilotBridgePlugin } from '
|
|
4
|
-
import { WindsurfBridgePlugin } from '
|
|
1
|
+
import { BridgeServer, loadConfig } from '../core/index.js';
|
|
2
|
+
import { CursorBridgePlugin } from '../plugins/cursor/index.js';
|
|
3
|
+
import { CopilotBridgePlugin } from '../plugins/copilot/index.js';
|
|
4
|
+
import { WindsurfBridgePlugin } from '../plugins/windsurf/index.js';
|
|
5
5
|
|
|
6
6
|
async function getPlugin(name: string) {
|
|
7
7
|
switch (name) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { BridgeConfig, DefaultConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function configPath(): string {
|
|
7
|
+
const home = os.homedir();
|
|
8
|
+
return path.join(home, '.config', 'llm-bridge', 'config.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadConfig(): BridgeConfig {
|
|
12
|
+
const envPort = process.env.LLM_BRIDGE_PORT;
|
|
13
|
+
const envHost = process.env.LLM_BRIDGE_HOST;
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const filePath = process.env.LLM_BRIDGE_CONFIG ?? configPath();
|
|
17
|
+
if (fs.existsSync(filePath)) {
|
|
18
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
19
|
+
const fileConfig = JSON.parse(raw) as Partial<BridgeConfig>;
|
|
20
|
+
const config = { ...DefaultConfig, ...fileConfig };
|
|
21
|
+
|
|
22
|
+
if (fileConfig.activePlugin && !fileConfig.defaultPlugin) {
|
|
23
|
+
config.defaultPlugin = fileConfig.activePlugin;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (envPort) config.port = parseInt(envPort, 10);
|
|
27
|
+
if (envHost) config.host = envHost;
|
|
28
|
+
return config;
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn('[llm-bridge] failed to load config file, using defaults:', err);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = { ...DefaultConfig };
|
|
35
|
+
if (envPort) config.port = parseInt(envPort, 10);
|
|
36
|
+
if (envHost) config.host = envHost;
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveConfig(config: BridgeConfig): void {
|
|
41
|
+
const filePath = configPath();
|
|
42
|
+
const dir = path.dirname(filePath);
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
|
45
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { BridgeSession, Message, ToolDefinition, StreamChunk } from './types.js';
|
|
3
|
+
import type { DaemonManager } from './daemon.js';
|
|
4
|
+
|
|
5
|
+
const MAX_BUFFER_SIZE = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export class DaemonBridgeSession implements BridgeSession {
|
|
8
|
+
private proc: ChildProcess | null = null;
|
|
9
|
+
private requestId = 0;
|
|
10
|
+
private busy = false;
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private daemon: DaemonManager,
|
|
14
|
+
private token: string,
|
|
15
|
+
private model: string,
|
|
16
|
+
private cwd: string,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async *send(messages: Message[], tools?: ToolDefinition[]): AsyncIterable<StreamChunk> {
|
|
20
|
+
if (this.busy) {
|
|
21
|
+
yield {
|
|
22
|
+
type: 'error',
|
|
23
|
+
content: 'Session is busy — concurrent send() calls are not supported',
|
|
24
|
+
finishReason: 'error',
|
|
25
|
+
};
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
this.busy = true;
|
|
29
|
+
|
|
30
|
+
if (!this.proc) {
|
|
31
|
+
const binaryPath = await this.daemon.locate();
|
|
32
|
+
if (!binaryPath) {
|
|
33
|
+
this.busy = false;
|
|
34
|
+
throw new Error(`Daemon binary '${this.daemon.binaryName}' not found`);
|
|
35
|
+
}
|
|
36
|
+
this.proc = this.daemon.spawn(binaryPath, []);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = ++this.requestId;
|
|
40
|
+
const request = {
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id,
|
|
43
|
+
method: 'chat/completions',
|
|
44
|
+
params: {
|
|
45
|
+
token: this.token,
|
|
46
|
+
model: this.model,
|
|
47
|
+
messages,
|
|
48
|
+
stream: true,
|
|
49
|
+
tools: tools?.map((t) => ({
|
|
50
|
+
type: 'function' as const,
|
|
51
|
+
function: {
|
|
52
|
+
name: t.function.name,
|
|
53
|
+
description: t.function.description,
|
|
54
|
+
parameters: t.function.parameters,
|
|
55
|
+
},
|
|
56
|
+
})),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
this.proc.stdin!.write(JSON.stringify(request) + '\n');
|
|
61
|
+
|
|
62
|
+
let buffer = '';
|
|
63
|
+
let stderrBuffer = '';
|
|
64
|
+
let finished = false;
|
|
65
|
+
let capturedError: Error | null = null;
|
|
66
|
+
let onDataResolve: (() => void) | null = null;
|
|
67
|
+
|
|
68
|
+
const onStderr = (data: Buffer) => {
|
|
69
|
+
stderrBuffer += data.toString();
|
|
70
|
+
};
|
|
71
|
+
this.proc.stderr?.on('data', onStderr);
|
|
72
|
+
|
|
73
|
+
const onData = (data: Buffer) => {
|
|
74
|
+
if (buffer.length <= MAX_BUFFER_SIZE) {
|
|
75
|
+
buffer += data.toString();
|
|
76
|
+
}
|
|
77
|
+
if (buffer.length > MAX_BUFFER_SIZE && !capturedError) {
|
|
78
|
+
capturedError = new Error(`stdout buffer exceeded max size of ${MAX_BUFFER_SIZE} bytes`);
|
|
79
|
+
onDataResolve?.();
|
|
80
|
+
}
|
|
81
|
+
onDataResolve?.();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const onError = (err: Error) => {
|
|
85
|
+
if (!capturedError) {
|
|
86
|
+
capturedError = err;
|
|
87
|
+
onDataResolve?.();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const onExit = (code: number | null) => {
|
|
92
|
+
if (!finished && !capturedError) {
|
|
93
|
+
capturedError = new Error(`Process exited with code ${code ?? 'unknown'}`);
|
|
94
|
+
onDataResolve?.();
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
this.proc.stdout!.on('data', onData);
|
|
99
|
+
this.proc.on('error', onError);
|
|
100
|
+
this.proc.on('exit', onExit);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
while (!finished) {
|
|
104
|
+
if (capturedError) {
|
|
105
|
+
const stderrInfo = stderrBuffer.trim() ? ` (stderr: ${stderrBuffer.slice(0, 500)})` : '';
|
|
106
|
+
yield {
|
|
107
|
+
type: 'error',
|
|
108
|
+
content: `Process error: ${(capturedError as Error).message}${stderrInfo}`,
|
|
109
|
+
finishReason: 'error',
|
|
110
|
+
};
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const newlineIndex = buffer.indexOf('\n');
|
|
115
|
+
if (newlineIndex === -1) {
|
|
116
|
+
const waitForData = new Promise<void>((resolve) => {
|
|
117
|
+
onDataResolve = resolve;
|
|
118
|
+
});
|
|
119
|
+
await waitForData;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const line = buffer.slice(0, newlineIndex);
|
|
124
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
125
|
+
|
|
126
|
+
if (!line.trim()) continue;
|
|
127
|
+
|
|
128
|
+
let parsed: any;
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(line);
|
|
131
|
+
} catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (parsed.error) {
|
|
136
|
+
yield {
|
|
137
|
+
type: 'error',
|
|
138
|
+
content: `JSON-RPC error: ${parsed.error.message}`,
|
|
139
|
+
finishReason: 'error',
|
|
140
|
+
};
|
|
141
|
+
finished = true;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (parsed.method === 'chat/chunk' && parsed.params) {
|
|
146
|
+
const delta = parsed.params.delta;
|
|
147
|
+
if (delta?.content) {
|
|
148
|
+
yield { type: 'text', content: delta.content };
|
|
149
|
+
}
|
|
150
|
+
if (delta?.tool_calls) {
|
|
151
|
+
for (const tc of delta.tool_calls) {
|
|
152
|
+
yield {
|
|
153
|
+
type: 'tool_call',
|
|
154
|
+
toolCall: {
|
|
155
|
+
id: tc.id ?? '',
|
|
156
|
+
name: tc.name ?? '',
|
|
157
|
+
arguments: tc.arguments ?? '',
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (parsed.method === 'chat/done' && parsed.params) {
|
|
165
|
+
const reason = parsed.params.finishReason;
|
|
166
|
+
yield {
|
|
167
|
+
type: 'done',
|
|
168
|
+
finishReason:
|
|
169
|
+
reason === 'stop'
|
|
170
|
+
? 'stop'
|
|
171
|
+
: reason === 'tool_calls'
|
|
172
|
+
? 'tool_calls'
|
|
173
|
+
: reason === 'length'
|
|
174
|
+
? 'length'
|
|
175
|
+
: 'error',
|
|
176
|
+
};
|
|
177
|
+
finished = true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (parsed.id === id && parsed.result) {
|
|
181
|
+
finished = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
this.proc?.stdout?.removeListener('data', onData);
|
|
186
|
+
this.proc?.removeListener('error', onError);
|
|
187
|
+
this.proc?.removeListener('exit', onExit);
|
|
188
|
+
this.proc?.stderr?.removeListener('data', onStderr);
|
|
189
|
+
this.busy = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async dispose(): Promise<void> {
|
|
194
|
+
if (this.proc) {
|
|
195
|
+
this.proc.kill();
|
|
196
|
+
this.proc = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|