Skip to content

Commit f804f03

Browse files
committed
feat: add --openai-api-target and --anthropic-api-target flags for custom LLM endpoints
Adds support for routing OpenAI and Anthropic API proxy traffic to custom endpoints, enabling use with internal LLM routers, Azure OpenAI, and other OpenAI/Anthropic-compatible APIs. Follows the existing --copilot-api-target pattern. Changes: - cli.ts: Add --openai-api-target and --anthropic-api-target CLI flags - types.ts: Add openaiApiTarget and anthropicApiTarget to WrapperConfig - docker-manager.ts: Pass OPENAI_API_TARGET / ANTHROPIC_API_TARGET to proxy container - containers/api-proxy/server.js: Read OPENAI_API_TARGET / ANTHROPIC_API_TARGET env vars (defaulting to api.openai.com / api.anthropic.com) instead of hardcoding Closes: github/gh-aw#20590
1 parent 1fe9e76 commit f804f03

5 files changed

Lines changed: 130 additions & 3 deletions

File tree

containers/api-proxy/server.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
4646
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
4747
const COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN;
4848

49+
// Configurable OpenAI API target host (supports internal LLM routers / Azure OpenAI)
50+
// Priority: OPENAI_API_TARGET env var > default
51+
const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com';
52+
53+
// Configurable Anthropic API target host (supports internal LLM routers)
54+
// Priority: ANTHROPIC_API_TARGET env var > default
55+
const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.com';
56+
4957
// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints)
5058
// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default
5159
function deriveCopilotApiTarget() {
@@ -76,6 +84,8 @@ const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
7684
logRequest('info', 'startup', {
7785
message: 'Starting AWF API proxy sidecar',
7886
squid_proxy: HTTPS_PROXY || 'not configured',
87+
openai_api_target: OPENAI_API_TARGET,
88+
anthropic_api_target: ANTHROPIC_API_TARGET,
7989
copilot_api_target: COPILOT_API_TARGET,
8090
providers: {
8191
openai: !!OPENAI_API_KEY,
@@ -397,7 +407,7 @@ if (OPENAI_API_KEY) {
397407
const contentLength = parseInt(req.headers['content-length'], 10) || 0;
398408
if (checkRateLimit(req, res, 'openai', contentLength)) return;
399409

400-
proxyRequest(req, res, 'api.openai.com', {
410+
proxyRequest(req, res, OPENAI_API_TARGET, {
401411
'Authorization': `Bearer ${OPENAI_API_KEY}`,
402412
}, 'openai');
403413
});
@@ -436,7 +446,7 @@ if (ANTHROPIC_API_KEY) {
436446
if (!req.headers['anthropic-version']) {
437447
anthropicHeaders['anthropic-version'] = '2023-06-01';
438448
}
439-
proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders, 'anthropic');
449+
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic');
440450
});
441451

442452
server.listen(10001, '0.0.0.0', () => {
@@ -488,7 +498,7 @@ if (ANTHROPIC_API_KEY) {
488498
if (!req.headers['anthropic-version']) {
489499
anthropicHeaders['anthropic-version'] = '2023-06-01';
490500
}
491-
proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders);
501+
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders);
492502
});
493503

494504
opencodeServer.listen(10004, '0.0.0.0', () => {

src/cli.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,20 @@ program
847847
' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' +
848848
' Can also be set via COPILOT_API_TARGET env var.',
849849
)
850+
.option(
851+
'--openai-api-target <host>',
852+
'Target hostname for OpenAI API requests in the api-proxy sidecar.\n' +
853+
' Defaults to api.openai.com. Useful for internal LLM routers\n' +
854+
' or Azure OpenAI / OpenAI-compatible endpoints.\n' +
855+
' Can also be set via OPENAI_API_TARGET env var.',
856+
)
857+
.option(
858+
'--anthropic-api-target <host>',
859+
'Target hostname for Anthropic API requests in the api-proxy sidecar.\n' +
860+
' Defaults to api.anthropic.com. Useful for internal LLM routers\n' +
861+
' or Anthropic-compatible endpoints.\n' +
862+
' Can also be set via ANTHROPIC_API_TARGET env var.',
863+
)
850864
.option(
851865
'--rate-limit-rpm <n>',
852866
'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)',
@@ -1136,6 +1150,8 @@ program
11361150
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
11371151
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
11381152
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
1153+
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
1154+
anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET,
11391155
};
11401156

11411157
// Build rate limit config when API proxy is enabled

src/docker-manager.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,6 +1893,54 @@ describe('docker-manager', () => {
18931893
expect(env.AWF_RATE_LIMIT_RPH).toBeUndefined();
18941894
expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined();
18951895
});
1896+
1897+
it('should pass OPENAI_API_TARGET to api-proxy when openaiApiTarget is set', () => {
1898+
const configWithTarget = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'llm-router.internal.example.com' };
1899+
const result = generateDockerCompose(configWithTarget, mockNetworkConfigWithProxy);
1900+
const proxy = result.services['api-proxy'];
1901+
const env = proxy.environment as Record<string, string>;
1902+
expect(env.OPENAI_API_TARGET).toBe('llm-router.internal.example.com');
1903+
});
1904+
1905+
it('should not pass OPENAI_API_TARGET to api-proxy when openaiApiTarget is not set', () => {
1906+
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' };
1907+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
1908+
const proxy = result.services['api-proxy'];
1909+
const env = proxy.environment as Record<string, string>;
1910+
expect(env.OPENAI_API_TARGET).toBeUndefined();
1911+
});
1912+
1913+
it('should pass ANTHROPIC_API_TARGET to api-proxy when anthropicApiTarget is set', () => {
1914+
const configWithTarget = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key', anthropicApiTarget: 'llm-router.internal.example.com' };
1915+
const result = generateDockerCompose(configWithTarget, mockNetworkConfigWithProxy);
1916+
const proxy = result.services['api-proxy'];
1917+
const env = proxy.environment as Record<string, string>;
1918+
expect(env.ANTHROPIC_API_TARGET).toBe('llm-router.internal.example.com');
1919+
});
1920+
1921+
it('should not pass ANTHROPIC_API_TARGET to api-proxy when anthropicApiTarget is not set', () => {
1922+
const configWithProxy = { ...mockConfig, enableApiProxy: true, anthropicApiKey: 'sk-ant-test-key' };
1923+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
1924+
const proxy = result.services['api-proxy'];
1925+
const env = proxy.environment as Record<string, string>;
1926+
expect(env.ANTHROPIC_API_TARGET).toBeUndefined();
1927+
});
1928+
1929+
it('should pass both OPENAI_API_TARGET and ANTHROPIC_API_TARGET when both are set', () => {
1930+
const configWithTargets = {
1931+
...mockConfig,
1932+
enableApiProxy: true,
1933+
openaiApiKey: 'sk-test-key',
1934+
anthropicApiKey: 'sk-ant-test-key',
1935+
openaiApiTarget: 'openai-router.internal.example.com',
1936+
anthropicApiTarget: 'anthropic-router.internal.example.com',
1937+
};
1938+
const result = generateDockerCompose(configWithTargets, mockNetworkConfigWithProxy);
1939+
const proxy = result.services['api-proxy'];
1940+
const env = proxy.environment as Record<string, string>;
1941+
expect(env.OPENAI_API_TARGET).toBe('openai-router.internal.example.com');
1942+
expect(env.ANTHROPIC_API_TARGET).toBe('anthropic-router.internal.example.com');
1943+
});
18961944
});
18971945
});
18981946

