Skip to content

Commit e71ded2

Browse files
committed
test(sdk-python): coverage for forwarded-header extraction and precedence
Add integration and edge-case coverage for the new _extract_forwarded_headers_from_config flow: wrapper-dict and raw x-* sources, context > configurable precedence, mixed-case key normalization, RuntimeError early-return clears stale ContextVar, exception path clears stale ContextVar, None and empty-config fallbacks, and a sync/async parity check that both call paths run the extraction.
1 parent 7b2f1e9 commit e71ded2

1 file changed

Lines changed: 371 additions & 1 deletion

File tree

sdk-python/tests/test_copilotkit_lg_middleware.py

Lines changed: 371 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@
3838
)
3939
from langchain.agents.middleware import ModelRequest
4040

41-
from copilotkit.copilotkit_lg_middleware import CopilotKitMiddleware
41+
from copilotkit.copilotkit_lg_middleware import (
42+
CopilotKitMiddleware,
43+
_extract_forwarded_headers_from_config,
44+
)
45+
from copilotkit.header_propagation import get_forwarded_headers, set_forwarded_headers
4246

4347

4448
# ---------------------------------------------------------------------------
@@ -560,3 +564,369 @@ def test_bedrock_fix_repairs_string_args_to_dicts():
560564

561565
repaired = next(m for m in messages if isinstance(m, AIMessage))
562566
assert repaired.tool_calls[0]["args"] == {"q": "hello"}
567+
568+
569+
# ---------------------------------------------------------------------------
570+
# _extract_forwarded_headers_from_config — raw x-* header extraction
571+
# ---------------------------------------------------------------------------
572+
573+
574+
class TestExtractForwardedHeadersFromConfig:
575+
"""Verify that raw x-* keys on config["configurable"] and config["context"]
576+
are extracted and pushed into the header-propagation ContextVar."""
577+
578+
def _patch_get_config(self, monkeypatch, config: dict):
579+
"""Patch langgraph.config.get_config to return *config*."""
580+
monkeypatch.setattr(
581+
"copilotkit.copilotkit_lg_middleware.get_config",
582+
lambda: config,
583+
raising=False,
584+
)
585+
# Also patch at the import site inside the function's local scope:
586+
# _extract_forwarded_headers_from_config does a local import, so we
587+
# need to patch the module it imports from.
588+
import langgraph.config as _lg_config
589+
590+
monkeypatch.setattr(_lg_config, "get_config", lambda: config)
591+
592+
def setup_method(self):
593+
"""Reset forwarded headers before each test."""
594+
set_forwarded_headers({})
595+
596+
def test_raw_x_header_on_configurable_is_extracted(self, monkeypatch):
597+
self._patch_get_config(
598+
monkeypatch,
599+
{
600+
"configurable": {
601+
"thread_id": "t-1",
602+
"x-aimock-context": "showcase/d5",
603+
},
604+
},
605+
)
606+
_extract_forwarded_headers_from_config()
607+
headers = get_forwarded_headers()
608+
assert headers["x-aimock-context"] == "showcase/d5"
609+
610+
def test_raw_x_header_on_context_is_extracted(self, monkeypatch):
611+
self._patch_get_config(
612+
monkeypatch,
613+
{
614+
"context": {
615+
"x-aimock-strict": "true",
616+
},
617+
"configurable": {},
618+
},
619+
)
620+
_extract_forwarded_headers_from_config()
621+
headers = get_forwarded_headers()
622+
assert headers["x-aimock-strict"] == "true"
623+
624+
def test_non_x_keys_on_configurable_are_not_extracted(self, monkeypatch):
625+
self._patch_get_config(
626+
monkeypatch,
627+
{
628+
"configurable": {
629+
"thread_id": "t-1",
630+
"user_id": "u-42",
631+
"checkpoint_ns": "",
632+
"x-aimock-context": "test",
633+
},
634+
},
635+
)
636+
_extract_forwarded_headers_from_config()
637+
headers = get_forwarded_headers()
638+
assert "thread_id" not in headers
639+
assert "user_id" not in headers
640+
assert "checkpoint_ns" not in headers
641+
assert headers == {"x-aimock-context": "test"}
642+
643+
def test_wrapper_dict_still_works(self, monkeypatch):
644+
"""Backward compat: the copilotkit_forwarded_headers wrapper dict
645+
is still the preferred source."""
646+
self._patch_get_config(
647+
monkeypatch,
648+
{
649+
"configurable": {
650+
"copilotkit_forwarded_headers": {
651+
"x-aimock-strict": "true",
652+
"x-custom-trace": "abc",
653+
},
654+
},
655+
},
656+
)
657+
_extract_forwarded_headers_from_config()
658+
headers = get_forwarded_headers()
659+
assert headers["x-aimock-strict"] == "true"
660+
assert headers["x-custom-trace"] == "abc"
661+
662+
def test_wrapper_dict_takes_precedence_over_raw_key(self, monkeypatch):
663+
"""When both the wrapper dict and a raw key provide the same header,
664+
the wrapper-dict value wins."""
665+
self._patch_get_config(
666+
monkeypatch,
667+
{
668+
"configurable": {
669+
"copilotkit_forwarded_headers": {
670+
"x-aimock-context": "from-wrapper",
671+
},
672+
"x-aimock-context": "from-raw",
673+
},
674+
},
675+
)
676+
_extract_forwarded_headers_from_config()
677+
headers = get_forwarded_headers()
678+
assert headers["x-aimock-context"] == "from-wrapper"
679+
680+
def test_wrapper_dict_keys_lowercased_at_insertion(self, monkeypatch):
681+
"""Wrapper-dict keys must be lowercased at insertion so that
682+
documented context > configurable precedence holds regardless of
683+
the casing the agent author used."""
684+
self._patch_get_config(
685+
monkeypatch,
686+
{
687+
"context": {
688+
"copilotkit_forwarded_headers": {
689+
"X-Trace": "from-context",
690+
},
691+
},
692+
"configurable": {
693+
"copilotkit_forwarded_headers": {
694+
"x-trace": "from-configurable",
695+
},
696+
},
697+
},
698+
)
699+
_extract_forwarded_headers_from_config()
700+
headers = get_forwarded_headers()
701+
# Context wins via first-write-wins (both lowercase to "x-trace").
702+
assert headers["x-trace"] == "from-context"
703+
# Only the lowercased key exists — no mixed-case duplicate.
704+
assert "X-Trace" not in headers
705+
706+
def test_multiple_raw_x_headers_extracted(self, monkeypatch):
707+
self._patch_get_config(
708+
monkeypatch,
709+
{
710+
"configurable": {
711+
"x-aimock-context": "showcase/d5",
712+
"x-aimock-strict": "true",
713+
"x-request-id": "req-123",
714+
"thread_id": "t-1",
715+
},
716+
},
717+
)
718+
_extract_forwarded_headers_from_config()
719+
headers = get_forwarded_headers()
720+
assert headers == {
721+
"x-aimock-context": "showcase/d5",
722+
"x-aimock-strict": "true",
723+
"x-request-id": "req-123",
724+
}
725+
726+
def test_no_headers_when_config_has_no_x_keys(self, monkeypatch):
727+
self._patch_get_config(
728+
monkeypatch,
729+
{
730+
"configurable": {
731+
"thread_id": "t-1",
732+
"user_id": "u-42",
733+
},
734+
},
735+
)
736+
_extract_forwarded_headers_from_config()
737+
headers = get_forwarded_headers()
738+
assert headers == {}
739+
740+
def test_runtime_error_clears_contextvar(self):
741+
"""When get_config() raises RuntimeError (not inside a runnable),
742+
the function clears the ContextVar so stale headers from a prior
743+
request do not leak through."""
744+
set_forwarded_headers({"x-stale": "leftover"})
745+
_extract_forwarded_headers_from_config()
746+
headers = get_forwarded_headers()
747+
assert headers == {}
748+
749+
def test_non_string_values_are_skipped(self, monkeypatch):
750+
"""Only string values are extracted; lists/dicts/ints are ignored."""
751+
self._patch_get_config(
752+
monkeypatch,
753+
{
754+
"configurable": {
755+
"x-valid": "yes",
756+
"x-list-value": ["a", "b"],
757+
"x-int-value": 42,
758+
"x-dict-value": {"nested": True},
759+
},
760+
},
761+
)
762+
_extract_forwarded_headers_from_config()
763+
headers = get_forwarded_headers()
764+
assert headers == {"x-valid": "yes"}
765+
766+
def test_contextvar_cleared_when_no_headers(self, monkeypatch):
767+
"""When the current call has no x-* headers, the ContextVar must be
768+
reset to an empty dict so stale headers from a previous call in the
769+
same async context do not leak through."""
770+
# Pre-populate the ContextVar with stale headers.
771+
set_forwarded_headers({"x-stale": "leftover"})
772+
assert get_forwarded_headers() == {"x-stale": "leftover"}
773+
774+
# Config has no x-* keys at all.
775+
self._patch_get_config(
776+
monkeypatch,
777+
{
778+
"configurable": {
779+
"thread_id": "t-1",
780+
},
781+
},
782+
)
783+
_extract_forwarded_headers_from_config()
784+
headers = get_forwarded_headers()
785+
assert headers == {}
786+
787+
def test_exception_safety_unexpected_config_shape(self, monkeypatch):
788+
"""If the config has an unexpected shape that raises during
789+
extraction, the function must not propagate the exception — header
790+
forwarding is best-effort and must never block the LLM call.
791+
Additionally, stale headers from a prior request must be cleared."""
792+
793+
class _ExplodingDict:
794+
"""A dict-like that raises on .get() to simulate unexpected shapes."""
795+
796+
def get(self, key, default=None):
797+
raise TypeError(f"boom on {key}")
798+
799+
import langgraph.config as _lg_config
800+
801+
monkeypatch.setattr(_lg_config, "get_config", lambda: _ExplodingDict())
802+
803+
# Pre-populate stale headers.
804+
set_forwarded_headers({"x-stale": "leftover"})
805+
806+
# Must not raise.
807+
_extract_forwarded_headers_from_config()
808+
809+
# The ContextVar must be cleared so stale headers don't leak.
810+
headers = get_forwarded_headers()
811+
assert headers == {}
812+
813+
def test_context_wins_over_configurable_in_wrapper_dict(self, monkeypatch):
814+
"""When both config["context"] and config["configurable"] have
815+
copilotkit_forwarded_headers with the same key, the context value
816+
wins (LangGraph >=0.6.0 introduced context as the newer preferred
817+
mechanism)."""
818+
self._patch_get_config(
819+
monkeypatch,
820+
{
821+
"context": {
822+
"copilotkit_forwarded_headers": {
823+
"x-aimock-context": "from-context",
824+
},
825+
},
826+
"configurable": {
827+
"copilotkit_forwarded_headers": {
828+
"x-aimock-context": "from-configurable",
829+
},
830+
},
831+
},
832+
)
833+
_extract_forwarded_headers_from_config()
834+
headers = get_forwarded_headers()
835+
assert headers["x-aimock-context"] == "from-context"
836+
837+
# -- F1: Integration test — wrap_model_call invokes header extraction ------
838+
839+
def test_wrap_model_call_invokes_header_extraction(self, monkeypatch):
840+
"""Removing the _extract_forwarded_headers_from_config() call from
841+
wrap_model_call would cause this test to fail, proving the call site
842+
is exercised end-to-end."""
843+
self._patch_get_config(
844+
monkeypatch,
845+
{
846+
"configurable": {"x-aimock-context": "via-wrap-model-call"},
847+
},
848+
)
849+
850+
captured_headers: dict[str, str] = {}
851+
852+
def handler(request):
853+
captured_headers.update(get_forwarded_headers())
854+
return "model-response"
855+
856+
middleware = CopilotKitMiddleware()
857+
request = _make_request(state={"messages": []})
858+
middleware.wrap_model_call(request, handler)
859+
860+
assert captured_headers.get("x-aimock-context") == "via-wrap-model-call"
861+
862+
# -- F2: Integration test — awrap_model_call (async) invokes extraction ----
863+
864+
def test_awrap_model_call_invokes_header_extraction(self, monkeypatch):
865+
"""Same as the sync test above but exercising the async code path."""
866+
self._patch_get_config(
867+
monkeypatch,
868+
{
869+
"configurable": {"x-aimock-context": "via-awrap-model-call"},
870+
},
871+
)
872+
873+
captured_headers: dict[str, str] = {}
874+
875+
async def handler(request):
876+
captured_headers.update(get_forwarded_headers())
877+
return "model-response"
878+
879+
middleware = CopilotKitMiddleware()
880+
request = _make_request(state={"messages": []})
881+
asyncio.run(middleware.awrap_model_call(request, handler))
882+
883+
assert captured_headers.get("x-aimock-context") == "via-awrap-model-call"
884+
885+
# -- F3: Wrapper dict on config["context"] only (LangGraph >=0.6.0) --------
886+
887+
def test_wrapper_dict_on_context_only(self, monkeypatch):
888+
"""The copilotkit_forwarded_headers wrapper dict on config['context']
889+
(not configurable) must also be extracted — this is the LangGraph
890+
>=0.6.0 path."""
891+
self._patch_get_config(
892+
monkeypatch,
893+
{
894+
"context": {
895+
"copilotkit_forwarded_headers": {"x-aimock-strict": "true"},
896+
},
897+
"configurable": {},
898+
},
899+
)
900+
_extract_forwarded_headers_from_config()
901+
headers = get_forwarded_headers()
902+
assert headers.get("x-aimock-strict") == "true"
903+
904+
# -- F6: None values for context / configurable (the `or {}` fallback) -----
905+
906+
def test_none_context_falls_back_to_configurable(self, monkeypatch):
907+
"""config['context'] = None must not crash; headers from configurable
908+
should still be extracted via the `or {}` fallback."""
909+
self._patch_get_config(
910+
monkeypatch,
911+
{
912+
"context": None,
913+
"configurable": {"x-aimock-context": "via-raw"},
914+
},
915+
)
916+
_extract_forwarded_headers_from_config()
917+
headers = get_forwarded_headers()
918+
assert headers.get("x-aimock-context") == "via-raw"
919+
920+
def test_none_configurable_falls_back_to_context(self, monkeypatch):
921+
"""config['configurable'] = None must not crash; headers from context
922+
should still be extracted via the `or {}` fallback."""
923+
self._patch_get_config(
924+
monkeypatch,
925+
{
926+
"context": {"x-aimock-context": "via-context"},
927+
"configurable": None,
928+
},
929+
)
930+
_extract_forwarded_headers_from_config()
931+
headers = get_forwarded_headers()
932+
assert headers.get("x-aimock-context") == "via-context"

0 commit comments

Comments
 (0)