Models: dynamic Copilot model listing via LM API; inference supports explicit model selection with 404 model_not_found when unavailable

Co-Authored-By: Lars Baunwall <larslb@thinkability.dk>
This commit is contained in:
Devin AI 2025-08-12 19:34:44 +00:00
parent f37d4306bc
commit d87fe971b9
2 changed files with 87 additions and 25 deletions

View file

@ -4,7 +4,7 @@ Local OpenAI-compatible HTTP facade to GitHub Copilot via the VS Code Language M
- Endpoints (local-only, default bind 127.0.0.1):
- POST /v1/chat/completions (SSE streaming; use "stream": false for non-streaming)
- GET /v1/models (synthetic listing: gpt-4o-copilot)
- GET /v1/models (dynamic listing of available Copilot models)
- GET /healthz (ok/unavailable + vscode.version)
- Copilot pipe: vscode.lm.selectChatModels({ vendor: "copilot", family: "gpt-4o" }) → model.sendRequest(messages)
@ -52,6 +52,28 @@ Optional: Packaging a VSIX
## Enabling the Language Model API (if required)
If your VS Code build requires enabling proposed APIs for the Language Model API, start with:
### Models and Selection
- The bridge lists available GitHub Copilot chat models using the VS Code Language Model API. Example:
curl http://127.0.0.1:&lt;port&gt;/v1/models
→ { "data": [ { "id": "gpt-4o-copilot", ... }, ... ] }
- To target a specific model for inference, set the "model" field in your POST body. The bridge accepts:
- IDs returned by /v1/models (e.g., "gpt-4o-copilot")
- A Copilot family name (e.g., "gpt-4o")
- "copilot" to allow default selection
Examples:
curl -N -H "Content-Type: application/json" \
-d '{"model":"gpt-4o-copilot","messages":[{"role":"user","content":"hello"}]}' \
http://127.0.0.1:&lt;port&gt;/v1/chat/completions
curl -N -H "Content-Type: application/json" \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}' \
http://127.0.0.1:&lt;port&gt;/v1/chat/completions
- If a requested model is unavailable, the bridge returns:
404 with { "error": { "code": "model_not_found", "reason": "not_found" } }.
- Stable VS Code: `code --enable-proposed-api thinkability.copilot-bridge`
- VS Code Insiders or Extension Development Host (F5) also works.

View file

@ -84,23 +84,40 @@ async function startBridge() {
}
if (req.method === 'GET' && req.url === '/v1/models') {
writeJson(res, 200, { data: [{ id: 'gpt-4o-copilot', object: 'model', owned_by: 'vscode-bridge' }] });
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' }] });
}
return;
}
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let model = await getModel();
if (!model) {
const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function');
const reason = !hasLM ? 'missing_language_model_api' : (lastReason || 'copilot_model_unavailable');
writeJson(res, 503, { error: { message: 'Copilot unavailable', type: 'server_error', code: 'copilot_unavailable', reason } });
return;
}
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' &&
@ -248,14 +265,14 @@ function normalizeMessagesLM(messages: any[], histWindow: number): any[] {
return result;
}
async function getModel(force = false): Promise<any | undefined> {
if (!force && modelCache) return modelCache;
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');
if (!hasLM) {
modelCache = undefined;
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}` : '';
@ -265,28 +282,34 @@ async function getModel(force = false): Promise<any | undefined> {
}
try {
let models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' });
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' });
}
}
if (!models || models.length === 0) {
modelCache = undefined;
lastReason = 'copilot_model_unavailable';
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('No Copilot language models available.');
if (verbose) output?.appendLine(family ? `No Copilot language models available for family="${family}".` : 'No Copilot language models available.');
return undefined;
}
modelCache = models[0];
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.`);
return modelCache;
if (verbose) output?.appendLine(`Copilot model selected${family ? ` (family=${family})` : ''}.`);
return chosen;
} catch (e: any) {
modelCache = undefined;
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}` : ''}`);
@ -296,15 +319,32 @@ async function getModel(force = false): Promise<any | undefined> {
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}`);
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)}`);
if (verbose) output?.appendLine(`LM select error: ${e?.message || String(e)}${family ? ` family=${family}` : ''}`);
}
return undefined;
}
}
async function listCopilotModels(): Promise<string[]> {
const hasLM = !!((vscode as any).lm && typeof (vscode as any).lm.selectChatModels === 'function');
if (!hasLM) return ['copilot'];
try {
const models: any[] = 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`;
});
return Array.from(new Set(ids));
} catch {
return ['copilot'];
}
}
function readJson(req: http.IncomingMessage): Promise<any> {
return new Promise((resolve, reject) => {
let data = '';