Skip to content

Commit a502b11

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 a502b11

6 files changed

Lines changed: 212 additions & 9 deletions

File tree

containers/api-proxy/server.js

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,54 @@ 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+
/**
50+
* Parse an API target value that may be a raw hostname, host:port, or full URL.
51+
* Returns { hostname, port } where port is undefined if not specified.
52+
* Logs a warning and falls back to the default hostname if the value is invalid.
53+
*/
54+
function parseApiTarget(envVar, rawValue, defaultHostname) {
55+
if (!rawValue) return { hostname: defaultHostname, port: undefined };
56+
57+
// If it looks like a full URL (has a scheme), parse it as a URL
58+
if (rawValue.includes('://')) {
59+
try {
60+
const parsed = new URL(rawValue);
61+
return { hostname: parsed.hostname, port: parsed.port || undefined };
62+
} catch {
63+
logRequest('warn', 'startup', { message: `${envVar}: invalid URL "${rawValue}", falling back to ${defaultHostname}` });
64+
return { hostname: defaultHostname, port: undefined };
65+
}
66+
}
67+
68+
// host:port form
69+
const colonIdx = rawValue.lastIndexOf(':');
70+
if (colonIdx > 0) {
71+
const hostname = rawValue.slice(0, colonIdx);
72+
const portStr = rawValue.slice(colonIdx + 1);
73+
const port = parseInt(portStr, 10);
74+
if (isNaN(port) || port < 1 || port > 65535) {
75+
logRequest('warn', 'startup', { message: `${envVar}: invalid port in "${rawValue}", falling back to ${defaultHostname}` });
76+
return { hostname: defaultHostname, port: undefined };
77+
}
78+
return { hostname, port: String(port) };
79+
}
80+
81+
// Plain hostname
82+
return { hostname: rawValue, port: undefined };
83+
}
84+
85+
// Configurable OpenAI API target (supports internal LLM routers / Azure OpenAI)
86+
// Priority: OPENAI_API_TARGET env var > default. Accepts hostname, host:port, or full URL.
87+
const _openaiTarget = parseApiTarget('OPENAI_API_TARGET', process.env.OPENAI_API_TARGET, 'api.openai.com');
88+
const OPENAI_API_TARGET = _openaiTarget.hostname;
89+
const OPENAI_API_PORT = _openaiTarget.port;
90+
91+
// Configurable Anthropic API target (supports internal LLM routers)
92+
// Priority: ANTHROPIC_API_TARGET env var > default. Accepts hostname, host:port, or full URL.
93+
const _anthropicTarget = parseApiTarget('ANTHROPIC_API_TARGET', process.env.ANTHROPIC_API_TARGET, 'api.anthropic.com');
94+
const ANTHROPIC_API_TARGET = _anthropicTarget.hostname;
95+
const ANTHROPIC_API_PORT = _anthropicTarget.port;
96+
4997
// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints)
5098
// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default
5199
function deriveCopilotApiTarget() {
@@ -76,6 +124,8 @@ const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
76124
logRequest('info', 'startup', {
77125
message: 'Starting AWF API proxy sidecar',
78126
squid_proxy: HTTPS_PROXY || 'not configured',
127+
openai_api_target: OPENAI_API_PORT ? `${OPENAI_API_TARGET}:${OPENAI_API_PORT}` : OPENAI_API_TARGET,
128+
anthropic_api_target: ANTHROPIC_API_PORT ? `${ANTHROPIC_API_TARGET}:${ANTHROPIC_API_PORT}` : ANTHROPIC_API_TARGET,
79129
copilot_api_target: COPILOT_API_TARGET,
80130
providers: {
81131
openai: !!OPENAI_API_KEY,
@@ -145,7 +195,7 @@ function isValidRequestId(id) {
145195
return typeof id === 'string' && id.length <= 128 && /^[\w\-\.]+$/.test(id);
146196
}
147197

148-
function proxyRequest(req, res, targetHost, injectHeaders, provider) {
198+
function proxyRequest(req, res, targetHost, injectHeaders, provider, targetPort) {
149199
const clientRequestId = req.headers['x-request-id'];
150200
const requestId = isValidRequestId(clientRequestId) ? clientRequestId : generateRequestId();
151201
const startTime = Date.now();
@@ -183,8 +233,9 @@ function proxyRequest(req, res, targetHost, injectHeaders, provider) {
183233
return;
184234
}
185235

186-
// Build target URL
187-
const targetUrl = new URL(req.url, `https://${targetHost}`);
236+
// Build target URL — include port if explicitly specified (e.g. for host:port targets)
237+
const targetBase = targetPort ? `https://${targetHost}:${targetPort}` : `https://${targetHost}`;
238+
const targetUrl = new URL(req.url, targetBase);
188239

189240
// Handle client-side errors (e.g. aborted connections)
190241
req.on('error', (err) => {
@@ -397,9 +448,9 @@ if (OPENAI_API_KEY) {
397448
const contentLength = parseInt(req.headers['content-length'], 10) || 0;
398449
if (checkRateLimit(req, res, 'openai', contentLength)) return;
399450

400-
proxyRequest(req, res, 'api.openai.com', {
451+
proxyRequest(req, res, OPENAI_API_TARGET, {
401452
'Authorization': `Bearer ${OPENAI_API_KEY}`,
402-
}, 'openai');
453+
}, 'openai', OPENAI_API_PORT);
403454
});
404455

405456
server.listen(HEALTH_PORT, '0.0.0.0', () => {
@@ -436,7 +487,7 @@ if (ANTHROPIC_API_KEY) {
436487
if (!req.headers['anthropic-version']) {
437488
anthropicHeaders['anthropic-version'] = '2023-06-01';
438489
}
439-
proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders, 'anthropic');
490+
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_PORT);
440491
});
441492

442493
server.listen(10001, '0.0.0.0', () => {
@@ -488,7 +539,7 @@ if (ANTHROPIC_API_KEY) {
488539
if (!req.headers['anthropic-version']) {
489540
anthropicHeaders['anthropic-version'] = '2023-06-01';
490541
}
491-
proxyRequest(req, res, 'api.anthropic.com', anthropicHeaders);
542+
proxyRequest(req, res, ANTHROPIC_API_TARGET, anthropicHeaders, 'opencode', ANTHROPIC_API_PORT);
492543
});
493544

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

docs/api-proxy-sidecar.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,39 @@ sudo awf --enable-api-proxy \
101101
-- your-multi-llm-tool
102102
```
103103

104+
### Custom/internal LLM endpoints
105+
106+
Use `--openai-api-target` or `--anthropic-api-target` to route requests to a custom endpoint (e.g., an internal LLM router, Azure OpenAI, or any OpenAI/Anthropic-compatible API) instead of the public defaults.
107+
108+
```bash
109+
# Route OpenAI/Codex requests to an internal LLM router
110+
export OPENAI_API_KEY="your-internal-key"
111+
112+
sudo awf --enable-api-proxy \
113+
--openai-api-target llm-router.internal.example.com \
114+
--allow-domains llm-router.internal.example.com \
115+
-- npx @openai/codex exec "do something"
116+
```
117+
118+
```bash
119+
# Route Anthropic/Claude requests to an internal LLM router
120+
export ANTHROPIC_API_KEY="your-internal-key"
121+
122+
sudo awf --enable-api-proxy \
123+
--anthropic-api-target llm-router.internal.example.com \
124+
--allow-domains llm-router.internal.example.com \
125+
-- claude-code "do something"
126+
```
127+
128+
The target value accepts:
129+
- A plain hostname: `llm-router.internal.example.com`
130+
- A `host:port` pair: `llm-router.internal.example.com:8443`
131+
- A full URL (scheme + host): `https://llm-router.internal.example.com/v1`
132+
133+
Both flags can also be set via environment variables:
134+
- `OPENAI_API_TARGET` — equivalent to `--openai-api-target`
135+
- `ANTHROPIC_API_TARGET` — equivalent to `--anthropic-api-target`
136+
104137
## Environment variables
105138

106139
AWF manages environment variables differently across the three containers (squid, api-proxy, agent) to ensure secure credential isolation.
@@ -123,6 +156,9 @@ The API proxy sidecar receives **real credentials** and routing configuration:
123156
| `OPENAI_API_KEY` | Real API key | `--enable-api-proxy` and env set | OpenAI API key (injected into requests) |
124157
| `ANTHROPIC_API_KEY` | Real API key | `--enable-api-proxy` and env set | Anthropic API key (injected into requests) |
125158
| `COPILOT_GITHUB_TOKEN` | Real token | `--enable-api-proxy` and env set | GitHub Copilot token (injected into requests) |
159+
| `OPENAI_API_TARGET` | Hostname or host:port | `--openai-api-target` or env set | Custom upstream for OpenAI requests (default: `api.openai.com`) |
160+
| `ANTHROPIC_API_TARGET` | Hostname or host:port | `--anthropic-api-target` or env set | Custom upstream for Anthropic requests (default: `api.anthropic.com`) |
161+
| `COPILOT_API_TARGET` | Hostname | `--copilot-api-target` or env set | Custom upstream for Copilot requests (default: `api.githubcopilot.com`) |
126162
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
127163
| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
128164

@@ -328,9 +364,8 @@ docker exec awf-squid cat /var/log/squid/access.log | grep DENIED
328364

329365
## Limitations
330366

331-
- Only supports OpenAI and Anthropic APIs
367+
- Only supports OpenAI, Anthropic, and GitHub Copilot APIs
332368
- Keys must be set as environment variables (not file-based)
333-
- No support for Azure OpenAI endpoints
334369
- No request/response logging (by design, for security)
335370

336371
## Related documentation

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
@@ -1065,10 +1069,16 @@ export function generateDockerCompose(
10651069
if (config.openaiApiKey) {
10661070
environment.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`;
10671071
logger.debug(`OpenAI API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}/v1`);
1072+
if (config.openaiApiTarget) {
1073+
logger.debug(`OpenAI API target overridden to: ${config.openaiApiTarget}`);
1074+
}
10681075
}
10691076
if (config.anthropicApiKey) {
10701077
environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`;
10711078
logger.debug(`Anthropic API will be proxied through sidecar at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`);
1079+
if (config.anthropicApiTarget) {
1080+
logger.debug(`Anthropic API target overridden to: ${config.anthropicApiTarget}`);
1081+
}
10721082

10731083
// Set placeholder token for Claude Code CLI compatibility
10741084
// Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy

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)