diff --git a/package.json b/package.json index d1e050c..eff190a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "copilot-bridge", "displayName": "Copilot Bridge", "description": "Local OpenAI-compatible chat endpoint bridging to GitHub Copilot via the VS Code Language Model API.", - "version": "0.1.0", + "version": "0.1.2", "publisher": "thinkability", "engines": { "vscode": "^1.90.0" diff --git a/src/extension.ts b/src/extension.ts index 53ad572..b8e13fe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,259 +2,507 @@ import * as vscode from 'vscode'; import * as http from 'http'; import { AddressInfo } from 'net'; -let server: http.Server | undefined; -let modelCache: any | undefined; -let statusItem: vscode.StatusBarItem | undefined; -let output: vscode.OutputChannel | undefined; -let running = false; -let activeRequests = 0; -let lastReason: string | undefined; +// Type definitions +interface ChatMessage { + readonly role: 'system' | 'user' | 'assistant'; + readonly content: string | MessageContent[]; +} -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); +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, + }; +}; + +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); ctx.subscriptions.push(vscode.commands.registerCommand('bridge.enable', async () => { await startBridge(); await getModel(true); })); + 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 info = state.server?.address(); 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 ? 'Set' : 'None'}`); + const config = getBridgeConfig(); + const hasToken = config.token.length > 0; + vscode.window.showInformationMessage( + `Copilot Bridge: ${state.running ? 'Enabled' : 'Disabled'} | Bound: ${bound} | Token: ${hasToken ? 'Set' : 'None'}` + ); })); - const cfg = vscode.workspace.getConfiguration('bridge'); - if (cfg.get('enabled')) { + const config = getBridgeConfig(); + if (config.enabled) { await startBridge(); } } -export async function deactivate() { +export async function deactivate(): Promise { await stopBridge(); } -async function startBridge() { - if (running) return; - running = true; +async function startBridge(): Promise { + if (state.running) return; + state.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; - const verbose = cfg.get('verbose') ?? false; - const maxConc = cfg.get('maxConcurrent') ?? 1; + const config = getBridgeConfig(); try { - server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { + state.server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { try { - if (verbose) output?.appendLine(`HTTP ${req.method} ${req.url}`); - if (token && req.headers.authorization !== `Bearer ${token}`) { - writeJson(res, 401, { error: { message: 'unauthorized', type: 'invalid_request_error', code: 'unauthorized' } }); + 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 (activeRequests >= maxConc) { + 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 (verbose) output?.appendLine(`429 throttled (active=${activeRequests}, max=${maxConc})`); + 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') { - const cfgNow = vscode.workspace.getConfiguration('bridge'); - const verboseNow = cfgNow.get('verbose') ?? false; - const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); - if (!modelCache && verboseNow) { - output?.appendLine(`Healthz: model=${modelCache ? 'present' : 'missing'} lmApi=${hasLM ? 'ok' : 'missing'}`); - await getModel(); - } - const unavailableReason = modelCache ? undefined : (!hasLM ? 'missing_language_model_api' : (lastReason || 'copilot_model_unavailable')); - writeJson(res, 200, { ok: true, copilot: modelCache ? 'ok' : 'unavailable', reason: unavailableReason, version: vscode.version }); + await handleHealthCheck(res, config.verbose); return; } if (req.method === 'GET' && req.url === '/v1/models') { - try { - const models = await listCopilotModels(); - writeJson(res, 200, { data: models.map((id: string) => ({ id, object: 'model', owned_by: 'vscode-bridge' })) }); - } catch (e: any) { - writeJson(res, 200, { data: [{ id: 'copilot', object: 'model', owned_by: 'vscode-bridge' }] }); - } + await handleModelsRequest(res); return; } if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { - activeRequests++; - if (verbose) output?.appendLine(`Request started (active=${activeRequests})`); - try { - const body = await readJson(req); - const requestedModel: string | undefined = typeof body?.model === 'string' ? body.model : undefined; - let familyOverride: string | undefined = undefined; - if (requestedModel && /-copilot$/i.test(requestedModel)) { - familyOverride = requestedModel.replace(/-copilot$/i, ''); - } else if (requestedModel && requestedModel.toLowerCase() === 'copilot') { - familyOverride = undefined; - } - - let model = await getModel(false, familyOverride); - const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); - if (!model && familyOverride && hasLM) { - lastReason = 'not_found'; - writeJson(res, 404, { error: { message: 'model not found', type: 'invalid_request_error', code: 'model_not_found', reason: 'not_found' } }); - return; - } - if (!model) { - const reason = !hasLM ? 'missing_language_model_api' : (lastReason || 'copilot_model_unavailable'); - writeJson(res, 503, { error: { message: 'Copilot unavailable', type: 'server_error', code: 'copilot_unavailable', reason } }); - return; - } - const messages = Array.isArray(body?.messages) ? body.messages : null; - if (!messages || messages.length === 0 || !messages.every((m: any) => - m && typeof m.role === 'string' && - /^(system|user|assistant)$/.test(m.role) && - m.content !== undefined && m.content !== null - )) { - writeJson(res, 400, { error: { message: 'invalid request', type: 'invalid_request_error', code: 'invalid_payload' } }); - return; - } - const lmMessages = normalizeMessagesLM(messages, hist); - const streamMode = body?.stream !== false; - - if (verbose) output?.appendLine('Sending request to Copilot via Language Model API...'); - const cts = new vscode.CancellationTokenSource(); - const response = await (model as any).sendRequest(lmMessages, {}, cts.token); - - 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)}`; - if (verbose) output?.appendLine(`SSE start id=${id}`); - try { - for await (const fragment of response.text as AsyncIterable) { - const payload = { - id, - object: 'chat.completion.chunk', - choices: [{ index: 0, delta: { content: fragment } }] - }; - res.write(`data: ${JSON.stringify(payload)}\n\n`); - } - if (verbose) output?.appendLine(`SSE end id=${id}`); - res.write('data: [DONE]\n\n'); - res.end(); - } catch (e: any) { - throw e; - } - return; - } else { - let buf = ''; - for await (const fragment of response.text as AsyncIterable) { - buf += fragment; - } - if (verbose) output?.appendLine(`Non-stream complete len=${buf.length}`); - 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; - } - } finally { - activeRequests--; - if (verbose) output?.appendLine(`Request complete (active=${activeRequests})`); - } + await handleChatCompletion(req, res, config); + return; } res.writeHead(404).end(); - } catch (e: any) { - output?.appendLine(`Error: ${e?.stack || e?.message || String(e)}`); - modelCache = undefined; - writeJson(res, 500, { error: { message: e?.message ?? 'internal_error', type: 'server_error', code: 'internal_error' } }); + } catch (error) { + handleServerError(error, res); } }); - 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: ${modelCache ? 'OK' : 'Unavailable'} @ ${shown}`; - output?.appendLine(`Started at http://${shown} | Copilot: ${modelCache ? 'ok' : 'unavailable'}`); - if (verbose) { - const tokenSet = token ? 'set' : 'unset'; - output?.appendLine(`Config: host=${host} port=${addr?.port ?? portCfg} hist=${hist} maxConcurrent=${maxConc} token=${tokenSet}`); - } - } catch (e: any) { - running = false; - output?.appendLine(`Failed to start: ${e?.stack || e?.message || String(e)}`); - statusItem!.text = 'Copilot Bridge: Error'; - throw e; + await startServer(state.server, config); + updateStatusAfterStart(config); + } catch (error) { + handleStartupError(error, config); } } -async function stopBridge() { - if (!running) return; - running = false; +// 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) { + 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 (!server) return resolve(); - server.close(() => resolve()); + if (!state.server) return resolve(); + state.server.close(() => resolve()); }); } finally { - server = undefined; - modelCache = undefined; - statusItem && (statusItem.text = 'Copilot Bridge: Disabled'); - output?.appendLine('Stopped'); + state.server = undefined; + state.modelCache = undefined; + if (state.statusItem) { + state.statusItem.text = 'Copilot Bridge: Disabled'; + } + state.output?.appendLine('Stopped'); } } -function toText(content: any): string { +// 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' && typeof content.text === 'string') return content.text; - try { return JSON.stringify(content); } catch (error) { return String(content); } -} + 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); + } +}; -function normalizeMessagesLM(messages: any[], histWindow: number): any[] { - 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 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 User = (vscode as any).LanguageModelChatMessage?.User; - const Assistant = (vscode as any).LanguageModelChatMessage?.Assistant; + const vsCode = vscode as any; + const User = vsCode.LanguageModelChatMessage?.User; + const Assistant = vsCode.LanguageModelChatMessage?.Assistant; - const result: any[] = []; + const result: unknown[] = []; let firstUserSeen = false; - for (const m of turns) { - if (m.role === 'user') { - let text = toText(m.content); - if (!firstUserSeen && sys) { - text = `[SYSTEM]\n${toText(sys.content)}\n\n[DIALOG]\nuser: ${text}`; + 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 (m.role === 'assistant') { - const text = toText(m.content); + } else if (message.role === 'assistant') { + const text = toText(message.content); result.push(Assistant ? Assistant(text) : { role: 'assistant', content: text }); } } - if (!firstUserSeen && sys) { - const text = `[SYSTEM]\n${toText(sys.content)}`; + if (!firstUserSeen && systemMessage) { + const text = `[SYSTEM]\n${toText(systemMessage.content)}`; result.unshift(User ? User(text) : { role: 'user', content: text }); } @@ -263,106 +511,162 @@ function normalizeMessagesLM(messages: any[], histWindow: number): any[] { } return result; -} +}; -async function getModel(force = false, family?: string): Promise { - if (!force && modelCache && !family) return modelCache; - const cfg = vscode.workspace.getConfiguration('bridge'); - const verbose = cfg.get('verbose') ?? false; - - const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); +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) modelCache = undefined; - lastReason = 'missing_language_model_api'; - const info = server ? server.address() : undefined; - const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : ''; - statusItem && (statusItem.text = `Copilot Bridge: Unavailable ${bound ? `@ ${bound}` : ''}`); - if (verbose) output?.appendLine('VS Code Language Model API not available; update VS Code or enable proposed API (Insiders/F5/--enable-proposed-api).'); + 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 { - let models: any[] | undefined; - if (family) { - models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot', family }); - } else { - models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); - if (!models || models.length === 0) { - models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot' }); - } - } + const models = await selectChatModels(family); if (!models || models.length === 0) { - if (!family) modelCache = undefined; - lastReason = family ? 'not_found' : 'copilot_model_unavailable'; - const info = server ? server.address() : undefined; - const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : ''; - statusItem && (statusItem.text = `Copilot Bridge: Unavailable ${bound ? `@ ${bound}` : ''}`); - if (verbose) output?.appendLine(family ? `No Copilot language models available for family="${family}".` : 'No Copilot language models available.'); + 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) modelCache = chosen; - lastReason = undefined; - const info = server ? server.address() : undefined; - const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : ''; - statusItem && (statusItem.text = `Copilot Bridge: OK ${bound ? `@ ${bound}` : ''}`); - if (verbose) output?.appendLine(`Copilot model selected${family ? ` (family=${family})` : ''}.`); - return chosen; - } catch (e: any) { - if (!family) modelCache = undefined; - const info = server ? server.address() : undefined; - const bound = info && typeof info === 'object' ? `${info.address}:${info.port}` : ''; - statusItem && (statusItem.text = `Copilot Bridge: Unavailable ${bound ? `@ ${bound}` : ''}`); - if ((vscode as any).LanguageModelError && e instanceof (vscode as any).LanguageModelError) { - const code = (e as any).code || ''; - if (/consent/i.test(e.message) || code === 'UserNotSignedIn') lastReason = 'consent_required'; - else if (code === 'RateLimited') lastReason = 'rate_limited'; - else if (code === 'NotFound') lastReason = 'not_found'; - else lastReason = 'copilot_unavailable'; - if (verbose) output?.appendLine(`LM select error: ${e.message} code=${code}${family ? ` family=${family}` : ''}`); - } else { - lastReason = 'copilot_unavailable'; - if (verbose) output?.appendLine(`LM select error: ${e?.message || String(e)}${family ? ` family=${family}` : ''}`); + 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 = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); + const hasLM = hasLanguageModelAPI(); if (!hasLM) return ['copilot']; + try { - const models: any[] = await (vscode as any).lm.selectChatModels({ vendor: 'copilot' }); + const models: LanguageModel[] = await (vscode as any).lm.selectChatModels({ vendor: 'copilot' }); if (!models || models.length === 0) return ['copilot']; - const ids = models.map((m: any, idx: number) => { - const family = (m as any)?.family || (m as any)?.modelFamily || (m as any)?.name || ''; - const norm = typeof family === 'string' && family.trim() ? family.trim().toLowerCase() : `copilot-${idx + 1}`; - return norm.endsWith('-copilot') ? norm : `${norm}-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']; } } -function readJson(req: http.IncomingMessage): Promise { +const readJson = (req: http.IncomingMessage): Promise => { return new Promise((resolve, reject) => { let data = ''; - req.on('data', (c: Buffer) => { data += c.toString(); }); + req.on('data', (chunk: any) => { + data += chunk.toString(); + }); req.on('end', () => { if (!data) return resolve({}); try { resolve(JSON.parse(data)); - } catch (e: any) { + } catch (error) { const snippet = data.length > 200 ? data.slice(0, 200) + '...' : data; - reject(new Error(`Failed to parse JSON: ${e && e.message ? e.message : String(e)}. Data: "${snippet}"`)); + const errorMessage = error instanceof Error ? error.message : String(error); + reject(new Error(`Failed to parse JSON: ${errorMessage}. Data: "${snippet}"`)); } }); req.on('error', reject); }); -} +}; -function writeJson(res: http.ServerResponse, status: number, obj: any) { +const writeJson = (res: http.ServerResponse, status: number, obj: unknown): void => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); -} +};