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