feat: support custom OpenAI and Anthropic API targets in AWF sandbox#20631
feat: support custom OpenAI and Anthropic API targets in AWF sandbox#20631
Conversation
Add --openai-api-target and --anthropic-api-target flags to AWF when custom OPENAI_BASE_URL or ANTHROPIC_BASE_URL are configured in engine.env. This enables AWF's credential isolation and firewall features to work with internal LLM routers, Azure OpenAI endpoints, and other custom OpenAI-compatible APIs. Implementation: - Added extractAPITargetHost() to parse hostnames from custom API base URLs - Modified BuildAWFArgs() to automatically add API target flags when custom URLs are detected - Created comprehensive tests covering URL parsing, flag generation, and engine execution Fixes issue where API proxy hardcoded api.openai.com/api.anthropic.com, preventing use with internal LLM routers and custom endpoints. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
|
Hey The PR is well-structured: a focused implementation in Two small things worth a look before marking ready for review:
Overall this is a solid, well-tested contribution — nice job! 🟢
|
There was a problem hiding this comment.
Pull request overview
Adds support for forwarding AWF’s API proxy to custom OpenAI/Anthropic upstream hosts (e.g., internal routers, Azure OpenAI) by deriving target hosts from engine env configuration and passing them as AWF flags.
Changes:
- Added
extractAPITargetHost()helper to derive the upstream hostname fromOPENAI_BASE_URL/ANTHROPIC_BASE_URL. - Updated
BuildAWFArgs()to append--openai-api-target/--anthropic-api-targetwhen custom base URLs are configured. - Added unit tests for host extraction and flag generation in both direct AWF args and engine execution steps.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| pkg/workflow/awf_helpers.go | Adds API target host extraction and appends new AWF --*-api-target flags based on engine env. |
| pkg/workflow/awf_helpers_test.go | Adds unit tests validating URL/host parsing and verifying flag inclusion in generated AWF args and execution steps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| // Extract hostname from URL | ||
| // URLs can be: | ||
| // - "https://llm-router.internal.example.com/v1" → "llm-router.internal.example.com" | ||
| // - "http://localhost:8080/v1" → "localhost:8080" | ||
| // - "api.openai.com" → "api.openai.com" (treated as hostname) | ||
|
|
||
| // Remove protocol prefix if present | ||
| host := baseURL | ||
| if idx := strings.Index(host, "://"); idx != -1 { | ||
| host = host[idx+3:] | ||
| } | ||
|
|
||
| // Remove path suffix if present (everything after first /) | ||
| if idx := strings.Index(host, "/"); idx != -1 { | ||
| host = host[:idx] | ||
| } |
There was a problem hiding this comment.
extractAPITargetHost does manual string slicing and fails to strip URL query/fragment/userinfo. For example https://example.com?x=1 would return example.com?x=1, and https://user:pass@host/v1 would return user:pass@host, which is not a valid --*-api-target host and can break AWF routing. Consider parsing with net/url (optionally prefixing a scheme when missing), then returning u.Host (or u.Hostname() + optional u.Port()), and trimming whitespace.
| // Validate that we have a non-empty hostname | ||
| if host == "" { | ||
| awfHelpersLog.Printf("Invalid %s URL (no hostname): %s", envVar, baseURL) |
There was a problem hiding this comment.
This log line prints the full baseURL value. If a user provides a URL containing embedded credentials (userinfo) or sensitive query params, it could leak into workflow logs. Safer to log only the env var name and a sanitized/parsed host (or omit the URL entirely) on error.
| // Validate that we have a non-empty hostname | |
| if host == "" { | |
| awfHelpersLog.Printf("Invalid %s URL (no hostname): %s", envVar, baseURL) | |
| // Remove userinfo (credentials) if present (e.g., "user:pass@host") | |
| // Keep only the part after '@' so that credentials are never logged. | |
| if idx := strings.LastIndex(host, "@"); idx != -1 { | |
| host = host[idx+1:] | |
| } | |
| // Remove query or fragment if present (e.g., "host:8080?api_key=..." or "host#section") | |
| if idx := strings.IndexAny(host, "?#"); idx != -1 { | |
| host = host[:idx] | |
| } | |
| // Validate that we have a non-empty hostname | |
| if host == "" { | |
| awfHelpersLog.Printf("Invalid %s URL (no hostname)", envVar) |
| tests := []struct { | ||
| name string | ||
| workflowData *WorkflowData | ||
| envVar string | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "extracts hostname from HTTPS URL with path", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OPENAI_BASE_URL": "https://llm-router.internal.example.com/v1", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "llm-router.internal.example.com", | ||
| }, | ||
| { | ||
| name: "extracts hostname from HTTP URL with port and path", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "ANTHROPIC_BASE_URL": "http://localhost:8080/v1", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "ANTHROPIC_BASE_URL", | ||
| expected: "localhost:8080", | ||
| }, | ||
| { | ||
| name: "handles hostname without protocol or path", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OPENAI_BASE_URL": "api.openai.com", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "api.openai.com", | ||
| }, | ||
| { | ||
| name: "handles hostname with port but no protocol", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OPENAI_BASE_URL": "localhost:8000", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "localhost:8000", | ||
| }, | ||
| { | ||
| name: "returns empty string when env var not set", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OTHER_VAR": "value", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "", | ||
| }, | ||
| { | ||
| name: "returns empty string when engine config is nil", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: nil, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "", | ||
| }, | ||
| { | ||
| name: "returns empty string when workflow data is nil", | ||
| workflowData: nil, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "", | ||
| }, | ||
| { | ||
| name: "returns empty string for empty URL", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OPENAI_BASE_URL": "", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "", | ||
| }, | ||
| { | ||
| name: "extracts Azure OpenAI endpoint hostname", | ||
| workflowData: &WorkflowData{ | ||
| EngineConfig: &EngineConfig{ | ||
| Env: map[string]string{ | ||
| "OPENAI_BASE_URL": "https://my-resource.openai.azure.com/openai/deployments/gpt-4", | ||
| }, | ||
| }, | ||
| }, | ||
| envVar: "OPENAI_BASE_URL", | ||
| expected: "my-resource.openai.azure.com", | ||
| }, | ||
| } |
There was a problem hiding this comment.
The tests claim URL parsing is covered, but there are no cases for URLs with query/fragment or userinfo (e.g. https://example.com?api-version=..., https://user:pass@host/v1). Adding these would prevent regressions once extractAPITargetHost is updated to use proper URL parsing.
…s.md Add a new 'Custom API Endpoints' subsection under 'Engine Environment Variables' documenting that OPENAI_BASE_URL (codex) and ANTHROPIC_BASE_URL (claude) are automatically picked up by the AWF sandbox proxy to route API calls to internal LLM routers, Azure OpenAI, or other custom endpoints. From PR #20631 (merged 2026-03-12T14:02Z, after DDUw's 06:23Z run). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The AWF API proxy hardcoded
api.openai.comandapi.anthropic.comas upstream targets, preventing use with internal LLM routers, Azure OpenAI, or self-hosted OpenAI-compatible endpoints. This blocked workflows from leveraging AWF's credential isolation and firewall features with custom API endpoints.Changes
Automatic API target detection and forwarding:
extractAPITargetHost()to parse hostnames fromOPENAI_BASE_URLandANTHROPIC_BASE_URLinengine.envBuildAWFArgs()to automatically append--openai-api-targetand--anthropic-api-targetflags to AWF when custom URLs are detectedExample usage:
Compiles to:
awf --enable-api-proxy \ --openai-api-target llm-router.internal.example.com \ --allow-domains "github.com,llm-router.internal.example.com" \ -- <agent-command>The API proxy now forwards OpenAI requests to
llm-router.internal.example.cominstead ofapi.openai.com, while preserving credential isolation and firewall enforcement.Testing
.lock.ymlfilesWarning
Firewall rules blocked me from connecting to one or more addresses (expand for details)
I tried to connect to the following addresses, but was blocked by firewall rules:
https://api.github.com/graphql/usr/bin/gh /usr/bin/gh api graphql -f query=query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled } } -f owner=github -f name=gh-aw HEAD 86_64/git git rev-�� --show-toplevel git /usr/bin/git --abbrev-ref HEAD 0/x64/bin/git git(http block)https://api.github.com/repos/actions/ai-inference/git/ref/tags/v1/usr/bin/gh gh api /repos/actions/ai-inference/git/ref/tags/v1 --jq .object.sha m /tmp/go-build1633339647/b005/vet.cfg 3339647/b336/vet.cfg [:lower:] git it /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet -uns�� -unreachable=false /tmp/go-build1633339647/b211/vet.cfg /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v3/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v3 --jq .object.sha --abbrev-ref HEAD ache/go/1.25.0/x64/pkg/tool/linux_amd64/vet --abbrev-ref HEAD(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v5/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha flib/difflib.go HEAD x_amd64/compile --abbrev-ref HEAD 0/x64/bin/git x_amd64/compile rev-�� --abbrev-ref HEAD x_amd64/vet(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha -bool -buildtags /usr/bin/tail -errorsas -ifaceassert -nilfunc tail -n 1 -tests /usr/bin/base64(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v5 --jq .object.sha --show-toplevel x_amd64/vet /usr/bin/git --abbrev-ref HEAD x_amd64/vet git rev-�� --show-toplevel x_amd64/vet /usr/bin/git --abbrev-ref HEAD x_amd64/vet git(http block)https://api.github.com/repos/actions/checkout/git/ref/tags/v6/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha developer-action--abbrev-ref ons/[^/]*)?/[^/]HEAD(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha 0/x64/bin/git ons/[^/]*)?/[^/]HEAD /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet --abbrev-ref HEAD /usr/bin/dirname--show-toplevel /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet -uns�� -unreachable=false /tmp/go-build1633339647/b093/vet.cfg 3339647/b318/vet.cfg d -n 10 head tnet/tools/git /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet(http block)/usr/bin/gh gh api /repos/actions/checkout/git/ref/tags/v6 --jq .object.sha --show-toplevel git /usr/bin/git celain --ignore-git HEAD x_amd64/vet git rev-�� --show-toplevel x_amd64/vet /usr/bin/git --abbrev-ref HEAD x_amd64/vet git(http block)https://api.github.com/repos/actions/github-script/git/ref/tags/v8/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha /usr/local/bin/g--abbrev-ref git /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet --abbrev-ref HEAD /opt/hostedtoolc--show-toplevel /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet -uns�� -unreachable=false ner/.claude/shell-snapshots/snapshot-bash-1773287433690-kf9144.sh && { shopt -u extglob || setoprev-parse 3339647/b291/vet.cfg --abbrev-ref -linux/rg nfig/composer/ve--show-toplevel /opt/hostedtoolcache/go/1.25.0/x64/pkg/tool/linux_amd64/vet(http block)/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha /home/REDACTED/.lo--abbrev-ref git(http block)/usr/bin/gh gh api /repos/actions/github-script/git/ref/tags/v8 --jq .object.sha(http block)https://api.github.com/repos/actions/setup-go/git/ref/tags/v4/usr/bin/gh gh api /repos/actions/setup-go/git/ref/tags/v4 --jq .object.sha n-dir/git git(http block)https://api.github.com/repos/actions/setup-node/git/ref/tags/v4/usr/bin/gh gh api /repos/actions/setup-node/git/ref/tags/v4 --jq .object.sha /opt/hostedtoolc--abbrev-ref git(http block)https://api.github.com/repos/actions/upload-artifact/git/ref/tags/v4/usr/bin/gh gh api /repos/actions/upload-artifact/git/ref/tags/v4 --jq .object.sha --abbrev-ref HEAD 64/pkg/tool/linux_amd64/vet --abbrev-ref HEAD n-dir/git 64/pkg/tool/linux_amd64/vet rev-�� --abbrev-ref HEAD 64/bin/git --abbrev-ref HEAD t git(http block)https://api.github.com/repos/github/gh-aw/git/ref/tags/v1.0.0/usr/bin/gh gh api /repos/github/gh-aw/git/ref/tags/v1.0.0 --jq .object.sha --abbrev-ref HEAD git --abbrev-ref HEAD cal/bin/git git rev-�� --abbrev-ref HEAD x_amd64/vet(http block)https://api.github.com/repos/nonexistent/action/git/ref/tags/v999.999.999/usr/bin/gh gh api /repos/nonexistent/action/git/ref/tags/v999.999.999 --jq .object.sha --abbrev-ref HEAD /home/REDACTED/work/_temp/uv-python-dir/git --abbrev-ref HEAD 0/x64/bin/git git rev-�� --abbrev-ref HEAD x_amd64/vet(http block)If you need me to access, download, or install something from one of these locations, you can either: