Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,53 @@
}
const COPILOT_API_TARGET = deriveCopilotApiTarget();

// GitHub REST API target host for endpoints that need the GitHub REST API
// (e.g., enterprise-specific endpoints). Currently unused — /models is served
// by the Copilot API, not the REST API — but kept for future GHES/GHEC needs.
// Priority: GITHUB_API_URL env var (hostname extracted) > auto-derive from GITHUB_SERVER_URL > default
function deriveGitHubApiTarget() {
// Explicit GITHUB_API_URL takes priority — this is the canonical source for enterprise deployments
if (process.env.GITHUB_API_URL) {
const target = normalizeApiTarget(process.env.GITHUB_API_URL);
if (target) return target;
}
Comment on lines +214 to +219
// Auto-derive from GITHUB_SERVER_URL for GHEC tenants (*.ghe.com)
const serverUrl = process.env.GITHUB_SERVER_URL;
if (serverUrl) {
try {
const hostname = new URL(serverUrl).hostname;
if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) {
// GHEC: GitHub REST API lives at api.<subdomain>.ghe.com
const subdomain = hostname.slice(0, -8); // Remove '.ghe.com'
return `api.${subdomain}.ghe.com`;
}
} catch {
// Invalid URL — fall through to default
}
}
return 'api.github.com';
}

/**
* Extract the base path from GITHUB_API_URL for GHES deployments
* (e.g. https://ghes.example.com/api/v3 → '/api/v3').
* Returns '' for github.com or when no path component is present.
*/
function deriveGitHubApiBasePath() {
const raw = process.env.GITHUB_API_URL;
if (!raw) return '';
try {
const parsed = new URL(raw.trim().startsWith('http') ? raw.trim() : `https://${raw.trim()}`);
const p = parsed.pathname.replace(/\/+$/, '');
return p === '/' ? '' : p;
} catch {
return '';
}
}

const GITHUB_API_TARGET = deriveGitHubApiTarget();
const GITHUB_API_BASE_PATH = deriveGitHubApiBasePath();

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable GITHUB_API_BASE_PATH.

// Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose)
const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;

Expand All @@ -218,6 +265,7 @@
anthropic: ANTHROPIC_API_TARGET,
gemini: GEMINI_API_TARGET,
copilot: COPILOT_API_TARGET,
github: GITHUB_API_TARGET,
},
api_base_paths: {
openai: OPENAI_API_BASE_PATH || '(none)',
Expand Down Expand Up @@ -1036,4 +1084,4 @@
}

// Export for testing
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };
123 changes: 122 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const http = require('http');
const tls = require('tls');
const { EventEmitter } = require('events');
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');
const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');

