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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user