@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,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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const MessageSchema = z.object({
|
|
4
|
+
role: z.enum(['system', 'user', 'assistant', 'tool', 'function']),
|
|
5
|
+
content: z.string().nullable().optional(),
|
|
6
|
+
name: z.string().optional(),
|
|
7
|
+
tool_call_id: z.string().optional(),
|
|
8
|
+
tool_calls: z
|
|
9
|
+
.array(
|
|
10
|
+
z.object({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
type: z.literal('function'),
|
|
13
|
+
function: z.object({
|
|
14
|
+
name: z.string(),
|
|
15
|
+
arguments: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
)
|
|
19
|
+
.optional(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type Message = z.infer<typeof MessageSchema>;
|
|
23
|
+
|
|
24
|
+
export const ToolDefinitionSchema = z.object({
|
|
25
|
+
type: z.literal('function'),
|
|
26
|
+
function: z.object({
|
|
27
|
+
name: z.string(),
|
|
28
|
+
description: z.string().optional(),
|
|
29
|
+
parameters: z.record(z.unknown()),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
|
|
34
|
+
|
|
35
|
+
export const ModelInfoSchema = z.object({
|
|
36
|
+
id: z.string(),
|
|
37
|
+
name: z.string(),
|
|
38
|
+
capabilities: z
|
|
39
|
+
.object({
|
|
40
|
+
streaming: z.boolean().optional(),
|
|
41
|
+
tools: z.boolean().optional(),
|
|
42
|
+
vision: z.boolean().optional(),
|
|
43
|
+
})
|
|
44
|
+
.optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
48
|
+
|
|
49
|
+
export type StreamChunkType = 'text' | 'tool_call' | 'tool_result' | 'error' | 'done';
|
|
50
|
+
export type FinishReason = 'stop' | 'tool_calls' | 'error' | 'length';
|
|
51
|
+
|
|
52
|
+
export interface StreamChunk {
|
|
53
|
+
type: StreamChunkType;
|
|
54
|
+
content?: string;
|
|
55
|
+
toolCall?: {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
arguments: string;
|
|
59
|
+
};
|
|
60
|
+
finishReason?: FinishReason;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BridgePlugin {
|
|
64
|
+
name: string;
|
|
65
|
+
version: string;
|
|
66
|
+
authenticate(config: Record<string, string>): Promise<boolean>;
|
|
67
|
+
listModels(config: Record<string, string>): Promise<ModelInfo[]>;
|
|
68
|
+
createSession(config: Record<string, string>, model: string): Promise<BridgeSession>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BridgeSession {
|
|
72
|
+
send(messages: Message[], tools?: ToolDefinition[]): AsyncIterable<StreamChunk>;
|
|
73
|
+
dispose(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PluginHealth {
|
|
77
|
+
name: string;
|
|
78
|
+
healthy: boolean;
|
|
79
|
+
lastChecked: Date;
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BridgeConfig {
|
|
84
|
+
activePlugin?: string;
|
|
85
|
+
defaultPlugin: string;
|
|
86
|
+
port: number;
|
|
87
|
+
host: string;
|
|
88
|
+
plugins: Record<string, Record<string, string>>;
|
|
89
|
+
sessionTTL: number;
|
|
90
|
+
toolMode: 'strict' | 'lenient';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const DefaultConfig: BridgeConfig = {
|
|
94
|
+
defaultPlugin: 'cursor',
|
|
95
|
+
port: 3849,
|
|
96
|
+
host: '127.0.0.1',
|
|
97
|
+
plugins: {},
|
|
98
|
+
sessionTTL: 1800,
|
|
99
|
+
toolMode: 'lenient',
|
|
100
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -7,8 +7,11 @@ import {
|
|
|
7
7
|
daemonStatusCommand,
|
|
8
8
|
daemonDownloadCommand,
|
|
9
9
|
daemonLocateCommand,
|
|
10
|
+
daemonReloadCommand,
|
|
10
11
|
} from './commands/daemon.js';
|
|
11
12
|
import { installDaemonCommand, uninstallDaemonCommand } from './commands/daemon.js';
|
|
13
|
+
import { loginCommand } from './commands/login.js';
|
|
14
|
+
import { logoutCommand } from './commands/logout.js';
|
|
12
15
|
|
|
13
16
|
const command = process.argv[2] ?? 'help';
|
|
14
17
|
|
|
@@ -44,11 +47,24 @@ async function main(): Promise<void> {
|
|
|
44
47
|
case 'locate':
|
|
45
48
|
await daemonLocateCommand();
|
|
46
49
|
break;
|
|
50
|
+
case 'reload':
|
|
51
|
+
await daemonReloadCommand();
|
|
52
|
+
break;
|
|
47
53
|
default:
|
|
48
|
-
console.log('Usage: llm-bridge daemon [status|download|locate]');
|
|
54
|
+
console.log('Usage: llm-bridge daemon [status|download|locate|reload]');
|
|
49
55
|
}
|
|
50
56
|
break;
|
|
51
57
|
}
|
|
58
|
+
case 'login': {
|
|
59
|
+
const provider = process.argv[3];
|
|
60
|
+
await loginCommand(provider);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'logout': {
|
|
64
|
+
const provider = process.argv[3];
|
|
65
|
+
await logoutCommand(provider);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
52
68
|
case 'help':
|
|
53
69
|
default:
|
|
54
70
|
console.log(`llm-bridge v1.0.0
|
|
@@ -56,11 +72,13 @@ async function main(): Promise<void> {
|
|
|
56
72
|
Usage:
|
|
57
73
|
llm-bridge init Interactive setup wizard (configure one or more providers)
|
|
58
74
|
llm-bridge start Launch bridge server (all configured plugins registered)
|
|
75
|
+
llm-bridge login [provider] OAuth login (copilot, cursor)
|
|
76
|
+
llm-bridge logout [provider] Remove stored OAuth token
|
|
59
77
|
llm-bridge configure Inject OpenCode config for the default provider
|
|
60
78
|
llm-bridge doctor Run diagnostics
|
|
61
|
-
llm-bridge install-daemon Install
|
|
62
|
-
llm-bridge uninstall-daemon Remove
|
|
63
|
-
llm-bridge daemon [status|download|locate] Manage Windsurf daemon binary
|
|
79
|
+
llm-bridge install-daemon Install platform daemon (LaunchAgent or systemd)
|
|
80
|
+
llm-bridge uninstall-daemon Remove platform daemon
|
|
81
|
+
llm-bridge daemon [status|download|locate|reload] Manage Windsurf daemon binary
|
|
64
82
|
llm-bridge help Show this help`);
|
|
65
83
|
}
|
|
66
84
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { OAuthConfig, StoredToken, DeviceCodeResponse } from './types.js';
|
|
2
|
+
|
|
3
|
+
const SLOW_DOWN_BACKOFF_MS = 5000;
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_S = 5;
|
|
5
|
+
const DEFAULT_TOKEN_EXPIRY_S = 3600;
|
|
6
|
+
|
|
7
|
+
export class DeviceFlow {
|
|
8
|
+
private deviceCode = '';
|
|
9
|
+
private interval = DEFAULT_POLL_INTERVAL_S * 1000;
|
|
10
|
+
private expiresAt = 0;
|
|
11
|
+
private firstPoll = true;
|
|
12
|
+
|
|
13
|
+
constructor(private config: OAuthConfig) {}
|
|
14
|
+
|
|
15
|
+
async start(): Promise<DeviceCodeResponse> {
|
|
16
|
+
const params = new URLSearchParams({
|
|
17
|
+
client_id: this.config.provider.clientId,
|
|
18
|
+
scope: this.config.provider.scopes.join(' '),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const response = await fetch(this.config.provider.authUrl, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
24
|
+
body: params.toString(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const body = await response.text();
|
|
29
|
+
throw new Error(`Device code request failed: ${response.status} ${body}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
|
|
35
|
+
throw new Error('Invalid device code response: missing required fields');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this.deviceCode = data.device_code as string;
|
|
39
|
+
this.interval = ((data.interval as number) ?? DEFAULT_POLL_INTERVAL_S) * 1000;
|
|
40
|
+
this.expiresAt = Date.now() + (data.expires_in as number) * 1000;
|
|
41
|
+
this.firstPoll = true;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
deviceCode: this.deviceCode,
|
|
45
|
+
userCode: data.user_code as string,
|
|
46
|
+
verificationUri: data.verification_uri as string,
|
|
47
|
+
expiresIn: data.expires_in as number,
|
|
48
|
+
interval: (data.interval as number) ?? DEFAULT_POLL_INTERVAL_S,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async poll(): Promise<StoredToken> {
|
|
53
|
+
if (!this.deviceCode) {
|
|
54
|
+
throw new Error('No device code. Call start() first.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
while (Date.now() < this.expiresAt) {
|
|
58
|
+
if (!this.firstPoll) {
|
|
59
|
+
await this.sleep(this.interval);
|
|
60
|
+
}
|
|
61
|
+
this.firstPoll = false;
|
|
62
|
+
|
|
63
|
+
const params = new URLSearchParams({
|
|
64
|
+
client_id: this.config.provider.clientId,
|
|
65
|
+
device_code: this.deviceCode,
|
|
66
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const response = await fetch(this.config.provider.tokenUrl, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
73
|
+
Accept: 'application/json',
|
|
74
|
+
},
|
|
75
|
+
body: params.toString(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
79
|
+
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
if (!data.access_token) {
|
|
82
|
+
throw new Error('Invalid token response: missing access_token');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const token: StoredToken = {
|
|
86
|
+
version: 1,
|
|
87
|
+
accessToken: data.access_token as string,
|
|
88
|
+
refreshToken: data.refresh_token as string | undefined,
|
|
89
|
+
expiresAt: Date.now() + ((data.expires_in as number) ?? DEFAULT_TOKEN_EXPIRY_S) * 1000,
|
|
90
|
+
scopes: (data.scope as string)?.split(' ') ?? this.config.provider.scopes,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
await this.config.store.set(this.config.provider.id, token);
|
|
94
|
+
return token;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const error = data.error as string;
|
|
98
|
+
if (error === 'slow_down') {
|
|
99
|
+
this.interval += ((data.interval as number) ?? 0) * 1000 + SLOW_DOWN_BACKOFF_MS;
|
|
100
|
+
} else if (error !== 'authorization_pending') {
|
|
101
|
+
throw new Error(`Device flow error: ${error}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error('Device flow expired');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private sleep(ms: number): Promise<void> {
|
|
109
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
+
import type { OAuthConfig, StoredToken, TokenStore, PKCEState } from './types.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TOKEN_EXPIRY_MS = 3600000;
|
|
5
|
+
|
|
6
|
+
export class OAuthFlow {
|
|
7
|
+
private pkceState: PKCEState | null = null;
|
|
8
|
+
|
|
9
|
+
constructor(
|
|
10
|
+
private config: OAuthConfig,
|
|
11
|
+
private store: TokenStore,
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async start(): Promise<string> {
|
|
15
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
16
|
+
const codeChallenge = this.generateCodeChallenge(codeVerifier);
|
|
17
|
+
const state = randomBytes(16).toString('hex');
|
|
18
|
+
|
|
19
|
+
this.pkceState = { codeVerifier, codeChallenge, state };
|
|
20
|
+
|
|
21
|
+
const params = new URLSearchParams({
|
|
22
|
+
response_type: 'code',
|
|
23
|
+
client_id: this.config.provider.clientId,
|
|
24
|
+
scope: this.config.provider.scopes.join(' '),
|
|
25
|
+
code_challenge: codeChallenge,
|
|
26
|
+
code_challenge_method: 'S256',
|
|
27
|
+
state,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (this.config.redirectUri) {
|
|
31
|
+
params.set('redirect_uri', this.config.redirectUri);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return `${this.config.provider.authUrl}?${params.toString()}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async callback(code: string, state: string): Promise<StoredToken> {
|
|
38
|
+
if (!this.pkceState) {
|
|
39
|
+
throw new Error('No pending PKCE state');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (state !== this.pkceState.state) {
|
|
43
|
+
throw new Error('State mismatch');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { codeVerifier } = this.pkceState;
|
|
47
|
+
this.pkceState = null;
|
|
48
|
+
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
grant_type: 'authorization_code',
|
|
51
|
+
code,
|
|
52
|
+
code_verifier: codeVerifier,
|
|
53
|
+
client_id: this.config.provider.clientId,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (this.config.redirectUri) {
|
|
57
|
+
params.set('redirect_uri', this.config.redirectUri);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const response = await fetch(this.config.provider.tokenUrl, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
63
|
+
body: params.toString(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const body = await response.text();
|
|
68
|
+
throw new Error(`Token exchange failed: ${response.status} ${body}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
72
|
+
|
|
73
|
+
const newToken: StoredToken = {
|
|
74
|
+
version: 1,
|
|
75
|
+
accessToken: data.access_token as string,
|
|
76
|
+
refreshToken: data.refresh_token as string | undefined,
|
|
77
|
+
expiresAt: data.expires_in
|
|
78
|
+
? Date.now() + (data.expires_in as number) * 1000
|
|
79
|
+
: Date.now() + DEFAULT_TOKEN_EXPIRY_MS,
|
|
80
|
+
scopes: this.config.provider.scopes,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await this.store.set(this.config.provider.id, newToken);
|
|
84
|
+
return newToken;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private generateCodeVerifier(): string {
|
|
88
|
+
return randomBytes(32).toString('base64url');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private generateCodeChallenge(verifier: string): string {
|
|
92
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { OAuthProvider, TokenStore, StoredToken, OAuthConfig } from './types.js';
|
|
2
|
+
export { createTokenStore } from './storage.js';
|
|
3
|
+
export { OAuthFlow } from './flow.js';
|
|
4
|
+
export { DeviceFlow } from './device-flow.js';
|
|
5
|
+
export { TokenLifecycle, type TokenLifecycleOptions } from './lifecycle.js';
|
|
6
|
+
export { providers } from './providers.js';
|