describe('normalizeApiTarget', () => {
it('should strip https:// prefix', () => {
Expand Down Expand Up @@ -165,6 +165,127 @@ describe('deriveCopilotApiTarget', () => {
});
});

describe('deriveGitHubApiTarget', () => {
let originalEnv;

beforeEach(() => {
originalEnv = {
GITHUB_API_URL: process.env.GITHUB_API_URL,
GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL,
};
delete process.env.GITHUB_API_URL;
delete process.env.GITHUB_SERVER_URL;
});

afterEach(() => {
if (originalEnv.GITHUB_API_URL !== undefined) {
process.env.GITHUB_API_URL = originalEnv.GITHUB_API_URL;
} else {
delete process.env.GITHUB_API_URL;
}
if (originalEnv.GITHUB_SERVER_URL !== undefined) {
process.env.GITHUB_SERVER_URL = originalEnv.GITHUB_SERVER_URL;
} else {
delete process.env.GITHUB_SERVER_URL;
}
});

describe('GITHUB_API_URL env var (highest priority)', () => {
it('should return hostname from GITHUB_API_URL full URL', () => {
process.env.GITHUB_API_URL = 'https://api.github.com';
expect(deriveGitHubApiTarget()).toBe('api.github.com');
});

it('should return hostname from GITHUB_API_URL for GHES', () => {
process.env.GITHUB_API_URL = 'https://github.internal/api/v3';
expect(deriveGitHubApiTarget()).toBe('github.internal');
});

it('should prefer GITHUB_API_URL over GITHUB_SERVER_URL', () => {
process.env.GITHUB_API_URL = 'https://api.mycompany.ghe.com';
process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com';
expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com');
});
});

describe('GHEC (*.ghe.com)', () => {
it('should return api.<subdomain>.ghe.com for GHEC tenant', () => {
process.env.GITHUB_SERVER_URL = 'https://mycompany.ghe.com';
expect(deriveGitHubApiTarget()).toBe('api.mycompany.ghe.com');
});

it('should handle multiple-level subdomains', () => {
process.env.GITHUB_SERVER_URL = 'https://sub.example.ghe.com';
expect(deriveGitHubApiTarget()).toBe('api.sub.example.ghe.com');
});
});

describe('Default behavior', () => {
it('should return api.github.com when no env vars are set', () => {
expect(deriveGitHubApiTarget()).toBe('api.github.com');
});

it('should return api.github.com for github.com GITHUB_SERVER_URL', () => {
process.env.GITHUB_SERVER_URL = 'https://github.com';
expect(deriveGitHubApiTarget()).toBe('api.github.com');
});

it('should return api.github.com for GHES without GITHUB_API_URL', () => {
// GHES without an explicit GITHUB_API_URL falls back to api.github.com.
// This is a known limitation: GHES deployments should set GITHUB_API_URL explicitly
// so deriveGitHubApiTarget() resolves to the correct enterprise API hostname.
process.env.GITHUB_SERVER_URL = 'https://github.internal';
expect(deriveGitHubApiTarget()).toBe('api.github.com');
});

it('should return api.github.com when GITHUB_SERVER_URL is invalid', () => {
process.env.GITHUB_SERVER_URL = 'not-a-valid-url';
expect(deriveGitHubApiTarget()).toBe('api.github.com');
});
});
});

describe('deriveGitHubApiBasePath', () => {
const savedEnv = {};

beforeEach(() => {
savedEnv.GITHUB_API_URL = process.env.GITHUB_API_URL;
delete process.env.GITHUB_API_URL;
});

afterEach(() => {
if (savedEnv.GITHUB_API_URL !== undefined) {
process.env.GITHUB_API_URL = savedEnv.GITHUB_API_URL;
} else {
delete process.env.GITHUB_API_URL;
}
});

it('should return empty string when GITHUB_API_URL is not set', () => {
expect(deriveGitHubApiBasePath()).toBe('');
});

it('should extract /api/v3 from GHES-style GITHUB_API_URL', () => {
process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3';
expect(deriveGitHubApiBasePath()).toBe('/api/v3');
});

it('should return empty string for github.com API URL (no path)', () => {
process.env.GITHUB_API_URL = 'https://api.github.com';
expect(deriveGitHubApiBasePath()).toBe('');
});

it('should strip trailing slashes', () => {
process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3/';
expect(deriveGitHubApiBasePath()).toBe('/api/v3');
});

it('should return empty string for invalid URL', () => {
process.env.GITHUB_API_URL = '://invalid';
expect(deriveGitHubApiBasePath()).toBe('');
});
});

describe('normalizeBasePath', () => {
it('should return empty string for undefined', () => {
expect(normalizeBasePath(undefined)).toBe('');
Expand Down
4 changes: 2 additions & 2 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1535,8 +1535,8 @@ export function generateDockerCompose(
...(config.geminiApiBasePath && { GEMINI_API_BASE_PATH: config.geminiApiBasePath }),
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
// Forward GITHUB_API_URL so api-proxy can use the correct GitHub REST API hostname
// on GHES/GHEC (e.g. https://ghes.example.com/api/v3 or api.mycompany.ghe.com)
// Forward GITHUB_API_URL so api-proxy can route /models to the correct GitHub REST API
// target on GHES/GHEC (e.g. api.mycompany.ghe.com instead of api.github.com)
...(process.env.GITHUB_API_URL && { GITHUB_API_URL: process.env.GITHUB_API_URL }),
// Route through Squid to respect domain whitelisting
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
Expand Down
Loading