From 21fbb82cf8a32f9819cfda5760de4d1e765b5453 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 17:44:46 -0800 Subject: [PATCH] fix(proxy): leave out a guardrail header whose recorded value can't be joined --- .../pass_through_endpoints.py | 26 +++++++++++++++++-- .../test_pass_through_endpoints.py | 14 ++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 451d2ae22af..f6d53b7864b 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -222,6 +222,9 @@ _GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( GUARDRAIL_SCAN_METADATA_METADATA_KEY, ) ) +_JOINED_HEADER_METADATA_FIELDS: Final = frozenset( + ("applied_guardrails", "applied_policies", GUARDRAIL_SCAN_IDS_METADATA_KEY) +) # RFC 9110's field-value, the one both h11 and Node's HTTP parser accept: no control characters, and only spaces and # tabs between visible characters. _SENDABLE_HEADER_VALUE: Final = re.compile(rb"(?:[\x21-\x7e\x80-\xff]+(?:[ \t]+[\x21-\x7e\x80-\xff]+)*)?") @@ -245,6 +248,22 @@ def _strip_client_guardrail_fields( metadata.pop(field) +def _is_name_list(value: object) -> bool: + return isinstance(value, list | tuple) and all( + isinstance(entry, str) for entry in _GUARDRAIL_ENTRIES.validate_python(value) + ) + + +def _joinable_header_metadata(recorded: Mapping[str, object]) -> Mapping[str, object]: + """``recorded`` without a value ``get_logging_caching_headers`` can't join into a header, which a custom guardrail + can record in the name lists.""" + return { + field: value + for field, value in recorded.items() + if field not in _JOINED_HEADER_METADATA_FIELDS or _is_name_list(value) + } + + def _merged_metadata_entry(prior: object, value: object) -> object: """``value``, or both combined: lists and tuples without repeats, and mappings with ``value``'s keys winning, as ``add_policy_sources_to_metadata`` combines policy sources on one request.""" @@ -279,7 +298,8 @@ def _guardrail_response_headers( Pre-call guardrails record on the request body's metadata, which ``init_kwargs_for_pass_through_endpoint`` moves to ``kwargs["litellm_params"]``, and post-call guardrails record on ``post_call_data``, so list entries such as the applied guardrail names are combined. A ``litellm_params`` object sent in the request body is never read. A header - that would take these headers past ``_GUARDRAIL_HEADERS_MAX_BYTES`` is left out. + that would take these headers past ``_GUARDRAIL_HEADERS_MAX_BYTES``, or whose recorded value can't be joined, is + left out. """ scopes: Final = ( as_str_mapping(parsed_body) or {}, @@ -297,7 +317,9 @@ def _guardrail_response_headers( field: reduce(_merged_metadata_entry, (value for _, value in entries)) for field, entries in groupby(sorted(recorded, key=lambda entry: entry[0]), key=lambda entry: entry[0]) } - headers: Final = _RESPONSE_HEADERS.validate_python(get_logging_caching_headers({"metadata": merged}) or {}) + headers: Final = _RESPONSE_HEADERS.validate_python( + get_logging_caching_headers({"metadata": _joinable_header_metadata(merged)}) or {} + ) sendable: Final = ( (name, value) for name, value in headers.items() diff --git a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 492d0d9c4c2..28c052d3c22 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -13790,6 +13790,20 @@ class TestGuardrailResponseHeaders: assert _guardrail_response_headers({"metadata": {"applied_guardrails": names}}) == {} + @pytest.mark.parametrize("value", [7, "name", {"a": 1}, ["ok", 7], [None]]) + @pytest.mark.parametrize("field", ["applied_guardrails", "applied_policies", "guardrail_scan_ids"]) + def test_recorded_value_that_is_not_a_list_of_names_costs_the_header(self, field, value): + assert _guardrail_response_headers(post_call_data={"metadata": {field: value}}) == {} + + def test_bad_post_call_value_costs_the_header_of_the_names_recorded_before_it(self): + assert ( + _guardrail_response_headers( + {"metadata": {"applied_guardrails": ["pre-call-guard"]}}, + post_call_data={"metadata": {"applied_guardrails": 7}}, + ) + == {} + ) + @pytest.mark.parametrize("control", ["\x01", "\x1f", "\x7f", "\x00", "\n"]) def test_guardrail_name_with_a_control_character_stays_out_of_the_header(self, control): assert _guardrail_response_headers({"metadata": {"applied_guardrails": [f"a{control}b"]}}) == {}