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.
This commit is contained in:
yucheng 2026-11-02 23:02:16 -08:00
parent 4afb8292d5
commit a9670e88db
2 changed files with 32 additions and 2 deletions

View file

@ -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]

View file

@ -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"]}}) == {}