Skip to content

Commit fce31b5

Browse files
committed
feat(sdk-python): add header propagation for X-AIMock-Strict
ContextVar-based ambient state + httpx event hook. Incoming x-aimock-* headers from AG-UI requests are forwarded to outgoing LLM API calls. Keys normalized to lowercase. Warning emitted when install_httpx_hook receives an unrecognized client type.
1 parent e85320a commit fce31b5

4 files changed

Lines changed: 266 additions & 0 deletions

File tree

sdk-python/copilotkit/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from .langgraph_agui_agent import LangGraphAGUIAgent
88
from .copilotkit_lg_middleware import CopilotKitMiddleware
99
from ag_ui_langgraph.middlewares.state_streaming import StateStreamingMiddleware, StateItem
10+
from .header_propagation import set_forwarded_headers, get_forwarded_headers, install_httpx_hook
1011

1112

1213

@@ -24,4 +25,7 @@
2425
"CopilotKitMiddleware",
2526
"StateStreamingMiddleware",
2627
"StateItem",
28+
"set_forwarded_headers",
29+
"get_forwarded_headers",
30+
"install_httpx_hook",
2731
]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Header propagation for forwarding x-aimock-* headers to outgoing LLM calls.
2+
3+
Uses Python contextvars for per-request ambient state in async FastAPI handlers.
4+
An httpx event hook reads the ContextVar and injects headers on outgoing requests.
5+
"""
6+
7+
import contextvars
8+
import warnings
9+
from typing import Dict
10+
11+
# Ambient per-request state for headers to forward to LLM calls
12+
_forwarded_headers: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
13+
'copilotkit_forwarded_headers'
14+
)
15+
16+
17+
def set_forwarded_headers(headers: Dict[str, str]) -> None:
18+
"""Store headers to forward to outgoing LLM calls.
19+
Filters to x-aimock-* headers only."""
20+
filtered = {k.lower(): v for k, v in headers.items() if k.lower().startswith('x-aimock-')}
21+
_forwarded_headers.set(filtered)
22+
23+
24+
def get_forwarded_headers() -> Dict[str, str]:
25+
"""Get headers that should be forwarded to outgoing LLM calls."""
26+
return _forwarded_headers.get({})
27+
28+
29+
def install_httpx_hook(client) -> None:
30+
"""Append an event hook to an httpx client that injects forwarded headers.
31+
32+
Works with OpenAI and Anthropic Python SDKs (both use httpx internally).
33+
No-op when no headers are set (demo traffic).
34+
35+
Parameters
36+
----------
37+
client : object
38+
An OpenAI/Anthropic client instance, or a raw httpx.Client/AsyncClient.
39+
For SDK clients the underlying transport lives at ``client._client``.
40+
"""
41+
def _inject_headers(request):
42+
headers = get_forwarded_headers()
43+
for key, value in headers.items():
44+
request.headers[key] = value
45+
46+
# OpenAI / Anthropic SDKs wrap an httpx client at client._client
47+
if hasattr(client, '_client') and hasattr(client._client, 'event_hooks'):
48+
client._client.event_hooks['request'].append(_inject_headers)
49+
elif hasattr(client, 'event_hooks'):
50+
# Raw httpx.Client / httpx.AsyncClient
51+
client.event_hooks['request'].append(_inject_headers)
52+
else:
53+
warnings.warn(
54+
f"install_httpx_hook: client of type {type(client).__name__} has no "
55+
"recognized event_hooks attribute; x-aimock-* headers will not be forwarded",
56+
stacklevel=2,
57+
)

sdk-python/copilotkit/integrations/fastapi.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from ..action import ActionDict
2222
from ..html import generate_info_html
23+
from ..header_propagation import set_forwarded_headers
2324
logging.basicConfig(level=logging.ERROR)
2425
logger = logging.getLogger(__name__)
2526

@@ -79,6 +80,9 @@ async def handler(request: Request, sdk: CopilotKitRemoteEndpoint):
7980
except: # pylint: disable=bare-except
8081
body = None
8182

83+
# Propagate x-aimock-* headers to outgoing LLM calls via ContextVar
84+
set_forwarded_headers(dict(request.headers))
85+
8286
path = request.path_params.get('path')
8387
method = request.method
8488
context = cast(
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Tests for header propagation (x-aimock-* headers to outgoing LLM calls)."""
2+
3+
import contextvars
4+
import warnings
5+
6+
import pytest
7+
8+
from copilotkit.header_propagation import (
9+
get_forwarded_headers,
10+
install_httpx_hook,
11+
set_forwarded_headers,
12+
)
13+
14+
15+
class TestSetForwardedHeaders:
16+
"""set_forwarded_headers filters to x-aimock-* only."""
17+
18+
def test_filters_to_aimock_headers(self):
19+
set_forwarded_headers({
20+
"x-aimock-strict": "true",
21+
"x-aimock-session": "abc123",
22+
"authorization": "Bearer token",
23+
"content-type": "application/json",
24+
})
25+
result = get_forwarded_headers()
26+
assert result == {
27+
"x-aimock-strict": "true",
28+
"x-aimock-session": "abc123",
29+
}
30+
31+
def test_case_insensitive_prefix_match(self):
32+
set_forwarded_headers({
33+
"X-AIMock-Strict": "true",
34+
"X-AIMOCK-SESSION": "xyz",
35+
})
36+
result = get_forwarded_headers()
37+
assert result == {
38+
"x-aimock-strict": "true",
39+
"x-aimock-session": "xyz",
40+
}
41+
42+
def test_empty_when_no_aimock_headers(self):
43+
set_forwarded_headers({
44+
"authorization": "Bearer token",
45+
"content-type": "application/json",
46+
})
47+
result = get_forwarded_headers()
48+
assert result == {}
49+
50+
def test_empty_input(self):
51+
set_forwarded_headers({})
52+
result = get_forwarded_headers()
53+
assert result == {}
54+
55+
56+
class TestGetForwardedHeaders:
57+
"""get_forwarded_headers returns empty dict by default."""
58+
59+
def test_default_is_empty_dict(self):
60+
# Reset to default by running in a fresh context
61+
ctx = contextvars.copy_context()
62+
result = ctx.run(get_forwarded_headers)
63+
assert result == {}
64+
65+
66+
class TestRoundTrip:
67+
"""set + get round-trip."""
68+
69+
def test_round_trip(self):
70+
headers = {"x-aimock-strict": "true", "x-aimock-foo": "bar"}
71+
set_forwarded_headers(headers)
72+
assert get_forwarded_headers() == headers
73+
74+
def test_overwrite(self):
75+
set_forwarded_headers({"x-aimock-a": "1"})
76+
set_forwarded_headers({"x-aimock-b": "2"})
77+
assert get_forwarded_headers() == {"x-aimock-b": "2"}
78+
79+
80+
class TestInstallHttpxHook:
81+
"""install_httpx_hook appends to event hooks."""
82+
83+
def test_appends_to_raw_httpx_client(self):
84+
"""Mock a raw httpx client with event_hooks dict."""
85+
class FakeClient:
86+
def __init__(self):
87+
self.event_hooks = {"request": []}
88+
89+
client = FakeClient()
90+
install_httpx_hook(client)
91+
assert len(client.event_hooks["request"]) == 1
92+
93+
def test_appends_to_sdk_wrapped_client(self):
94+
"""Mock an OpenAI/Anthropic SDK client with _client attribute."""
95+
class FakeTransport:
96+
def __init__(self):
97+
self.event_hooks = {"request": []}
98+
99+
class FakeSDKClient:
100+
def __init__(self):
101+
self._client = FakeTransport()
102+
103+
client = FakeSDKClient()
104+
install_httpx_hook(client)
105+
assert len(client._client.event_hooks["request"]) == 1
106+
107+
def test_hook_injects_headers(self):
108+
"""The installed hook reads from ContextVar and injects headers."""
109+
class FakeHeaders(dict):
110+
"""Dict that also supports item assignment like httpx Headers."""
111+
pass
112+
113+
class FakeRequest:
114+
def __init__(self):
115+
self.headers = FakeHeaders()
116+
117+
class FakeClient:
118+
def __init__(self):
119+
self.event_hooks = {"request": []}
120+
121+
client = FakeClient()
122+
install_httpx_hook(client)
123+
124+
# Set headers in ContextVar
125+
set_forwarded_headers({"x-aimock-strict": "true"})
126+
127+
# Simulate httpx calling the hook
128+
request = FakeRequest()
129+
client.event_hooks["request"][0](request)
130+
131+
assert request.headers["x-aimock-strict"] == "true"
132+
133+
def test_hook_noop_when_no_headers(self):
134+
"""Hook is a no-op when ContextVar is empty (demo traffic)."""
135+
class FakeRequest:
136+
def __init__(self):
137+
self.headers = {}
138+
139+
class FakeClient:
140+
def __init__(self):
141+
self.event_hooks = {"request": []}
142+
143+
# Reset ContextVar to simulate a fresh request with no aimock headers
144+
set_forwarded_headers({})
145+
146+
client = FakeClient()
147+
install_httpx_hook(client)
148+
request = FakeRequest()
149+
client.event_hooks["request"][0](request)
150+
assert request.headers == {}
151+
152+
def test_no_event_hooks_warns(self):
153+
"""Client without event_hooks emits a warning."""
154+
with warnings.catch_warnings(record=True) as w:
155+
warnings.simplefilter("always")
156+
install_httpx_hook(object())
157+
assert len(w) == 1
158+
assert "event_hooks" in str(w[0].message)
159+
160+
161+
class TestContextVarIsolation:
162+
"""ContextVar provides proper isolation across contexts."""
163+
164+
def test_context_isolation(self):
165+
"""Headers set in one context don't leak to another."""
166+
results = {}
167+
168+
def task_a():
169+
set_forwarded_headers({"x-aimock-task": "a"})
170+
results["a"] = get_forwarded_headers()
171+
172+
def task_b():
173+
set_forwarded_headers({"x-aimock-task": "b"})
174+
results["b"] = get_forwarded_headers()
175+
176+
# Run in separate contexts to verify isolation
177+
ctx_a = contextvars.copy_context()
178+
ctx_b = contextvars.copy_context()
179+
180+
ctx_a.run(task_a)
181+
ctx_b.run(task_b)
182+
183+
assert results["a"] == {"x-aimock-task": "a"}
184+
assert results["b"] == {"x-aimock-task": "b"}
185+
186+
def test_child_context_does_not_pollute_parent(self):
187+
"""Setting headers in a child context does not affect the parent."""
188+
# Ensure clean state in a fresh context
189+
def _run():
190+
parent_before = get_forwarded_headers()
191+
192+
def child():
193+
set_forwarded_headers({"x-aimock-child": "yes"})
194+
195+
ctx = contextvars.copy_context()
196+
ctx.run(child)
197+
198+
parent_after = get_forwarded_headers()
199+
assert parent_before == parent_after
200+
201+
contextvars.copy_context().run(_run)

0 commit comments

Comments
 (0)