Skip to content

Commit feddd88

Browse files
Copilotlpcox
andauthored
fix(api-proxy): fetch models from BYOK custom providers and fix models_url in reflect (#2699)
* Initial plan * fix: fetch models from BYOK providers and fix models_url in reflect Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/fa22a190-1e6c-4e84-89b3-8ea2d15b3226 * refactor: extract modelsPath to closure var, fix test name Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/fa22a190-1e6c-4e84-89b3-8ea2d15b3226 * fix: use apiKey for custom targets, guard against basePath='/' Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/27e1c51d-5ef2-419d-9c13-909ce9832e9f Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 80aa844 commit feddd88

2 files changed

Lines changed: 173 additions & 6 deletions

File tree

containers/api-proxy/providers/copilot.js

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ function createCopilotAdapter(env, deps = {}) {
154154

155155
const bodyTransform = deps.bodyTransform || null;
156156

157+
// Pre-computed models path used by getModelsFetchConfig and getReflectionInfo.
158+
// For BYOK/custom providers the base path prefix is included (e.g. /api/v1/models
159+
// for COPILOT_PROVIDER_BASE_URL=https://openrouter.ai/api/v1).
160+
// A basePath of '/' (normalizeBasePath returns '/') is treated as no prefix to
161+
// avoid producing '//models'.
162+
const modelsPath = (basePath && basePath !== '/') ? `${basePath}/models` : '/models';
163+
157164
return {
158165
name: 'copilot',
159166
port: 10002,
@@ -237,29 +244,54 @@ function createCopilotAdapter(env, deps = {}) {
237244
},
238245

239246
getModelsFetchConfig() {
240-
// Only COPILOT_GITHUB_TOKEN is accepted by the /models endpoint
241-
if (!githubToken) return null;
247+
if (!authToken) return null;
248+
249+
// Standard Copilot API (api.githubcopilot.com):
250+
// The /models endpoint only accepts GitHub OAuth tokens (COPILOT_GITHUB_TOKEN).
251+
// Skip startup model fetch when only a BYOK API key is configured.
252+
if (rawTarget === 'api.githubcopilot.com') {
253+
if (!githubToken) return null;
254+
return {
255+
url: `https://${rawTarget}/models`,
256+
opts: {
257+
method: 'GET',
258+
headers: {
259+
'Authorization': `Bearer ${githubToken}`,
260+
'Copilot-Integration-Id': integrationId,
261+
},
262+
},
263+
cacheKey: 'copilot',
264+
};
265+
}
266+
267+
// BYOK / custom provider (e.g. OpenRouter):
268+
// Use the explicit BYOK API key (COPILOT_API_KEY) rather than authToken
269+
// to ensure we never send a GitHub OAuth token to third-party providers.
270+
// Skip the fetch when no BYOK key is configured.
271+
if (!apiKey) return null;
242272
return {
243-
url: `https://${rawTarget}/models`,
273+
url: `https://${rawTarget}${modelsPath}`,
244274
opts: {
245275
method: 'GET',
246276
headers: {
247-
'Authorization': `Bearer ${githubToken}`,
248-
'Copilot-Integration-Id': integrationId,
277+
'Authorization': `Bearer ${apiKey}`,
249278
},
250279
},
251280
cacheKey: 'copilot',
252281
};
253282
},
254283

255284
getReflectionInfo() {
285+
// For BYOK / custom providers, include the base path in the models URL so
286+
// that clients (e.g. the gh-aw framework) use the correct endpoint to
287+
// discover available models (e.g. /api/v1/models for OpenRouter).
256288
return {
257289
provider: 'copilot',
258290
port: 10002,
259291
base_url: 'http://api-proxy:10002',
260292
configured: !!authToken,
261293
models_cache_key: 'copilot',
262-
models_url: 'http://api-proxy:10002/models',
294+
models_url: `http://api-proxy:10002${modelsPath}`,
263295
};
264296
},
265297

containers/api-proxy/server.test.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,18 @@ describe('fetchStartupModels', () => {
569569
expect(cachedModels.copilot).toBeUndefined();
570570
});
571571

572+
it('should populate cachedModels.copilot when BYOK key + custom provider target (adapter-based path)', async () => {
573+
mockHttpsRequestWithBody(200, '{"data":[{"id":"minimax/minimax-m2.5:free"},{"id":"openai/gpt-4o"}]}');
574+
const adapter = createCopilotAdapter({
575+
COPILOT_API_KEY: 'sk-or-byok-key',
576+
COPILOT_API_TARGET: 'openrouter.ai',
577+
COPILOT_API_BASE_PATH: '/api/v1',
578+
});
579+
await fetchStartupModels([adapter]);
580+
// Models from the custom BYOK provider should be cached
581+
expect(cachedModels.copilot).toEqual(['minimax/minimax-m2.5:free', 'openai/gpt-4o']);
582+
});
583+
572584
it('should skip fetching when no keys are configured', async () => {
573585
const spy = jest.spyOn(https, 'request');
574586
await fetchStartupModels({
@@ -1508,6 +1520,129 @@ describe('provider adapter alwaysBind', () => {
15081520
});
15091521
});
15101522

1523+
// ── Copilot adapter BYOK model fetch ──────────────────────────────────────────
1524+
//
1525+
// These tests verify that the Copilot adapter fetches models from a custom
1526+
// BYOK provider (e.g. OpenRouter) at startup, and that the reflect response
1527+
// includes the correct base-path-aware models URL.
1528+
//
1529+
1530+
describe('copilot adapter BYOK model fetch', () => {
1531+
it('getModelsFetchConfig returns null for BYOK key on standard Copilot API (no GitHub token)', () => {
1532+
const adapter = createCopilotAdapter({
1533+
COPILOT_API_KEY: 'sk-byok-key',
1534+
COPILOT_API_TARGET: 'api.githubcopilot.com',
1535+
});
1536+
expect(adapter.getModelsFetchConfig()).toBeNull();
1537+
});
1538+
1539+
it('getModelsFetchConfig returns fetch config for BYOK key on custom target', () => {
1540+
const adapter = createCopilotAdapter({
1541+
COPILOT_API_KEY: 'sk-or-key',
1542+
COPILOT_API_TARGET: 'openrouter.ai',
1543+
COPILOT_API_BASE_PATH: '/api/v1',
1544+
});
1545+
const config = adapter.getModelsFetchConfig();
1546+
expect(config).not.toBeNull();
1547+
expect(config.url).toBe('https://openrouter.ai/api/v1/models');
1548+
expect(config.opts.method).toBe('GET');
1549+
expect(config.opts.headers['Authorization']).toBe('Bearer sk-or-key');
1550+
expect(config.cacheKey).toBe('copilot');
1551+
});
1552+
1553+
it('getModelsFetchConfig uses github token for standard Copilot API target', () => {
1554+
const adapter = createCopilotAdapter({
1555+
COPILOT_GITHUB_TOKEN: 'ghu_token',
1556+
});
1557+
const config = adapter.getModelsFetchConfig();
1558+
expect(config).not.toBeNull();
1559+
expect(config.url).toBe('https://api.githubcopilot.com/models');
1560+
expect(config.opts.headers['Authorization']).toBe('Bearer ghu_token');
1561+
expect(config.opts.headers['Copilot-Integration-Id']).toBeDefined();
1562+
expect(config.cacheKey).toBe('copilot');
1563+
});
1564+
1565+
it('getModelsFetchConfig returns null when no auth token is configured', () => {
1566+
const adapter = createCopilotAdapter({});
1567+
expect(adapter.getModelsFetchConfig()).toBeNull();
1568+
});
1569+
1570+
it('getModelsFetchConfig uses /models directly when basePath is not configured', () => {
1571+
// When no basePath is set, /models is used directly (no prefix)
1572+
const adapter = createCopilotAdapter({
1573+
COPILOT_API_KEY: 'sk-custom-key',
1574+
COPILOT_API_TARGET: 'custom.llm.example.com',
1575+
});
1576+
const config = adapter.getModelsFetchConfig();
1577+
expect(config).not.toBeNull();
1578+
expect(config.url).toBe('https://custom.llm.example.com/models');
1579+
});
1580+
1581+
it('getModelsFetchConfig uses /models (not //models) when basePath is "/"', () => {
1582+
// normalizeBasePath('/') returns '/' — ensure we don't produce //models
1583+
const adapter = createCopilotAdapter({
1584+
COPILOT_API_KEY: 'sk-custom-key',
1585+
COPILOT_API_TARGET: 'custom.llm.example.com',
1586+
COPILOT_API_BASE_PATH: '/',
1587+
});
1588+
const config = adapter.getModelsFetchConfig();
1589+
expect(config).not.toBeNull();
1590+
expect(config.url).toBe('https://custom.llm.example.com/models');
1591+
expect(config.url).not.toContain('//models');
1592+
});
1593+
1594+
it('getModelsFetchConfig uses COPILOT_API_KEY (not GitHub token) for custom targets even when both are set', () => {
1595+
// Verify that the GitHub OAuth token is never sent to third-party BYOK providers
1596+
const adapter = createCopilotAdapter({
1597+
COPILOT_GITHUB_TOKEN: 'ghu_github_token',
1598+
COPILOT_API_KEY: 'sk-byok-key',
1599+
COPILOT_API_TARGET: 'openrouter.ai',
1600+
COPILOT_API_BASE_PATH: '/api/v1',
1601+
});
1602+
const config = adapter.getModelsFetchConfig();
1603+
expect(config).not.toBeNull();
1604+
expect(config.opts.headers['Authorization']).toBe('Bearer sk-byok-key');
1605+
expect(config.opts.headers['Authorization']).not.toContain('ghu_github_token');
1606+
});
1607+
1608+
it('getModelsFetchConfig returns null for custom target when only github token is set (no BYOK key)', () => {
1609+
// Without an explicit COPILOT_API_KEY there is nothing to authenticate with
1610+
// at the custom provider — skip the fetch rather than forward the GitHub token.
1611+
const adapter = createCopilotAdapter({
1612+
COPILOT_GITHUB_TOKEN: 'ghu_token',
1613+
COPILOT_API_TARGET: 'openrouter.ai',
1614+
});
1615+
expect(adapter.getModelsFetchConfig()).toBeNull();
1616+
});
1617+
1618+
it('getReflectionInfo includes /models for standard Copilot API (no base path)', () => {
1619+
const adapter = createCopilotAdapter({ COPILOT_GITHUB_TOKEN: 'ghu_token' });
1620+
const info = adapter.getReflectionInfo();
1621+
expect(info.models_url).toBe('http://api-proxy:10002/models');
1622+
});
1623+
1624+
it('getReflectionInfo includes base path in models_url for BYOK providers', () => {
1625+
const adapter = createCopilotAdapter({
1626+
COPILOT_API_KEY: 'sk-or-key',
1627+
COPILOT_API_TARGET: 'openrouter.ai',
1628+
COPILOT_API_BASE_PATH: '/api/v1',
1629+
});
1630+
const info = adapter.getReflectionInfo();
1631+
expect(info.models_url).toBe('http://api-proxy:10002/api/v1/models');
1632+
});
1633+
1634+
it('getReflectionInfo uses /models (not //models) when basePath is "/"', () => {
1635+
const adapter = createCopilotAdapter({
1636+
COPILOT_API_KEY: 'sk-or-key',
1637+
COPILOT_API_TARGET: 'openrouter.ai',
1638+
COPILOT_API_BASE_PATH: '/',
1639+
});
1640+
const info = adapter.getReflectionInfo();
1641+
expect(info.models_url).toBe('http://api-proxy:10002/models');
1642+
expect(info.models_url).not.toContain('//models');
1643+
});
1644+
});
1645+
15111646
describe('extractBillingHeaders', () => {
15121647
it('returns null when no billing headers present', () => {
15131648
expect(extractBillingHeaders({ 'content-type': 'application/json' })).toBeNull();

0 commit comments

Comments
 (0)