Skip to content
Open
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
4 changes: 2 additions & 2 deletions .github/workflows/smoke-gemini.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions docs/adr/26060-add-gemini-api-target-to-awf-proxy.md
Original file line number Diff line number Diff line change
@@ -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 <hostname>` 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 <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 `Get<Engine>APITarget()` helper that reads `<ENGINE>_API_BASE_URL` with a default fallback, (b) a call in `BuildAWFArgs()` to emit the `--<engine>-api-target` flag, and (c) inclusion in `computeAllowedDomainsForSanitization()`.
2. Engine-specific environment variables for custom endpoints **MUST** follow the naming convention `<ENGINE_UPPERCASE>_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.*
19 changes: 17 additions & 2 deletions docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence says "Three environment variables" but then lists four (OPENAI_BASE_URL, ANTHROPIC_BASE_URL, GITHUB_COPILOT_BASE_URL, GEMINI_API_BASE_URL). Update the count or rephrase (e.g., "The following environment variables...") to avoid confusing readers.

Suggested change
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.
The following 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.

Copilot uses AI. Check for mistakes.

This enables workflows to use internal LLM routers, Azure OpenAI deployments, corporate Copilot proxies, or other compatible endpoints without bypassing AWF's security model.

Expand Down Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +318 to +331
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GEMINI_API_BASE_URL is now treated as a compile-time signal to set --gemini-api-target/--gemini-api-base-path, but it’s also a runtime env var that GeminiEngine currently sets to the LLM gateway URL when the firewall is enabled. In pkg/workflow/gemini_engine.go, GEMINI_API_BASE_URL is set to host.docker.internal: and then later engine.env is copied into the step env (maps.Copy), which would overwrite the proxy URL if the user configures GEMINI_API_BASE_URL (causing Gemini CLI to bypass the gateway and fail because GEMINI_API_KEY is excluded, or bypass the intended credential isolation). Consider ensuring that in firewall mode GEMINI_API_BASE_URL is always forced to the LLM gateway URL after applying engine.env (or strip GEMINI_API_BASE_URL from engine.env at runtime and use it only for generating the AWF flags).

Copilot uses AI. Check for mistakes.

// Add SSL Bump support for HTTPS content inspection (v0.9.0+)
sslBumpArgs := getSSLBumpArgs(firewallConfig)
awfArgs = append(awfArgs, sslBumpArgs...)
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
//
Expand Down
Loading