Skip to content

Commit daca1e4

Browse files
feat(sdk): make API key header name configurable
Default stays X-API-Key; pass api_key_header=... or set AGENT_CONTROL_API_KEY_HEADER to override when the upstream auth expects a different header.
1 parent 6655af2 commit daca1e4

2 files changed

Lines changed: 94 additions & 5 deletions

File tree

sdks/python/src/agent_control/client.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,17 @@
2727
class _AgentControlAuth(httpx.Auth):
2828
"""Attach local API-key credentials unless a request already has Bearer auth."""
2929

30-
def __init__(self, api_key: str | None) -> None:
30+
def __init__(self, api_key: str | None, header_name: str = "X-API-Key") -> None:
3131
self._api_key = api_key
32+
self._header_name = header_name
3233

3334
def auth_flow(
3435
self,
3536
request: httpx.Request,
3637
) -> Generator[httpx.Request, httpx.Response, None]:
3738
if self._api_key and "Authorization" not in request.headers:
38-
if "X-API-Key" not in request.headers:
39-
request.headers["X-API-Key"] = self._api_key
39+
if self._header_name not in request.headers:
40+
request.headers[self._header_name] = self._api_key
4041
yield request
4142

4243

@@ -49,7 +50,9 @@ class AgentControlClient:
4950
agents, policies, controls, evaluation.
5051
5152
Authentication:
52-
The client supports API key authentication via the X-API-Key header.
53+
The client supports API key authentication. By default the key is
54+
sent on the ``X-API-Key`` header; set ``api_key_header`` (or the
55+
``AGENT_CONTROL_API_KEY_HEADER`` environment variable) to override.
5356
API key can be provided:
5457
1. Directly via the `api_key` parameter
5558
2. Via the AGENT_CONTROL_API_KEY environment variable
@@ -63,17 +66,28 @@ class AgentControlClient:
6366
os.environ["AGENT_CONTROL_API_KEY"] = "my-secret-key"
6467
async with AgentControlClient() as client:
6568
await client.health_check()
69+
70+
# Custom header name (e.g., when the upstream auth expects something
71+
# other than X-API-Key). The header name applies to every request
72+
# this client sends.
73+
async with AgentControlClient(
74+
api_key="my-secret-key", api_key_header="X-Custom-API-Key"
75+
) as client:
76+
await client.health_check()
6677
"""
6778

6879
# Environment variable name for API key
6980
API_KEY_ENV_VAR = "AGENT_CONTROL_API_KEY"
81+
API_KEY_HEADER_ENV_VAR = "AGENT_CONTROL_API_KEY_HEADER"
82+
DEFAULT_API_KEY_HEADER = "X-API-Key"
7083
BASE_URL_ENV_VAR = "AGENT_CONTROL_URL"
7184

7285
def __init__(
7386
self,
7487
base_url: str | None = None,
7588
timeout: float = 30.0,
7689
api_key: str | None = None,
90+
api_key_header: str | None = None,
7791
runtime_auth_mode: RuntimeAuthMode | str | None = None,
7892
runtime_token_cache: RuntimeTokenCache | None = None,
7993
runtime_token_refresh_margin_seconds: int = (_DEFAULT_RUNTIME_TOKEN_REFRESH_MARGIN_SECONDS),
@@ -88,6 +102,10 @@ def __init__(
88102
timeout: Request timeout in seconds
89103
api_key: API key for authentication. If not provided, will attempt
90104
to read from AGENT_CONTROL_API_KEY environment variable.
105+
api_key_header: HTTP header name to send the API key on. Defaults
106+
to ``X-API-Key``; the AGENT_CONTROL_API_KEY_HEADER
107+
environment variable overrides the default. Useful when
108+
the configured upstream auth expects a different header.
91109
runtime_auth_mode: Runtime auth mode for evaluation requests. ``auto``
92110
attempts target-bound JWT exchange and falls back to normal
93111
request auth when the exchange endpoint is unavailable. ``jwt``
@@ -104,6 +122,11 @@ def __init__(
104122
self.base_url = resolved_base_url.rstrip("/")
105123
self.timeout = timeout
106124
self._api_key = api_key or os.environ.get(self.API_KEY_ENV_VAR)
125+
self._api_key_header = (
126+
api_key_header
127+
or os.environ.get(self.API_KEY_HEADER_ENV_VAR)
128+
or self.DEFAULT_API_KEY_HEADER
129+
)
107130
configured_runtime_mode = runtime_auth_mode or os.environ.get(_RUNTIME_AUTH_MODE_ENV_VAR)
108131
self._runtime_auth_mode = normalize_runtime_auth_mode(configured_runtime_mode)
109132
if runtime_token_refresh_margin_seconds < 0:
@@ -119,6 +142,11 @@ def api_key(self) -> str | None:
119142
"""Get the configured API key (read-only)."""
120143
return self._api_key
121144

145+
@property
146+
def api_key_header(self) -> str:
147+
"""Get the header name the API key is sent on (read-only)."""
148+
return self._api_key_header
149+
122150
@property
123151
def runtime_auth_mode(self) -> RuntimeAuthMode:
124152
"""Get the configured runtime auth mode (read-only)."""
@@ -159,7 +187,7 @@ async def __aenter__(self) -> "AgentControlClient":
159187
base_url=self.base_url,
160188
timeout=self.timeout,
161189
headers=self._get_headers(),
162-
auth=_AgentControlAuth(self._api_key),
190+
auth=_AgentControlAuth(self._api_key, self._api_key_header),
163191
transport=self._transport,
164192
event_hooks={"response": [self._check_server_version]},
165193
)

sdks/python/tests/test_client.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,67 @@ def handler(request: httpx.Request) -> httpx.Response:
7878
assert seen_requests[0].headers["X-API-Key"] == "test-key"
7979

8080

81+
@pytest.mark.asyncio
82+
async def test_client_uses_configured_api_key_header_name() -> None:
83+
# Given: a client configured to send the API key on a custom header
84+
seen_requests: list[httpx.Request] = []
85+
86+
def handler(request: httpx.Request) -> httpx.Response:
87+
seen_requests.append(request)
88+
return httpx.Response(200, json={"ok": True})
89+
90+
transport = httpx.MockTransport(handler)
91+
92+
async with AgentControlClient(
93+
base_url="https://agent-control.test",
94+
api_key="test-key",
95+
api_key_header="X-Custom-API-Key",
96+
transport=transport,
97+
) as client:
98+
# When: making a request
99+
response = await client.http_client.get("/api/v1/agents")
100+
101+
# Then: the key is on the configured header and the default is absent
102+
assert response.status_code == 200
103+
assert seen_requests[0].headers["X-Custom-API-Key"] == "test-key"
104+
assert "X-API-Key" not in seen_requests[0].headers
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_client_reads_api_key_header_from_env(
109+
monkeypatch: pytest.MonkeyPatch,
110+
) -> None:
111+
# Given: AGENT_CONTROL_API_KEY_HEADER set in the environment
112+
monkeypatch.setenv("AGENT_CONTROL_API_KEY_HEADER", "X-Custom-API-Key")
113+
seen_requests: list[httpx.Request] = []
114+
115+
def handler(request: httpx.Request) -> httpx.Response:
116+
seen_requests.append(request)
117+
return httpx.Response(200, json={"ok": True})
118+
119+
transport = httpx.MockTransport(handler)
120+
121+
async with AgentControlClient(
122+
base_url="https://agent-control.test",
123+
api_key="test-key",
124+
transport=transport,
125+
) as client:
126+
# When: no api_key_header is passed to the constructor
127+
response = await client.http_client.get("/api/v1/agents")
128+
129+
# Then: the env-var value is used
130+
assert response.status_code == 200
131+
assert seen_requests[0].headers["X-Custom-API-Key"] == "test-key"
132+
133+
134+
def test_client_exposes_default_api_key_header() -> None:
135+
# Given: a client with no explicit header override
136+
client = AgentControlClient(api_key="test-key")
137+
138+
# Then: the property reports the documented default
139+
assert client.api_key_header == "X-API-Key"
140+
141+
81142
@pytest.mark.asyncio
82143
async def test_runtime_evaluation_exchanges_and_caches_bearer_token() -> None:
83144
exchange_calls = 0

0 commit comments

Comments
 (0)