src/docker-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,10 @@ export function generateDockerCompose(
10081008
...(config.copilotGithubToken && { COPILOT_GITHUB_TOKEN: config.copilotGithubToken }),
10091009
// Configurable Copilot API target (for GHES/GHEC support)
10101010
...(config.copilotApiTarget && { COPILOT_API_TARGET: config.copilotApiTarget }),
1011+
// Configurable OpenAI API target (for internal LLM routers / Azure OpenAI)
1012+
...(config.openaiApiTarget && { OPENAI_API_TARGET: config.openaiApiTarget }),
1013+
// Configurable Anthropic API target (for internal LLM routers)
1014+
...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: config.anthropicApiTarget }),
10111015
// Forward GITHUB_SERVER_URL so api-proxy can auto-derive enterprise endpoints
10121016
...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }),
10131017
// Route through Squid to respect domain whitelisting
@@ -1083,6 +1087,12 @@ export function generateDockerCompose(
10831087
if (config.copilotGithubToken) {
10841088
environment.COPILOT_API_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`;
10851089
logger.debug(`GitHub Copilot API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.COPILOT}`);
1090+
if (config.openaiApiTarget) {
1091+
logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`);
1092+
}
1093+
if (config.anthropicApiTarget) {
1094+
logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`);
1095+
}
10861096
if (config.copilotApiTarget) {
10871097
logger.debug(`Copilot API target overridden to: ${config.copilotApiTarget}`);
10881098
}

src/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,49 @@ export interface WrapperConfig {
506506
* ```
507507
*/
508508
copilotApiTarget?: string;
509+
510+
/**
511+
* Target hostname for OpenAI API requests (used by API proxy sidecar)
512+
*
513+
* When enableApiProxy is true, this hostname is passed to the Node.js sidecar
514+
* as `OPENAI_API_TARGET`. The proxy will forward OpenAI API requests to this host
515+
* instead of the default `api.openai.com`.
516+
*
517+
* Useful for internal LLM routers, Azure OpenAI endpoints, or any
518+
* OpenAI-compatible self-hosted API.
519+
*
520+
* Can be set via:
521+
* - CLI flag: `--openai-api-target <host>`
522+
* - Environment variable: `OPENAI_API_TARGET`
523+
*
524+
* @default 'api.openai.com'
525+
* @example
526+
* ```bash
527+
* awf --enable-api-proxy --openai-api-target llm-router.internal.example.com -- command
528+
* ```
529+
*/
530+
openaiApiTarget?: string;
531+
532+
/**
533+
* Target hostname for Anthropic API requests (used by API proxy sidecar)
534+
*
535+
* When enableApiProxy is true, this hostname is passed to the Node.js sidecar
536+
* as `ANTHROPIC_API_TARGET`. The proxy will forward Anthropic API requests to this host
537+
* instead of the default `api.anthropic.com`.
538+
*
539+
* Useful for internal LLM routers or any Anthropic-compatible self-hosted API.
540+
*
541+
* Can be set via:
542+
* - CLI flag: `--anthropic-api-target <host>`
543+
* - Environment variable: `ANTHROPIC_API_TARGET`
544+
*
545+
* @default 'api.anthropic.com'
546+
* @example
547+
* ```bash
548+
* awf --enable-api-proxy --anthropic-api-target llm-router.internal.example.com -- command
549+
* ```
550+
*/
551+
anthropicApiTarget?: string;
509552
}
510553

511554
/**

0 commit comments

Comments
 (0)