Skip to content

Commit 4a94f62

Browse files
lpcoxCopilotCopilot
authored
feat: add Google Gemini API proxy support (port 10003) (#1640)
* feat: add Google Gemini API proxy support (port 10003) Add full Gemini API proxy support to the AWF api-proxy sidecar, matching the pattern of existing OpenAI, Anthropic, and Copilot providers. This enables Gemini CLI to work inside the AWF sandbox without requiring workarounds in gh-aw. Changes: - Add GEMINI port 10003 to API_PROXY_PORTS - Add geminiApiKey, geminiApiTarget, geminiApiBasePath to WrapperConfig - Add --gemini-api-target and --gemini-api-base-path CLI flags - Add GEMINI_API_KEY to excluded env vars when api-proxy is enabled - Set placeholder GEMINI_API_KEY in agent container (Gemini CLI v0.65.0+ exits 41 without auth when GEMINI_API_BASE_URL is set) - Set GEMINI_API_BASE_URL pointing to sidecar in agent env - Add .gemini to whitelisted home subdirectories (bind mount + chroot) - Add Gemini proxy server in server.js using x-goog-api-key header - Expose port 10003 in Dockerfile Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add tests for Gemini API proxy support in docker-manager (#1654) Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/03ba1344-8f2e-4d67-890f-46e665522db4 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * test: add missing Gemini API target test coverage to fix CI branch coverage regression (#1662) * test: add missing Gemini API target test coverage in cli.test.ts Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/0ed05562-3ddb-490f-9b6a-dd5cfa3bb0fc * fix: rename describe block to reflect all API target constants 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> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent b70b7a7 commit 4a94f62

File tree

7 files changed

+333
-6
lines changed

7 files changed

+333
-6
lines changed

containers/api-proxy/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ USER apiproxy
2727
# 10000 - OpenAI API proxy (also serves as health check endpoint)
2828
# 10001 - Anthropic API proxy
2929
# 10002 - GitHub Copilot API proxy
30+
# 10003 - Google Gemini API proxy
3031
# 10004 - OpenCode API proxy (routes to Anthropic)
31-
EXPOSE 10000 10001 10002 10004
32+
EXPOSE 10000 10001 10002 10003 10004
3233

3334
# Use exec form so node is PID 1 and receives SIGTERM directly
3435
CMD ["node", "server.js"]

containers/api-proxy/server.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ function shouldStripHeader(name) {
6262
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || '').trim() || undefined;
6363
const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || '').trim() || undefined;
6464
const COPILOT_GITHUB_TOKEN = (process.env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
65+
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;
6566

6667
// Configurable API target hosts (supports custom endpoints / internal LLM routers)
6768
const OPENAI_API_TARGET = process.env.OPENAI_API_TARGET || 'api.openai.com';
6869
const ANTHROPIC_API_TARGET = process.env.ANTHROPIC_API_TARGET || 'api.anthropic.com';
70+
const GEMINI_API_TARGET = process.env.GEMINI_API_TARGET || 'generativelanguage.googleapis.com';
6971

7072
/**
7173
* Normalizes a base path for use as a URL path prefix.
@@ -115,6 +117,7 @@ function buildUpstreamPath(reqUrl, targetHost, basePath) {
115117
// Optional base path prefixes for API targets (e.g. /serving-endpoints for Databricks)
116118
const OPENAI_API_BASE_PATH = normalizeBasePath(process.env.OPENAI_API_BASE_PATH);
117119
const ANTHROPIC_API_BASE_PATH = normalizeBasePath(process.env.ANTHROPIC_API_BASE_PATH);
120+
const GEMINI_API_BASE_PATH = normalizeBasePath(process.env.GEMINI_API_BASE_PATH);
118121

119122
// Configurable Copilot API target host (supports GHES/GHEC / custom endpoints)
120123
// Priority: COPILOT_API_TARGET env var > auto-derive from GITHUB_SERVER_URL > default
@@ -160,15 +163,18 @@ logRequest('info', 'startup', {
160163
api_targets: {
161164
openai: OPENAI_API_TARGET,
162165
anthropic: ANTHROPIC_API_TARGET,
166+
gemini: GEMINI_API_TARGET,
163167
copilot: COPILOT_API_TARGET,
164168
},
165169
api_base_paths: {
166170
openai: OPENAI_API_BASE_PATH || '(none)',
167171
anthropic: ANTHROPIC_API_BASE_PATH || '(none)',
172+
gemini: GEMINI_API_BASE_PATH || '(none)',
168173
},
169174
providers: {
170175
openai: !!OPENAI_API_KEY,
171176
anthropic: !!ANTHROPIC_API_KEY,
177+
gemini: !!GEMINI_API_KEY,
172178
copilot: !!COPILOT_GITHUB_TOKEN,
173179
},
174180
});
@@ -707,6 +713,7 @@ function healthResponse() {
707713
providers: {
708714
openai: !!OPENAI_API_KEY,
709715
anthropic: !!ANTHROPIC_API_KEY,
716+
gemini: !!GEMINI_API_KEY,
710717
copilot: !!COPILOT_GITHUB_TOKEN,
711718
},
712719
metrics_summary: metrics.getSummary(),
@@ -840,6 +847,34 @@ if (require.main === module) {
840847
});
841848
}
842849

850+
// Google Gemini API proxy (port 10003)
851+
if (GEMINI_API_KEY) {
852+
const geminiServer = http.createServer((req, res) => {
853+
if (req.url === '/health' && req.method === 'GET') {
854+
res.writeHead(200, { 'Content-Type': 'application/json' });
855+
res.end(JSON.stringify({ status: 'healthy', service: 'gemini-proxy' }));
856+
return;
857+
}
858+
859+
const contentLength = parseInt(req.headers['content-length'], 10) || 0;
860+
if (checkRateLimit(req, res, 'gemini', contentLength)) return;
861+
862+
proxyRequest(req, res, GEMINI_API_TARGET, {
863+
'x-goog-api-key': GEMINI_API_KEY,
864+
}, 'gemini', GEMINI_API_BASE_PATH);
865+
});
866+
867+
geminiServer.on('upgrade', (req, socket, head) => {
868+
proxyWebSocket(req, socket, head, GEMINI_API_TARGET, {
869+
'x-goog-api-key': GEMINI_API_KEY,
870+
}, 'gemini', GEMINI_API_BASE_PATH);
871+
});
872+
873+
geminiServer.listen(10003, '0.0.0.0', () => {
874+
logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET });
875+
});
876+
}
877+
843878
// OpenCode API proxy (port 10004) — routes to Anthropic (default BYOK provider)
844879
// OpenCode gets a separate port from Claude (10001) for per-engine rate limiting,
845880
// metrics isolation, and future provider routing (OpenCode is BYOK and may route

src/cli.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -1780,11 +1780,12 @@ describe('cli', () => {
17801780
});
17811781
});
17821782

1783-
describe('DEFAULT_OPENAI_API_TARGET and DEFAULT_ANTHROPIC_API_TARGET', () => {
1783+
describe('DEFAULT_*_API_TARGET constants', () => {
17841784
it('should have correct default values', () => {
17851785
expect(DEFAULT_OPENAI_API_TARGET).toBe('api.openai.com');
17861786
expect(DEFAULT_ANTHROPIC_API_TARGET).toBe('api.anthropic.com');
17871787
expect(DEFAULT_COPILOT_API_TARGET).toBe('api.githubcopilot.com');
1788+
expect(DEFAULT_GEMINI_API_TARGET).toBe('generativelanguage.googleapis.com');
17881789
});
17891790
});
17901791

@@ -1988,6 +1989,55 @@ describe('cli', () => {
19881989
expect(warnings[1]).toContain('--anthropic-api-target=anthropic.internal');
19891990
expect(warnings[2]).toContain('--copilot-api-target=copilot.internal');
19901991
});
1992+
1993+
it('should emit warning for custom Gemini target not in allowed domains', () => {
1994+
const warnings: string[] = [];
1995+
emitApiProxyTargetWarnings(
1996+
{ enableApiProxy: true, geminiApiTarget: 'custom.gemini-router.internal' },
1997+
['github.com'],
1998+
(msg) => warnings.push(msg)
1999+
);
2000+
expect(warnings).toHaveLength(1);
2001+
expect(warnings[0]).toContain('--gemini-api-target=custom.gemini-router.internal');
2002+
});
2003+
2004+
it('should emit no warnings when custom Gemini target is in allowed domains', () => {
2005+
const warnings: string[] = [];
2006+
emitApiProxyTargetWarnings(
2007+
{ enableApiProxy: true, geminiApiTarget: 'gemini.example.com' },
2008+
['example.com'],
2009+
(msg) => warnings.push(msg)
2010+
);
2011+
expect(warnings).toHaveLength(0);
2012+
});
2013+
2014+
it('should use default Gemini target when geminiApiTarget is undefined', () => {
2015+
const warnings: string[] = [];
2016+
emitApiProxyTargetWarnings(
2017+
{ enableApiProxy: true, geminiApiTarget: undefined },
2018+
['github.com'],
2019+
(msg) => warnings.push(msg)
2020+
);
2021+
// Default target is not in 'github.com' but since it IS the default, no warning is emitted
2022+
expect(warnings).toHaveLength(0);
2023+
});
2024+
2025+
it('should emit warnings for all four custom targets when none are in allowed domains', () => {
2026+
const warnings: string[] = [];
2027+
emitApiProxyTargetWarnings(
2028+
{
2029+
enableApiProxy: true,
2030+
openaiApiTarget: 'openai.internal',
2031+
anthropicApiTarget: 'anthropic.internal',
2032+
copilotApiTarget: 'copilot.internal',
2033+
geminiApiTarget: 'gemini.internal',
2034+
},
2035+
['github.com'],
2036+
(msg) => warnings.push(msg)
2037+
);
2038+
expect(warnings).toHaveLength(4);
2039+
expect(warnings[3]).toContain('--gemini-api-target=gemini.internal');
2040+
});
19912041
});
19922042

19932043
describe('resolveApiTargetsToAllowedDomains', () => {
@@ -2117,6 +2167,28 @@ describe('cli', () => {
21172167
// Whitespace-only and empty values are filtered out
21182168
expect(domains).toHaveLength(0);
21192169
});
2170+
2171+
it('should add gemini-api-target option to allowed domains', () => {
2172+
const domains: string[] = ['github.com'];
2173+
resolveApiTargetsToAllowedDomains({ geminiApiTarget: 'custom.gemini.internal' }, domains);
2174+
expect(domains).toContain('custom.gemini.internal');
2175+
expect(domains).toContain('https://custom.gemini.internal');
2176+
});
2177+
2178+
it('should read GEMINI_API_TARGET from env when flag not set', () => {
2179+
const domains: string[] = [];
2180+
const env = { GEMINI_API_TARGET: 'env.gemini.internal' };
2181+
resolveApiTargetsToAllowedDomains({}, domains, env);
2182+
expect(domains).toContain('env.gemini.internal');
2183+
});
2184+
2185+
it('should prefer geminiApiTarget option over GEMINI_API_TARGET env var', () => {
2186+
const domains: string[] = [];
2187+
const env = { GEMINI_API_TARGET: 'env.gemini.internal' };
2188+
resolveApiTargetsToAllowedDomains({ geminiApiTarget: 'flag.gemini.internal' }, domains, env);
2189+
expect(domains).toContain('flag.gemini.internal');
2190+
expect(domains).not.toContain('env.gemini.internal');
2191+
});
21202192
});
21212193

21222194
describe('formatItem', () => {

src/cli.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ export function processAgentImageOption(
252252
export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com';
253253
/** Default upstream hostname for Anthropic API requests in the api-proxy sidecar */
254254
export const DEFAULT_ANTHROPIC_API_TARGET = 'api.anthropic.com';
255+
/** Default upstream hostname for Google Gemini API requests in the api-proxy sidecar */
256+
export const DEFAULT_GEMINI_API_TARGET = 'generativelanguage.googleapis.com';
255257
/** Default upstream hostname for GitHub Copilot API requests in the api-proxy sidecar (when running on github.com) */
256258
export const DEFAULT_COPILOT_API_TARGET = 'api.githubcopilot.com';
257259

@@ -345,7 +347,7 @@ export function validateApiTargetInAllowedDomains(
345347
* @param warn - Function to emit a warning message
346348
*/
347349
export function emitApiProxyTargetWarnings(
348-
config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string },
350+
config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string },
349351
allowedDomains: string[],
350352
warn: (msg: string) => void
351353
): void {
@@ -380,6 +382,16 @@ export function emitApiProxyTargetWarnings(
380382
if (copilotTargetWarning) {
381383
warn(`⚠️ ${copilotTargetWarning}`);
382384
}
385+
386+
const geminiTargetWarning = validateApiTargetInAllowedDomains(
387+
config.geminiApiTarget ?? DEFAULT_GEMINI_API_TARGET,
388+
DEFAULT_GEMINI_API_TARGET,
389+
'--gemini-api-target',
390+
allowedDomains
391+
);
392+
if (geminiTargetWarning) {
393+
warn(`⚠️ ${geminiTargetWarning}`);
394+
}
383395
}
384396

385397
/**
@@ -495,6 +507,7 @@ export function resolveApiTargetsToAllowedDomains(
495507
copilotApiTarget?: string;
496508
openaiApiTarget?: string;
497509
anthropicApiTarget?: string;
510+
geminiApiTarget?: string;
498511
},
499512
allowedDomains: string[],
500513
env: Record<string, string | undefined> = process.env,
@@ -520,6 +533,12 @@ export function resolveApiTargetsToAllowedDomains(
520533
apiTargets.push(env['ANTHROPIC_API_TARGET']);
521534
}
522535

536+
if (options.geminiApiTarget) {
537+
apiTargets.push(options.geminiApiTarget);
538+
} else if (env['GEMINI_API_TARGET']) {
539+
apiTargets.push(env['GEMINI_API_TARGET']);
540+
}
541+
523542
// Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant
524543
const ghecDomains = extractGhecDomainsFromServerUrl(env);
525544
if (ghecDomains.length > 0) {
@@ -1371,6 +1390,14 @@ program
13711390
'--anthropic-api-base-path <path>',
13721391
'Base path prefix for Anthropic API requests (e.g. /anthropic)',
13731392
)
1393+
.option(
1394+
'--gemini-api-target <host>',
1395+
'Target hostname for Gemini API requests (default: generativelanguage.googleapis.com)',
1396+
)
1397+
.option(
1398+
'--gemini-api-base-path <path>',
1399+
'Base path prefix for Gemini API requests',
1400+
)
13741401
.option(
13751402
'--rate-limit-rpm <n>',
13761403
'Max requests per minute per provider (requires --enable-api-proxy)',
@@ -1751,11 +1778,14 @@ program
17511778
openaiApiKey: process.env.OPENAI_API_KEY,
17521779
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
17531780
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
1781+
geminiApiKey: process.env.GEMINI_API_KEY,
17541782
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
17551783
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
17561784
openaiApiBasePath: options.openaiApiBasePath || process.env.OPENAI_API_BASE_PATH,
17571785
anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET,
17581786
anthropicApiBasePath: options.anthropicApiBasePath || process.env.ANTHROPIC_API_BASE_PATH,
1787+
geminiApiTarget: options.geminiApiTarget || process.env.GEMINI_API_TARGET,
1788+
geminiApiBasePath: options.geminiApiBasePath || process.env.GEMINI_API_BASE_PATH,
17591789
};
17601790

17611791
// Parse and validate --agent-timeout

0 commit comments

Comments
 (0)