diff --git a/package-lock.json b/package-lock.json index b61aa4d..101756b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,15 @@ { "name": "copilot-bridge", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-bridge", - "version": "0.1.0", + "version": "0.1.2", + "dependencies": { + "polka": "^0.5.2" + }, "devDependencies": { "@types/node": "^20.10.0", "@types/vscode": "^1.90.0", @@ -17,6 +20,15 @@ "vscode": "^1.90.0" } }, + "node_modules/@arr/every": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@arr/every/-/every-1.0.1.tgz", + "integrity": "sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -332,6 +344,12 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-0.5.0.tgz", + "integrity": "sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==", + "license": "MIT" + }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -2518,6 +2536,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/matchit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/matchit/-/matchit-1.1.0.tgz", + "integrity": "sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==", + "license": "MIT", + "dependencies": { + "@arr/every": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2998,6 +3028,16 @@ "node": ">=4" } }, + "node_modules/polka": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/polka/-/polka-0.5.2.tgz", + "integrity": "sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==", + "license": "MIT", + "dependencies": { + "@polka/url": "^0.5.0", + "trouter": "^2.0.1" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3874,6 +3914,18 @@ "node": ">=8.0" } }, + "node_modules/trouter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/trouter/-/trouter-2.0.1.tgz", + "integrity": "sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==", + "license": "MIT", + "dependencies": { + "matchit": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index eff190a..a233397 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,9 @@ "watch": "tsc -w -p .", "vscode:prepublish": "npm run compile" }, + "dependencies": { + "polka": "^0.5.2" + }, "devDependencies": { "@types/node": "^20.10.0", "@types/vscode": "^1.90.0", diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b0cf558 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,24 @@ +import * as vscode from 'vscode'; + +export interface BridgeConfig { + readonly enabled: boolean; + readonly host: string; + readonly port: number; + readonly token: string; + readonly historyWindow: number; + readonly verbose: boolean; + readonly maxConcurrent: number; +} + +export const getBridgeConfig = (): BridgeConfig => { + const cfg = vscode.workspace.getConfiguration('bridge'); + return { + enabled: cfg.get('enabled') ?? false, + host: cfg.get('host') ?? '127.0.0.1', + port: cfg.get('port') ?? 0, + token: (cfg.get('token') ?? '').trim(), + historyWindow: cfg.get('historyWindow') ?? 3, + verbose: cfg.get('verbose') ?? false, + maxConcurrent: cfg.get('maxConcurrent') ?? 1, + }; +}; diff --git a/src/extension.ts b/src/extension.ts index b8e13fe..695e652 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,119 +1,17 @@ import * as vscode from 'vscode'; -import * as http from 'http'; -import { AddressInfo } from 'net'; - -// Type definitions -interface ChatMessage { - readonly role: 'system' | 'user' | 'assistant'; - readonly content: string | MessageContent[]; -} - -interface MessageContent { - readonly type: string; - readonly text?: string; - readonly [key: string]: unknown; -} - -interface ChatCompletionRequest { - readonly model?: string; - readonly messages: ChatMessage[]; - readonly stream?: boolean; - readonly [key: string]: unknown; -} - -interface BridgeConfig { - readonly enabled: boolean; - readonly host: string; - readonly port: number; - readonly token: string; - readonly historyWindow: number; - readonly verbose: boolean; - readonly maxConcurrent: number; -} - -interface ErrorResponse { - readonly error: { - readonly message: string; - readonly type: string; - readonly code: string; - readonly reason?: string; - }; -} - -interface LanguageModelAPI { - readonly selectChatModels: (selector?: { vendor?: string; family?: string }) => Promise; -} - -interface LanguageModel { - readonly family?: string; - readonly modelFamily?: string; - readonly name?: string; - readonly sendRequest: (messages: unknown[], options: unknown, token: vscode.CancellationToken) => Promise; -} - -interface LanguageModelResponse { - readonly text: AsyncIterable; -} - -// Module state -interface BridgeState { - server?: http.Server; - modelCache?: LanguageModel; - statusItem?: vscode.StatusBarItem; - output?: vscode.OutputChannel; - running: boolean; - activeRequests: number; - lastReason?: string; -} - -const state: BridgeState = { - running: false, - activeRequests: 0, -}; - -// Type guards -const isValidRole = (role: unknown): role is 'system' | 'user' | 'assistant' => - typeof role === 'string' && ['system', 'user', 'assistant'].includes(role); - -const isChatMessage = (msg: unknown): msg is ChatMessage => - typeof msg === 'object' && - msg !== null && - 'role' in msg && - 'content' in msg && - isValidRole((msg as any).role) && - ((msg as any).content !== undefined && (msg as any).content !== null); - -const isChatCompletionRequest = (body: unknown): body is ChatCompletionRequest => - typeof body === 'object' && - body !== null && - 'messages' in body && - Array.isArray((body as any).messages) && - (body as any).messages.length > 0 && - (body as any).messages.every(isChatMessage); - -const hasLanguageModelAPI = (): boolean => - !!(vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'; - -// Configuration helpers -const getBridgeConfig = (): BridgeConfig => { - const cfg = vscode.workspace.getConfiguration('bridge'); - return { - enabled: cfg.get('enabled') ?? false, - host: cfg.get('host') ?? '127.0.0.1', - port: cfg.get('port') ?? 0, - token: (cfg.get('token') ?? '').trim(), - historyWindow: cfg.get('historyWindow') ?? 3, - verbose: cfg.get('verbose') ?? false, - maxConcurrent: cfg.get('maxConcurrent') ?? 1, - }; -}; +import { getBridgeConfig } from './config'; +import { state } from './state'; +import { ensureOutput, verbose } from './log'; +import { ensureStatusBar } from './status'; +import { startServer, stopServer } from './http/server'; +import { getModel } from './models'; export async function activate(ctx: vscode.ExtensionContext): Promise { - state.output = vscode.window.createOutputChannel('Copilot Bridge'); - state.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); - state.statusItem.text = 'Copilot Bridge: Disabled'; - state.statusItem.show(); - ctx.subscriptions.push(state.statusItem, state.output); + ensureOutput(); + ensureStatusBar(); + state.statusItem!.text = 'Copilot Bridge: Disabled'; + state.statusItem!.show(); + ctx.subscriptions.push(state.statusItem!, state.output!); ctx.subscriptions.push(vscode.commands.registerCommand('bridge.enable', async () => { await startBridge(); @@ -126,7 +24,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions.push(vscode.commands.registerCommand('bridge.status', async () => { const info = state.server?.address(); - const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : 'n/a'; + const bound = info && typeof info === 'object' ? `${(info as any).address}:${(info as any).port}` : 'n/a'; const config = getBridgeConfig(); const hasToken = config.token.length > 0; vscode.window.showInformationMessage( @@ -147,526 +45,27 @@ export async function deactivate(): Promise { async function startBridge(): Promise { if (state.running) return; state.running = true; - - const config = getBridgeConfig(); - try { - state.server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - if (config.verbose) state.output?.appendLine(`HTTP ${req.method} ${req.url}`); - - if (!isAuthorized(req, config.token)) { - writeErrorResponse(res, 401, 'unauthorized', 'invalid_request_error', 'unauthorized'); - return; - } - - if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { - if (state.activeRequests >= config.maxConcurrent) { - res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '1' }); - res.end(JSON.stringify({ - error: { - message: 'too many requests', - type: 'rate_limit_error', - code: 'rate_limit_exceeded' - } - })); - if (config.verbose) { - state.output?.appendLine(`429 throttled (active=${state.activeRequests}, max=${config.maxConcurrent})`); - } - return; - } - } - - if (req.method === 'GET' && req.url === '/healthz') { - await handleHealthCheck(res, config.verbose); - return; - } - - if (req.method === 'GET' && req.url === '/v1/models') { - await handleModelsRequest(res); - return; - } - - if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { - await handleChatCompletion(req, res, config); - return; - } - - res.writeHead(404).end(); - } catch (error) { - handleServerError(error, res); - } - }); - - await startServer(state.server, config); - updateStatusAfterStart(config); - } catch (error) { - handleStartupError(error, config); - } -} - -// Helper functions -const isAuthorized = (req: http.IncomingMessage, token: string): boolean => - !token || req.headers.authorization === `Bearer ${token}`; - -const writeErrorResponse = ( - res: http.ServerResponse, - status: number, - message: string, - type: string, - code: string, - reason?: string -): void => { - writeJson(res, status, { - error: { message, type, code, ...(reason && { reason }) } - }); -}; - -const handleServerError = (error: unknown, res: http.ServerResponse): void => { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; - - state.output?.appendLine(`Error: ${errorStack || errorMessage}`); - state.modelCache = undefined; - writeErrorResponse(res, 500, errorMessage || 'internal_error', 'server_error', 'internal_error'); -}; - -const handleStartupError = (error: unknown, config: BridgeConfig): never => { - state.running = false; - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; - - state.output?.appendLine(`Failed to start: ${errorStack || errorMessage}`); - if (state.statusItem) { - state.statusItem.text = 'Copilot Bridge: Error'; - } - throw error; -}; - -const startServer = async (server: http.Server, config: BridgeConfig): Promise => { - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(config.port, config.host, () => resolve()); - }); -}; - -const updateStatusAfterStart = (config: BridgeConfig): void => { - const addr = state.server?.address() as AddressInfo | null; - const shown = addr ? `${addr.address}:${addr.port}` : `${config.host}:${config.port}`; - - if (state.statusItem) { - state.statusItem.text = `Copilot Bridge: ${state.modelCache ? 'OK' : 'Unavailable'} @ ${shown}`; - } - - state.output?.appendLine(`Started at http://${shown} | Copilot: ${state.modelCache ? 'ok' : 'unavailable'}`); - - if (config.verbose) { - const tokenStatus = config.token ? 'set' : 'unset'; - state.output?.appendLine( - `Config: host=${config.host} port=${addr?.port ?? config.port} hist=${config.historyWindow} maxConcurrent=${config.maxConcurrent} token=${tokenStatus}` - ); - } -}; - -const handleHealthCheck = async (res: http.ServerResponse, verbose: boolean): Promise => { - const hasLM = hasLanguageModelAPI(); - if (!state.modelCache && verbose) { - state.output?.appendLine(`Healthz: model=${state.modelCache ? 'present' : 'missing'} lmApi=${hasLM ? 'ok' : 'missing'}`); - await getModel(); - } - - const unavailableReason = state.modelCache - ? undefined - : (!hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable')); - - writeJson(res, 200, { - ok: true, - copilot: state.modelCache ? 'ok' : 'unavailable', - reason: unavailableReason, - version: vscode.version - }); -}; - -const handleModelsRequest = async (res: http.ServerResponse): Promise => { - try { - const models = await listCopilotModels(); - writeJson(res, 200, { - data: models.map((id: string) => ({ - id, - object: 'model', - owned_by: 'vscode-bridge' - })) - }); - } catch { - writeJson(res, 200, { - data: [{ - id: 'copilot', - object: 'model', - owned_by: 'vscode-bridge' - }] - }); - } -}; - -const handleChatCompletion = async ( - req: http.IncomingMessage, - res: http.ServerResponse, - config: BridgeConfig -): Promise => { - state.activeRequests++; - if (config.verbose) { - state.output?.appendLine(`Request started (active=${state.activeRequests})`); - } - - try { - const body = await readJson(req); - if (!isChatCompletionRequest(body)) { - writeErrorResponse(res, 400, 'invalid request', 'invalid_request_error', 'invalid_payload'); - return; - } - - const { model: requestedModel, stream = true } = body; - const familyOverride = extractModelFamily(requestedModel); - - const model = await getModel(false, familyOverride); - if (!model) { - const hasLM = hasLanguageModelAPI(); - if (familyOverride && hasLM) { - state.lastReason = 'not_found'; - writeErrorResponse(res, 404, 'model not found', 'invalid_request_error', 'model_not_found', 'not_found'); - return; - } - - const reason = !hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable'); - writeErrorResponse(res, 503, 'Copilot unavailable', 'server_error', 'copilot_unavailable', reason); - return; - } - - const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow); - - if (config.verbose) { - state.output?.appendLine(`Sending request to Copilot via Language Model API... ${model.family || model.modelFamily || model.name || 'unknown'}`); - } - - const cts = new vscode.CancellationTokenSource(); - const response = await model.sendRequest(lmMessages, {}, cts.token); - - if (stream) { - await handleStreamResponse(res, response, config.verbose); - } else { - await handleNonStreamResponse(res, response, config.verbose); - } - } finally { - state.activeRequests--; - if (config.verbose) { - state.output?.appendLine(`Request complete (active=${state.activeRequests})`); - } - } -}; - -const extractModelFamily = (requestedModel?: string): string | undefined => { - if (!requestedModel) return undefined; - - if (/-copilot$/i.test(requestedModel)) { - return requestedModel.replace(/-copilot$/i, ''); - } - - if (requestedModel.toLowerCase() === 'copilot') { - return undefined; - } - - return undefined; -}; - -const handleStreamResponse = async ( - res: http.ServerResponse, - response: LanguageModelResponse, - verbose: boolean -): Promise => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - }); - - const id = `cmp_${Math.random().toString(36).slice(2)}`; - if (verbose) { - state.output?.appendLine(`SSE start id=${id}`); - } - - try { - for await (const fragment of response.text) { - const payload = { - id, - object: 'chat.completion.chunk', - choices: [{ index: 0, delta: { content: fragment } }] - }; - res.write(`data: ${JSON.stringify(payload)}\n\n`); - } - - if (verbose) { - state.output?.appendLine(`SSE end id=${id}`); - } - - res.write('data: [DONE]\n\n'); - res.end(); - } catch (error) { + await startServer(); + } catch (error: any) { + state.running = false; + state.statusItem!.text = 'Copilot Bridge: Error'; + verbose(error?.stack || String(error)); throw error; } -}; - -const handleNonStreamResponse = async ( - res: http.ServerResponse, - response: LanguageModelResponse, - verbose: boolean -): Promise => { - let content = ''; - for await (const fragment of response.text) { - content += fragment; - } - - if (verbose) { - state.output?.appendLine(`Non-stream complete len=${content.length}`); - } - - writeJson(res, 200, { - id: `cmpl_${Math.random().toString(36).slice(2)}`, - object: 'chat.completion', - choices: [{ - index: 0, - message: { role: 'assistant', content }, - finish_reason: 'stop' - }] - }); -}; +} async function stopBridge(): Promise { if (!state.running) return; state.running = false; - try { - await new Promise((resolve) => { - if (!state.server) return resolve(); - state.server.close(() => resolve()); - }); + await stopServer(); } finally { state.server = undefined; state.modelCache = undefined; if (state.statusItem) { state.statusItem.text = 'Copilot Bridge: Disabled'; } - state.output?.appendLine('Stopped'); + verbose('Stopped'); } } - -// Text conversion utility -const toText = (content: unknown): string => { - if (typeof content === 'string') return content; - if (Array.isArray(content)) return content.map(toText).join('\n'); - if (content && typeof content === 'object' && 'text' in content && typeof (content as any).text === 'string') { - return (content as any).text; - } - try { - return JSON.stringify(content); - } catch { - return String(content); - } -}; - -const normalizeMessagesLM = (messages: ChatMessage[], histWindow: number): unknown[] => { - const systemMessages = messages.filter((m) => m.role === 'system'); - const systemMessage = systemMessages[systemMessages.length - 1]; // Take the last system message - const conversationMessages = messages - .filter((m) => m.role === 'user' || m.role === 'assistant') - .slice(-histWindow * 2); - - const vsCode = vscode as any; - const User = vsCode.LanguageModelChatMessage?.User; - const Assistant = vsCode.LanguageModelChatMessage?.Assistant; - - const result: unknown[] = []; - let firstUserSeen = false; - - for (const message of conversationMessages) { - if (message.role === 'user') { - let text = toText(message.content); - if (!firstUserSeen && systemMessage) { - text = `[SYSTEM]\n${toText(systemMessage.content)}\n\n[DIALOG]\nuser: ${text}`; - firstUserSeen = true; - } - result.push(User ? User(text) : { role: 'user', content: text }); - } else if (message.role === 'assistant') { - const text = toText(message.content); - result.push(Assistant ? Assistant(text) : { role: 'assistant', content: text }); - } - } - - if (!firstUserSeen && systemMessage) { - const text = `[SYSTEM]\n${toText(systemMessage.content)}`; - result.unshift(User ? User(text) : { role: 'user', content: text }); - } - - if (result.length === 0) { - result.push(User ? User('') : { role: 'user', content: '' }); - } - - return result; -}; - -async function getModel(force = false, family?: string): Promise { - if (!force && state.modelCache && !family) return state.modelCache; - - const config = getBridgeConfig(); - const hasLM = hasLanguageModelAPI(); - - if (!hasLM) { - if (!family) state.modelCache = undefined; - state.lastReason = 'missing_language_model_api'; - updateStatusWithError(); - if (config.verbose) { - state.output?.appendLine('VS Code Language Model API not available; update VS Code or enable proposed API (Insiders/F5/--enable-proposed-api).'); - } - return undefined; - } - - try { - const models = await selectChatModels(family); - if (!models || models.length === 0) { - if (!family) state.modelCache = undefined; - state.lastReason = family ? 'not_found' : 'copilot_model_unavailable'; - updateStatusWithError(); - if (config.verbose) { - const message = family - ? `No Copilot language models available for family="${family}".` - : 'No Copilot language models available.'; - state.output?.appendLine(message); - } - return undefined; - } - - const chosen = models[0]; - if (!family) state.modelCache = chosen; - state.lastReason = undefined; - updateStatusWithSuccess(); - if (config.verbose) { - const familyInfo = family ? ` (family=${family})` : ''; - state.output?.appendLine(`Copilot model selected${familyInfo}.`); - } - return chosen; - } catch (error) { - if (!family) state.modelCache = undefined; - updateStatusWithError(); - handleModelSelectionError(error, family, config.verbose); - return undefined; - } -} - -const selectChatModels = async (family?: string): Promise => { - const lm = (vscode as any).lm; - if (family) { - return await lm.selectChatModels({ vendor: 'copilot', family }); - } else { - // Try gpt-4o first, fallback to any copilot model - let models = await lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); - state.output?.appendLine(`Fallback to gpt-4o. Family requested was: ${family}.`); - - if (!models || models.length === 0) { - models = await lm.selectChatModels({ vendor: 'copilot' }); - } - return models; - } -}; - -const updateStatusWithError = (): void => { - const info = state.server?.address() as AddressInfo | null; - const bound = info ? `${info.address}:${info.port}` : ''; - if (state.statusItem) { - state.statusItem.text = `Copilot Bridge: Unavailable ${bound ? `@ ${bound}` : ''}`; - } -}; - -const updateStatusWithSuccess = (): void => { - const info = state.server?.address() as AddressInfo | null; - const bound = info ? `${info.address}:${info.port}` : ''; - if (state.statusItem) { - state.statusItem.text = `Copilot Bridge: OK ${bound ? `@ ${bound}` : ''}`; - } -}; - -const handleModelSelectionError = (error: unknown, family: string | undefined, verbose: boolean): void => { - const vsCode = vscode as any; - - if (vsCode.LanguageModelError && error instanceof vsCode.LanguageModelError) { - const code = (error as any).code || ''; - const errorMessage = error instanceof Error ? error.message : String(error); - - if (/consent/i.test(errorMessage) || code === 'UserNotSignedIn') { - state.lastReason = 'consent_required'; - } else if (code === 'RateLimited') { - state.lastReason = 'rate_limited'; - } else if (code === 'NotFound') { - state.lastReason = 'not_found'; - } else { - state.lastReason = 'copilot_unavailable'; - } - - if (verbose) { - const familyInfo = family ? ` family=${family}` : ''; - state.output?.appendLine(`LM select error: ${errorMessage} code=${code}${familyInfo}`); - } - } else { - state.lastReason = 'copilot_unavailable'; - if (verbose) { - const errorMessage = error instanceof Error ? error.message : String(error); - const familyInfo = family ? ` family=${family}` : ''; - state.output?.appendLine(`LM select error: ${errorMessage}${familyInfo}`); - } - } -}; - -async function listCopilotModels(): Promise { - const hasLM = hasLanguageModelAPI(); - if (!hasLM) return ['copilot']; - - try { - const models: LanguageModel[] = await (vscode as any).lm.selectChatModels({ vendor: 'copilot' }); - if (!models || models.length === 0) return ['copilot']; - - const ids = models.map((model, index) => { - const family = model.family || model.modelFamily || model.name || ''; - const normalized = typeof family === 'string' && family.trim() - ? family.trim().toLowerCase() - : `copilot-${index + 1}`; - return normalized.endsWith('-copilot') ? normalized : `${normalized}-copilot`; - }); - - return Array.from(new Set(ids)); - } catch { - return ['copilot']; - } -} - -const readJson = (req: http.IncomingMessage): Promise => { - return new Promise((resolve, reject) => { - let data = ''; - req.on('data', (chunk: any) => { - data += chunk.toString(); - }); - req.on('end', () => { - if (!data) return resolve({}); - try { - resolve(JSON.parse(data)); - } catch (error) { - const snippet = data.length > 200 ? data.slice(0, 200) + '...' : data; - const errorMessage = error instanceof Error ? error.message : String(error); - reject(new Error(`Failed to parse JSON: ${errorMessage}. Data: "${snippet}"`)); - } - }); - req.on('error', reject); - }); -}; - -const writeJson = (res: http.ServerResponse, status: number, obj: unknown): void => { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(obj)); -}; diff --git a/src/http/auth.ts b/src/http/auth.ts new file mode 100644 index 0000000..cb45dfa --- /dev/null +++ b/src/http/auth.ts @@ -0,0 +1,4 @@ +import type { IncomingMessage } from 'http'; + +export const isAuthorized = (req: IncomingMessage, token: string): boolean => + !token || req.headers.authorization === `Bearer ${token}`; diff --git a/src/http/routes/chat.ts b/src/http/routes/chat.ts new file mode 100644 index 0000000..0f494a3 --- /dev/null +++ b/src/http/routes/chat.ts @@ -0,0 +1,87 @@ +import * as vscode from 'vscode'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { state } from '../../state'; +import { getBridgeConfig } from '../../config'; +import { extractModelFamily, isChatCompletionRequest, normalizeMessagesLM } from '../../messages'; +import { getModel, hasLMApi } from '../../models'; +import { readJson, writeErrorResponse, writeJson } from '../utils'; +import { verbose } from '../../log'; + +export const handleChatCompletion = async (req: IncomingMessage, res: ServerResponse): Promise => { + const config = getBridgeConfig(); + state.activeRequests++; + verbose(`Request started (active=${state.activeRequests})`); + try { + const body = await readJson(req); + if (!isChatCompletionRequest(body)) { + writeErrorResponse(res, 400, 'invalid request', 'invalid_request_error', 'invalid_payload'); + return; + } + const { model: requestedModel, stream = true } = body; + const familyOverride = extractModelFamily(requestedModel); + const model = await getModel(false, familyOverride); + if (!model) { + const hasLM = hasLMApi(); + if (familyOverride && hasLM) { + state.lastReason = 'not_found'; + writeErrorResponse(res, 404, 'model not found', 'invalid_request_error', 'model_not_found', 'not_found'); + return; + } + const reason = !hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable'); + writeErrorResponse(res, 503, 'Copilot unavailable', 'server_error', 'copilot_unavailable', reason); + return; + } + const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow); + verbose(`Sending request to Copilot via Language Model API... ${model.family || model.modelFamily || model.name || 'unknown'}`); + const cts = new vscode.CancellationTokenSource(); + const response = await model.sendRequest(lmMessages, {}, cts.token); + if (stream) { + await handleStreamResponse(res, response); + } else { + await handleNonStreamResponse(res, response); + } + } finally { + state.activeRequests--; + verbose(`Request complete (active=${state.activeRequests})`); + } +}; + +const handleStreamResponse = async (res: ServerResponse, response: any): Promise => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + const id = `cmp_${Math.random().toString(36).slice(2)}`; + verbose(`SSE start id=${id}`); + for await (const fragment of response.text) { + const payload = { + id, + object: 'chat.completion.chunk', + choices: [{ index: 0, delta: { content: fragment } }], + }; + res.write(`data: ${JSON.stringify(payload)}\n\n`); + } + verbose(`SSE end id=${id}`); + res.write('data: [DONE]\n\n'); + res.end(); +}; + +const handleNonStreamResponse = async (res: ServerResponse, response: any): Promise => { + let content = ''; + for await (const fragment of response.text) { + content += fragment; + } + verbose(`Non-stream complete len=${content.length}`); + writeJson(res, 200, { + id: `cmpl_${Math.random().toString(36).slice(2)}`, + object: 'chat.completion', + choices: [ + { + index: 0, + message: { role: 'assistant', content }, + finish_reason: 'stop', + }, + ], + }); +}; diff --git a/src/http/routes/health.ts b/src/http/routes/health.ts new file mode 100644 index 0000000..87eb9a8 --- /dev/null +++ b/src/http/routes/health.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; +import type { ServerResponse } from 'http'; +import { writeJson } from '../utils'; +import { hasLMApi, getModel } from '../../models'; +import { state } from '../../state'; +import { verbose } from '../../log'; + +export const handleHealthCheck = async (res: ServerResponse, v: boolean): Promise => { + const hasLM = hasLMApi(); + if (!state.modelCache && v) { + verbose(`Healthz: model=${state.modelCache ? 'present' : 'missing'} lmApi=${hasLM ? 'ok' : 'missing'}`); + await getModel(); + } + const unavailableReason = state.modelCache + ? undefined + : (!hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable')); + writeJson(res, 200, { + ok: true, + copilot: state.modelCache ? 'ok' : 'unavailable', + reason: unavailableReason, + version: vscode.version, + }); +}; diff --git a/src/http/routes/models.ts b/src/http/routes/models.ts new file mode 100644 index 0000000..fdd89ff --- /dev/null +++ b/src/http/routes/models.ts @@ -0,0 +1,25 @@ +import { writeJson } from '../utils'; +import { listCopilotModels } from '../../models'; + +export const handleModelsRequest = async (res: any): Promise => { + try { + const models = await listCopilotModels(); + writeJson(res, 200, { + data: models.map((id: string) => ({ + id, + object: 'model', + owned_by: 'vscode-bridge', + })), + }); + } catch { + writeJson(res, 200, { + data: [ + { + id: 'copilot', + object: 'model', + owned_by: 'vscode-bridge', + }, + ], + }); + } +}; diff --git a/src/http/server.ts b/src/http/server.ts new file mode 100644 index 0000000..0ae8b1e --- /dev/null +++ b/src/http/server.ts @@ -0,0 +1,78 @@ +const polka = require('polka'); +import type { Server } from 'http'; +import { getBridgeConfig } from '../config'; +import { state } from '../state'; +import { isAuthorized } from './auth'; +import { handleHealthCheck } from './routes/health'; +import { handleModelsRequest } from './routes/models'; +import { handleChatCompletion } from './routes/chat'; +import { writeErrorResponse } from './utils'; +import { ensureOutput, verbose } from '../log'; +import { updateStatusAfterStart } from '../status'; + +export const startServer = async (): Promise => { + if (state.server) return; + const config = getBridgeConfig(); + ensureOutput(); + + const app = polka(); + + app.use((req: any, res: any, next: any) => { + verbose(`HTTP ${req.method} ${req.url}`); + if (!isAuthorized(req, config.token)) { + writeErrorResponse(res, 401, 'unauthorized', 'invalid_request_error', 'unauthorized'); + return; + } + next(); + }); + + app.get('/healthz', async (_req: any, res: any) => { + await handleHealthCheck(res, config.verbose); + }); + + app.get('/v1/models', async (_req: any, res: any) => { + await handleModelsRequest(res); + }); + + app.post('/v1/chat/completions', async (req: any, res: any) => { + if (state.activeRequests >= config.maxConcurrent) { + res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '1' }); + res.end(JSON.stringify({ + error: { + message: 'too many requests', + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + }, + })); + verbose(`429 throttled (active=${state.activeRequests}, max=${config.maxConcurrent})`); + return; + } + try { + await handleChatCompletion(req, res); + } catch (e: any) { + const msg = e instanceof Error ? e.message : String(e); + writeErrorResponse(res, 500, msg || 'internal_error', 'server_error', 'internal_error'); + } + }); + + await new Promise((resolve, reject) => { + const srv: Server = app.listen(config.port, config.host, (err?: any) => { + if (err) { + reject(err); + } else { + state.server = srv; + updateStatusAfterStart(); + resolve(); + } + }); + srv.on('error', reject); + }); +}; + +export const stopServer = async (): Promise => { + await new Promise((resolve) => { + if (!state.server) return resolve(); + state.server.close(() => resolve()); + }); + state.server = undefined; +}; diff --git a/src/http/utils.ts b/src/http/utils.ts new file mode 100644 index 0000000..ba87ef3 --- /dev/null +++ b/src/http/utils.ts @@ -0,0 +1,42 @@ +import type { ServerResponse, IncomingMessage } from 'http'; + +export interface ErrorResponse { + readonly error: { + readonly message: string; + readonly type: string; + readonly code: string; + readonly reason?: string; + }; +} + +export const writeJson = (res: ServerResponse, status: number, body: any): void => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +}; + +export const writeErrorResponse = ( + res: ServerResponse, + status: number, + message: string, + type: string, + code: string, + reason?: string +): void => { + writeJson(res, status, { + error: { message, type, code, ...(reason && { reason }) }, + }); +}; + +export const readJson = (req: IncomingMessage): Promise => + new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => (data += c)); + req.on('end', () => { + try { + resolve(data ? JSON.parse(data) : {}); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..0e5b276 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; +import { state } from './state'; +import { getBridgeConfig } from './config'; + +export const ensureOutput = (): void => { + if (!state.output) { + state.output = vscode.window.createOutputChannel('Copilot Bridge'); + } +}; + +export const info = (msg: string): void => { + ensureOutput(); + state.output?.appendLine(msg); +}; + +export const verbose = (msg: string): void => { + const cfg = getBridgeConfig(); + if (!cfg.verbose) return; + ensureOutput(); + state.output?.appendLine(msg); +}; + +export const error = (msg: string): void => { + ensureOutput(); + state.output?.appendLine(msg); +}; diff --git a/src/messages.ts b/src/messages.ts new file mode 100644 index 0000000..733039e --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,101 @@ +import * as vscode from 'vscode'; + +export interface ChatMessage { + readonly role: 'system' | 'user' | 'assistant'; + readonly content: string | MessageContent[]; +} + +export interface MessageContent { + readonly type: string; + readonly text?: string; + readonly [key: string]: unknown; +} + +export interface ChatCompletionRequest { + readonly model?: string; + readonly messages: ChatMessage[]; + readonly stream?: boolean; + readonly [key: string]: unknown; +} + +const isValidRole = (role: unknown): role is 'system' | 'user' | 'assistant' => + typeof role === 'string' && ['system', 'user', 'assistant'].includes(role); + +export const isChatMessage = (msg: unknown): msg is ChatMessage => + typeof msg === 'object' && + msg !== null && + 'role' in msg && + 'content' in msg && + isValidRole((msg as any).role) && + ((msg as any).content !== undefined && (msg as any).content !== null); + +export const isChatCompletionRequest = (body: unknown): body is ChatCompletionRequest => + typeof body === 'object' && + body !== null && + 'messages' in body && + Array.isArray((body as any).messages) && + (body as any).messages.length > 0 && + (body as any).messages.every(isChatMessage); + +const toText = (content: unknown): string => { + if (typeof content === 'string') return content; + if (Array.isArray(content)) return content.map(toText).join('\n'); + if (content && typeof content === 'object' && 'text' in content && typeof (content as any).text === 'string') { + return (content as any).text; + } + try { + return JSON.stringify(content); + } catch { + return String(content); + } +}; + +export const normalizeMessagesLM = (messages: ChatMessage[], histWindow: number): unknown[] => { + const systemMessages = messages.filter((m) => m.role === 'system'); + const systemMessage = systemMessages[systemMessages.length - 1]; + const conversationMessages = messages + .filter((m) => m.role === 'user' || m.role === 'assistant') + .slice(-histWindow * 2); + + const User = (vscode as any).LanguageModelChatMessage?.User; + const Assistant = (vscode as any).LanguageModelChatMessage?.Assistant; + + const result: unknown[] = []; + let firstUserSeen = false; + + for (const message of conversationMessages) { + if (message.role === 'user') { + let text = toText(message.content); + if (!firstUserSeen && systemMessage) { + text = `[SYSTEM]\n${toText(systemMessage.content)}\n\n[DIALOG]\nuser: ${text}`; + firstUserSeen = true; + } + result.push(User ? User(text) : { role: 'user', content: text }); + } else if (message.role === 'assistant') { + const text = toText(message.content); + result.push(Assistant ? Assistant(text) : { role: 'assistant', content: text }); + } + } + + if (!firstUserSeen && systemMessage) { + const text = `[SYSTEM]\n${toText(systemMessage.content)}`; + result.unshift(User ? User(text) : { role: 'user', content: text }); + } + + if (result.length === 0) { + result.push(User ? User('') : { role: 'user', content: '' }); + } + + return result; +}; + +export const extractModelFamily = (requestedModel?: string): string | undefined => { + if (!requestedModel) return undefined; + if (/-copilot$/i.test(requestedModel)) { + return requestedModel.replace(/-copilot$/i, ''); + } + if (requestedModel.toLowerCase() === 'copilot') { + return undefined; + } + return undefined; +}; diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..aaa68a9 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; +import { state, LanguageModel } from './state'; +import { updateStatusWithError, updateStatusWithSuccess } from './status'; +import { verbose } from './log'; + +const hasLanguageModelAPI = (): boolean => + !!(vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'; + +export const selectChatModels = async (family?: string): Promise => { + const lm = (vscode as any).lm; + const selector = family ? { family } : undefined; + const models = await lm.selectChatModels(selector); + return models as unknown as LanguageModel[]; +}; + +export const getModel = async (force = false, family?: string): Promise => { + if (!force && state.modelCache && !family) return state.modelCache; + + const hasLM = hasLanguageModelAPI(); + + if (!hasLM) { + if (!family) state.modelCache = undefined; + state.lastReason = 'missing_language_model_api'; + updateStatusWithError(); + verbose('VS Code Language Model API not available; update VS Code or enable proposed API (Insiders/F5/--enable-proposed-api).'); + return undefined; + } + + try { + const models = await selectChatModels(family); + if (!models || models.length === 0) { + if (!family) state.modelCache = undefined; + state.lastReason = family ? 'not_found' : 'copilot_model_unavailable'; + updateStatusWithError(); + const m = family ? `no models for family ${family}` : 'no copilot models available'; + verbose(m); + return undefined; + } + state.modelCache = models[0]; + state.lastReason = undefined; + updateStatusWithSuccess(); + return state.modelCache; + } catch (e: any) { + handleModelSelectionError(e, family); + return undefined; + } +}; + +export const handleModelSelectionError = (error: unknown, family?: string): void => { + const msg = error instanceof Error ? error.message : String(error); + if (/not found/i.test(msg) || /Unknown model family/i.test(msg)) { + state.lastReason = 'not_found'; + } else if (/No chat models/i.test(msg)) { + state.lastReason = 'copilot_model_unavailable'; + } else { + state.lastReason = 'copilot_model_unavailable'; + } + updateStatusWithError(); + const fam = family ? ` family=${family}` : ''; + verbose(`Model selection failed: ${msg}${fam}`); +}; + +export const listCopilotModels = async (): Promise => { + try { + const models = await selectChatModels(); + const ids = models.map((m: any) => { + const normalized = m.family || m.modelFamily || m.name || 'copilot'; + return `${normalized}-copilot`; + }); + return ids.length ? ids : ['copilot']; + } catch { + return ['copilot']; + } +}; + +export const hasLMApi = hasLanguageModelAPI; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..b356fba --- /dev/null +++ b/src/state.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; +import type { Server } from 'http'; + +export interface LanguageModel { + readonly family?: string; + readonly modelFamily?: string; + readonly name?: string; + readonly sendRequest: (messages: unknown[], options: unknown, token: vscode.CancellationToken) => Promise; +} + +export interface LanguageModelResponse { + readonly text: AsyncIterable; +} + +export interface BridgeState { + server?: Server; + modelCache?: LanguageModel; + statusItem?: vscode.StatusBarItem; + output?: vscode.OutputChannel; + running: boolean; + activeRequests: number; + lastReason?: string; +} + +export const state: BridgeState = { + running: false, + activeRequests: 0, +}; diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..67bb58b --- /dev/null +++ b/src/status.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { AddressInfo } from 'net'; +import { state } from './state'; +import { getBridgeConfig } from './config'; +import { info } from './log'; + +export const ensureStatusBar = (): void => { + if (!state.statusItem) { + state.statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + state.statusItem.text = 'Copilot Bridge: Disabled'; + state.statusItem.show(); + } +}; + +export const updateStatusAfterStart = (): void => { + const cfg = getBridgeConfig(); + const addr = state.server?.address() as AddressInfo | null; + const shown = addr ? `${addr.address}:${addr.port}` : `${cfg.host}:${cfg.port}`; + if (state.statusItem) { + state.statusItem.text = `Copilot Bridge: ${state.modelCache ? 'OK' : 'Unavailable'} @ ${shown}`; + } + info(`Started at http://${shown} | Copilot: ${state.modelCache ? 'ok' : 'unavailable'}`); +}; + +export const updateStatusWithError = (): void => { + const cfg = getBridgeConfig(); + const addr = state.server?.address() as AddressInfo | null; + const shown = addr ? `${addr.address}:${addr.port}` : `${cfg.host}:${cfg.port}`; + if (state.statusItem) { + state.statusItem.text = `Copilot Bridge: Unavailable @ ${shown}`; + } +}; + +export const updateStatusWithSuccess = (): void => { + const cfg = getBridgeConfig(); + const addr = state.server?.address() as AddressInfo | null; + const shown = addr ? `${addr.address}:${addr.port}` : `${cfg.host}:${cfg.port}`; + if (state.statusItem) { + state.statusItem.text = `Copilot Bridge: OK @ ${shown}`; + } +}; diff --git a/src/types/polka.d.ts b/src/types/polka.d.ts new file mode 100644 index 0000000..5a3bccc --- /dev/null +++ b/src/types/polka.d.ts @@ -0,0 +1,2 @@ +declare function polka(...args: any[]): any; +export = polka;