mirror of
https://github.com/larsbaunwall/vscode-copilot-bridge.git
synced 2025-10-05 22:22:59 +00:00
Clean-up the extention code
This commit is contained in:
parent
2067dd2e83
commit
f4e6b390e3
2 changed files with 550 additions and 246 deletions
|
|
@ -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"
|
||||
|
|
|
|||
794
src/extension.ts
794
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<LanguageModel[]>;
|
||||
}
|
||||
|
||||
interface LanguageModel {
|
||||
readonly family?: string;
|
||||
readonly modelFamily?: string;
|
||||
readonly name?: string;
|
||||
readonly sendRequest: (messages: unknown[], options: unknown, token: vscode.CancellationToken) => Promise<LanguageModelResponse>;
|
||||
}
|
||||
|
||||
interface LanguageModelResponse {
|
||||
readonly text: AsyncIterable<string>;
|
||||
}
|
||||
|
||||
// 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<boolean>('enabled') ?? false,
|
||||
host: cfg.get<string>('host') ?? '127.0.0.1',
|
||||
port: cfg.get<number>('port') ?? 0,
|
||||
token: (cfg.get<string>('token') ?? '').trim(),
|
||||
historyWindow: cfg.get<number>('historyWindow') ?? 3,
|
||||
verbose: cfg.get<boolean>('verbose') ?? false,
|
||||
maxConcurrent: cfg.get<number>('maxConcurrent') ?? 1,
|
||||
};
|
||||
};
|
||||
|
||||
export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
|
||||
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<string>('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<boolean>('enabled')) {
|
||||
const config = getBridgeConfig();
|
||||
if (config.enabled) {
|
||||
await startBridge();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
export async function deactivate(): Promise<void> {
|
||||
await stopBridge();
|
||||
}
|
||||
|
||||
async function startBridge() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
async function startBridge(): Promise<void> {
|
||||
if (state.running) return;
|
||||
state.running = true;
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('bridge');
|
||||
const host = cfg.get<string>('host') ?? '127.0.0.1';
|
||||
const portCfg = cfg.get<number>('port') ?? 0;
|
||||
const token = (cfg.get<string>('token') ?? '').trim();
|
||||
const hist = cfg.get<number>('historyWindow') ?? 3;
|
||||
const verbose = cfg.get<boolean>('verbose') ?? false;
|
||||
const maxConc = cfg.get<number>('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<boolean>('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<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();
|
||||
} catch (e: any) {
|
||||
throw e;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
let buf = '';
|
||||
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)}`,
|
||||
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<void>((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<void> => {
|
||||
await new Promise<void>((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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
if (!state.running) return;
|
||||
state.running = false;
|
||||
|
||||
try {
|
||||
await new Promise<void>((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<any | undefined> {
|
||||
if (!force && modelCache && !family) return modelCache;
|
||||
const cfg = vscode.workspace.getConfiguration('bridge');
|
||||
const verbose = cfg.get<boolean>('verbose') ?? false;
|
||||
|
||||
const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function');
|
||||
async function getModel(force = false, family?: string): Promise<LanguageModel | undefined> {
|
||||
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<LanguageModel[]> => {
|
||||
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<string[]> {
|
||||
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<any> {
|
||||
const readJson = (req: http.IncomingMessage): Promise<unknown> => {
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue