diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index dfe20b6f..c71801fe 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -37,9 +37,14 @@ other runtime API. reported as `inputTruncated: true` in the response. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: - - **GitHub Copilot**: OpenAI-compatible `/chat/completions` on - `https://api.githubcopilot.com` (or `copilot-api.`) with the - stored device-OAuth token as the bearer — no token exchange, no expiry. + - **GitHub Copilot**: fetches the requested model's authenticated `/models` + metadata from `https://api.githubcopilot.com` (or + `copilot-api.`) and honors its advertised endpoint, preferring + Anthropic-compatible `/v1/messages`, then OpenAI `/responses`, then + `/chat/completions`. Models without `supported_endpoints` retain the legacy + Chat Completions default; metadata, missing-model, and unsupported-endpoint + failures are surfaced instead of guessing. The stored device-OAuth token is + used as the bearer with no token exchange or expiry. - **OpenAI OAuth (ChatGPT plan)**: streaming Responses API on `https://chatgpt.com/backend-api/codex/responses` with `ChatGPT-Account-Id`; expired tokens are refreshed against diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index df3bdd1a..3eb12cb5 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -11,6 +11,7 @@ import { getAuthEntryForProvider } from './resolve.js'; // opencode repo). auth.json credentials never leave this process. const REQUEST_TIMEOUT_MS = 60_000; +const COPILOT_MODELS_TIMEOUT_MS = 5_000; // Generous default: thinking models that can't be switched off (DeepSeek, // Qwen, …) spend part of this budget on reasoning before the actual answer. const DEFAULT_MAX_OUTPUT_TOKENS = 4_000; @@ -183,14 +184,53 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, return text; }; -const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { - const response = await fetch('https://api.anthropic.com/v1/messages', { +const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { + const trimmedBase = baseURL.replace(/\/+$/, ''); + const response = await fetch(`${trimmedBase}/responses`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01', + ...headers, + }, + body: JSON.stringify({ + model: modelID, + ...(system ? { instructions: system } : {}), + input: [{ + role: 'user', + content: [{ type: 'input_text', text: prompt }], + }], + max_output_tokens: maxOutputTokens, + stream: false, + store: false, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, providerLabel); + } + const payload = await response.json(); + const text = typeof payload?.output_text === 'string' + ? payload.output_text + : Array.isArray(payload?.output) + ? payload.output + .flatMap((item) => (Array.isArray(item?.content) ? item.content : [])) + .map((part) => (part?.type === 'output_text' && typeof part.text === 'string' ? part.text : '')) + .join('') + : ''; + if (!text.trim()) { + throw new Error(`${providerLabel} returned no text output`); + } + return text; +}; + +const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, }, body: JSON.stringify({ model: modelID, @@ -201,7 +241,7 @@ const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { - throw await httpError(response, 'Anthropic'); + throw await httpError(response, providerLabel); } const payload = await response.json(); const text = (payload?.content || []) @@ -209,11 +249,69 @@ const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens .map((part) => part.text) .join(''); if (!text) { - throw new Error('Anthropic returned no text content'); + throw new Error(`${providerLabel} returned no text content`); } return text; }; +const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => callMessages({ + url: 'https://api.anthropic.com/v1/messages', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + modelID, + prompt, + system, + maxOutputTokens, + providerLabel: 'Anthropic', +}); + +const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { + const trimmedBase = baseURL.replace(/\/+$/, ''); + const response = await fetch(`${trimmedBase}/models`, { + headers: { + Accept: 'application/json', + ...headers, + }, + signal: AbortSignal.timeout(COPILOT_MODELS_TIMEOUT_MS), + }); + if (!response.ok) { + throw await httpError(response, 'GitHub Copilot models'); + } + + let payload; + try { + payload = await response.json(); + } catch { + throw new Error('GitHub Copilot models returned invalid JSON'); + } + if (!Array.isArray(payload?.data)) { + throw new Error('GitHub Copilot models returned an invalid model list'); + } + + const model = payload.data.find((item) => item && typeof item === 'object' && item.id === modelID); + if (!model) { + throw new Error(`GitHub Copilot model "${modelID}" was not returned by /models`); + } + if (model.supported_endpoints === undefined) { + return 'chat'; + } + if (!Array.isArray(model.supported_endpoints)) { + throw new Error(`GitHub Copilot model "${modelID}" returned invalid endpoint metadata`); + } + if (model.supported_endpoints.includes('/v1/messages')) { + return 'messages'; + } + if (model.supported_endpoints.includes('/responses')) { + return 'responses'; + } + if (model.supported_endpoints.includes('/chat/completions')) { + return 'chat'; + } + throw new Error(`GitHub Copilot model "${modelID}" has no supported text endpoint`); +}; + const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3') @@ -395,21 +493,44 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider const baseURL = entry.enterpriseUrl ? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}` : 'https://api.githubcopilot.com'; - return callOpenaiCompatible({ + const authHeaders = { + Authorization: `Bearer ${token}`, + 'User-Agent': USER_AGENT, + 'X-GitHub-Api-Version': '2026-06-01', + }; + const headers = { + ...authHeaders, + 'Openai-Intent': 'conversation-edits', + 'x-initiator': 'agent', + }; + const endpoint = await getCopilotEndpoint({ baseURL, - headers: { - Authorization: `Bearer ${token}`, - 'User-Agent': USER_AGENT, - 'Openai-Intent': 'conversation-edits', - 'x-initiator': 'agent', - 'X-GitHub-Api-Version': '2026-06-01', - }, + headers: authHeaders, + modelID, + }); + const request = { + baseURL, + headers, modelID, prompt, system, maxOutputTokens: tokens, providerLabel: 'GitHub Copilot', - }); + }; + if (endpoint === 'messages') { + return callMessages({ + ...request, + url: `${baseURL.replace(/\/+$/, '')}/v1/messages`, + headers: { + ...headers, + 'anthropic-version': '2023-06-01', + }, + }); + } + if (endpoint === 'responses') { + return callOpenaiResponses(request); + } + return callOpenaiCompatible(request); } if (providerID === 'openai' && entry.type === 'oauth') { diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 86602217..0b1a45ec 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -451,3 +451,187 @@ describe('callSmallModel — Google thinking configuration', () => { expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 }); }); }); + +describe('callSmallModel — GitHub Copilot endpoint routing', () => { + let fetchMock; + let originalFetch; + + const copilotAuth = (overrides = {}) => ({ + 'github-copilot': { + type: 'oauth', + access: 'test-token', + refresh: 'test-token', + expires: 0, + ...overrides, + }, + }); + + const jsonResponse = (payload, status = 200) => new Response( + JSON.stringify(payload), + { + status, + headers: { 'Content-Type': 'application/json' }, + }, + ); + + const callCopilot = (modelID, options = {}) => callSmallModel({ + auth: copilotAuth(options.auth), + catalog: {}, + workingDirectory: '/proj', + providerID: 'github-copilot', + modelID, + prompt: 'summarize this diff', + system: 'Write a commit message', + maxOutputTokens: 100, + }); + + beforeEach(() => { + fetchMock = vi.fn(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + readConfig.mockReset(); + readConfig.mockReturnValue({}); + readConfigLayers.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('routes a model advertising /responses through the Responses API', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: [{ + id: 'mai-code-1-flash-picker', + supported_endpoints: ['/responses'], + }], + })) + .mockResolvedValueOnce(jsonResponse({ + output: [{ + type: 'message', + content: [{ type: 'output_text', text: 'feat: add summary' }], + }], + })); + + await expect(callCopilot('mai-code-1-flash-picker')).resolves.toBe('feat: add summary'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://api.githubcopilot.com/models'); + expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/responses'); + const body = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(body).toMatchObject({ + model: 'mai-code-1-flash-picker', + instructions: 'Write a commit message', + max_output_tokens: 100, + stream: false, + store: false, + input: [{ + role: 'user', + content: [{ type: 'input_text', text: 'summarize this diff' }], + }], + }); + expect(JSON.stringify(body)).not.toContain('test-token'); + }); + + it('prefers /v1/messages when a model advertises multiple endpoints', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: [{ + id: 'claude-opus-4.7', + supported_endpoints: ['/chat/completions', '/responses', '/v1/messages'], + }], + })) + .mockResolvedValueOnce(jsonResponse({ + content: [{ type: 'text', text: 'fix: route Claude correctly' }], + })); + + await expect(callCopilot('claude-opus-4.7')).resolves.toBe('fix: route Claude correctly'); + + expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/v1/messages'); + const body = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(body).toMatchObject({ + model: 'claude-opus-4.7', + system: 'Write a commit message', + max_tokens: 100, + messages: [{ role: 'user', content: 'summarize this diff' }], + }); + }); + + it('uses /chat/completions when the model advertises the chat endpoint', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: [{ + id: 'gpt-5.4-nano', + supported_endpoints: ['/chat/completions'], + }], + })) + .mockResolvedValueOnce(ok('chore: update summary')); + + await expect(callCopilot('gpt-5.4-nano')).resolves.toBe('chore: update summary'); + + expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions'); + }); + + it('keeps chat completions as the legacy default when endpoint metadata is absent', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: [{ id: 'gpt-4o-mini' }], + })) + .mockResolvedValueOnce(ok('docs: clarify behavior')); + + await expect(callCopilot('gpt-4o-mini')).resolves.toBe('docs: clarify behavior'); + + expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions'); + }); + + it('uses the enterprise Copilot host for metadata and generation', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ + data: [{ + id: 'mai-code-1-flash-picker', + supported_endpoints: ['/responses'], + }], + })) + .mockResolvedValueOnce(jsonResponse({ + output_text: 'fix: support enterprise routing', + })); + + await expect(callCopilot('mai-code-1-flash-picker', { + auth: { enterpriseUrl: 'https://ghe.example.com/' }, + })).resolves.toBe('fix: support enterprise routing'); + + expect(String(fetchMock.mock.calls[0][0])).toBe('https://copilot-api.ghe.example.com/models'); + expect(String(fetchMock.mock.calls[1][0])).toBe('https://copilot-api.ghe.example.com/responses'); + }); + + it('surfaces Copilot model metadata request failures without generating', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unavailable' }, 503)); + + await expect(callCopilot('mai-code-1-flash-picker')) + .rejects.toThrow('GitHub Copilot models request failed with 503'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects missing models and unsupported advertised endpoints', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ + data: [{ id: 'different-model', supported_endpoints: ['/responses'] }], + })); + + await expect(callCopilot('mai-code-1-flash-picker')) + .rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" was not returned by /models'); + + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce(jsonResponse({ + data: [{ + id: 'mai-code-1-flash-picker', + supported_endpoints: ['/future'], + }], + })); + + await expect(callCopilot('mai-code-1-flash-picker')) + .rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" has no supported text endpoint'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +});