1- """Header propagation for forwarding x-* prefixed 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- Matches the CopilotKit runtime's extractForwardableHeaders() behavior.
1+ """Forward CopilotKit request-context headers onto outbound LLM/provider HTTP calls
2+ so downstream services (e.g. the aimock test server, proxies, request routing /
3+ fixture-matching infrastructure) can correlate the outbound provider call with the
4+ original inbound request.
5+
6+ What this module does
7+ ---------------------
8+ On each inbound request the application stores a small set of ``x-*`` prefixed
9+ headers (for example ``x-aimock-context``, ``x-aimock-session``, ``x-request-id``,
10+ ``x-trace-id``) on a per-request ``contextvars.ContextVar``. When the application
11+ later makes an outbound HTTP call to an LLM provider (OpenAI, Anthropic, or any
12+ client that wraps ``httpx``), an httpx request event hook reads that ContextVar
13+ and copies those same headers onto the outbound request so downstream services
14+ can correlate the two.
15+
16+ This is plain header propagation, not data collection. Scope and limits:
17+
18+ * Only headers the application itself set on the request context via
19+ ``set_forwarded_headers`` are forwarded. The module never reads request
20+ bodies, cookies, user data, credentials, or anything off the inbound
21+ request beyond the headers explicitly handed to it.
22+ * Only ``x-*`` prefixed headers pass the filter; ``authorization``,
23+ ``content-type``, and any other non ``x-*`` headers are dropped.
24+ * Nothing is collected, persisted, logged, or sent anywhere by this module
25+ itself — it only attaches headers to an HTTP request that the caller was
26+ already going to make. There is no telemetry, no out-of-band channel, and
27+ no end-user data flow.
28+
29+ Mechanics
30+ ---------
31+ ``install_httpx_hook`` does two small things:
32+
33+ 1. It walks the ``._client`` chain on the given object (modern provider SDKs
34+ wrap their httpx client behind several layers of ``._client``) to find the
35+ first object that exposes an httpx-style ``event_hooks`` mapping.
36+ 2. It attaches a request event hook to that mapping. The hook flavor matches
37+ the client: an async coroutine hook for ``httpx.AsyncClient`` (httpx awaits
38+ request hooks on async clients), and a plain sync hook for ``httpx.Client``.
39+ Installation is idempotent via a marker attribute on the installed callable.
40+
41+ This mirrors the CopilotKit runtime's ``extractForwardableHeaders()`` behavior
42+ on the Node side so the Python SDK forwards the same set of context headers.
643"""
744
845import contextvars
946import warnings
1047from typing import Any , Dict , Optional
1148
12- # Ambient per-request state for headers to forward to LLM calls
49+ # Per-request storage for the set of headers the application has asked to forward
50+ # onto outbound LLM/provider calls (populated by ``set_forwarded_headers``).
1351_forwarded_headers : contextvars .ContextVar [Dict [str , str ]] = contextvars .ContextVar (
1452 "copilotkit_forwarded_headers"
1553)
2866
2967
3068def set_forwarded_headers (headers : Dict [str , str ]) -> None :
31- """Store headers to forward to outgoing LLM calls.
32- Filters to x-* prefixed headers only."""
69+ """Record the set of headers to forward onto outbound LLM/provider calls
70+ made later in this request context.
71+
72+ Only ``x-*`` prefixed headers are kept; everything else is dropped.
73+ """
3374 filtered = {k .lower (): v for k , v in headers .items () if k .lower ().startswith ("x-" )}
3475 _forwarded_headers .set (filtered )
3576
3677
3778def get_forwarded_headers () -> Dict [str , str ]:
38- """Get headers that should be forwarded to outgoing LLM calls."""
79+ """Return the headers the application has asked to forward onto outbound
80+ LLM/provider calls in the current request context."""
3981 return _forwarded_headers .get ({})
4082
4183
@@ -60,14 +102,16 @@ def _find_event_hooks_target(client: Any) -> Optional[Any]:
60102
61103
62104def install_httpx_hook (client : Any ) -> None :
63- """Append an event hook to an httpx client that injects forwarded headers.
105+ """Attach a request event hook to ``client``'s underlying httpx client so
106+ that headers recorded via ``set_forwarded_headers`` are copied onto
107+ outbound requests.
64108
65109 Works with OpenAI and Anthropic Python SDKs (both wrap httpx internally,
66110 sometimes via several layers of ``._client`` indirection), as well as raw
67111 ``httpx.Client`` / ``httpx.AsyncClient`` instances.
68112
69- For ``httpx.AsyncClient`` an async hook is installed (httpx awaits request
70- hooks on async clients); for sync clients a sync hook is installed .
113+ For ``httpx.AsyncClient`` an async hook is attached (httpx awaits request
114+ hooks on async clients); for sync clients a sync hook is attached .
71115
72116 Idempotent: a marker attribute on the installed callable prevents double
73117 installation on the same target.
@@ -94,7 +138,7 @@ def install_httpx_hook(client: Any) -> None:
94138 if getattr (existing , _HOOK_MARKER , False ):
95139 return
96140
97- # Decide sync vs async hook flavor based on the target class.
141+ # Choose sync vs async hook flavor based on the target class.
98142 # httpx.AsyncClient awaits request hooks; a sync hook returning None would
99143 # raise "TypeError: object NoneType can't be used in 'await' expression",
100144 # which surfaces as APIConnectionError to the caller.
@@ -134,8 +178,9 @@ def _is_async_httpx_target(target: Any) -> bool:
134178 match against ``"AsyncClient"``. Avoids a broad ``startswith("Async")``
135179 check, which would misclassify a sync client whose MRO happens to
136180 include an ``Async*``-named base (e.g. ``AsyncContextManager``) as
137- async — installing an async hook that httpx calls synchronously,
138- leaving the coroutine unawaited and headers silently dropped.
181+ async — attaching an async hook that httpx calls synchronously would
182+ leave the coroutine unawaited and the forwarded headers would not be
183+ attached to the outbound request.
139184 """
140185 try :
141186 import httpx # local import keeps httpx an optional concern at import time
0 commit comments