mirror of
https://github.com/larsbaunwall/vscode-copilot-bridge.git
synced 2025-10-05 22:22:59 +00:00
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 <larslb@thinkability.dk>
This commit is contained in:
parent
71495c6812
commit
81dd561730
4 changed files with 130 additions and 122 deletions
189
src/extension.ts
189
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<number>('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<boolean>('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<string>) {
|
||||
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<void>((resolve) => {
|
||||
const h2 = chatStream.onDidEnd(() => {
|
||||
h1.dispose();
|
||||
h2.dispose();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
for await (const fragment of response.text as AsyncIterable<string>) {
|
||||
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<vscode.ChatAccess | undefined> {
|
||||
if (!force && access) return access;
|
||||
|
||||
async function getModel(force = false): Promise<any | undefined> {
|
||||
if (!force && modelCache) return modelCache;
|
||||
const cfg = vscode.workspace.getConfiguration('bridge');
|
||||
const verbose = cfg.get<boolean>('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<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue