From 4a4d21e2d24733786f2cd196e2ac2df245995ec3 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Thu, 23 Apr 2026 15:35:00 -0400 Subject: [PATCH 1/2] Support AWF proxy endpoints via AWF_COPILOT_PROXY env var When running inside an AWF sandbox, the API endpoint is a local proxy URL (e.g., http://172.30.0.30:10002) that the provider registry doesn't recognize. The AWF_COPILOT_PROXY env var names the upstream provider (e.g., 'api.githubcopilot.com') whose behaviour (headers, model defaults, catalog parsing) should be used with the proxy URL as base_url. This lets the taskflow agent work seamlessly through AWF's credential-isolating API proxy sidecar. --- src/seclab_taskflow_agent/capi.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/seclab_taskflow_agent/capi.py b/src/seclab_taskflow_agent/capi.py index 8bb43cc..96bdb05 100644 --- a/src/seclab_taskflow_agent/capi.py +++ b/src/seclab_taskflow_agent/capi.py @@ -140,12 +140,34 @@ def check_tool_calls(self, _model: str, model_info: dict) -> bool: } def get_provider(endpoint: str | None = None) -> APIProvider: - """Return the ``APIProvider`` for the given (or configured) endpoint URL.""" + """Return the ``APIProvider`` for the given (or configured) endpoint URL. + + When running inside an AWF (Agentic Workflow Firewall) sandbox, the + ``AWF_COPILOT_PROXY`` env var names the upstream provider whose behaviour + (headers, model defaults, catalog format) the local proxy mirrors. + The proxy URL is used as ``base_url`` while all other provider traits + come from the named upstream. + """ url = endpoint or get_AI_endpoint() netloc = urlparse(url).netloc provider = _PROVIDERS.get(netloc) if provider is not None: return provider + + # AWF proxy support: AWF_COPILOT_PROXY names the upstream provider + # (e.g. "api.githubcopilot.com") whose behaviour this proxy mirrors. + awf_upstream = os.getenv("AWF_COPILOT_PROXY") + if awf_upstream: + upstream = _PROVIDERS.get(awf_upstream) + if upstream: + return type(upstream)( + name=upstream.name, + base_url=url, + models_catalog=upstream.models_catalog, + default_model=upstream.default_model, + extra_headers=dict(upstream.extra_headers), + ) + # Unknown endpoint — return a generic provider with the given base URL return APIProvider(name="custom", base_url=url, default_model="please-set-default-model-via-env") From 1879d014534e58d7859b0effc3b769a2e3f5ac30 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Thu, 23 Apr 2026 16:16:19 -0400 Subject: [PATCH 2/2] Address review feedback: dataclasses.replace, tests, input normalization - Use dataclasses.replace() instead of manual field reconstruction to avoid silently dropping fields if APIProvider gains new ones - Normalize AWF_COPILOT_PROXY input: accept both bare hostnames (api.githubcopilot.com) and full URLs (https://api.githubcopilot.com) - Add 4 test cases covering bare hostname, full URL, unknown upstream, and unset env var scenarios --- src/seclab_taskflow_agent/capi.py | 20 +++++++++++--------- tests/test_capi_extended.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/seclab_taskflow_agent/capi.py b/src/seclab_taskflow_agent/capi.py index 96bdb05..fa73992 100644 --- a/src/seclab_taskflow_agent/capi.py +++ b/src/seclab_taskflow_agent/capi.py @@ -11,6 +11,7 @@ from __future__ import annotations +import dataclasses import json import logging import os @@ -147,6 +148,10 @@ def get_provider(endpoint: str | None = None) -> APIProvider: (headers, model defaults, catalog format) the local proxy mirrors. The proxy URL is used as ``base_url`` while all other provider traits come from the named upstream. + + ``AWF_COPILOT_PROXY`` accepts either a bare hostname + (``api.githubcopilot.com``) or a full URL + (``https://api.githubcopilot.com``). """ url = endpoint or get_AI_endpoint() netloc = urlparse(url).netloc @@ -156,17 +161,14 @@ def get_provider(endpoint: str | None = None) -> APIProvider: # AWF proxy support: AWF_COPILOT_PROXY names the upstream provider # (e.g. "api.githubcopilot.com") whose behaviour this proxy mirrors. - awf_upstream = os.getenv("AWF_COPILOT_PROXY") + awf_upstream = os.getenv("AWF_COPILOT_PROXY", "").strip() if awf_upstream: - upstream = _PROVIDERS.get(awf_upstream) + # Normalize: accept both bare hostnames and full URLs. + parsed = urlparse(awf_upstream) + key = parsed.netloc or parsed.path + upstream = _PROVIDERS.get(key) if upstream: - return type(upstream)( - name=upstream.name, - base_url=url, - models_catalog=upstream.models_catalog, - default_model=upstream.default_model, - extra_headers=dict(upstream.extra_headers), - ) + return dataclasses.replace(upstream, base_url=url) # Unknown endpoint — return a generic provider with the given base URL return APIProvider(name="custom", base_url=url, default_model="please-set-default-model-via-env") diff --git a/tests/test_capi_extended.py b/tests/test_capi_extended.py index 28a3d06..e3a1188 100644 --- a/tests/test_capi_extended.py +++ b/tests/test_capi_extended.py @@ -123,3 +123,26 @@ def test_custom_endpoint(self): assert p.name == "custom" assert p.base_url == "https://my-custom-llm.example.com/v1/" assert not p.extra_headers + + def test_awf_proxy_bare_hostname(self, monkeypatch): + monkeypatch.setenv("AWF_COPILOT_PROXY", "api.githubcopilot.com") + p = get_provider("http://172.30.0.30:10002") + assert p.name == "copilot" + assert p.base_url == "http://172.30.0.30:10002/" + assert p.default_model == "gpt-4.1" + assert "Copilot-Integration-Id" in p.extra_headers + + def test_awf_proxy_full_url(self, monkeypatch): + monkeypatch.setenv("AWF_COPILOT_PROXY", "https://api.githubcopilot.com") + p = get_provider("http://172.30.0.30:10002") + assert p.name == "copilot" + assert p.base_url == "http://172.30.0.30:10002/" + + def test_awf_proxy_unknown_upstream(self, monkeypatch): + monkeypatch.setenv("AWF_COPILOT_PROXY", "not-a-real-provider.com") + p = get_provider("http://172.30.0.30:10002") + assert p.name == "custom" + + def test_awf_proxy_not_set(self): + p = get_provider("http://172.30.0.30:10002") + assert p.name == "custom"