mirror of
https://github.com/larsbaunwall/vscode-copilot-bridge.git
synced 2025-10-05 22:22:59 +00:00
Refactor code to make it more readable. Fix model family selection that was not working
This commit is contained in:
parent
7782ff0727
commit
5127dc0b7f
11 changed files with 242 additions and 211 deletions
|
|
@ -12,13 +12,14 @@ export interface BridgeConfig {
|
|||
|
||||
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,
|
||||
};
|
||||
const resolved = {
|
||||
enabled: cfg.get('enabled', false),
|
||||
host: cfg.get('host', '127.0.0.1'),
|
||||
port: cfg.get('port', 0),
|
||||
token: cfg.get('token', '').trim(),
|
||||
historyWindow: cfg.get('historyWindow', 3),
|
||||
verbose: cfg.get('verbose', false),
|
||||
maxConcurrent: cfg.get('maxConcurrent', 1),
|
||||
} satisfies BridgeConfig;
|
||||
return resolved;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as vscode from 'vscode';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { getBridgeConfig } from './config';
|
||||
import { state } from './state';
|
||||
import { ensureOutput, verbose } from './log';
|
||||
|
|
@ -9,9 +10,9 @@ import { getModel } from './models';
|
|||
export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
|
||||
ensureOutput();
|
||||
ensureStatusBar();
|
||||
state.statusItem!.text = 'Copilot Bridge: Disabled';
|
||||
state.statusItem!.show();
|
||||
ctx.subscriptions.push(state.statusItem!, state.output!);
|
||||
state.statusBarItem!.text = 'Copilot Bridge: Disabled';
|
||||
state.statusBarItem!.show();
|
||||
ctx.subscriptions.push(state.statusBarItem!, state.output!);
|
||||
|
||||
ctx.subscriptions.push(vscode.commands.registerCommand('bridge.enable', async () => {
|
||||
await startBridge();
|
||||
|
|
@ -24,7 +25,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
|
|||
|
||||
ctx.subscriptions.push(vscode.commands.registerCommand('bridge.status', async () => {
|
||||
const info = state.server?.address();
|
||||
const bound = info && typeof info === 'object' ? `${(info as any).address}:${(info as any).port}` : 'n/a';
|
||||
const bound = (info && typeof info === 'object' && 'address' in info && 'port' in info)
|
||||
? `${(info as AddressInfo).address}:${(info as AddressInfo).port}`
|
||||
: 'n/a';
|
||||
const config = getBridgeConfig();
|
||||
const hasToken = config.token.length > 0;
|
||||
vscode.window.showInformationMessage(
|
||||
|
|
@ -47,10 +50,14 @@ async function startBridge(): Promise<void> {
|
|||
state.running = true;
|
||||
try {
|
||||
await startServer();
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
state.running = false;
|
||||
state.statusItem!.text = 'Copilot Bridge: Error';
|
||||
verbose(error?.stack || String(error));
|
||||
state.statusBarItem!.text = 'Copilot Bridge: Error';
|
||||
if (error instanceof Error) {
|
||||
verbose(error.stack || error.message);
|
||||
} else {
|
||||
verbose(String(error));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -63,8 +70,8 @@ async function stopBridge(): Promise<void> {
|
|||
} finally {
|
||||
state.server = undefined;
|
||||
state.modelCache = undefined;
|
||||
if (state.statusItem) {
|
||||
state.statusItem.text = 'Copilot Bridge: Disabled';
|
||||
if (state.statusBarItem) {
|
||||
state.statusBarItem.text = 'Copilot Bridge: Disabled';
|
||||
}
|
||||
verbose('Stopped');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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 { isChatCompletionRequest, normalizeMessagesLM } from '../../messages';
|
||||
import { getModel, hasLMApi } from '../../models';
|
||||
import { readJson, writeErrorResponse, writeJson } from '../utils';
|
||||
import { verbose } from '../../log';
|
||||
|
|
@ -11,67 +11,66 @@ export const handleChatCompletion = async (req: IncomingMessage, res: ServerResp
|
|||
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;
|
||||
return writeErrorResponse(res, 400, 'invalid request', 'invalid_request_error', 'invalid_payload');
|
||||
}
|
||||
const { model: requestedModel, stream = true } = body;
|
||||
const familyOverride = extractModelFamily(requestedModel);
|
||||
const model = await getModel(false, familyOverride);
|
||||
|
||||
const requestedModel = body.model;
|
||||
const stream = body.stream !== false; // default true
|
||||
const model = await getModel(false, requestedModel);
|
||||
|
||||
if (!model) {
|
||||
const hasLM = hasLMApi();
|
||||
if (familyOverride && hasLM) {
|
||||
if (requestedModel && hasLM) {
|
||||
state.lastReason = 'not_found';
|
||||
writeErrorResponse(res, 404, 'model not found', 'invalid_request_error', 'model_not_found', 'not_found');
|
||||
return;
|
||||
return writeErrorResponse(res, 404, 'model not found', 'invalid_request_error', 'model_not_found', 'not_found');
|
||||
}
|
||||
const reason = !hasLM ? 'missing_language_model_api' : (state.lastReason || 'copilot_model_unavailable');
|
||||
writeErrorResponse(res, 503, 'Copilot unavailable', 'server_error', 'copilot_unavailable', reason);
|
||||
return;
|
||||
return writeErrorResponse(res, 503, 'Copilot unavailable', 'server_error', 'copilot_unavailable', reason);
|
||||
}
|
||||
const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow);
|
||||
verbose(`Sending request to Copilot via Language Model API... ${model.family || model.modelFamily || model.name || 'unknown'}`);
|
||||
|
||||
const lmMessages = normalizeMessagesLM(body.messages, config.historyWindow) as vscode.LanguageModelChatMessage[];
|
||||
verbose(`LM request via API model=${model.family || model.id || 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);
|
||||
}
|
||||
await sendResponse(res, response, stream);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
writeErrorResponse(res, 500, msg || 'internal_error', 'server_error', 'internal_error');
|
||||
} 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`);
|
||||
const sendResponse = async (res: ServerResponse, response: vscode.LanguageModelChatResponse, stream: boolean): Promise<void> => {
|
||||
if (stream) {
|
||||
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) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
id,
|
||||
object: 'chat.completion.chunk',
|
||||
choices: [{ index: 0, delta: { content: fragment } }],
|
||||
})}\n\n`);
|
||||
}
|
||||
verbose(`SSE end id=${id}`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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)}`,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { writeJson } from '../utils';
|
||||
import { listCopilotModels } from '../../models';
|
||||
import type { ServerResponse } from 'http';
|
||||
|
||||
export const handleModelsRequest = async (res: any): Promise<void> => {
|
||||
export const handleModelsRequest = async (res: ServerResponse): Promise<void> => {
|
||||
try {
|
||||
const models = await listCopilotModels();
|
||||
writeJson(res, 200, {
|
||||
|
|
@ -13,13 +14,7 @@ export const handleModelsRequest = async (res: any): Promise<void> => {
|
|||
});
|
||||
} catch {
|
||||
writeJson(res, 200, {
|
||||
data: [
|
||||
{
|
||||
id: 'copilot',
|
||||
object: 'model',
|
||||
owned_by: 'vscode-bridge',
|
||||
},
|
||||
],
|
||||
data: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const polka = require('polka');
|
||||
import type { Server } from 'http';
|
||||
import polka from 'polka';
|
||||
import type { Server, IncomingMessage, ServerResponse } from 'http';
|
||||
import { getBridgeConfig } from '../config';
|
||||
import { state } from '../state';
|
||||
import { isAuthorized } from './auth';
|
||||
|
|
@ -8,16 +8,30 @@ import { handleModelsRequest } from './routes/models';
|
|||
import { handleChatCompletion } from './routes/chat';
|
||||
import { writeErrorResponse } from './utils';
|
||||
import { ensureOutput, verbose } from '../log';
|
||||
import { updateStatusAfterStart } from '../status';
|
||||
import { updateStatus } from '../status';
|
||||
|
||||
export const startServer = async (): Promise<void> => {
|
||||
if (state.server) return;
|
||||
const config = getBridgeConfig();
|
||||
ensureOutput();
|
||||
|
||||
const app = polka();
|
||||
const app = polka({
|
||||
onError: (err, req, res) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
verbose(`HTTP error: ${msg}`);
|
||||
if (!res.headersSent) {
|
||||
writeErrorResponse(res, 500, msg || 'internal_error', 'server_error', 'internal_error');
|
||||
} else {
|
||||
try { res.end(); } catch {/* ignore */}
|
||||
}
|
||||
},
|
||||
onNoMatch: (_req, res) => {
|
||||
writeErrorResponse(res, 404, 'not found', 'invalid_request_error', 'route_not_found');
|
||||
},
|
||||
});
|
||||
|
||||
app.use((req: any, res: any, next: any) => {
|
||||
// Logging + auth middleware
|
||||
app.use((req: IncomingMessage & { method?: string; url?: string }, res: ServerResponse, next: () => void) => {
|
||||
verbose(`HTTP ${req.method} ${req.url}`);
|
||||
if (!isAuthorized(req, config.token)) {
|
||||
writeErrorResponse(res, 401, 'unauthorized', 'invalid_request_error', 'unauthorized');
|
||||
|
|
@ -26,15 +40,15 @@ export const startServer = async (): Promise<void> => {
|
|||
next();
|
||||
});
|
||||
|
||||
app.get('/healthz', async (_req: any, res: any) => {
|
||||
app.get('/health', async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
await handleHealthCheck(res, config.verbose);
|
||||
});
|
||||
|
||||
app.get('/v1/models', async (_req: any, res: any) => {
|
||||
app.get('/v1/models', async (_req: IncomingMessage, res: ServerResponse) => {
|
||||
await handleModelsRequest(res);
|
||||
});
|
||||
|
||||
app.post('/v1/chat/completions', async (req: any, res: any) => {
|
||||
app.post('/v1/chat/completions', async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (state.activeRequests >= config.maxConcurrent) {
|
||||
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '1' });
|
||||
res.end(JSON.stringify({
|
||||
|
|
@ -49,36 +63,25 @@ export const startServer = async (): Promise<void> => {
|
|||
}
|
||||
try {
|
||||
await handleChatCompletion(req, res);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
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;
|
||||
}
|
||||
const srv = app.server as Server | undefined;
|
||||
if (!srv) return reject(new Error('Server failed to start'));
|
||||
state.server = srv;
|
||||
updateStatusAfterStart();
|
||||
resolved = true;
|
||||
updateStatus('start');
|
||||
resolve();
|
||||
});
|
||||
const srv = app.server as Server | undefined;
|
||||
srv?.on('error', reject);
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,31 +9,44 @@ export interface ErrorResponse {
|
|||
};
|
||||
}
|
||||
|
||||
export const writeJson = (res: ServerResponse, status: number, body: any): void => {
|
||||
export const writeJson = <T>(res: ServerResponse, status: number, body: T): void => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
export const writeErrorResponse = (
|
||||
export function writeErrorResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
message: string,
|
||||
type: string,
|
||||
code: string
|
||||
): void;
|
||||
export function writeErrorResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
message: string,
|
||||
type: string,
|
||||
code: string,
|
||||
reason: string
|
||||
): void;
|
||||
export function writeErrorResponse(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
message: string,
|
||||
type: string,
|
||||
code: string,
|
||||
reason?: string
|
||||
): void => {
|
||||
writeJson(res, status, {
|
||||
error: { message, type, code, ...(reason && { reason }) },
|
||||
});
|
||||
};
|
||||
): void {
|
||||
writeJson(res, status, { error: { message, type, code, ...(reason ? { reason } : {}) } });
|
||||
}
|
||||
|
||||
export const readJson = (req: IncomingMessage): Promise<any> =>
|
||||
new Promise((resolve, reject) => {
|
||||
export const readJson = <T = unknown>(req: IncomingMessage): Promise<T> =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
let data = '';
|
||||
req.on('data', (c) => (data += c));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(data ? JSON.parse(data) : {});
|
||||
resolve((data ? JSON.parse(data) : {}) as T);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,30 +18,31 @@ export interface ChatCompletionRequest {
|
|||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const isValidRole = (role: unknown): role is 'system' | 'user' | 'assistant' =>
|
||||
typeof role === 'string' && ['system', 'user', 'assistant'].includes(role);
|
||||
const VALID_ROLES = ['system', 'user', 'assistant'] as const;
|
||||
type Role = typeof VALID_ROLES[number];
|
||||
const isValidRole = (role: unknown): role is Role => typeof role === 'string' && VALID_ROLES.includes(role as 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 isChatMessage = (msg: unknown): msg is ChatMessage => {
|
||||
if (typeof msg !== 'object' || msg === null) return false;
|
||||
const candidate = msg as Record<string, unknown>;
|
||||
if (!('role' in candidate) || !('content' in candidate)) return false;
|
||||
return isValidRole(candidate.role) && candidate.content !== undefined && candidate.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);
|
||||
export const isChatCompletionRequest = (body: unknown): body is ChatCompletionRequest => {
|
||||
if (typeof body !== 'object' || body === null) return false;
|
||||
const candidate = body as Record<string, unknown>;
|
||||
if (!('messages' in candidate)) return false;
|
||||
const messages = candidate.messages;
|
||||
return Array.isArray(messages) && messages.length > 0 && 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;
|
||||
if (content && typeof content === 'object' && 'text' in content) {
|
||||
const textVal = (content as { text?: unknown }).text;
|
||||
if (typeof textVal === 'string') return textVal;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content);
|
||||
|
|
@ -50,52 +51,41 @@ const toText = (content: unknown): string => {
|
|||
}
|
||||
};
|
||||
|
||||
export const normalizeMessagesLM = (messages: ChatMessage[], histWindow: number): unknown[] => {
|
||||
export const normalizeMessagesLM = (
|
||||
messages: readonly ChatMessage[],
|
||||
histWindow: number
|
||||
): (vscode.LanguageModelChatMessage | { role: 'user' | 'assistant'; content: string })[] => {
|
||||
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 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 lmMsg = (vscode as unknown as { LanguageModelChatMessage?: typeof vscode.LanguageModelChatMessage }).LanguageModelChatMessage;
|
||||
const UserFactory = lmMsg?.User;
|
||||
const AssistantFactory = lmMsg?.Assistant;
|
||||
|
||||
const result: unknown[] = [];
|
||||
const result: (vscode.LanguageModelChatMessage | { role: 'user' | 'assistant'; content: string })[] = [];
|
||||
let firstUserSeen = false;
|
||||
|
||||
for (const message of conversationMessages) {
|
||||
if (message.role === 'user') {
|
||||
let text = toText(message.content);
|
||||
for (const m of conversationMessages) {
|
||||
if (m.role === 'user') {
|
||||
let text = toText(m.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 });
|
||||
result.push(UserFactory ? UserFactory(text) : { role: 'user', content: text });
|
||||
} else {
|
||||
const text = toText(m.content);
|
||||
result.push(AssistantFactory ? AssistantFactory(text) : { role: 'assistant', content: text });
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstUserSeen && systemMessage) {
|
||||
const text = `[SYSTEM]\n${toText(systemMessage.content)}`;
|
||||
result.unshift(User ? User(text) : { role: 'user', content: text });
|
||||
result.unshift(UserFactory ? UserFactory(text) : { role: 'user', content: text });
|
||||
}
|
||||
|
||||
if (result.length === 0) {
|
||||
result.push(User ? User('') : { role: 'user', content: '' });
|
||||
}
|
||||
if (result.length === 0) result.push(UserFactory ? UserFactory('') : { 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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,46 +1,49 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { state, LanguageModel } from './state';
|
||||
import { updateStatusWithError, updateStatusWithSuccess } from './status';
|
||||
import { state } from './state';
|
||||
import { updateStatus } from './status';
|
||||
import { verbose } from './log';
|
||||
|
||||
const hasLanguageModelAPI = (): boolean =>
|
||||
!!(vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function';
|
||||
// VS Code Language Model API (see selectChatModels docs in latest VS Code API reference)
|
||||
const hasLanguageModelAPI = (): boolean => typeof vscode.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 selectChatModels = async (family?: string): Promise<vscode.LanguageModelChat[]> => {
|
||||
const selector: vscode.LanguageModelChatSelector | undefined = family ? { family } : undefined;
|
||||
return vscode.lm.selectChatModels(selector);
|
||||
};
|
||||
|
||||
export const getModel = async (force = false, family?: string): Promise<LanguageModel | undefined> => {
|
||||
export const getModel = async (force = false, family?: string): Promise<vscode.LanguageModelChat | undefined> => {
|
||||
if (!force && state.modelCache && !family) return state.modelCache;
|
||||
|
||||
// Mark that we've attempted at least one model fetch (affects status bar messaging)
|
||||
state.modelAttempted = true;
|
||||
|
||||
const hasLM = hasLanguageModelAPI();
|
||||
|
||||
if (!hasLM) {
|
||||
if (!family) state.modelCache = undefined;
|
||||
state.lastReason = 'missing_language_model_api';
|
||||
updateStatusWithError();
|
||||
updateStatus('error');
|
||||
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) {
|
||||
// Prefer selecting by vendor 'copilot' if no family specified to reduce unrelated models
|
||||
const models: vscode.LanguageModelChat[] = family
|
||||
? await selectChatModels(family)
|
||||
: await vscode.lm.selectChatModels({ vendor: 'copilot' });
|
||||
if (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);
|
||||
updateStatus('error');
|
||||
verbose(family ? `no models for family ${family}` : 'no copilot models available');
|
||||
return undefined;
|
||||
}
|
||||
state.modelCache = models[0];
|
||||
state.modelCache = models[0]; // keep first for now; future: choose by quality or family preference
|
||||
state.lastReason = undefined;
|
||||
updateStatusWithSuccess();
|
||||
updateStatus('success');
|
||||
return state.modelCache;
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
handleModelSelectionError(e, family);
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -55,7 +58,7 @@ export const handleModelSelectionError = (error: unknown, family?: string): void
|
|||
} else {
|
||||
state.lastReason = 'copilot_model_unavailable';
|
||||
}
|
||||
updateStatusWithError();
|
||||
updateStatus('error');
|
||||
const fam = family ? ` family=${family}` : '';
|
||||
verbose(`Model selection failed: ${msg}${fam}`);
|
||||
};
|
||||
|
|
@ -63,9 +66,9 @@ export const handleModelSelectionError = (error: unknown, family?: string): void
|
|||
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`;
|
||||
const ids = models.map((m: vscode.LanguageModelChat) => {
|
||||
const normalized = m.family || m.id || m.name || 'copilot';
|
||||
return `${normalized}`;
|
||||
});
|
||||
return ids.length ? ids : ['copilot'];
|
||||
} catch {
|
||||
|
|
|
|||
17
src/state.ts
17
src/state.ts
|
|
@ -1,28 +1,19 @@
|
|||
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;
|
||||
modelCache?: vscode.LanguageModelChat; // official API type
|
||||
statusBarItem?: vscode.StatusBarItem;
|
||||
output?: vscode.OutputChannel;
|
||||
running: boolean;
|
||||
activeRequests: number;
|
||||
lastReason?: string;
|
||||
modelAttempted?: boolean; // whether we've attempted to resolve a model yet
|
||||
}
|
||||
|
||||
export const state: BridgeState = {
|
||||
running: false,
|
||||
activeRequests: 0,
|
||||
modelAttempted: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,37 +5,38 @@ 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();
|
||||
if (!state.statusBarItem) {
|
||||
state.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
state.statusBarItem.text = 'Copilot Bridge: Disabled';
|
||||
state.statusBarItem.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 type BridgeStatusKind = 'start' | 'error' | 'success';
|
||||
|
||||
export const updateStatusWithError = (): void => {
|
||||
export const updateStatus = (kind: BridgeStatusKind): 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}`;
|
||||
if (!state.statusBarItem) return;
|
||||
|
||||
switch (kind) {
|
||||
case 'start': {
|
||||
const availability = state.modelCache ? 'OK' : (state.modelAttempted ? 'Unavailable' : 'Pending');
|
||||
state.statusBarItem.text = `Copilot Bridge: ${availability} @ ${shown}`;
|
||||
info(`Started at http://${shown} | Copilot: ${state.modelCache ? 'ok' : (state.modelAttempted ? 'unavailable' : 'pending')}`);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
state.statusBarItem.text = `Copilot Bridge: Unavailable @ ${shown}`;
|
||||
break;
|
||||
case 'success':
|
||||
state.statusBarItem.text = `Copilot Bridge: OK @ ${shown}`;
|
||||
break;
|
||||
default:
|
||||
// Exhaustive check in case of future extension
|
||||
const _never: never = kind;
|
||||
return _never;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
32
src/types/polka.d.ts
vendored
32
src/types/polka.d.ts
vendored
|
|
@ -1,2 +1,30 @@
|
|||
declare function polka(...args: any[]): any;
|
||||
export = polka;
|
||||
declare module 'polka' {
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export interface PolkaRequest extends IncomingMessage {
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type Next = () => void;
|
||||
export type Middleware = (req: PolkaRequest, res: ServerResponse, next: Next) => void;
|
||||
export type Handler = (req: PolkaRequest, res: ServerResponse) => void;
|
||||
|
||||
export interface PolkaOptions {
|
||||
onError?: (err: Error, req: PolkaRequest, res: ServerResponse, next: Next) => void;
|
||||
onNoMatch?: (req: PolkaRequest, res: ServerResponse) => void;
|
||||
}
|
||||
|
||||
export interface PolkaInstance {
|
||||
use(mw: Middleware): PolkaInstance;
|
||||
use(path: string, mw: Middleware): PolkaInstance;
|
||||
get(path: string, handler: Handler): PolkaInstance;
|
||||
post(path: string, handler: Handler): PolkaInstance;
|
||||
put(path: string, handler: Handler): PolkaInstance;
|
||||
delete(path: string, handler: Handler): PolkaInstance;
|
||||
listen(port: number, host: string, cb: () => void): void;
|
||||
server?: import('http').Server;
|
||||
}
|
||||
|
||||
function polka(options?: PolkaOptions): PolkaInstance;
|
||||
export default polka;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue