diff --git a/README.md b/README.md index 086041c..b87e3f0 100644 --- a/README.md +++ b/README.md @@ -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:<port>/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:<port>/v1/chat/completions + +curl -N -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}' \ + http://127.0.0.1:<port>/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. diff --git a/src/extension.ts b/src/extension.ts index b332792..53ad572 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 { - if (!force && modelCache) return modelCache; +async function getModel(force = false, family?: string): Promise { + if (!force && modelCache && !family) return modelCache; const cfg = vscode.workspace.getConfiguration('bridge'); const verbose = cfg.get('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 { } try { - let models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot', family: 'gpt-4o' }); - if (!models || models.length === 0) { - models = await (vscode as any).lm.selectChatModels({ vendor: 'copilot' }); + 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 { 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 { + 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 { return new Promise((resolve, reject) => { let data = '';