From acd068ae959e59b9737216f2d42b04a0adc67b45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:42:29 +0000 Subject: [PATCH 1/3] Initial plan From efbfdc260f3b43d5c14200a70ef60b7b982bb9fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:41:56 +0000 Subject: [PATCH 2/3] feat: add --gemini-api-target to AWF proxy for Gemini API routing Add Gemini API proxy handler support by emitting --gemini-api-target flag in BuildAWFArgs(). Unlike OpenAI/Anthropic/Copilot where AWF has built-in default routing, Gemini requires an explicit target so the proxy knows where to forward requests. - Add GetGeminiAPITarget() helper with default generativelanguage.googleapis.com - Support custom GEMINI_API_BASE_URL in engine.env for custom endpoints - Add --gemini-api-base-path support for path-based routing - Update computeAllowedDomainsForSanitization to include Gemini target - Add comprehensive tests for new Gemini API target functionality - Update documentation to mention GEMINI_API_BASE_URL - Recompile smoke-gemini.lock.yml with new --gemini-api-target flag Fixes #25969 Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ec512605-b010-4767-bc1f-f2b520a9263f Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/smoke-gemini.lock.yml | 4 +- docs/src/content/docs/reference/engines.md | 19 +- pkg/workflow/awf_helpers.go | 43 +++++ pkg/workflow/awf_helpers_test.go | 215 +++++++++++++++++++++ pkg/workflow/domains.go | 6 + 5 files changed, 283 insertions(+), 4 deletions(-) diff --git a/.github/workflows/smoke-gemini.lock.yml b/.github/workflows/smoke-gemini.lock.yml index beffafa9c68..e5a91978e73 100644 --- a/.github/workflows/smoke-gemini.lock.yml +++ b/.github/workflows/smoke-gemini.lock.yml @@ -913,7 +913,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 - sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --exclude-env GH_AW_GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,*.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --exclude-env GH_AW_GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains '*.githubusercontent.com,*.googleapis.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,docs.github.com,generativelanguage.googleapis.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy --gemini-api-target generativelanguage.googleapis.com \ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && gemini --yolo --output-format stream-json --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: DEBUG: gemini-cli:* @@ -1401,7 +1401,7 @@ jobs: touch /tmp/gh-aw/agent-step-summary.md (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log) # shellcheck disable=SC1003 - sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --allow-domains '*.googleapis.com,generativelanguage.googleapis.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy \ + sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GEMINI_API_KEY --allow-domains '*.googleapis.com,generativelanguage.googleapis.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org' --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.18 --skip-pull --enable-api-proxy --gemini-api-target generativelanguage.googleapis.com \ -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && gemini --yolo --output-format stream-json --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: DEBUG: gemini-cli:* diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 2834521198e..8e8f06d26fe 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -156,7 +156,7 @@ The specified hostname must also be listed in `network.allowed` for the firewall #### Custom API Endpoints via Environment Variables -Three environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), and `GITHUB_COPILOT_BASE_URL` (for `copilot`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container. +Three environment variables receive special treatment when set in `engine.env`: `OPENAI_BASE_URL` (for `codex`), `ANTHROPIC_BASE_URL` (for `claude`), `GITHUB_COPILOT_BASE_URL` (for `copilot`), and `GEMINI_API_BASE_URL` (for `gemini`). When any of these is present, the API proxy automatically routes API calls to the specified host instead of the default endpoint. Firewall enforcement remains active, but this routing layer is not a separate authentication boundary for arbitrary code already running inside the agent container. This enables workflows to use internal LLM routers, Azure OpenAI deployments, corporate Copilot proxies, or other compatible endpoints without bypassing AWF's security model. @@ -205,7 +205,22 @@ network: `GITHUB_COPILOT_BASE_URL` is used as a fallback when `engine.api-target` is not explicitly set. If both are configured, `engine.api-target` takes precedence. -The custom hostname is extracted from the URL and passed to the AWF `--openai-api-target`, `--anthropic-api-target`, or `--copilot-api-target` flag automatically at compile time. No additional configuration is required. +For Gemini workflows routed through a custom Gemini-compatible endpoint: + +```yaml wrap +engine: + id: gemini + env: + GEMINI_API_BASE_URL: "https://gemini-proxy.internal.example.com" + GEMINI_API_KEY: ${{ secrets.PROXY_API_KEY }} + +network: + allowed: + - github.com + - gemini-proxy.internal.example.com +``` + +The custom hostname is extracted from the URL and passed to the AWF `--openai-api-target`, `--anthropic-api-target`, `--copilot-api-target`, or `--gemini-api-target` flag automatically at compile time. No additional configuration is required. ### Engine Command-Line Arguments diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index b5eb213bad6..8e3356c4085 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -315,6 +315,21 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfHelpersLog.Printf("Added --copilot-api-target=%s", copilotTarget) } + // Add Gemini API target for the LLM gateway proxy. + // Unlike OpenAI/Anthropic/Copilot where AWF has built-in default routing, + // Gemini requires an explicit target so the proxy knows where to forward requests. + // Defaults to generativelanguage.googleapis.com when the engine is Gemini. + if geminiTarget := GetGeminiAPITarget(config.WorkflowData, config.EngineName); geminiTarget != "" { + awfArgs = append(awfArgs, "--gemini-api-target", geminiTarget) + awfHelpersLog.Printf("Added --gemini-api-target=%s", geminiTarget) + } + + geminiBasePath := extractAPIBasePath(config.WorkflowData, "GEMINI_API_BASE_URL") + if geminiBasePath != "" { + awfArgs = append(awfArgs, "--gemini-api-base-path", geminiBasePath) + awfHelpersLog.Printf("Added --gemini-api-base-path=%s", geminiBasePath) + } + // Add SSL Bump support for HTTPS content inspection (v0.9.0+) sslBumpArgs := getSSLBumpArgs(firewallConfig) awfArgs = append(awfArgs, sslBumpArgs...) @@ -491,6 +506,7 @@ func extractAPIBasePath(workflowData *WorkflowData, envVar string) string { // - Codex: OPENAI_BASE_URL → --openai-api-target // - Claude: ANTHROPIC_BASE_URL → --anthropic-api-target // - Copilot: GITHUB_COPILOT_BASE_URL → --copilot-api-target (fallback when api-target not set) +// - Gemini: GEMINI_API_BASE_URL → --gemini-api-target (default: generativelanguage.googleapis.com) // // Returns empty string if neither source is configured. func GetCopilotAPITarget(workflowData *WorkflowData) string { @@ -503,6 +519,33 @@ func GetCopilotAPITarget(workflowData *WorkflowData) string { return extractAPITargetHost(workflowData, "GITHUB_COPILOT_BASE_URL") } +// DefaultGeminiAPITarget is the default Gemini API endpoint hostname. +// AWF's proxy sidecar needs this target to forward Gemini API requests, since +// unlike OpenAI/Anthropic/Copilot, the proxy has no built-in default handler for Gemini. +const DefaultGeminiAPITarget = "generativelanguage.googleapis.com" + +// GetGeminiAPITarget returns the effective Gemini API target hostname for the LLM gateway proxy. +// Unlike other engines where AWF has built-in default routing, Gemini requires an explicit target. +// +// Resolution order: +// 1. GEMINI_API_BASE_URL in engine.env (custom endpoint) +// 2. Default: generativelanguage.googleapis.com (when engine is "gemini") +// +// Returns empty string if the engine is not Gemini and no custom GEMINI_API_BASE_URL is configured. +func GetGeminiAPITarget(workflowData *WorkflowData, engineName string) string { + // Check for custom GEMINI_API_BASE_URL in engine.env + if customTarget := extractAPITargetHost(workflowData, "GEMINI_API_BASE_URL"); customTarget != "" { + return customTarget + } + + // Default to the standard Gemini API endpoint when engine is Gemini + if engineName == "gemini" { + return DefaultGeminiAPITarget + } + + return "" +} + // ComputeAWFExcludeEnvVarNames returns the list of environment variable names that must be // excluded from the agent container's visible environment via AWF's --exclude-env flag. // diff --git a/pkg/workflow/awf_helpers_test.go b/pkg/workflow/awf_helpers_test.go index 73700c14353..9ff500e0535 100644 --- a/pkg/workflow/awf_helpers_test.go +++ b/pkg/workflow/awf_helpers_test.go @@ -919,3 +919,218 @@ func TestAWFSupportsCliProxy(t *testing.T) { }) } } + +// TestGetGeminiAPITarget tests the GetGeminiAPITarget helper that resolves the effective +// Gemini API target from GEMINI_API_BASE_URL in engine.env or the default endpoint. +func TestGetGeminiAPITarget(t *testing.T) { + tests := []struct { + name string + workflowData *WorkflowData + engineName string + expected string + }{ + { + name: "returns default target for gemini engine with no custom URL", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "gemini", + }, + }, + engineName: "gemini", + expected: "generativelanguage.googleapis.com", + }, + { + name: "custom GEMINI_API_BASE_URL takes precedence over default", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "gemini", + Env: map[string]string{ + "GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1", + }, + }, + }, + engineName: "gemini", + expected: "gemini-proxy.internal.company.com", + }, + { + name: "returns empty for non-gemini engine without custom URL", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "claude", + }, + }, + engineName: "claude", + expected: "", + }, + { + name: "returns empty when workflowData is nil", + workflowData: nil, + engineName: "gemini", + expected: "generativelanguage.googleapis.com", + }, + { + name: "returns custom target for non-gemini engine with GEMINI_API_BASE_URL", + workflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ + ID: "custom", + Env: map[string]string{ + "GEMINI_API_BASE_URL": "https://custom-proxy.example.com", + }, + }, + }, + engineName: "custom", + expected: "custom-proxy.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetGeminiAPITarget(tt.workflowData, tt.engineName) + assert.Equal(t, tt.expected, result, "GetGeminiAPITarget should return expected hostname") + }) + } +} + +// TestAWFGeminiAPITargetFlags tests that BuildAWFArgs includes --gemini-api-target flag +// for the Gemini engine with default and custom endpoints. +func TestAWFGeminiAPITargetFlags(t *testing.T) { + t.Run("includes default gemini-api-target flag for gemini engine", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "gemini", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "gemini", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--gemini-api-target", "Should include --gemini-api-target flag") + assert.Contains(t, argsStr, "generativelanguage.googleapis.com", "Should include default Gemini API hostname") + }) + + t.Run("includes custom gemini-api-target flag when GEMINI_API_BASE_URL is configured", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "gemini", + Env: map[string]string{ + "GEMINI_API_BASE_URL": "https://gemini-proxy.internal.company.com/v1", + "GEMINI_API_KEY": "${{ secrets.GEMINI_PROXY_KEY }}", + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "gemini", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--gemini-api-target", "Should include --gemini-api-target flag") + assert.Contains(t, argsStr, "gemini-proxy.internal.company.com", "Should include custom Gemini hostname") + }) + + t.Run("does not include gemini-api-target for non-gemini engine without custom URL", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "claude", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.NotContains(t, argsStr, "--gemini-api-target", "Should not include --gemini-api-target for non-gemini engine") + }) + + t.Run("includes gemini-api-base-path when custom URL has path component", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "gemini", + Env: map[string]string{ + "GEMINI_API_BASE_URL": "https://gemini-proxy.company.com/serving-endpoints", + "GEMINI_API_KEY": "${{ secrets.GEMINI_PROXY_KEY }}", + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + config := AWFCommandConfig{ + EngineName: "gemini", + WorkflowData: workflowData, + AllowedDomains: "github.com", + } + + args := BuildAWFArgs(config) + argsStr := strings.Join(args, " ") + + assert.Contains(t, argsStr, "--gemini-api-base-path", "Should include --gemini-api-base-path flag") + assert.Contains(t, argsStr, "/serving-endpoints", "Should include the path component") + }) +} + +// TestGeminiEngineIncludesGeminiAPITarget tests that the Gemini engine execution +// step includes --gemini-api-target when firewall is enabled. +func TestGeminiEngineIncludesGeminiAPITarget(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "gemini", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewGeminiEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) < 2 { + t.Fatal("Expected at least two execution steps (settings + execution)") + } + + // steps[0] = Write Gemini Settings, steps[1] = Execute Gemini CLI + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "--gemini-api-target", "Should include --gemini-api-target flag") + assert.Contains(t, stepContent, "generativelanguage.googleapis.com", "Should include default Gemini API hostname") +} diff --git a/pkg/workflow/domains.go b/pkg/workflow/domains.go index ef98b2cc561..1962cb60e9b 100644 --- a/pkg/workflow/domains.go +++ b/pkg/workflow/domains.go @@ -751,6 +751,12 @@ func (c *Compiler) computeAllowedDomainsForSanitization(data *WorkflowData) stri base = mergeAPITargetDomains(base, copilotAPITarget) } + // Add Gemini API target domains so GH_AW_ALLOWED_DOMAINS stays in sync with --allow-domains. + // Resolved from GEMINI_API_BASE_URL in engine.env or default generativelanguage.googleapis.com. + if geminiAPITarget := GetGeminiAPITarget(data, engineID); geminiAPITarget != "" { + base = mergeAPITargetDomains(base, geminiAPITarget) + } + return base } From e3d387716ab20087d64b430b8352f2b565d0ebbb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:55:05 +0000 Subject: [PATCH 3/3] docs(adr): add draft ADR-26060 for Gemini proxy API target routing Draft ADR documenting the decision to add --gemini-api-target support to the AWF proxy helper layer, mirroring the existing pattern for OpenAI, Anthropic, and Copilot engines. Co-Authored-By: Claude Sonnet 4.6 --- ...6060-add-gemini-api-target-to-awf-proxy.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/adr/26060-add-gemini-api-target-to-awf-proxy.md diff --git a/docs/adr/26060-add-gemini-api-target-to-awf-proxy.md b/docs/adr/26060-add-gemini-api-target-to-awf-proxy.md new file mode 100644 index 00000000000..1b4927881ea --- /dev/null +++ b/docs/adr/26060-add-gemini-api-target-to-awf-proxy.md @@ -0,0 +1,79 @@ +# ADR-26060: Add Gemini API Target Routing to AWF Proxy + +**Date**: 2026-04-13 +**Status**: Draft +**Deciders**: pelikhan, Copilot + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +The AWF proxy sidecar provides an LLM gateway that routes API requests from agent containers to external AI providers. For OpenAI (codex), Anthropic (claude), and Copilot engines, the proxy has built-in default routing targets. Gemini was integrated as an engine but never received a corresponding proxy routing target: when a workflow runs with `engine: gemini` and the network firewall enabled, `GEMINI_API_BASE_URL` points at the proxy on port 10003, but the proxy cannot forward the request and returns `API_KEY_INVALID`. The fix must follow the existing pattern for other engines to stay consistent and maintainable. + +### Decision + +We will add `GetGeminiAPITarget()` to the AWF helpers layer and wire it into `BuildAWFArgs()` so that the `--gemini-api-target` flag is emitted whenever the engine is Gemini. The default target is `generativelanguage.googleapis.com`; when `GEMINI_API_BASE_URL` is set in `engine.env`, the hostname extracted from that URL takes precedence. When the custom URL includes a path component, `--gemini-api-base-path` is also emitted. This mirrors the existing pattern used for `--openai-api-target`, `--anthropic-api-target`, and `--copilot-api-target`, keeping the engine routing model uniform. + +### Alternatives Considered + +#### Alternative 1: Hard-code the Gemini default target inside the AWF sidecar binary + +The AWF sidecar could be patched to know about Gemini's default endpoint without requiring the caller to pass `--gemini-api-target`. This would eliminate the need for the go-layer change. However, it couples the sidecar to a specific vendor endpoint, making it harder to test independently and requiring a sidecar release for every new engine. The current pattern—caller-supplied targets—keeps the sidecar generic. + +#### Alternative 2: Require users to always set `GEMINI_API_BASE_URL` explicitly + +Without a default target, users who want to use the public Gemini endpoint would need to add `GEMINI_API_BASE_URL: "https://generativelanguage.googleapis.com"` to every workflow. This adds boilerplate and differs from every other engine, which all route to a sensible default without extra configuration. The experience asymmetry is a significant usability cost. + +#### Alternative 3: Use `engine.api-target` YAML field instead of an environment variable + +The Copilot engine already has an `engine.api-target` field in the workflow YAML that overrides `GITHUB_COPILOT_BASE_URL`. We could introduce a similar `engine.api-target` for Gemini. However, no other engine besides Copilot uses this field, and adding it only for Gemini would create inconsistency. Using `GEMINI_API_BASE_URL` in `engine.env` aligns Gemini with the codex and claude pattern. + +### Consequences + +#### Positive +- Gemini engine workflows now work correctly when the network firewall is enabled — the proxy can forward requests to the correct upstream. +- Users get custom endpoint support (`GEMINI_API_BASE_URL`) consistent with the codex and claude engines. +- The implementation follows the established engine-routing pattern; new engines in the future can be added the same way. +- `GH_AW_ALLOWED_DOMAINS` is kept in sync with `--allow-domains` via the existing `computeAllowedDomainsForSanitization` hook. + +#### Negative +- `BuildAWFArgs` grows slightly larger; the engine-specific target logic is co-located in one function rather than being dispatched polymorphically. +- A hard-coded constant (`DefaultGeminiAPITarget`) must be updated if Google changes the Gemini API hostname, though this is an unlikely scenario. + +#### Neutral +- The smoke-test lock file (`.github/workflows/smoke-gemini.lock.yml`) must be recompiled to include `--gemini-api-target generativelanguage.googleapis.com` in generated `awf` invocations. +- Documentation for custom API endpoints in `docs/src/content/docs/reference/engines.md` gains a Gemini example section, extending an existing pattern rather than introducing new concepts. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Gemini Proxy Target Resolution + +1. When the active engine is `gemini` and `GEMINI_API_BASE_URL` is not set in `engine.env`, implementations **MUST** emit `--gemini-api-target generativelanguage.googleapis.com` in the `awf` command arguments. +2. When `GEMINI_API_BASE_URL` is set in `engine.env`, implementations **MUST** extract the hostname from that URL and emit `--gemini-api-target ` instead of the default. +3. When `GEMINI_API_BASE_URL` contains a non-empty path component (e.g. `/v1/beta`), implementations **MUST** also emit `--gemini-api-base-path `. +4. Implementations **MUST NOT** emit `--gemini-api-target` when the engine is not `gemini` and `GEMINI_API_BASE_URL` is not configured. +5. The `DefaultGeminiAPITarget` constant **SHOULD** be the single source of truth for the default Gemini hostname; it **MUST NOT** be duplicated as a string literal elsewhere in the codebase. + +### Domain Allowlist Synchronization + +1. The effective Gemini API target hostname **MUST** be included in the domain set computed by `computeAllowedDomainsForSanitization()` so that `GH_AW_ALLOWED_DOMAINS` and `--allow-domains` remain consistent. +2. Implementations **MUST** call `GetGeminiAPITarget()` with the same `engineID` used for the proxy flag, ensuring both paths resolve identically. + +### Custom Endpoint Pattern + +1. New engine API-target integrations **SHOULD** follow the same three-part pattern established here: (a) a `GetAPITarget()` helper that reads `_API_BASE_URL` with a default fallback, (b) a call in `BuildAWFArgs()` to emit the `---api-target` flag, and (c) inclusion in `computeAllowedDomainsForSanitization()`. +2. Engine-specific environment variables for custom endpoints **MUST** follow the naming convention `_API_BASE_URL` (e.g. `GEMINI_API_BASE_URL`, `OPENAI_BASE_URL`). + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*