From 81dd561730aadea32177fb115959deb055c4e4e4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:46:01 +0000 Subject: [PATCH] Refactor: migrate Copilot bridge to VS Code Language Model API (vscode.lm). Replace Chat API flow, add robust LM guards and reason codes, update README/manifest, remove Chat API shim. Co-Authored-By: Lars Baunwall --- README.md | 39 ++++---- package.json | 6 +- src/extension.ts | 189 ++++++++++++++++++++++---------------- src/vscode-chat-shim.d.ts | 18 ---- 4 files changed, 130 insertions(+), 122 deletions(-) delete mode 100644 src/vscode-chat-shim.d.ts diff --git a/README.md b/README.md index 585bc47..086041c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # VS Code Copilot Bridge (Desktop, Inference-only) -Local OpenAI-compatible HTTP facade to GitHub Copilot Chat via the VS Code Chat provider. +Local OpenAI-compatible HTTP facade to GitHub Copilot via the VS Code Language Model API. - Endpoints (local-only, default bind 127.0.0.1): - POST /v1/chat/completions (SSE streaming; use "stream": false for non-streaming) - GET /v1/models (synthetic listing: gpt-4o-copilot) - GET /healthz (ok/unavailable + vscode.version) -- Copilot pipe: vscode.chat.requestChatAccess('copilot') → startSession().sendRequest({ prompt }) +- Copilot pipe: vscode.lm.selectChatModels({ vendor: "copilot", family: "gpt-4o" }) → model.sendRequest(messages) - Prompt normalization: last system message + last N user/assistant turns (default 3) rendered as: [SYSTEM] @@ -49,26 +49,25 @@ Optional: Packaging a VSIX npm i -g @vscode/vsce vsce package - Then install the generated .vsix via “Extensions: Install from VSIX…” -## Enabling the VS Code Chat proposed API +## Enabling the Language Model API (if required) -The `vscode.chat.requestChatAccess` API is currently proposed. To use this extension at runtime, enable proposed APIs for this extension: +If your VS Code build requires enabling proposed APIs for the Language Model API, start with: +- Stable VS Code: `code --enable-proposed-api thinkability.copilot-bridge` +- VS Code Insiders or Extension Development Host (F5) also works. -- Stable VS Code: - - Start with: `code --enable-proposed-api thinkability.copilot-bridge` -- VS Code Insiders: - - Proposed APIs can be used when running the extension from source (F5) or with the flag above -- Run from source: - - Open this folder in VS Code and press F5 (Extension Development Host) - -When the proposed API is not enabled, the Output (“Copilot Bridge”) will show: -“VS Code Chat proposed API not enabled; start VS Code with: code --enable-proposed-api thinkability.copilot-bridge, or run via F5/Insiders.” +When the API is not available, the Output (“Copilot Bridge”) will show: +“VS Code Language Model API not available; update VS Code or enable proposed API.” ## Troubleshooting - /healthz shows `copilot: "unavailable"` with a `reason`: - - `missing_chat_api`: VS Code Chat proposed API not enabled (use the flag above) - - `copilot_unavailable`: Copilot access not granted (sign in to GitHub Copilot) -- POST /v1/chat/completions returns 503 with `reason` giving the same codes as above. + - `missing_language_model_api`: Language Model API not available + - `copilot_model_unavailable`: No Copilot models selectable + - `consent_required`: User consent/sign-in required for Copilot models + - `rate_limited`: Provider throttling + - `not_found`: Requested model not found + - `copilot_unavailable`: Other provider errors +- POST /v1/chat/completions returns 503 with the same `reason` codes. ## Configuration (bridge.*) @@ -86,10 +85,10 @@ To see verbose logs: 2) Open: View → Output → select “Copilot Bridge” in the dropdown 3) Trigger a request (e.g., curl /v1/chat/completions). You’ll see: - HTTP request lines (method/path) - - Access acquisition attempts (“Copilot access missing; attempting to acquire…”, “Copilot access acquired.”) + - Model selection attempts (“Copilot model selected.”) - SSE lifecycle (“SSE start …”, “SSE end …”) - - Health checks (best-effort access check when verbose is on) - - Proposed API diagnostics (e.g., “VS Code Chat proposed API not enabled…”) + - Health checks (best-effort model check when verbose is on) + - API diagnostics (e.g., missing Language Model API) - bridge.verbose (boolean; default false): verbose logs to “Copilot Bridge” output channel ## Manual Testing (curl) @@ -132,4 +131,4 @@ Concurrency: - Build: npm run compile - Watch: npm run watch - Main: src/extension.ts -- Local type shim for Chat API: src/vscode-chat-shim.d.ts (for TypeScript on stable @types/vscode) +- Note: Previously used Chat API shims are no longer needed; the bridge now uses the Language Model API. diff --git a/package.json b/package.json index 2fbf68a..d1e050c 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,12 @@ { "name": "copilot-bridge", "displayName": "Copilot Bridge", - "description": "Local OpenAI-compatible chat endpoint bridging to GitHub Copilot Chat via VS Code Chat provider.", + "description": "Local OpenAI-compatible chat endpoint bridging to GitHub Copilot via the VS Code Language Model API.", "version": "0.1.0", "publisher": "thinkability", "engines": { "vscode": "^1.90.0" }, - "enabledApiProposals": [ - "chat", - "chatProvider" - ], "extensionKind": [ "ui" ], diff --git a/src/extension.ts b/src/extension.ts index 3e31eaa..48cb533 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,7 +3,7 @@ import * as http from 'http'; import { AddressInfo } from 'net'; let server: http.Server | undefined; -let access: vscode.ChatAccess | undefined; +let modelCache: any | undefined; let statusItem: vscode.StatusBarItem | undefined; let output: vscode.OutputChannel | undefined; let running = false; @@ -19,6 +19,7 @@ export async function activate(ctx: vscode.ExtensionContext) { 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(); @@ -53,12 +54,6 @@ async function startBridge() { const maxConc = cfg.get('maxConcurrent') ?? 1; try { - try { - access = await vscode.chat.requestChatAccess('copilot'); - } catch { - access = undefined; - } - server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => { try { if (verbose) output?.appendLine(`HTTP ${req.method} ${req.url}`); @@ -78,13 +73,13 @@ async function startBridge() { if (req.method === 'GET' && req.url === '/healthz') { const cfgNow = vscode.workspace.getConfiguration('bridge'); const verboseNow = cfgNow.get('verbose') ?? false; - const hasProposal = !!((vscode as any).chat && typeof (vscode as any).chat.requestChatAccess === 'function'); - if (!access && verboseNow) { - if (verboseNow) output?.appendLine(`Healthz: access=${access ? 'present' : 'missing'} proposal=${hasProposal ? 'ok' : 'missing'}`); - await getAccess(); + const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); + if (!modelCache && verboseNow) { + if (verboseNow) output?.appendLine(`Healthz: model=${modelCache ? 'present' : 'missing'} lmApi=${hasLM ? 'ok' : 'missing'}`); + await getModel(); } - const unavailableReason = access ? undefined : (!hasProposal ? 'missing_chat_api' : (lastReason || 'copilot_unavailable')); - writeJson(res, 200, { ok: true, copilot: access ? 'ok' : 'unavailable', reason: unavailableReason, version: vscode.version }); + 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 }); return; } @@ -94,17 +89,10 @@ async function startBridge() { } if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { - if (!access) { - if (verbose) output?.appendLine('Copilot access missing; attempting to acquire...'); - await getAccess(); - } - if (!access) { - if (verbose) output?.appendLine('Copilot access missing; attempting to acquire...'); - await getAccess(); - } - if (!access) { - const hasProposal = !!((vscode as any).chat && typeof (vscode as any).chat.requestChatAccess === 'function'); - const reason = !hasProposal ? 'missing_chat_api' : (lastReason || 'copilot_unavailable'); + let model = await getModel(); + if (!model) { + const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); + 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; } @@ -122,12 +110,12 @@ async function startBridge() { writeJson(res, 400, { error: { message: 'invalid request', type: 'invalid_request_error', code: 'invalid_payload' } }); return; } - const prompt = normalizeMessages(messages, hist); + const lmMessages = normalizeMessagesLM(messages, hist); const streamMode = body?.stream !== false; - if (verbose) output?.appendLine('Starting Copilot chat session...'); - const session = await access.startSession(); - const chatStream = await session.sendRequest({ prompt, attachments: [] }); + 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, { @@ -137,34 +125,27 @@ async function startBridge() { }); const id = `cmp_${Math.random().toString(36).slice(2)}`; if (verbose) output?.appendLine(`SSE start id=${id}`); - const h1 = chatStream.onDidProduceContent((chunk: string) => { - const payload = { - id, - object: 'chat.completion.chunk', - choices: [{ index: 0, delta: { content: chunk } }] - }; - res.write(`data: ${JSON.stringify(payload)}\n\n`); - }); - const endAll = () => { + 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(); - h1.dispose(); - h2.dispose(); - }; - const h2 = chatStream.onDidEnd(endAll); - req.on('close', endAll); + } catch (e: any) { + throw e; + } return; } else { let buf = ''; - const h1 = chatStream.onDidProduceContent((chunk: string) => { buf += chunk; }); - await new Promise((resolve) => { - const h2 = chatStream.onDidEnd(() => { - h1.dispose(); - h2.dispose(); - resolve(); - }); - }); + 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)}`, @@ -182,7 +163,7 @@ async function startBridge() { res.writeHead(404).end(); } catch (e: any) { output?.appendLine(`Error: ${e?.stack || e?.message || String(e)}`); - access = undefined; + modelCache = undefined; writeJson(res, 500, { error: { message: e?.message ?? 'internal_error', type: 'server_error', code: 'internal_error' } }); } }); @@ -194,8 +175,8 @@ async function startBridge() { 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'}`); + 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}`); @@ -218,62 +199,112 @@ async function stopBridge() { }); } finally { server = undefined; - access = undefined; + modelCache = 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); } - }; +function 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); } +} + +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 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}`; + + const User = (vscode as any).LanguageModelChatMessage?.User; + const Assistant = (vscode as any).LanguageModelChatMessage?.Assistant; + + const result: any[] = []; + 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}`; + firstUserSeen = true; + } + result.push(User ? User(text) : { role: 'user', content: text }); + } else if (m.role === 'assistant') { + const text = toText(m.content); + result.push(Assistant ? Assistant(text) : { role: 'assistant', content: text }); + } + } + + if (!firstUserSeen && sys) { + const text = `[SYSTEM]\n${toText(sys.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 getAccess(force = false): Promise { - if (!force && access) return access; + +async function getModel(force = false): Promise { + if (!force && modelCache) return modelCache; const cfg = vscode.workspace.getConfiguration('bridge'); const verbose = cfg.get('verbose') ?? false; - const hasProposal = !!((vscode as any).chat && typeof (vscode as any).chat.requestChatAccess === 'function'); - if (!hasProposal) { - access = undefined; - lastReason = 'missing_chat_api'; + const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function'); + if (!hasLM) { + 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 Chat proposed API not enabled; start VS Code with: code --enable-proposed-api thinkability.copilot-bridge, or run via F5/Insiders.'); + if (verbose) 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 newAccess = await (vscode as any).chat.requestChatAccess('copilot'); - access = newAccess; + let 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' }); + } + if (!models || models.length === 0) { + modelCache = undefined; + lastReason = '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('No Copilot language models available.'); + return undefined; + } + modelCache = models[0]; 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 access acquired.'); - return access; + if (verbose) output?.appendLine(`Copilot model selected.`); + return modelCache; } catch (e: any) { - access = undefined; - lastReason = 'copilot_unavailable'; + 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 (verbose) output?.appendLine(`Copilot access request failed: ${e?.message || String(e)}`); + 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}`); + } else { + lastReason = 'copilot_unavailable'; + if (verbose) output?.appendLine(`LM select error: ${e?.message || String(e)}`); + } return undefined; } } - function readJson(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ''; diff --git a/src/vscode-chat-shim.d.ts b/src/vscode-chat-shim.d.ts deleted file mode 100644 index 2032ba1..0000000 --- a/src/vscode-chat-shim.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'vscode' { - namespace chat { - function requestChatAccess(providerId: string): Promise; - } - - interface ChatAccess { - startSession(): Promise; - } - - interface ChatSession { - sendRequest(options: { prompt: string; attachments: any[] }): Promise; - } - - interface ChatResponseStream { - onDidProduceContent(handler: (chunk: string) => void): { dispose(): void }; - onDidEnd(handler: () => void): { dispose(): void }; - } -}