Skip to content

Commit dcaa80b

Browse files
CopilotlpcoxCopilot
authored
fix: route Copilot /models through GitHub REST API in api-proxy (#1942)
* Initial plan * fix: route Copilot /models through GitHub REST API in api-proxy Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/f2e14a98-ef78-407d-9faf-79ea4a31623a * fix: address review feedback for /models routing - Extract GHES base path from GITHUB_API_URL so /models requests include the /api/v3 prefix on enterprise deployments - Wrap URL parsing in try/catch to prevent crashes on malformed request targets (returns 400 instead) - Use consistent 'copilot' provider label for rate limiting on /models requests (was 'copilot-models') - Gate /models dispatch to GET method only Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: route /models to Copilot API, not GitHub REST API PR #1952 (merged to main) correctly identified that /models is served by the Copilot API at api.githubcopilot.com, not the GitHub REST API. Routing to api.github.com with a Copilot token causes 401, making the Copilot CLI crash silently (exit 1, zero output, 1 second). Revert /models routing to COPILOT_API_TARGET. Keep deriveGitHubApiTarget and deriveGitHubApiBasePath functions for potential future GHES/GHEC use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 218a001 commit dcaa80b

3 files changed

Lines changed: 173 additions & 4 deletions

File tree

containers/api-proxy/server.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,53 @@ function deriveCopilotApiTarget() {
207207
}
208208
const COPILOT_API_TARGET = deriveCopilotApiTarget();
209209

210+
// GitHub REST API target host for endpoints that need the GitHub REST API
211+
// (e.g., enterprise-specific endpoints). Currently unused — /models is served
212+
// by the Copilot API, not the REST API — but kept for future GHES/GHEC needs.
213+
// Priority: GITHUB_API_URL env var (hostname extracted) > auto-derive from GITHUB_SERVER_URL > default
214+
function deriveGitHubApiTarget() {
215+
// Explicit GITHUB_API_URL takes priority — this is the canonical source for enterprise deployments
216+
if (process.env.GITHUB_API_URL) {
217+
const target = normalizeApiTarget(process.env.GITHUB_API_URL);
218+
if (target) return target;
219+
}
220+
// Auto-derive from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com)
221+
const serverUrl = process.env.GITHUB_SERVER_URL;
222+
if (serverUrl) {
223+
try {
224+
const hostname = new URL(serverUrl).hostname;
225+
if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) {
226+
// GHEC: GitHub REST API lives at api.<subdomain>.ghe.com
227+
const subdomain = hostname.slice(0, -8); // Remove '.ghe.com'
228+
return `api.${subdomain}.ghe.com`;
229+
}
230+
} catch {
231+
// Invalid URL — fall through to default
232+
}
233+
}
234+
return 'api.github.com';
235+
}
236+
237+
/**
238+
* Extract the base path from GITHUB_API_URL for GHES deployments
239+
* (e.g. https://ghes.example.com/api/v3 → '/api/v3').
240+
* Returns '' for github.com or when no path component is present.
241+
*/
242+
function deriveGitHubApiBasePath() {
243+
const raw = process.env.GITHUB_API_URL;
244+
if (!raw) return '';
245+
try {
246+
const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`);
247+
const p = parsed.pathname.replace(/\/+$/, '');
248+
return p === '/' ? '' : p;
249+
} catch {
250+
return '';
251+
}
252+
}
253+
254+
const GITHUB_API_TARGET = deriveGitHubApiTarget();
255+
const GITHUB_API_BASE_PATH = deriveGitHubApiBasePath();
256+
210257
// Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose)
211258
const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
212259

@@ -218,6 +265,7 @@ logRequest('info', 'startup', {
218265
anthropic: ANTHROPIC_API_TARGET,
219266
gemini: GEMINI_API_TARGET,
220267
copilot: COPILOT_API_TARGET,
268+
github: GITHUB_API_TARGET,
221269
},
222270
api_base_paths: {
223271
openai: OPENAI_API_BASE_PATH || '(none)',
@@ -1036,4 +1084,4 @@ if (require.main === module) {
10361084
}
10371085

10381086
// Export for testing
1039-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };
1087+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };

containers/api-proxy/server.test.js

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
const http = require('http');
66
const tls = require('tls');
77
const { EventEmitter } = require('events');
8-
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');
8+
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');
99

1010
describe('normalizeApiTarget', () => {
1111
it('should strip https:// prefix', () => {
@@ -165,6 +165,127 @@ describe('deriveCopilotApiTarget', () => {
165165
});
166166
});
167167

168+
describe('deriveGitHubApiTarget', () => {
169+
let originalEnv;
170+
171+
beforeEach(() => {
172+
originalEnv = {
173+
GITHUB_API_URL: process.env.GITHUB_API_URL,
174+
GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL,
175+
};
176+
delete process.env.GITHUB_API_URL;
177+
delete process.env.GITHUB_SERVER_URL;
178+
});
179+
180+
afterEach(() => {
181+
if (originalEnv.GITHUB_API_URL !== undefined) {
182+
process.env.GITHUB_API_URL = originalEnv.GITHUB_API_URL;
183+
} else {
184+
delete process.env.GITHUB_API_URL;
185+
}
186+
if (originalEnv.GITHUB_SERVER_URL !== undefined) {
187+
process.env.GITHUB_SERVER_URL = originalEnv.GITHUB_SERVER_URL;
188+
} else {
189+
delete process.env.GITHUB_SERVER_URL;
190+
}
191+
});
192+
193+
describe('GITHUB_API_URL env var (highest priority)', () => {
194+
it('should return hostname from GITHUB_API_URL full URL', () => {
195+
process.env.GITHUB_API_URL = 'https://api.github.com';
196+
expect(deriveGitHubApiTarget()).toBe('api.github.com');
197+
});
198+
199+
it('should return hostname from GITHUB_API_URL for GHES', () => {
200+
process.env.GITHUB_API_URL = 'https://github.internal/api/v3';
201+
expect(deriveGitHubApiTarget()).toBe('github.internal');
202+
});
203+
204+
it('should prefer GITHUB_API_URL over GITHUB_SERVER_URL', () => {
205+
process.env.GITHUB_API_URL = 'https://api.mycompany.ghe.com';
206+
process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com';
207+
expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com');
208+
});
209+
});
210+
211+
describe('GHEC (*.ghe.com)', () => {
212+
it('should return api.<subdomain>.ghe.com for GHEC tenant', () => {
213+
process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com';
214+
expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com');
215+
});
216+
217+
it('should handle multiple-level subdomains', () => {
218+
process.env.GITHUB_SERVER_URL = 'https://sub.example.ghe.com';
219+
expect(deriveGitHubApiTarget()).toBe('api.sub.example.ghe.com');
220+
});
221+
});
222+
223+
describe('Default behavior', () => {
224+
it('should return api.github.com when no env vars are set', () => {
225+
expect(deriveGitHubApiTarget()).toBe('api.github.com');
226+
});
227+
228+
it('should return api.github.com for github.com GITHUB_SERVER_URL', () => {
229+
process.env.GITHUB_SERVER_URL = 'https://github.com';
230+
expect(deriveGitHubApiTarget()).toBe('api.github.com');
231+
});
232+
233+
it('should return api.github.com for GHES without GITHUB_API_URL', () => {
234+
// GHES without an explicit GITHUB_API_URL falls back to api.github.com.
235+
// This is a known limitation: GHES deployments should set GITHUB_API_URL explicitly
236+
// so deriveGitHubApiTarget() resolves to the correct enterprise API hostname.
237+
process.env.GITHUB_SERVER_URL = 'https://github.internal';
238+
expect(deriveGitHubApiTarget()).toBe('api.github.com');
239+
});
240+
241+
it('should return api.github.com when GITHUB_SERVER_URL is invalid', () => {
242+
process.env.GITHUB_SERVER_URL = 'not-a-valid-url';
243+
expect(deriveGitHubApiTarget()).toBe('api.github.com');
244+
});
245+
});
246+
});
247+
248+
describe('deriveGitHubApiBasePath', () => {
249+
const savedEnv = {};
250+
251+
beforeEach(() => {
252+
savedEnv.GITHUB_API_URL = process.env.GITHUB_API_URL;
253+
delete process.env.GITHUB_API_URL;
254+
});
255+
256+
afterEach(() => {
257+
if (savedEnv.GITHUB_API_URL !== undefined) {
258+
process.env.GITHUB_API_URL = savedEnv.GITHUB_API_URL;
259+
} else {
260+
delete process.env.GITHUB_API_URL;
261+
}
262+
});
263+
264+
it('should return empty string when GITHUB_API_URL is not set', () => {
265+
expect(deriveGitHubApiBasePath()).toBe('');
266+
});
267+
268+
it('should extract /api/v3 from GHES-style GITHUB_API_URL', () => {
269+
process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3';
270+
expect(deriveGitHubApiBasePath()).toBe('/api/v3');
271+
});
272+
273+
it('should return empty string for github.com API URL (no path)', () => {
274+
process.env.GITHUB_API_URL = 'https://api.github.com';
275+
expect(deriveGitHubApiBasePath()).toBe('');
276+
});
277+
278+
it('should strip trailing slashes', () => {
279+
process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3/';
280+
expect(deriveGitHubApiBasePath()).toBe('/api/v3');
281+
});
282+
283+
it('should return empty string for invalid URL', () => {
284+
process.env.GITHUB_API_URL = '://invalid';
285+
expect(deriveGitHubApiBasePath()).toBe('');
286+
});
287+
});
288+
168289
describe('normalizeBasePath', () => {
169290
it('should return empty string for undefined', () => {
170291
expect(normalizeBasePath(undefined)).toBe('');

src/docker-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,8 +1535,8 @@ export function generateDockerCompose(
15351535
...(config.geminiApiBasePath && { GEMINI_API_BASE_PATH: config.geminiApiBasePath }),
15361536
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
15371537
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
1538-
// Forward GITHUB_API_URL so api-proxy can use the correct GitHub REST API hostname
1539-
// on GHES/GHEC (e.g. https://ghes.example.com/api/v3 or api.mycompany.ghe.com)
1538+
// Forward GITHUB_API_URL so api-proxy can route /models to the correct GitHub REST API
1539+
// target on GHES/GHEC (e.g. api.mycompany.ghe.com instead of api.github.com)
15401540
...(process.env.GITHUB_API_URL && { GITHUB_API_URL: process.env.GITHUB_API_URL }),
15411541
// Route through Squid to respect domain whitelisting
15421542
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,

0 commit comments

Comments
 (0)