diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47ff571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +out +.vscode-test +*.vsix diff --git a/package.json b/package.json new file mode 100644 index 0000000..0eb7eaa --- /dev/null +++ b/package.json @@ -0,0 +1,63 @@ +{ + "name": "copilot-bridge", + "displayName": "Copilot Bridge", + "description": "Local OpenAI-compatible chat endpoint bridging to GitHub Copilot Chat via VS Code Chat provider.", + "version": "0.1.0", + "publisher": "thinkability", + "engines": { + "vscode": "^1.90.0" + }, + "categories": [ + "Other" + ], + "activationEvents": [ + "onStartupFinished", + "onCommand:bridge.enable", + "onCommand:bridge.disable", + "onCommand:bridge.status" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { "command": "bridge.enable", "title": "Copilot Bridge: Enable" }, + { "command": "bridge.disable", "title": "Copilot Bridge: Disable" }, + { "command": "bridge.status", "title": "Copilot Bridge: Status" } + ], + "configuration": { + "title": "Copilot Bridge", + "properties": { + "bridge.enabled": { + "type": "boolean", + "default": false + }, + "bridge.host": { + "type": "string", + "default": "127.0.0.1" + }, + "bridge.port": { + "type": "number", + "default": 0 + }, + "bridge.token": { + "type": "string", + "default": "" + }, + "bridge.historyWindow": { + "type": "number", + "default": 3, + "minimum": 0 + } + } + } + }, + "scripts": { + "compile": "tsc -p .", + "watch": "tsc -w -p .", + "vscode:prepublish": "npm run compile" + }, + "devDependencies": { + "@types/node": "^20.10.0", + "typescript": "^5.4.0", + "vscode": "^1.1.40" + } +} diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..41f7a73 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,201 @@ +import * as vscode from 'vscode'; +import * as http from 'http'; +import { AddressInfo } from 'net'; + +let server: http.Server | undefined; +let access: vscode.ChatAccess | undefined; +let statusItem: vscode.StatusBarItem | undefined; +let output: vscode.OutputChannel | undefined; +let running = false; + +export async function activate(ctx: vscode.ExtensionContext) { + output = vscode.window.createOutputChannel('Copilot Bridge'); + statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + statusItem.text = 'Copilot Bridge: Disabled'; + statusItem.show(); + ctx.subscriptions.push(statusItem, output); + + ctx.subscriptions.push(vscode.commands.registerCommand('bridge.enable', async () => { + await startBridge(); + })); + ctx.subscriptions.push(vscode.commands.registerCommand('bridge.disable', async () => { + await stopBridge(); + })); + ctx.subscriptions.push(vscode.commands.registerCommand('bridge.status', async () => { + const info = server ? server.address() : undefined; + const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : 'n/a'; + const needsToken = !!(vscode.workspace.getConfiguration('bridge').get('token') || '').trim(); + vscode.window.showInformationMessage(`Copilot Bridge: ${running ? 'Enabled' : 'Disabled'} | Bound: ${bound} | Token: ${needsToken ? 'Required' : 'None'}`); + })); + + const cfg = vscode.workspace.getConfiguration('bridge'); + if (cfg.get('enabled')) { + await startBridge(); + } +} + +export async function deactivate() { + await stopBridge(); +} + +async function startBridge() { + if (running) return; + running = true; + + const cfg = vscode.workspace.getConfiguration('bridge'); + const host = cfg.get('host') ?? '127.0.0.1'; + const portCfg = cfg.get('port') ?? 0; + const token = (cfg.get('token') ?? '').trim(); + const hist = cfg.get('historyWindow') ?? 3; + + try { + try { + access = await vscode.chat.requestChatAccess('copilot'); + } catch { + access = undefined; + } + + server = http.createServer(async (req, res) => { + try { + if (token && req.headers.authorization !== `Bearer ${token}`) { + writeJson(res, 401, { error: { message: 'unauthorized', type: 'invalid_request_error', code: 'unauthorized' } }); + return; + } + + if (req.method === 'GET' && req.url === '/healthz') { + writeJson(res, 200, { ok: true, copilot: access ? 'ok' : 'unavailable', version: vscode.version }); + return; + } + + if (req.method === 'GET' && req.url === '/v1/models') { + writeJson(res, 200, { data: [{ id: 'gpt-4o-copilot', object: 'model', owned_by: 'vscode-bridge' }] }); + return; + } + + if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { + if (!access) { + writeJson(res, 503, { error: { message: 'Copilot unavailable', type: 'server_error', code: 'copilot_unavailable' } }); + return; + } + + const body = await readJson(req); + const messages = Array.isArray(body?.messages) ? body.messages : []; + const prompt = normalizeMessages(messages, hist); + const streamMode = body?.stream !== false; + + const session = await access.startSession(); + const chatStream = await session.sendRequest({ prompt, attachments: [] }); + + if (streamMode) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + const id = `cmp_${Math.random().toString(36).slice(2)}`; + const h1 = chatStream.onDidProduceContent((chunk) => { + const payload = { + id, + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: { content: chunk } }] + }; + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }); + const endAll = () => { + res.write('data: [DONE]\n\n'); + res.end(); + h1.dispose(); + h2.dispose(); + }; + const h2 = chatStream.onDidEnd(endAll); + req.on('close', endAll); + return; + } else { + let buf = ''; + const h1 = chatStream.onDidProduceContent((chunk) => { buf += chunk; }); + await new Promise((resolve) => { + const h2 = chatStream.onDidEnd(() => { + h1.dispose(); + h2.dispose(); + resolve(); + }); + }); + writeJson(res, 200, { + id: `cmpl_${Math.random().toString(36).slice(2)}`, + object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', content: buf }, finish_reason: 'stop' }] + }); + return; + } + } + + res.writeHead(404).end(); + } catch (e: any) { + output?.appendLine(`Error: ${e?.stack || e?.message || String(e)}`); + writeJson(res, 500, { error: { message: e?.message ?? 'internal_error', type: 'server_error', code: 'internal_error' } }); + } + }); + + await new Promise((resolve, reject) => { + server!.once('error', reject); + server!.listen(portCfg, host, () => resolve()); + }); + + const addr = server.address() as AddressInfo | null; + const shown = addr ? `${addr.address}:${addr.port}` : `${host}:${portCfg}`; + statusItem!.text = `Copilot Bridge: ${access ? 'OK' : 'Unavailable'} @ ${shown}`; + output?.appendLine(`Started at http://${shown} | Copilot: ${access ? 'ok' : 'unavailable'}`); + } catch (e: any) { + running = false; + output?.appendLine(`Failed to start: ${e?.stack || e?.message || String(e)}`); + statusItem!.text = 'Copilot Bridge: Error'; + throw e; + } +} + +async function stopBridge() { + if (!running) return; + running = false; + try { + await new Promise((resolve) => { + if (!server) return resolve(); + server.close(() => resolve()); + }); + } finally { + server = undefined; + access = undefined; + statusItem && (statusItem.text = 'Copilot Bridge: Disabled'); + output?.appendLine('Stopped'); + } +} + +function normalizeMessages(messages: any[], histWindow: number): string { + const toText = (content: any): string => { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map(toText).join('\n'); + if (content && typeof content === 'object' && typeof content.text === 'string') return content.text; + try { return JSON.stringify(content); } catch { return String(content); } + }; + const sys = messages.filter((m) => m && m.role === 'system').pop(); + const turns = messages.filter((m) => m && (m.role === 'user' || m.role === 'assistant')).slice(-histWindow * 2); + const dialog = turns.map((m) => `${m.role}: ${toText(m.content)}`).join('\n'); + const sysPart = sys ? `[SYSTEM]\n${toText(sys.content)}\n\n` : ''; + return `${sysPart}[DIALOG]\n${dialog}`; +} + +function readJson(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => { data += c; }); + req.on('end', () => { + if (!data) return resolve({}); + try { resolve(JSON.parse(data)); } catch (e) { reject(e); } + }); + req.on('error', reject); + }); +} + +function writeJson(res: http.ServerResponse, status: number, obj: any) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cbfe1c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "out", + "rootDir": "src", + "strict": true, + "sourceMap": true, + "types": ["node", "vscode"] + }, + "include": ["src/**/*.ts"] +}