From a9670e88dbb2aa783dba2424eb8b4b9e405b8c2e Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 23:02:16 -0800 Subject: [PATCH] fix(proxy): keep a pass-through response when a recorded guardrail value can't be encoded A custom guardrail that records a value JSON can't encode, or a mixed-key dict in a list field, raised while the guardrail headers were built and turned the provider's reply into a 500. The guardrail headers are now left out on that response. --- .../pass_through_endpoints.py | 15 ++++++++++++++- .../test_pass_through_endpoints.py | 19 ++++++++++++++++++- 2 files changed, 32 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 f6d53b7864b..1384884c8ca 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -290,7 +290,7 @@ def _with_header_in_budget(kept: Mapping[str, str], header: tuple[str, str]) -> return MappingProxyType({**kept, header[0]: header[1]}) if size <= _GUARDRAIL_HEADERS_MAX_BYTES else kept -def _guardrail_response_headers( +def _recorded_guardrail_headers( parsed_body: object = None, kwargs: object = None, post_call_data: object = None ) -> Mapping[str, str]: """The ``x-litellm-applied-guardrails`` family of response headers for what guardrails recorded on this request. @@ -329,6 +329,19 @@ def _guardrail_response_headers( return reduce(_with_header_in_budget, sendable, no_headers) +def _guardrail_response_headers( + parsed_body: object = None, kwargs: object = None, post_call_data: object = None +) -> Mapping[str, str]: + """``_recorded_guardrail_headers``, or none of them when a recorded value can't be merged or JSON-encoded.""" + try: + return _recorded_guardrail_headers(parsed_body, kwargs, post_call_data) + except (TypeError, ValueError, RecursionError) as error: + verbose_proxy_logger.warning( + "Leaving out guardrail response headers: a recorded value can't be encoded (%s)", type(error).__name__ + ) + return MappingProxyType({}) + + class _RegisteredPassThroughRoute(TypedDict): endpoint_id: ReadOnly[str] path: ReadOnly[str | None] 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 28c052d3c22..a6f3b47b1a6 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 @@ -11,7 +11,7 @@ import zlib from collections.abc import AsyncIterator, Callable, Mapping, Sequence from contextlib import ExitStack, contextmanager from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from io import BytesIO from types import MappingProxyType, ModuleType, SimpleNamespace from typing import Final, Optional, cast @@ -13804,6 +13804,23 @@ class TestGuardrailResponseHeaders: == {} ) + @pytest.mark.parametrize( + "entry", + [{"s1"}, {"at": datetime(2026, 1, 1, tzinfo=timezone.utc)}, object()], + ids=["set", "datetime", "object"], + ) + def test_scan_metadata_json_cannot_encode_costs_the_headers_not_the_response(self, entry): + assert _guardrail_response_headers(post_call_data={"metadata": {"guardrail_scan_metadata": [entry]}}) == {} + + @pytest.mark.parametrize("field", ["applied_guardrails", "guardrail_scan_ids", "guardrail_scan_metadata"]) + def test_mixed_key_entry_recorded_before_and_after_the_call_costs_the_headers_not_the_response(self, field): + entries: Final = [{1: "a", "b": 2}] + + assert ( + _guardrail_response_headers({"metadata": {field: entries}}, post_call_data={"metadata": {field: entries}}) + == {} + ) + @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"]}}) == {}