fix(web): honor Copilot model endpoints (#2382)

Route small-model requests through each Copilot model's advertised Messages, Responses, or Chat Completions endpoint.
This commit is contained in:
Jakub Syty
2026-07-23 22:48:18 +03:00
committed by GitHub
parent 38222d07c8
commit ddbb3c1db0
3 changed files with 328 additions and 18 deletions
@@ -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.<enterprise>`) 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.<enterprise>`) 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
+136 -15
View File
@@ -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') {
@@ -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);
});
});