|
38 | 38 | ) |
39 | 39 | from langchain.agents.middleware import ModelRequest |
40 | 40 |
|
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 |
42 | 46 |
|
43 | 47 |
|
44 | 48 | # --------------------------------------------------------------------------- |
@@ -560,3 +564,369 @@ def test_bedrock_fix_repairs_string_args_to_dicts(): |
560 | 564 |
|
561 | 565 | repaired = next(m for m in messages if isinstance(m, AIMessage)) |
562 | 566 | 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