From cc57a4fbd6530f0648b925d0f3e010abcc986a6c Mon Sep 17 00:00:00 2001 From: yucheng Date: Fri, 30 Oct 2026 13:18:45 -0700 Subject: [PATCH 01/27] feat(proxy): set x-litellm-applied-guardrails on pass-through responses Pass-through responses never carried the applied-guardrails header family, so a caller on /openai, /anthropic, /gemini or /vertex_ai could not see which guardrails ran. Every pass-through exit (buffered, relayed, streamed, guardrail-modified and error) now sets the headers from what the pre-call and post-call guardrails recorded, combining both lists. The metadata fields those headers read are stripped from the client body first, so a caller cannot claim a guardrail ran. --- .../pass_through_endpoints.py | 70 +++++++- ...t_passthrough_applied_guardrails_header.py | 164 ++++++++++++++++++ .../test_pass_through_endpoints.py | 35 ++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/integration/authorization/test_passthrough_applied_guardrails_header.py diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 23c1202ea07..af99e0a88ac 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -104,6 +104,12 @@ from litellm.proxy.common_request_processing import ( open_sse_before_first_byte, resolve_litellm_call_id, ) +from litellm.proxy.common_utils.callback_utils import ( + GUARDRAIL_SCAN_IDS_METADATA_KEY, + GUARDRAIL_SCAN_METADATA_METADATA_KEY, + TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY, + get_logging_caching_headers, # pyright: ignore[reportUnknownVariableType] # untyped request_data param +) from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id from litellm.proxy.common_utils.http_parsing_utils import ( # noqa: F401 # legacy module exports _read_request_body, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export @@ -176,14 +182,63 @@ pass_through_endpoint_logging: Final = PassThroughEndpointLogging() _METADATA_KEYS: Final = frozenset(("litellm_metadata", "metadata")) _DECODED_JSON: Final = TypeAdapter(object) _GUARDRAIL_ENTRIES: Final = TypeAdapter(list[object]) +_RESPONSE_HEADERS: Final = TypeAdapter(Mapping[str, str]) _KEPT_OUT_OF_LITELLM_PARAMS: Final = _METADATA_KEYS | frozenset(wire_names(ProxyRequestState)) _PRICING_MODEL_OVERRIDE_PARAMS: Final = frozenset(("base_model",)) +# The metadata fields get_logging_caching_headers turns into response headers. +_GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( + ( + "applied_guardrails", + "applied_policies", + "policy_sources", + "semantic-similarity", + GUARDRAIL_SCAN_IDS_METADATA_KEY, + GUARDRAIL_SCAN_METADATA_METADATA_KEY, + TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY, + "pillar_response_headers", + "pillar_flagged", + ) +) def _is_mergeable_client_metadata(key: str, value: object) -> bool: return not key.startswith("user_api_key_") and (key != "model_info" or isinstance(value, Mapping)) +def _strip_client_guardrail_header_metadata( + data: dict[str, object], # mutable-ok: strips the request body in place, like strip_client_guardrail_information +) -> None: + """Drop the metadata fields that become guardrail response headers, so only the guardrails that ran set them.""" + for metadata in (as_str_mapping(data.get(key)) for key in sorted(_METADATA_KEYS)): + if isinstance(metadata, dict): + for field in _GUARDRAIL_HEADER_METADATA_FIELDS & metadata.keys(): + metadata.pop(field) + + +def _merged_metadata_entry(prior: object, value: object) -> object: + """``value``, or both lists combined without repeats when ``prior`` and ``value`` are lists.""" + if not isinstance(prior, list) or not isinstance(value, list): + return value + prior_entries: Final = _GUARDRAIL_ENTRIES.validate_python(prior) + return [*prior_entries, *(e for e in _GUARDRAIL_ENTRIES.validate_python(value) if e not in prior_entries)] + + +def _guardrail_response_headers(*request_data: object) -> Mapping[str, str]: + """The ``x-litellm-applied-guardrails`` family of response headers for what guardrails recorded in these dicts. + + Reads the metadata of each dict and of its ``litellm_params``. The pre-call and post-call hooks record on + different dicts, so list entries such as the applied guardrail names are combined. + """ + merged: Final = dict[str, object]() + for data in request_data: + fields = as_str_mapping(data) or {} + for scope in (fields, as_str_mapping(fields.get("litellm_params")) or {}): + for metadata_key in sorted(_METADATA_KEYS): + for key, value in (as_str_mapping(scope.get(metadata_key)) or {}).items(): + merged[key] = _merged_metadata_entry(merged.get(key), value) + return _RESPONSE_HEADERS.validate_python(get_logging_caching_headers({"metadata": merged}) or {}) + + class _RegisteredPassThroughRoute(TypedDict): endpoint_id: ReadOnly[str] path: ReadOnly[str | None] @@ -410,7 +465,10 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): headers: httpx.Headers, litellm_call_id: str | None = None, custom_headers: Mapping[str, str] | None = None, + guardrail_request_data: Sequence[object] = (), ) -> dict: + """The upstream response headers to relay, plus the call id, ``custom_headers`` and the guardrail headers + for what guardrails recorded in ``guardrail_request_data``.""" # Exclude headers that uvicorn writes itself (server, date) and # encoding/length headers that don't survive re-serialization. # If we forward the upstream's Server header, uvicorn adds its @@ -435,6 +493,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): key: value for key, value in custom_headers.items() if key.lower() not in excluded_headers } return_headers.update(sanitized_custom_headers) + return_headers.update(_guardrail_response_headers(*guardrail_request_data)) return return_headers @@ -1343,6 +1402,7 @@ async def pass_through_request( client_body: Final = TypeAdapter(dict[str, object]).validate_python(_parsed_body) strip_client_guardrail_information(client_body) + _strip_client_guardrail_header_metadata(client_body) if not _key_or_team_allows_client_pricing_override(user_api_key_dict): _strip_client_pricing_overrides(client_body) eu_jev_client_body: Final = _eu_jev_client_body(custom_llm_provider, client_body) @@ -1706,6 +1766,7 @@ async def pass_through_request( _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( headers=relay_response.headers, litellm_call_id=litellm_call_id, + guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) callback_headers = await proxy_logging_obj.post_call_response_headers_hook( data=_parsed_body or {}, @@ -1808,6 +1869,7 @@ async def pass_through_request( _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( headers=detected_relay_response.headers, litellm_call_id=litellm_call_id, + guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) callback_headers = await proxy_logging_obj.post_call_response_headers_hook( data=_parsed_body or {}, @@ -1884,6 +1946,7 @@ async def pass_through_request( headers=HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, custom_headers=relay_custom_headers, + guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ), ) @@ -2052,6 +2115,7 @@ async def pass_through_request( response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, custom_headers=custom_headers, + guardrail_request_data=(_parsed_body, kwargs, post_call_guardrail_data), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) emitted_call_id: Final = ( JSON_OBJECT.validate_python(response_headers).get(LITELLM_CALL_ID_HEADER) @@ -2110,7 +2174,10 @@ async def pass_through_request( content=json.dumps(error_body), status_code=200, media_type="application/json", - headers={"x-litellm-call-id": litellm_call_id}, + headers={ + "x-litellm-call-id": litellm_call_id, + **_guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + }, ) except Exception as e: custom_headers = ProxyBaseLLMRequestProcessing.get_custom_headers( @@ -2120,6 +2187,7 @@ async def pass_through_request( cache_key=None, api_base=str(url._uri_reference) if url else None, ) + custom_headers.update(_guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data)) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # untyped request and header dicts if CustomGuardrail._is_guardrail_intervention(e): verbose_proxy_logger.warning( "pass_through_endpoint: request blocked by guardrail - %s", diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py new file mode 100644 index 00000000000..c3337083e78 --- /dev/null +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -0,0 +1,164 @@ +import json +from collections.abc import Iterator, Mapping +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.anthropic_sse import message_json +from integration._support.client import Gateway, Scenario +from integration._support.openai_wire import chat_reply +from integration._support.process import OwnedProxy, owned_proxy_process +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import JsonValue, TypeAdapter + +PRE_CALL: Final = "passthrough-header-pre" +POST_CALL: Final = "passthrough-header-post" +HEADER: Final = "x-litellm-applied-guardrails" +OPENAI_ROUTE: Final = "/openai/v1/chat/completions" +ANTHROPIC_ROUTE: Final = "/anthropic/v1/messages" +GEMINI_ROUTE: Final = "/gemini/v1beta/models/gemini-2.5-flash:generateContent" +_CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml" +_JSON: Final = TypeAdapter(dict[str, JsonValue]) + + +def _route_body(route: str) -> dict[str, JsonValue]: + match route: + case "/anthropic/v1/messages": + return {"model": "claude-sonnet-5-5", "max_tokens": 16, "messages": [{"role": "user", "content": "Hi"}]} + case "/gemini/v1beta/models/gemini-2.5-flash:generateContent": + return {"contents": [{"role": "user", "parts": [{"text": "Hi"}]}]} + case _: + return {"model": "gpt-5.6", "messages": [{"role": "user", "content": "Hi"}]} + + +def _allow(_request: Request) -> Reply: + return Reply(body=json.dumps({"action": "NONE"}).encode()) + + +def _openai_reply(request: Request) -> Reply: + streaming: Final = _JSON.validate_json(request.body).get("stream") is True + return chat_reply("chatcmpl-passthrough-header", "gpt-5.6", "Hello", stream=streaming) + + +def _anthropic_reply(_request: Request) -> Reply: + return Reply(body=message_json("msg_passthrough_header", "claude-sonnet-5-5", "Hello")) + + +def _gemini_reply(_request: Request) -> Reply: + return Reply( + body=json.dumps( + { + "candidates": [{"content": {"role": "model", "parts": [{"text": "Hello"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}, + } + ).encode() + ) + + +@dataclass(frozen=True, slots=True) +class HeaderRig: + owned: OwnedProxy + upstreams: Mapping[str, Wire] + + +@pytest.fixture(scope="module") +def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[HeaderRig]: + directory: Final = tmp_path_factory.mktemp("passthrough-applied-guardrails-header") + with ExitStack() as stack: + vendor: Final = stack.enter_context(wire_server(_allow)) + upstreams: Final = MappingProxyType( + { + OPENAI_ROUTE: stack.enter_context(wire_server(_openai_reply)), + ANTHROPIC_ROUTE: stack.enter_context(wire_server(_anthropic_reply)), + GEMINI_ROUTE: stack.enter_context(wire_server(_gemini_reply)), + } + ) + config: Final = _JSON.validate_python(yaml.safe_load(_CONFIG.read_text())) + path: Final = directory / "passthrough-applied-guardrails-header.yaml" + path.write_text( + yaml.safe_dump( + { + **config, + "guardrails": [ + { + "guardrail_name": name, + "litellm_params": { + "guardrail": "generic_guardrail_api", + "mode": mode, + "default_on": False, + "api_base": vendor.url, + "api_key": "synthetic-guardrail-key", + }, + } + for name, mode in ((PRE_CALL, "pre_call"), (POST_CALL, "post_call")) + ], + } + ) + ) + environment: Final = { + "OPENAI_API_BASE": upstreams[OPENAI_ROUTE].url, + "OPENAI_API_KEY": "synthetic-openai-key", + "ANTHROPIC_API_BASE": upstreams[ANTHROPIC_ROUTE].url, + "ANTHROPIC_API_KEY": "synthetic-anthropic-key", + "GEMINI_API_BASE": upstreams[GEMINI_ROUTE].url, + "GEMINI_API_KEY": "synthetic-gemini-key", + } + owned: Final = stack.enter_context(owned_proxy_process(session_gateway, directory, environment, config=path)) + yield HeaderRig(owned, upstreams) + + +def _send( + gateway: Gateway, scenario: Scenario, route: str, body: Mapping[str, JsonValue], *guardrails: str +) -> httpx.Response: + key: Final = scenario.key(metadata={"guardrails": list(guardrails)}) if guardrails else scenario.key() + return gateway.request("POST", route, body, key=key, headers={"x-goog-api-key": key}) + + +@pytest.mark.parametrize("route", [OPENAI_ROUTE, ANTHROPIC_ROUTE, GEMINI_ROUTE]) +def test_response_names_the_guardrail_that_ran(rig: HeaderRig, route: str) -> None: + gateway: Final = rig.owned.gateway + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, route, _route_body(route), PRE_CALL) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == PRE_CALL + + +def test_streamed_response_names_the_guardrail_that_ran(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + with gateway.scenario() as scenario: + response: Final = _send( + gateway, scenario, OPENAI_ROUTE, {**_route_body(OPENAI_ROUTE), "stream": True}, PRE_CALL + ) + + assert response.status_code == 200, response.text + assert response.headers.get("content-type", "").startswith("text/event-stream") + assert response.headers.get(HEADER) == PRE_CALL + + +def test_response_names_pre_and_post_call_guardrails(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, _route_body(OPENAI_ROUTE), PRE_CALL, POST_CALL) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == f"{PRE_CALL},{POST_CALL}" + + +def test_client_cannot_claim_a_guardrail_ran(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + forged: Final = { + **_route_body(OPENAI_ROUTE), + "metadata": {"applied_guardrails": ["forged-guardrail"], "guardrail_scan_ids": ["forged-scan"]}, + } + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, forged) + + assert response.status_code == 200, response.text + assert HEADER not in response.headers + assert "x-litellm-guardrail-scan-id" not in response.headers 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 294ad45d795..51d4de3ef48 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 @@ -51,6 +51,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( SafeRouteAdder, _build_passthrough_failure_request_payload, _registered_pass_through_routes, + _strip_client_guardrail_header_metadata, _truncate_upstream_error_body, _update_metadata_with_tags_in_header, _with_trace_context, @@ -11472,3 +11473,37 @@ class TestInheritAuthGuardrails: assert "guardrails" not in pre_call[0].get("metadata", {}) post_call.assert_not_awaited() + + +class TestGuardrailResponseHeaders: + def test_pre_and_post_call_guardrails_are_combined_without_repeats(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {"model": "gpt-5.6"}, + {"litellm_params": {"metadata": {"applied_guardrails": ["pre"], "guardrail_scan_ids": ["scan-1"]}}}, + {"metadata": {"applied_guardrails": ["pre", "post"]}}, + None, + ), + ) + + assert headers["x-litellm-applied-guardrails"] == "pre,post" + assert headers["x-litellm-guardrail-scan-id"] == "scan-1" + + def test_no_guardrail_data_adds_no_headers(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=({"metadata": {"tags": ["team-a"]}},), + ) + + assert headers == {"content-type": "application/json"} + + def test_client_sent_guardrail_header_fields_are_stripped(self): + body: Final = { + "metadata": {"applied_guardrails": ["forged"], "pillar_flagged": True, "tags": ["team-a"]}, + "litellm_metadata": {"guardrail_scan_ids": ["forged-scan"]}, + } + + _strip_client_guardrail_header_metadata(body) + + assert body == {"metadata": {"tags": ["team-a"]}, "litellm_metadata": {}} From 344a8121c93890a75ad77f952b9e83612947e16d Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 01:01:19 -0700 Subject: [PATCH 02/27] fix(proxy): build pass-through guardrail headers only from proxy-written metadata --- .../pass_through_endpoints.py | 43 +++++++---- ...t_passthrough_applied_guardrails_header.py | 22 ++++-- .../test_pass_through_endpoints.py | 74 ++++++++++++++++--- 3 files changed, 109 insertions(+), 30 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index f6d07d1408d..cfcef7d1936 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -9,7 +9,7 @@ from base64 import b64encode from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime -from itertools import chain, count, groupby +from itertools import chain, count, groupby, product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, TypedDict, cast from urllib.parse import urlencode, urlparse @@ -218,26 +218,39 @@ def _strip_client_guardrail_header_metadata( def _merged_metadata_entry(prior: object, value: object) -> object: """``value``, or both lists combined without repeats when ``prior`` and ``value`` are lists.""" - if not isinstance(prior, list) or not isinstance(value, list): + if not isinstance(prior, list | tuple) or not isinstance(value, list): return value - prior_entries: Final = _GUARDRAIL_ENTRIES.validate_python(prior) - return [*prior_entries, *(e for e in _GUARDRAIL_ENTRIES.validate_python(value) if e not in prior_entries)] + entries: Final = (*_GUARDRAIL_ENTRIES.validate_python(prior), *_GUARDRAIL_ENTRIES.validate_python(value)) + return tuple( + MappingProxyType({json.dumps(entry, sort_keys=True, default=str): entry for entry in entries}).values() + ) -def _guardrail_response_headers(*request_data: object) -> Mapping[str, str]: - """The ``x-litellm-applied-guardrails`` family of response headers for what guardrails recorded in these dicts. +def _encodes_as_latin1(value: str) -> bool: + return all(ord(character) < 256 for character in value) - Reads the metadata of each dict and of its ``litellm_params``. The pre-call and post-call hooks record on - different dicts, so list entries such as the applied guardrail names are combined. + +def _guardrail_response_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. + + 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. """ + scopes: Final = ( + as_str_mapping(parsed_body) or {}, + as_str_mapping((as_str_mapping(kwargs) or {}).get("litellm_params")) or {}, + as_str_mapping(post_call_data) or {}, + ) merged: Final = dict[str, object]() - for data in request_data: - fields = as_str_mapping(data) or {} - for scope in (fields, as_str_mapping(fields.get("litellm_params")) or {}): - for metadata_key in sorted(_METADATA_KEYS): - for key, value in (as_str_mapping(scope.get(metadata_key)) or {}).items(): - merged[key] = _merged_metadata_entry(merged.get(key), value) - return _RESPONSE_HEADERS.validate_python(get_logging_caching_headers({"metadata": merged}) or {}) + for scope, metadata_key in product(scopes, sorted(_METADATA_KEYS)): + metadata = as_str_mapping(scope.get(metadata_key)) or {} + for field in _GUARDRAIL_HEADER_METADATA_FIELDS & metadata.keys(): + merged[field] = _merged_metadata_entry(merged.get(field), metadata[field]) + headers: Final = _RESPONSE_HEADERS.validate_python(get_logging_caching_headers({"metadata": merged}) or {}) + return MappingProxyType({name: value for name, value in headers.items() if _encodes_as_latin1(value)}) class _RegisteredPassThroughRoute(TypedDict): diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py index c3337083e78..5a6656ca999 100644 --- a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -18,6 +18,7 @@ from pydantic import JsonValue, TypeAdapter PRE_CALL: Final = "passthrough-header-pre" POST_CALL: Final = "passthrough-header-post" +NON_LATIN1: Final = "passthrough-header-pre\u2014em" HEADER: Final = "x-litellm-applied-guardrails" OPENAI_ROUTE: Final = "/openai/v1/chat/completions" ANTHROPIC_ROUTE: Final = "/anthropic/v1/messages" @@ -95,7 +96,7 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I "api_key": "synthetic-guardrail-key", }, } - for name, mode in ((PRE_CALL, "pre_call"), (POST_CALL, "post_call")) + for name, mode in ((PRE_CALL, "pre_call"), (POST_CALL, "post_call"), (NON_LATIN1, "pre_call")) ], } ) @@ -150,15 +151,26 @@ def test_response_names_pre_and_post_call_guardrails(rig: HeaderRig) -> None: assert response.headers.get(HEADER) == f"{PRE_CALL},{POST_CALL}" -def test_client_cannot_claim_a_guardrail_ran(rig: HeaderRig) -> None: +@pytest.mark.parametrize("field", ["metadata", "litellm_params"]) +def test_client_cannot_claim_a_guardrail_ran(rig: HeaderRig, field: str) -> None: gateway: Final = rig.owned.gateway - forged: Final = { - **_route_body(OPENAI_ROUTE), - "metadata": {"applied_guardrails": ["forged-guardrail"], "guardrail_scan_ids": ["forged-scan"]}, + claim: Final[dict[str, JsonValue]] = { + "applied_guardrails": ["forged-guardrail"], + "guardrail_scan_ids": ["forged-scan"], } + forged: Final = {**_route_body(OPENAI_ROUTE), field: claim if field == "metadata" else {"metadata": claim}} with gateway.scenario() as scenario: response: Final = _send(gateway, scenario, OPENAI_ROUTE, forged) assert response.status_code == 200, response.text assert HEADER not in response.headers assert "x-litellm-guardrail-scan-id" not in response.headers + + +def test_guardrail_name_a_header_cannot_carry_is_left_out(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, _route_body(OPENAI_ROUTE), NON_LATIN1) + + assert response.status_code == 200, response.text + assert HEADER not in response.headers 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 1f210e24522..da9bc5b51a6 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 @@ -11335,13 +11335,23 @@ async def test_passthrough_header_and_callback_share_finalized_cost( client, cleanup = _inject_fake_passthrough_client(httpx.MockTransport(upstream), timeout=None) try: - request: Final = Request({ - "type": "http", "method": "GET", "path": "/passthrough-relay/results", - "headers": [], "query_string": b"", "scheme": "http", "server": ("test", 80), - }) + request: Final = Request( + { + "type": "http", + "method": "GET", + "path": "/passthrough-relay/results", + "headers": [], + "query_string": b"", + "scheme": "http", + "server": ("test", 80), + } + ) result: Final = await pass_through_request( - request=request, target="http://cost-upstream.invalid/results", custom_headers={}, - user_api_key_dict=UserAPIKeyAuth(api_key="hashed"), cost_per_request=flat_cost, + request=request, + target="http://cost-upstream.invalid/results", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="hashed"), + cost_per_request=flat_cost, ) payload: Final = await asyncio.wait_for(observed, timeout=10) await GLOBAL_LOGGING_WORKER.flush() @@ -11489,9 +11499,7 @@ class TestInheritAuthGuardrails: async def test_request_body_guardrails_are_added_to_the_key_guardrails(self): auth: Final = UserAPIKeyAuth(api_key="sk-test", metadata={"guardrails": ["key-guard"]}) - pre_call, _ = await self._run( - self._chat_body(guardrails=["body-guard"]), inherit=True, user_api_key_dict=auth - ) + pre_call, _ = await self._run(self._chat_body(guardrails=["body-guard"]), inherit=True, user_api_key_dict=auth) assert pre_call[0]["metadata"]["guardrails"] == ["key-guard", "body-guard"] assert "guardrails" not in pre_call[0] @@ -11531,13 +11539,59 @@ class TestGuardrailResponseHeaders: {"model": "gpt-5.6"}, {"litellm_params": {"metadata": {"applied_guardrails": ["pre"], "guardrail_scan_ids": ["scan-1"]}}}, {"metadata": {"applied_guardrails": ["pre", "post"]}}, - None, ), ) assert headers["x-litellm-applied-guardrails"] == "pre,post" assert headers["x-litellm-guardrail-scan-id"] == "scan-1" + def test_a_litellm_params_object_in_the_request_body_is_never_read(self): + forged: Final = {"metadata": {"applied_guardrails": ["forged"], "guardrail_scan_ids": ["fake"]}} + + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {"model": "gpt-5.6", "litellm_params": forged}, + {"litellm_params": {"metadata": {}}}, + {"model": "gpt-5.6", "litellm_params": forged, "metadata": {"guardrails": ["post"]}}, + ), + ) + + assert headers == {"content-type": "application/json"} + + def test_guardrail_name_outside_latin1_is_left_out_of_the_headers(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {}, + { + "litellm_params": { + "metadata": {"applied_guardrails": ["pii\u2014guard"], "guardrail_scan_ids": ["s1"]} + } + }, + ), + ) + + assert headers == {"content-type": "application/json", "x-litellm-guardrail-scan-id": "s1"} + assert Response(content=b"{}", headers=headers).status_code == 200 + + def test_metadata_outside_the_header_fields_is_never_read(self): + class _Unreadable: + __hash__ = object.__hash__ + + def __eq__(self, other: object) -> bool: + raise AssertionError("non-header metadata was read") + + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {"metadata": {"unused": [_Unreadable()]}}, + {"litellm_params": {"metadata": {"unused": [_Unreadable()]}}}, + ), + ) + + assert headers == {"content-type": "application/json"} + def test_no_guardrail_data_adds_no_headers(self): headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( headers=httpx.Headers({"content-type": "application/json"}), From c5861198b50987e8d18a3010d185b31b4ad51b3d Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 01:52:58 -0700 Subject: [PATCH 03/27] fix(proxy): keep tuple scan ids across both guardrail stages and merge header fields without mutation --- .../pass_through_endpoints.py | 31 ++++++++++++------- .../test_pass_through_endpoints.py | 16 ++++++++-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index de561ce32e5..cedc001b0eb 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -9,6 +9,7 @@ from base64 import b64encode from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from datetime import datetime +from functools import reduce from itertools import chain, count, groupby, product from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, TypedDict, cast @@ -192,7 +193,6 @@ _GUARDRAIL_ENTRIES: Final = TypeAdapter(list[object]) _RESPONSE_HEADERS: Final = TypeAdapter(Mapping[str, str]) _KEPT_OUT_OF_LITELLM_PARAMS: Final = _METADATA_KEYS | frozenset(wire_names(ProxyRequestState)) _PRICING_MODEL_OVERRIDE_PARAMS: Final = frozenset(("base_model",)) -# The metadata fields get_logging_caching_headers turns into response headers. _GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( ( "applied_guardrails", @@ -212,10 +212,12 @@ def _is_mergeable_client_metadata(key: str, value: object) -> bool: return not key.startswith("user_api_key_") and (key != "model_info" or isinstance(value, Mapping)) -def _strip_client_guardrail_header_metadata( +def _strip_client_guardrail_fields( data: dict[str, object], # mutable-ok: strips the request body in place, like strip_client_guardrail_information ) -> None: - """Drop the metadata fields that become guardrail response headers, so only the guardrails that ran set them.""" + """Drop the guardrail logging and the metadata fields that become guardrail response headers from a client body, + so only the guardrails that ran set them.""" + strip_client_guardrail_information(data) for metadata in (as_str_mapping(data.get(key)) for key in sorted(_METADATA_KEYS)): if isinstance(metadata, dict): for field in _GUARDRAIL_HEADER_METADATA_FIELDS & metadata.keys(): @@ -223,8 +225,8 @@ def _strip_client_guardrail_header_metadata( def _merged_metadata_entry(prior: object, value: object) -> object: - """``value``, or both lists combined without repeats when ``prior`` and ``value`` are lists.""" - if not isinstance(prior, list | tuple) or not isinstance(value, list): + """``value``, or both combined without repeats when ``prior`` and ``value`` are lists or tuples.""" + if not isinstance(prior, list | tuple) or not isinstance(value, list | tuple): return value entries: Final = (*_GUARDRAIL_ENTRIES.validate_python(prior), *_GUARDRAIL_ENTRIES.validate_python(value)) return tuple( @@ -250,11 +252,17 @@ def _guardrail_response_headers( as_str_mapping((as_str_mapping(kwargs) or {}).get("litellm_params")) or {}, as_str_mapping(post_call_data) or {}, ) - merged: Final = dict[str, object]() - for scope, metadata_key in product(scopes, sorted(_METADATA_KEYS)): - metadata = as_str_mapping(scope.get(metadata_key)) or {} - for field in _GUARDRAIL_HEADER_METADATA_FIELDS & metadata.keys(): - merged[field] = _merged_metadata_entry(merged.get(field), metadata[field]) + metadatas: Final = tuple( + as_str_mapping(scope.get(metadata_key)) or {} for scope, metadata_key in product(scopes, sorted(_METADATA_KEYS)) + ) + recorded: Final = chain.from_iterable( + ((field, metadata[field]) for field in sorted(_GUARDRAIL_HEADER_METADATA_FIELDS & metadata.keys())) + for metadata in metadatas + ) + merged: Final = { + 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 {}) return MappingProxyType({name: value for name, value in headers.items() if _encodes_as_latin1(value)}) @@ -1464,8 +1472,7 @@ async def pass_through_request( ) client_body: Final = TypeAdapter(dict[str, object]).validate_python(_parsed_body) - strip_client_guardrail_information(client_body) - _strip_client_guardrail_header_metadata(client_body) + _strip_client_guardrail_fields(client_body) if not _key_or_team_allows_client_pricing_override(user_api_key_dict): _strip_client_pricing_overrides(client_body) eu_jev_client_body: Final = _eu_jev_client_body(custom_llm_provider, client_body) 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 0a3137bbdf9..bb31c1daacb 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 @@ -51,7 +51,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( SafeRouteAdder, _build_passthrough_failure_request_payload, _registered_pass_through_routes, - _strip_client_guardrail_header_metadata, + _strip_client_guardrail_fields, _truncate_upstream_error_body, _update_metadata_with_tags_in_header, _with_trace_context, @@ -11922,6 +11922,18 @@ class TestGuardrailResponseHeaders: assert headers["x-litellm-applied-guardrails"] == "pre,post" assert headers["x-litellm-guardrail-scan-id"] == "scan-1" + def test_scan_ids_recorded_as_tuples_before_and_after_the_call_are_both_kept(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {}, + {"litellm_params": {"metadata": {"guardrail_scan_ids": ("pre-scan",)}}}, + {"metadata": {"guardrail_scan_ids": ("post-scan",)}}, + ), + ) + + assert headers["x-litellm-guardrail-scan-id"] == "pre-scan,post-scan" + def test_a_litellm_params_object_in_the_request_body_is_never_read(self): forged: Final = {"metadata": {"applied_guardrails": ["forged"], "guardrail_scan_ids": ["fake"]}} @@ -11983,6 +11995,6 @@ class TestGuardrailResponseHeaders: "litellm_metadata": {"guardrail_scan_ids": ["forged-scan"]}, } - _strip_client_guardrail_header_metadata(body) + _strip_client_guardrail_fields(body) assert body == {"metadata": {"tags": ["team-a"]}, "litellm_metadata": {}} From 5172a03a4dd6f367a79cd65457edd0c3b0714991 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 10:09:55 -0700 Subject: [PATCH 04/27] fix(proxy): carry the guardrail headers on a guardrail's own ProxyException --- .../pass_through_endpoints.py | 4 ++- .../test_pass_through_endpoints.py | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 9127fb2bdca..a89099759ce 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2263,7 +2263,8 @@ async def pass_through_request( cache_key=None, api_base=str(url._uri_reference) if url else None, ) - custom_headers.update(_guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data)) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] # untyped request and header dicts + guardrail_headers: Final = _guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data) # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + custom_headers.update(guardrail_headers) # pyright: ignore[reportUnknownMemberType] # untyped header dict if CustomGuardrail._is_guardrail_intervention(e): verbose_proxy_logger.warning( "pass_through_endpoint: request blocked by guardrail - %s", @@ -2300,6 +2301,7 @@ async def pass_through_request( ######################################################### if isinstance(e, ProxyException): + e.headers = {**e.headers, **guardrail_headers} raise if isinstance(e, HTTPException): raise ProxyException( 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 bb31c1daacb..62a41720545 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 @@ -486,6 +486,38 @@ async def test_pass_through_request_preserves_proxy_exception_status(): assert exc.value.param == "limit" +@pytest.mark.asyncio +async def test_guardrail_proxy_exception_carries_the_guardrails_that_ran(): + blocked: Final = ProxyException(message="blocked by guardrail", type="None", param="None", code=400) + + async def record_then_block( + user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str, endpoint_type: object + ) -> None: + data["metadata"] = {"applied_guardrails": ["passed-guard"]} # mutable-ok: a guardrail records on the live body + raise blocked + + with patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging: + mock_proxy_logging.post_call_failure_hook = AsyncMock() + mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=record_then_block) + + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.body = AsyncMock(return_value=b'{"model": "gpt-5.6", "messages": []}') + mock_request.headers = Headers({"content-type": "application/json"}) + mock_request.query_params = QueryParams({}) + + with pytest.raises(ProxyException) as exc: + await pass_through_request( + request=mock_request, + target="http://test.com/v1/chat/completions", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) + + assert exc.value is blocked + assert exc.value.headers["x-litellm-applied-guardrails"] == "passed-guard" + + def test_is_langfuse_route(): """ Test that the is_langfuse_route method correctly identifies Langfuse routes From f51ca91711d3302c3d7717c0abae61a923d0dbe5 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 11:59:07 -0700 Subject: [PATCH 05/27] fix(proxy): keep guardrail headers on upstream errors, and merge mapping metadata from both guardrail passes --- .../pass_through_endpoints.py | 59 +++++++++++---- ...t_passthrough_applied_guardrails_header.py | 74 ++++++++++++++++++- .../test_pass_through_endpoints.py | 12 +++ 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 7f5cdbc6777..8f907faf8d3 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -226,7 +226,12 @@ def _strip_client_guardrail_fields( def _merged_metadata_entry(prior: object, value: object) -> object: - """``value``, or both combined without repeats when ``prior`` and ``value`` are lists or tuples.""" + """``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.""" + prior_mapping: Final = as_str_mapping(prior) + value_mapping: Final = as_str_mapping(value) + if prior_mapping is not None and value_mapping is not None: + return {**prior_mapping, **value_mapping} if not isinstance(prior, list | tuple) or not isinstance(value, list | tuple): return value entries: Final = (*_GUARDRAIL_ENTRIES.validate_python(prior), *_GUARDRAIL_ENTRIES.validate_python(value)) @@ -1274,6 +1279,26 @@ async def _log_passthrough_upstream_failure( return relay_response +async def _log_passthrough_upstream_failure_keeping_guardrail_headers( + response: httpx.Response, + user_api_key_dict: UserAPIKeyAuth, + request_payload: dict[str, object], # mutable-ok: the failure hooks lift fields onto request_data in place + logging_obj: LiteLLMLoggingObj, + parsed_body: Mapping[str, object] | None, + kwargs: Mapping[str, object] | None, +) -> Mapping[str, str]: + """Log an upstream error like ``_log_passthrough_upstream_failure`` and return the guardrail response headers, + read before the failure hooks rebuild ``kwargs``' metadata without the guardrail fields.""" + guardrail_headers: Final = _guardrail_response_headers(parsed_body, kwargs) + await _log_passthrough_upstream_failure( + response=response, + user_api_key_dict=user_api_key_dict, + request_payload=request_payload, + logging_obj=logging_obj, + ) + return guardrail_headers + + async def _relay_reporting_failures( stream: AsyncGenerator[bytes, None], upstream_status: int, @@ -1823,6 +1848,13 @@ async def pass_through_request( ) apply_upstream_reported_operation(logging_obj, response.headers) + # Built before the failure hooks rebuild kwargs' metadata without the guardrail fields. The relay + # response differs only in body-framing headers, which get_response_headers drops anyway. + _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + ) relay_response: Final = await _log_passthrough_upstream_failure( response=response, user_api_key_dict=user_api_key_dict, @@ -1840,11 +1872,6 @@ async def pass_through_request( ) # Call response headers hook for streaming pass-through - _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( - headers=relay_response.headers, - litellm_call_id=litellm_call_id, - guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts - ) callback_headers = await proxy_logging_obj.post_call_response_headers_hook( data=_parsed_body or {}, user_api_key_dict=user_api_key_dict, @@ -1926,6 +1953,13 @@ async def pass_through_request( logging_obj.stream = True logging_obj.model_call_details["stream"] = True + # Built before the failure hooks rebuild kwargs' metadata without the guardrail fields. The relay + # response differs only in body-framing headers, which get_response_headers drops anyway. + _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( + headers=response.headers, + litellm_call_id=litellm_call_id, + guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + ) detected_relay_response: Final = await _log_passthrough_upstream_failure( response=response, user_api_key_dict=user_api_key_dict, @@ -1943,11 +1977,6 @@ async def pass_through_request( ) # Call response headers hook for detected streaming pass-through - _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( - headers=detected_relay_response.headers, - litellm_call_id=litellm_call_id, - guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts - ) callback_headers = await proxy_logging_obj.post_call_response_headers_hook( data=_parsed_body or {}, user_api_key_dict=user_api_key_dict, @@ -2047,11 +2076,13 @@ async def pass_through_request( upstream_model=upstream_model, ) failure_request_payload["response_body"] = response_body - await _log_passthrough_upstream_failure( + upstream_guardrail_headers: Final = await _log_passthrough_upstream_failure_keeping_guardrail_headers( response=response, user_api_key_dict=user_api_key_dict, - request_payload=failure_request_payload, + request_payload=failure_request_payload, # pyright: ignore[reportUnknownArgumentType] # untyped request dicts logging_obj=logging_obj, + parsed_body=_parsed_body, # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + kwargs=kwargs, # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) if response.status_code < 400 and response_body is not None and post_call_guardrails: @@ -2191,7 +2222,7 @@ async def pass_through_request( response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, - custom_headers=custom_headers, + custom_headers={**custom_headers, **upstream_guardrail_headers}, guardrail_request_data=(_parsed_body, kwargs, post_call_guardrail_data), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) emitted_call_id: Final = ( diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py index 5a6656ca999..b6b40547d8b 100644 --- a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -19,6 +19,8 @@ from pydantic import JsonValue, TypeAdapter PRE_CALL: Final = "passthrough-header-pre" POST_CALL: Final = "passthrough-header-post" NON_LATIN1: Final = "passthrough-header-pre\u2014em" +BLOCKER: Final = "passthrough-header-block" +MISSING_MODEL: Final = "passthrough-header-missing-model" HEADER: Final = "x-litellm-applied-guardrails" OPENAI_ROUTE: Final = "/openai/v1/chat/completions" ANTHROPIC_ROUTE: Final = "/anthropic/v1/messages" @@ -41,9 +43,18 @@ def _allow(_request: Request) -> Reply: return Reply(body=json.dumps({"action": "NONE"}).encode()) +def _block(_request: Request) -> Reply: + return Reply(body=json.dumps({"action": "BLOCKED", "blocked_reason": "synthetic block"}).encode()) + + def _openai_reply(request: Request) -> Reply: - streaming: Final = _JSON.validate_json(request.body).get("stream") is True - return chat_reply("chatcmpl-passthrough-header", "gpt-5.6", "Hello", stream=streaming) + payload: Final = _JSON.validate_json(request.body) + if payload.get("model") == MISSING_MODEL: + return Reply( + status=404, + body=json.dumps({"error": {"message": "model not found", "type": "invalid_request_error"}}).encode(), + ) + return chat_reply("chatcmpl-passthrough-header", "gpt-5.6", "Hello", stream=payload.get("stream") is True) def _anthropic_reply(_request: Request) -> Reply: @@ -72,6 +83,7 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I directory: Final = tmp_path_factory.mktemp("passthrough-applied-guardrails-header") with ExitStack() as stack: vendor: Final = stack.enter_context(wire_server(_allow)) + blocking_vendor: Final = stack.enter_context(wire_server(_block)) upstreams: Final = MappingProxyType( { OPENAI_ROUTE: stack.enter_context(wire_server(_openai_reply)), @@ -92,11 +104,16 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I "guardrail": "generic_guardrail_api", "mode": mode, "default_on": False, - "api_base": vendor.url, + "api_base": api_base, "api_key": "synthetic-guardrail-key", }, } - for name, mode in ((PRE_CALL, "pre_call"), (POST_CALL, "post_call"), (NON_LATIN1, "pre_call")) + for name, mode, api_base in ( + (PRE_CALL, "pre_call", vendor.url), + (POST_CALL, "post_call", vendor.url), + (NON_LATIN1, "pre_call", vendor.url), + (BLOCKER, "pre_call", blocking_vendor.url), + ) ], } ) @@ -174,3 +191,52 @@ def test_guardrail_name_a_header_cannot_carry_is_left_out(rig: HeaderRig) -> Non assert response.status_code == 200, response.text assert HEADER not in response.headers + + +@pytest.mark.parametrize("stream", [False, True]) +def test_upstream_error_names_the_guardrail_that_ran(rig: HeaderRig, stream: bool) -> None: + gateway: Final = rig.owned.gateway + body: Final = {**_route_body(OPENAI_ROUTE), "model": MISSING_MODEL, "stream": stream} + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, body, PRE_CALL) + + assert response.status_code == 404, response.text + assert response.headers.get(HEADER) == PRE_CALL + + +def test_blocked_request_names_the_blocking_guardrail(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + upstream: Final = rig.upstreams[OPENAI_ROUTE] + upstream.drain() + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, _route_body(OPENAI_ROUTE), PRE_CALL, BLOCKER) + + assert response.status_code == 400, response.text + assert response.headers.get(HEADER) == f"{PRE_CALL},{BLOCKER}" + assert upstream.drain() == () + + +@pytest.mark.parametrize("field", ["metadata", "litellm_metadata"]) +def test_client_claims_beside_a_real_guardrail_are_dropped(rig: HeaderRig, field: str) -> None: + gateway: Final = rig.owned.gateway + claim: Final[dict[str, JsonValue]] = { + "applied_guardrails": ["forged-guardrail"], + "guardrail_scan_ids": ["forged-scan"], + } + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, {**_route_body(OPENAI_ROUTE), field: claim}, PRE_CALL) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == PRE_CALL + assert "forged-scan" not in response.headers.get("x-litellm-guardrail-scan-id", "") + + +@pytest.mark.parametrize("claim", ["none", 7, ""]) +def test_non_list_client_claim_does_not_fail_a_guarded_request(rig: HeaderRig, claim: JsonValue) -> None: + gateway: Final = rig.owned.gateway + body: Final = {**_route_body(OPENAI_ROUTE), "metadata": {"applied_guardrails": claim}} + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, body, PRE_CALL) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == PRE_CALL 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 6f3ff0b5ed4..e2a318732dd 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 @@ -11980,6 +11980,18 @@ class TestGuardrailResponseHeaders: assert headers["x-litellm-guardrail-scan-id"] == "pre-scan,post-scan" + def test_policy_sources_recorded_before_and_after_the_call_are_both_kept(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"content-type": "application/json"}), + guardrail_request_data=( + {}, + {"litellm_params": {"metadata": {"policy_sources": {"pre-policy": "key", "shared": "team"}}}}, + {"metadata": {"policy_sources": {"post-policy": "tag", "shared": "key"}}}, + ), + ) + + assert headers["x-litellm-policy-sources"] == "pre-policy=key; shared=key; post-policy=tag" + def test_a_litellm_params_object_in_the_request_body_is_never_read(self): forged: Final = {"metadata": {"applied_guardrails": ["forged"], "guardrail_scan_ids": ["fake"]}} From e1e35c64d3927c46a10c8c73db1cde4fde38a42f Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 13:27:48 -0700 Subject: [PATCH 06/27] fix(proxy): keep Pillar headers off pass-through responses and drop header values HTTP can't carry --- .../pass_through_endpoints.py | 15 +++--- ...t_passthrough_applied_guardrails_header.py | 52 +++++++++++++++++++ .../test_pass_through_endpoints.py | 35 ++++++++++++- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 067de31228a..1528579770d 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -113,7 +113,6 @@ from litellm.proxy.common_request_processing import ( from litellm.proxy.common_utils.callback_utils import ( GUARDRAIL_SCAN_IDS_METADATA_KEY, GUARDRAIL_SCAN_METADATA_METADATA_KEY, - TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY, get_logging_caching_headers, # pyright: ignore[reportUnknownVariableType] # untyped request_data param ) from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id @@ -199,14 +198,12 @@ _GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( "applied_guardrails", "applied_policies", "policy_sources", - "semantic-similarity", GUARDRAIL_SCAN_IDS_METADATA_KEY, GUARDRAIL_SCAN_METADATA_METADATA_KEY, - TRUSTED_PILLAR_RESPONSE_HEADERS_METADATA_KEY, - "pillar_response_headers", - "pillar_flagged", ) ) +# h11's header field-value grammar: no NUL, line breaks or other whitespace beyond spaces and tabs between characters. +_SENDABLE_HEADER_VALUE: Final = re.compile(rb"(?:[^\x00\s]+(?:[ \t]+[^\x00\s]+)*)?") def _is_mergeable_client_metadata(key: str, value: object) -> bool: @@ -240,8 +237,10 @@ def _merged_metadata_entry(prior: object, value: object) -> object: ) -def _encodes_as_latin1(value: str) -> bool: - return all(ord(character) < 256 for character in value) +def _is_sendable_header_value(value: str) -> bool: + return all(ord(character) < 256 for character in value) and bool( + _SENDABLE_HEADER_VALUE.fullmatch(value.encode("latin-1")) + ) def _guardrail_response_headers( @@ -270,7 +269,7 @@ def _guardrail_response_headers( 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 {}) - return MappingProxyType({name: value for name, value in headers.items() if _encodes_as_latin1(value)}) + return MappingProxyType({name: value for name, value in headers.items() if _is_sendable_header_value(value)}) class _RegisteredPassThroughRoute(TypedDict): diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py index b6b40547d8b..b4c05f6a1ef 100644 --- a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -21,6 +21,9 @@ POST_CALL: Final = "passthrough-header-post" NON_LATIN1: Final = "passthrough-header-pre\u2014em" BLOCKER: Final = "passthrough-header-block" MISSING_MODEL: Final = "passthrough-header-missing-model" +PILLAR: Final = "passthrough-header-pillar" +TAG_POLICY: Final = "passthrough-header-tag-policy" +POLICY_TAG_PREFIX: Final = "passthrough-header-tag-" HEADER: Final = "x-litellm-applied-guardrails" OPENAI_ROUTE: Final = "/openai/v1/chat/completions" ANTHROPIC_ROUTE: Final = "/anthropic/v1/messages" @@ -47,6 +50,10 @@ def _block(_request: Request) -> Reply: return Reply(body=json.dumps({"action": "BLOCKED", "blocked_reason": "synthetic block"}).encode()) +def _pillar_allow(_request: Request) -> Reply: + return Reply(body=json.dumps({"flagged": False, "session_id": "pillar-vendor-session"}).encode()) + + def _openai_reply(request: Request) -> Reply: payload: Final = _JSON.validate_json(request.body) if payload.get("model") == MISSING_MODEL: @@ -84,6 +91,7 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I with ExitStack() as stack: vendor: Final = stack.enter_context(wire_server(_allow)) blocking_vendor: Final = stack.enter_context(wire_server(_block)) + pillar_vendor: Final = stack.enter_context(wire_server(_pillar_allow)) upstreams: Final = MappingProxyType( { OPENAI_ROUTE: stack.enter_context(wire_server(_openai_reply)), @@ -114,7 +122,21 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I (NON_LATIN1, "pre_call", vendor.url), (BLOCKER, "pre_call", blocking_vendor.url), ) + ] + + [ + { + "guardrail_name": PILLAR, + "litellm_params": { + "guardrail": "pillar", + "mode": "pre_call", + "default_on": False, + "api_base": pillar_vendor.url, + "api_key": "synthetic-pillar-key", + }, + } ], + "policies": {TAG_POLICY: {"guardrails": {"add": [PRE_CALL]}}}, + "policy_attachments": [{"policy": TAG_POLICY, "tags": [f"{POLICY_TAG_PREFIX}*"]}], } ) ) @@ -240,3 +262,33 @@ def test_non_list_client_claim_does_not_fail_a_guarded_request(rig: HeaderRig, c assert response.status_code == 200, response.text assert response.headers.get(HEADER) == PRE_CALL + + +def test_pillar_headers_stay_off_pass_through_responses(rig: HeaderRig) -> None: + gateway: Final = rig.owned.gateway + forged: Final[dict[str, JsonValue]] = {"pillar_session_id_response": "client-session", "pillar_scanners": {"x": 1}} + with gateway.scenario() as scenario: + response: Final = _send( + gateway, scenario, OPENAI_ROUTE, {**_route_body(OPENAI_ROUTE), "metadata": forged}, PILLAR + ) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == PILLAR + assert [name for name, _ in response.headers.multi_items() if name.startswith("x-pillar-")] == [] + + +@pytest.mark.parametrize("suffix", ["team\n", "x\r\nX-Injected: yes"]) +def test_header_value_with_a_line_break_is_left_out(rig: HeaderRig, suffix: str) -> None: + gateway: Final = rig.owned.gateway + body: Final[dict[str, JsonValue]] = { + **_route_body(OPENAI_ROUTE), + "metadata": {"tags": [f"{POLICY_TAG_PREFIX}{suffix}"]}, + } + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, body) + + assert response.status_code == 200, response.text + assert response.headers.get(HEADER) == PRE_CALL + assert response.headers.get("x-litellm-applied-policies") == TAG_POLICY + assert "x-litellm-policy-sources" not in response.headers + assert "x-injected" not in response.headers 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 104dd5beb47..d007efdc93e 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 @@ -17,6 +17,7 @@ from typing import Final, Optional, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID +import h11 import httpx import pytest import respx @@ -51,6 +52,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( SafeRouteAdder, _build_passthrough_failure_request_payload, _registered_pass_through_routes, + _guardrail_response_headers, _strip_client_guardrail_fields, _truncate_upstream_error_body, _update_metadata_with_tags_in_header, @@ -12069,4 +12071,35 @@ class TestGuardrailResponseHeaders: _strip_client_guardrail_fields(body) - assert body == {"metadata": {"tags": ["team-a"]}, "litellm_metadata": {}} + assert body == {"metadata": {"pillar_flagged": True, "tags": ["team-a"]}, "litellm_metadata": {}} + + def test_pillar_fields_recorded_on_the_request_add_no_pillar_headers(self): + headers: Final = _guardrail_response_headers( + {"metadata": {"pillar_session_id_response": "client-session"}}, + { + "litellm_params": { + "metadata": { + "applied_guardrails": ["pillar"], + "pillar_flagged": False, + "pillar_response_headers": {"x-pillar-flagged": "false", "x-pillar-session-id": "vendor"}, + "_pillar_response_headers_trusted": True, + } + } + }, + ) + + assert headers == {"x-litellm-applied-guardrails": "pillar"} + + @pytest.mark.parametrize("tag", ["prod-team\n", "prod-x\r\nX-Injected: yes", "prod\x00"]) + def test_header_value_an_http_response_cannot_carry_is_left_out(self, tag: str): + headers: Final = _guardrail_response_headers( + {}, + { + "litellm_params": { + "metadata": {"applied_policies": ["tag-policy"], "policy_sources": {"tag-policy": f"tag:{tag}"}} + } + }, + ) + + assert headers == {"x-litellm-applied-policies": "tag-policy"} + assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 From 0bbc31c26056e2d25b17f623a60de5cfa3a46d07 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 13:52:10 -0700 Subject: [PATCH 07/27] refactor(proxy): drop the get_response_headers docstring --- litellm/proxy/pass_through_endpoints/pass_through_endpoints.py | 2 -- 1 file changed, 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 e5281f452e8..0a29a71cd06 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -501,8 +501,6 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): custom_headers: Mapping[str, str] | None = None, guardrail_request_data: Sequence[object] = (), ) -> dict: - """The upstream response headers to relay, plus the call id, ``custom_headers`` and the guardrail headers - for what guardrails recorded in ``guardrail_request_data``.""" # Exclude headers that uvicorn writes itself (server, date) and # encoding/length headers that don't survive re-serialization. # If we forward the upstream's Server header, uvicorn adds its From 159a8e201fad7989c5298f97ba4bda1c16abeabc Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 14:15:53 -0700 Subject: [PATCH 08/27] fix(proxy): leave out guardrail response headers past 8 KB on pass-through routes A body naming thousands of guardrails put every name in x-litellm-applied-guardrails, so the response header block outgrew what clients accept (Node 16 KB, httpx 100 KB) and the client failed after the upstream had answered. A guardrail header that would take the guardrail headers past 8 KB is now left out, and the others stay. --- .../pass_through_endpoints.py | 14 ++++++++++++-- ...est_passthrough_applied_guardrails_header.py | 17 +++++++++++++++++ .../test_pass_through_endpoints.py | 15 +++++++++++++++ 3 files changed, 44 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 0a29a71cd06..3b0f9ff79bf 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -205,6 +205,8 @@ _GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( ) # h11's header field-value grammar: no NUL, line breaks or other whitespace beyond spaces and tabs between characters. _SENDABLE_HEADER_VALUE: Final = re.compile(rb"(?:[^\x00\s]+(?:[ \t]+[^\x00\s]+)*)?") +# Half of the 16 KB response header limit in Node.js, the smallest common client limit, leaving room for the others. +_GUARDRAIL_HEADERS_MAX_BYTES: Final = 8192 def _is_mergeable_client_metadata(key: str, value: object) -> bool: @@ -244,6 +246,11 @@ def _is_sendable_header_value(value: str) -> bool: ) +def _with_header_in_budget(kept: Mapping[str, str], header: tuple[str, str]) -> Mapping[str, str]: + size: Final = sum(len(name) + len(value) for name, value in (*kept.items(), header)) + return MappingProxyType({**kept, header[0]: header[1]}) if size <= _GUARDRAIL_HEADERS_MAX_BYTES else kept + + def _guardrail_response_headers( parsed_body: object = None, kwargs: object = None, post_call_data: object = None ) -> Mapping[str, str]: @@ -251,7 +258,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. + 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. """ scopes: Final = ( as_str_mapping(parsed_body) or {}, @@ -270,7 +278,9 @@ def _guardrail_response_headers( 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 {}) - return MappingProxyType({name: value for name, value in headers.items() if _is_sendable_header_value(value)}) + sendable: Final = ((name, value) for name, value in headers.items() if _is_sendable_header_value(value)) + no_headers: Final[Mapping[str, str]] = MappingProxyType({}) + return reduce(_with_header_in_budget, sendable, no_headers) class _RegisteredPassThroughRoute(TypedDict): diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py index b4c05f6a1ef..df6fefd26a4 100644 --- a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -292,3 +292,20 @@ def test_header_value_with_a_line_break_is_left_out(rig: HeaderRig, suffix: str) assert response.headers.get("x-litellm-applied-policies") == TAG_POLICY assert "x-litellm-policy-sources" not in response.headers assert "x-injected" not in response.headers + + +@pytest.mark.parametrize("stream", [False, True]) +def test_response_to_a_body_naming_thousands_of_guardrails_stays_readable(rig: HeaderRig, stream: bool) -> None: + gateway: Final = rig.owned.gateway + body: Final[dict[str, JsonValue]] = { + **_route_body(OPENAI_ROUTE), + "stream": stream, + "guardrails": [f"caller-named-{index}" for index in range(10_000)], + "metadata": {"tags": [f"{POLICY_TAG_PREFIX}prod"]}, + } + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, body) + + assert response.status_code == 200, response.text + assert HEADER not in response.headers + assert response.headers.get("x-litellm-applied-policies") == TAG_POLICY 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 1d9a3be5e2f..5db94e7bcea 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 @@ -12125,3 +12125,18 @@ class TestGuardrailResponseHeaders: assert headers == {"x-litellm-applied-policies": "tag-policy"} assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 + + def test_guardrail_headers_past_the_size_budget_are_left_out_so_a_client_can_read_the_response(self): + names: Final = [f"caller-named-{index}" for index in range(10_000)] + headers: Final = _guardrail_response_headers( + {}, {"litellm_params": {"metadata": {"applied_guardrails": names, "applied_policies": ["tag-policy"]}}} + ) + server: Final = h11.Connection(h11.SERVER) + server.receive_data(b"GET / HTTP/1.1\r\nhost: proxy\r\n\r\n") + server.next_event() + client: Final = h11.Connection(h11.CLIENT) + client.send(h11.Request(method="GET", target="/", headers=[("host", "proxy")])) + client.receive_data(server.send(h11.Response(status_code=200, headers=[*headers.items()])) or b"") + + assert headers == {"x-litellm-applied-policies": "tag-policy"} + assert isinstance(client.next_event(), h11.Response) From 0a94cfeb0b0aad873d3c13221c1c3cad44bb8023 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 10:53:32 -0800 Subject: [PATCH 09/27] perf(proxy): skip the sendability scan for a guardrail header past the size budget A header over the budget is left out anyway, so scanning it character by character only stalled the event loop on a caller-sized guardrail list. --- .../pass_through_endpoints.py | 6 +++++- .../test_pass_through_endpoints.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index a7dc7bdddcb..0437e5450a0 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -284,7 +284,11 @@ def _guardrail_response_headers( 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 {}) - sendable: Final = ((name, value) for name, value in headers.items() if _is_sendable_header_value(value)) + sendable: Final = ( + (name, value) + for name, value in headers.items() + if len(value) <= _GUARDRAIL_HEADERS_MAX_BYTES and _is_sendable_header_value(value) + ) no_headers: Final[Mapping[str, str]] = MappingProxyType({}) return reduce(_with_header_in_budget, sendable, no_headers) 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 41769050624..e47a2634663 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 @@ -6,6 +6,7 @@ import json import logging import os import sys +import time import uuid import zlib from collections.abc import AsyncIterator, Callable, Mapping, Sequence @@ -13724,6 +13725,23 @@ class TestGuardrailResponseHeaders: assert headers == {"x-litellm-applied-policies": "tag-policy"} assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 + def test_guardrail_header_past_the_size_budget_is_left_out_without_scanning_its_characters(self): + names: Final = [f"g{index}" for index in range(300_000)] + + def _best_cpu_seconds(run: Callable[[], object]) -> float: + def _timed() -> float: + started: Final = time.process_time() + run() + return time.process_time() - started + + return min(_timed() for _ in range(3)) + + metadata: Final = {"metadata": {"applied_guardrails": names}} + assert _guardrail_response_headers(metadata) == {} + assert _best_cpu_seconds(lambda: _guardrail_response_headers(metadata)) < 15 * _best_cpu_seconds( + lambda: ",".join(names) + ) + def test_guardrail_headers_past_the_size_budget_are_left_out_so_a_client_can_read_the_response(self): names: Final = [f"caller-named-{index}" for index in range(10_000)] headers: Final = _guardrail_response_headers( From c23429ccde910d249483b00b0c3891a19544569c Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 12:14:11 -0800 Subject: [PATCH 10/27] fix(proxy): keep guardrail headers readable by strict clients The header filter followed h11's grammar, which lets control characters through that Node's parser rejects after the provider answered. It now follows RFC 9110. The 8 KB budget covered one set of headers at a time, so a value kept from the set built before post-call could push the merged headers past it. The final set now counts against the same budget as the earlier one. --- .../pass_through_endpoints.py | 18 ++++++++--- .../test_pass_through_endpoints.py | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 4a99b51d527..ff2c5bcad6c 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -221,8 +221,9 @@ _GUARDRAIL_HEADER_METADATA_FIELDS: Final = frozenset( GUARDRAIL_SCAN_METADATA_METADATA_KEY, ) ) -# h11's header field-value grammar: no NUL, line breaks or other whitespace beyond spaces and tabs between characters. -_SENDABLE_HEADER_VALUE: Final = re.compile(rb"(?:[^\x00\s]+(?:[ \t]+[^\x00\s]+)*)?") +# 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]+)*)?") # Half of the 16 KB response header limit in Node.js, the smallest common client limit, leaving room for the others. _GUARDRAIL_HEADERS_MAX_BYTES: Final = 8192 @@ -532,6 +533,7 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): litellm_call_id: str | None = None, custom_headers: Mapping[str, str] | None = None, guardrail_request_data: Sequence[object] = (), + earlier_guardrail_headers: Mapping[str, str] = MappingProxyType({}), ) -> dict: # Exclude headers that uvicorn writes itself (server, date) and # encoding/length headers that don't survive re-serialization. @@ -557,7 +559,14 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): key: value for key, value in custom_headers.items() if key.lower() not in excluded_headers } return_headers.update(sanitized_custom_headers) - return_headers.update(_guardrail_response_headers(*guardrail_request_data)) + guardrail_headers: Final = _guardrail_response_headers(*guardrail_request_data) + return_headers.update( + reduce( + _with_header_in_budget, + (header for header in earlier_guardrail_headers.items() if header[0] not in guardrail_headers), + guardrail_headers, + ) + ) return return_headers @@ -2353,7 +2362,8 @@ async def pass_through_request( response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, - custom_headers={**custom_headers, **upstream_guardrail_headers}, + custom_headers=custom_headers, + earlier_guardrail_headers=upstream_guardrail_headers, guardrail_request_data=(_parsed_body, kwargs, post_call_guardrail_data), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts ) emitted_call_id: Final = ( 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 add21b7def8..a1c0f5767ae 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 @@ -13742,6 +13742,37 @@ class TestGuardrailResponseHeaders: lambda: ",".join(names) ) + @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"]}}) == {} + + def test_guardrail_name_with_a_latin1_character_and_a_space_stays_in_the_header(self): + assert _guardrail_response_headers({"metadata": {"applied_guardrails": ["caf\xe9 guard"]}}) == { + "x-litellm-applied-guardrails": "caf\xe9 guard" + } + + def test_guardrail_headers_stay_in_budget_across_the_earlier_and_the_final_set(self): + final_value: Final = "n" * 8000 + response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + httpx.Headers(), + guardrail_request_data=({"metadata": {"applied_guardrails": [final_value]}},), + earlier_guardrail_headers={"x-litellm-policy-sources": "s" * 7900, "x-litellm-applied-guardrails": "old"}, + ) + + assert response_headers == {"x-litellm-applied-guardrails": final_value} + + def test_earlier_guardrail_header_within_the_budget_is_kept(self): + response_headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + httpx.Headers(), + guardrail_request_data=({"metadata": {"applied_guardrails": ["post-call"]}},), + earlier_guardrail_headers={"x-litellm-policy-sources": "p=q"}, + ) + + assert response_headers == { + "x-litellm-applied-guardrails": "post-call", + "x-litellm-policy-sources": "p=q", + } + def test_guardrail_headers_past_the_size_budget_are_left_out_so_a_client_can_read_the_response(self): names: Final = [f"caller-named-{index}" for index in range(10_000)] headers: Final = _guardrail_response_headers( From eea61c7eacfdeab88c033a6192abafa925863ab3 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 12:59:23 -0800 Subject: [PATCH 11/27] test(proxy): check the guardrail header size gate without timing it --- .../test_pass_through_endpoints.py | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 a1c0f5767ae..fa3a2601dc3 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 @@ -6,7 +6,6 @@ import json import logging import os import sys -import time import uuid import zlib from collections.abc import AsyncIterator, Callable, Mapping, Sequence @@ -13726,21 +13725,15 @@ class TestGuardrailResponseHeaders: assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 def test_guardrail_header_past_the_size_budget_is_left_out_without_scanning_its_characters(self): - names: Final = [f"g{index}" for index in range(300_000)] + names: Final = [f"g{index}" for index in range(2_000)] - def _best_cpu_seconds(run: Callable[[], object]) -> float: - def _timed() -> float: - started: Final = time.process_time() - run() - return time.process_time() - started + with patch( + "litellm.proxy.pass_through_endpoints.pass_through_endpoints._is_sendable_header_value" + ) as is_sendable: + headers: Final = _guardrail_response_headers({"metadata": {"applied_guardrails": names}}) - return min(_timed() for _ in range(3)) - - metadata: Final = {"metadata": {"applied_guardrails": names}} - assert _guardrail_response_headers(metadata) == {} - assert _best_cpu_seconds(lambda: _guardrail_response_headers(metadata)) < 15 * _best_cpu_seconds( - lambda: ",".join(names) - ) + assert headers == {} + is_sendable.assert_not_called() @pytest.mark.parametrize("control", ["\x01", "\x1f", "\x7f", "\x00", "\n"]) def test_guardrail_name_with_a_control_character_stays_out_of_the_header(self, control): From e5efa5664bf7a84ead11b5cc8afc5235234fb334 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 13:43:46 -0800 Subject: [PATCH 12/27] test(proxy): name a policy pipeline's step in the pass-through applied guardrails header --- .../authorization/test_passthrough_auth_guardrails.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/authorization/test_passthrough_auth_guardrails.py b/tests/integration/authorization/test_passthrough_auth_guardrails.py index d292fe41059..e8d38689547 100644 --- a/tests/integration/authorization/test_passthrough_auth_guardrails.py +++ b/tests/integration/authorization/test_passthrough_auth_guardrails.py @@ -615,6 +615,7 @@ def test_post_call_policy_pipeline_decides_what_a_flagged_step_does(rig: AuthGua assert response.status_code == 200, response.text assert len(rig.key_post_call_vendor.drain()) == 1 + assert KEY_POST_CALL in response.headers["x-litellm-applied-guardrails"].split(",") def test_pre_call_policy_pipeline_relays_the_provider_bytes(rig: AuthGuardrailRig) -> None: From c4767ccf2b8543fa290934c817cc4b0e33f5078c Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 14:02:15 -0800 Subject: [PATCH 13/27] fix(proxy): read the guardrail headers of a modified pass-through response before the failure hook The failure hook can rebuild the metadata it shares with the post-call data, which dropped the guardrails that ran from x-litellm-applied-guardrails on a modified response. --- .../pass_through_endpoints.py | 3 +- .../test_pass_through_endpoints.py | 33 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index f278b7dd6ed..793d07bfdde 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2428,6 +2428,7 @@ async def pass_through_request( e.guardrail_name, str(e.message or "")[:200], ) + modified_response_headers: Final = _guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data) # pyright: ignore[reportUnknownArgumentType] # untyped request dicts try: await proxy_logging_obj.post_call_failure_hook( user_api_key_dict=user_api_key_dict, @@ -2460,7 +2461,7 @@ async def pass_through_request( media_type="application/json", headers={ "x-litellm-call-id": litellm_call_id, - **_guardrail_response_headers(_parsed_body, kwargs, post_call_guardrail_data), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + **modified_response_headers, }, ) except Exception as e: 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 7e6f434f5d4..2b0932fb8fd 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 @@ -8,7 +8,7 @@ import os import sys import uuid import zlib -from collections.abc import AsyncIterator, Callable, Mapping, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from contextlib import ExitStack, contextmanager from dataclasses import dataclass from datetime import datetime @@ -13312,6 +13312,8 @@ class TestInheritAuthGuardrails: inherit: bool, user_api_key_dict: UserAPIKeyAuth, guardrails: tuple[CustomGuardrail, ...] = (), + post_call_side_effect: Callable[..., Awaitable[None]] | None = None, + failure_side_effect: Callable[..., Awaitable[None]] | None = None, ) -> tuple[Response, list[dict[str, object]], AsyncMock]: pre_call_data: Final[list[dict[str, object]]] = [] @@ -13321,7 +13323,10 @@ class TestInheritAuthGuardrails: proxy_logging: Final = MagicMock() proxy_logging.pre_call_hook = AsyncMock(side_effect=_record_pre_call) - proxy_logging.post_call_success_hook = AsyncMock(return_value={"id": "chatcmpl-1"}) + proxy_logging.post_call_success_hook = AsyncMock( + return_value={"id": "chatcmpl-1"}, side_effect=post_call_side_effect + ) + proxy_logging.post_call_failure_hook = AsyncMock(side_effect=failure_side_effect) proxy_logging.post_call_response_headers_hook = AsyncMock(return_value={}) upstream: Final = httpx.Response( status_code=200, @@ -13373,6 +13378,30 @@ class TestInheritAuthGuardrails: def _chat_body(**extra: object) -> dict[str, object]: return {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}], **extra} + @pytest.mark.asyncio + async def test_modified_response_names_a_guardrail_the_failure_hook_drops(self): + async def _modify_response(**kwargs: object) -> None: + data: Final = cast(dict[str, dict[str, object]], kwargs["data"]) # cast-ok: the hook data the handler built + data["metadata"]["applied_guardrails"] = ["key-guard"] + raise ModifyResponseException( + message="replaced", model="gpt-4o", request_data=data, guardrail_name="key-guard" + ) + + async def _drop_guardrail_fields(**kwargs: object) -> None: + cast(dict[str, dict[str, object]], kwargs["request_data"])["metadata"].pop("applied_guardrails", None) # cast-ok: payload + + response, _, _ = await self._run_with_response( + self._chat_body(), + inherit=True, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", metadata={"guardrails": ["key-guard"]}), + guardrails=(self._guardrail("key-guard", GuardrailEventHooks.post_call),), + post_call_side_effect=_modify_response, + failure_side_effect=_drop_guardrail_fields, + ) + + assert response.status_code == 200 + assert response.headers["x-litellm-applied-guardrails"] == "key-guard" + @pytest.mark.asyncio async def test_key_team_and_project_guardrails_reach_pre_and_post_call(self): auth: Final = UserAPIKeyAuth( From bdfb8d58476a0ee3fdecf59b1195a24b38e83187 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 14:03:51 -0800 Subject: [PATCH 14/27] test(proxy): check the guardrail header size budget by its result --- .../test_pass_through_endpoints.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 2b0932fb8fd..1ee3ee6c0a9 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,16 +13790,10 @@ class TestGuardrailResponseHeaders: assert headers == {"x-litellm-applied-policies": "tag-policy"} assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 - def test_guardrail_header_past_the_size_budget_is_left_out_without_scanning_its_characters(self): + def test_guardrail_header_past_the_size_budget_is_left_out(self): names: Final = [f"g{index}" for index in range(2_000)] - with patch( - "litellm.proxy.pass_through_endpoints.pass_through_endpoints._is_sendable_header_value" - ) as is_sendable: - headers: Final = _guardrail_response_headers({"metadata": {"applied_guardrails": names}}) - - assert headers == {} - is_sendable.assert_not_called() + assert _guardrail_response_headers({"metadata": {"applied_guardrails": names}}) == {} @pytest.mark.parametrize("control", ["\x01", "\x1f", "\x7f", "\x00", "\n"]) def test_guardrail_name_with_a_control_character_stays_out_of_the_header(self, control): From a8dc662b933fce540c99b1d9007adc212cc5fe5c Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 14:27:44 -0800 Subject: [PATCH 15/27] fix(proxy): keep post-call guardrail headers from the pass before a successful reply --- .../pass_through_endpoints.py | 7 +- .../test_passthrough_auth_guardrails.py | 17 ++- .../test_pass_through_endpoints.py | 103 +++++++++--------- 3 files changed, 70 insertions(+), 57 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 793d07bfdde..7b28b25ffc6 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1444,8 +1444,11 @@ async def _log_passthrough_upstream_failure_keeping_guardrail_headers( kwargs: Mapping[str, object] | None, ) -> Mapping[str, str]: """Log an upstream error like ``_log_passthrough_upstream_failure`` and return the guardrail response headers, - read before the failure hooks rebuild ``kwargs``' metadata without the guardrail fields.""" - guardrail_headers: Final = _guardrail_response_headers(parsed_body, kwargs) + read before the failure hooks rebuild ``kwargs``' metadata without the guardrail fields. A successful response + has no failure hooks to read around, so it returns none.""" + guardrail_headers: Final = ( + _guardrail_response_headers(parsed_body, kwargs) if response.status_code >= 400 else MappingProxyType({}) + ) await _log_passthrough_upstream_failure( response=response, user_api_key_dict=user_api_key_dict, diff --git a/tests/integration/authorization/test_passthrough_auth_guardrails.py b/tests/integration/authorization/test_passthrough_auth_guardrails.py index e8d38689547..819a2653f6b 100644 --- a/tests/integration/authorization/test_passthrough_auth_guardrails.py +++ b/tests/integration/authorization/test_passthrough_auth_guardrails.py @@ -47,6 +47,7 @@ PHONE_POST_CALL: Final = "passthrough-auth-phone-post-call" PIPELINE_BLOCKS: Final = "passthrough-pipeline-blocks-the-response" PIPELINE_ALLOWS: Final = "passthrough-pipeline-allows-the-response" PIPELINE_BEFORE_THE_CALL: Final = "passthrough-pipeline-before-the-call" +PIPELINE_MODIFIES: Final = "passthrough-pipeline-modifies-the-response" _CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml" _JSON: Final = TypeAdapter(dict[str, JsonValue]) _JSON_LIST: Final = TypeAdapter(list[JsonValue]) @@ -181,10 +182,11 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I PIPELINE_BLOCKS: _pipeline_policy("post_call", KEY_POST_CALL, "block"), PIPELINE_ALLOWS: _pipeline_policy("post_call", KEY_POST_CALL, "allow"), PIPELINE_BEFORE_THE_CALL: _pipeline_policy("pre_call", GUARDRAIL, "block"), + PIPELINE_MODIFIES: _pipeline_policy("post_call", KEY_POST_CALL, "modify_response"), }, "policy_attachments": [ {"policy": policy, "tags": [policy]} - for policy in (PIPELINE_BLOCKS, PIPELINE_ALLOWS, PIPELINE_BEFORE_THE_CALL) + for policy in (PIPELINE_BLOCKS, PIPELINE_ALLOWS, PIPELINE_BEFORE_THE_CALL, PIPELINE_MODIFIES) ], "model_list": [ *_JSON_LIST.validate_python(config.get("model_list") or []), @@ -618,6 +620,19 @@ def test_post_call_policy_pipeline_decides_what_a_flagged_step_does(rig: AuthGua assert KEY_POST_CALL in response.headers["x-litellm-applied-guardrails"].split(",") +def test_response_a_policy_pipeline_modifies_names_the_step_that_ran(rig: AuthGuardrailRig) -> None: + gateway: Final = rig.owned.gateway + with gateway.scenario() as scenario: + key: Final = scenario.key() + _drain(rig) + + response: Final = _send(gateway, OPENAI_ROUTE, _tagged_body(OPENAI_ROUTE, PIPELINE_MODIFIES), key) + + assert response.status_code == 200, response.text + assert response.json()["error"]["type"] == "content_filter" + assert KEY_POST_CALL in response.headers["x-litellm-applied-guardrails"].split(",") + + def test_pre_call_policy_pipeline_relays_the_provider_bytes(rig: AuthGuardrailRig) -> None: gateway: Final = rig.owned.gateway with gateway.scenario() as scenario: 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 1ee3ee6c0a9..6d22cb8f739 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 @@ -8,7 +8,7 @@ import os import sys import uuid import zlib -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import AsyncIterator, Callable, Mapping, Sequence from contextlib import ExitStack, contextmanager from dataclasses import dataclass from datetime import datetime @@ -64,6 +64,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( _build_passthrough_failure_request_payload, _registered_pass_through_routes, _guardrail_response_headers, + _log_passthrough_upstream_failure_keeping_guardrail_headers, _strip_client_guardrail_fields, _truncate_upstream_error_body, _update_metadata_with_tags_in_header, @@ -503,38 +504,61 @@ async def test_pass_through_request_preserves_proxy_exception_status(): assert exc.value.param == "limit" -@pytest.mark.asyncio -async def test_guardrail_proxy_exception_carries_the_guardrails_that_ran(): - blocked: Final = ProxyException(message="blocked by guardrail", type="None", param="None", code=400) - - async def record_then_block( - user_api_key_dict: UserAPIKeyAuth, data: dict[str, object], call_type: str, endpoint_type: object +class _RecordsThenRaisesProxyException(CustomGuardrail): + async def async_pre_call_hook( + self, + user_api_key_dict: UserAPIKeyAuth, + cache: DualCache, + data: dict[str, object], + call_type: str, ) -> None: data["metadata"] = {"applied_guardrails": ["passed-guard"]} # mutable-ok: a guardrail records on the live body - raise blocked + raise ProxyException(message="blocked by guardrail", type="None", param="None", code=400) - with patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging: - mock_proxy_logging.post_call_failure_hook = AsyncMock() - mock_proxy_logging.pre_call_hook = AsyncMock(side_effect=record_then_block) - mock_request = MagicMock(spec=Request) - mock_request.method = "POST" - mock_request.body = AsyncMock(return_value=b'{"model": "gpt-5.6", "messages": []}') - mock_request.headers = Headers({"content-type": "application/json"}) - mock_request.query_params = QueryParams({}) +@pytest.mark.asyncio +async def test_guardrail_proxy_exception_carries_the_guardrails_that_ran(): + guardrail: Final = _RecordsThenRaisesProxyException( + guardrail_name="passed-guard", event_hook=GuardrailEventHooks.pre_call, default_on=True + ) + mock_request: Final = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.body = AsyncMock(return_value=b'{"model": "gpt-5.6", "messages": []}') + mock_request.headers = Headers({"content-type": "application/json"}) + mock_request.query_params = QueryParams({}) - with pytest.raises(ProxyException) as exc: - await pass_through_request( - request=mock_request, - target="http://test.com/v1/chat/completions", - custom_headers={}, - user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), - ) + with ( + pytest.MonkeyPatch.context() as litellm_patch, + patch( # test-quality-ok: proxy_logging_obj is a proxy_server module global read inside pass_through_request + "litellm.proxy.proxy_server.proxy_logging_obj", ProxyLogging(user_api_key_cache=DualCache()) + ), + pytest.raises(ProxyException) as exc, + ): + litellm_patch.setattr(litellm, "callbacks", [guardrail]) + await pass_through_request( + request=mock_request, + target="http://test.com/v1/chat/completions", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) - assert exc.value is blocked assert exc.value.headers["x-litellm-applied-guardrails"] == "passed-guard" +@pytest.mark.asyncio +async def test_successful_response_keeps_no_guardrail_headers_from_before_the_post_call_pass(): + headers: Final = await _log_passthrough_upstream_failure_keeping_guardrail_headers( + response=httpx.Response(status_code=200), + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + request_payload={}, + logging_obj=MagicMock(), + parsed_body={"metadata": {"applied_guardrails": ["pre-call-guard"]}}, + kwargs=None, + ) + + assert headers == {} + + def test_is_langfuse_route(): """ Test that the is_langfuse_route method correctly identifies Langfuse routes @@ -13312,8 +13336,6 @@ class TestInheritAuthGuardrails: inherit: bool, user_api_key_dict: UserAPIKeyAuth, guardrails: tuple[CustomGuardrail, ...] = (), - post_call_side_effect: Callable[..., Awaitable[None]] | None = None, - failure_side_effect: Callable[..., Awaitable[None]] | None = None, ) -> tuple[Response, list[dict[str, object]], AsyncMock]: pre_call_data: Final[list[dict[str, object]]] = [] @@ -13323,10 +13345,7 @@ class TestInheritAuthGuardrails: proxy_logging: Final = MagicMock() proxy_logging.pre_call_hook = AsyncMock(side_effect=_record_pre_call) - proxy_logging.post_call_success_hook = AsyncMock( - return_value={"id": "chatcmpl-1"}, side_effect=post_call_side_effect - ) - proxy_logging.post_call_failure_hook = AsyncMock(side_effect=failure_side_effect) + proxy_logging.post_call_success_hook = AsyncMock(return_value={"id": "chatcmpl-1"}) proxy_logging.post_call_response_headers_hook = AsyncMock(return_value={}) upstream: Final = httpx.Response( status_code=200, @@ -13378,30 +13397,6 @@ class TestInheritAuthGuardrails: def _chat_body(**extra: object) -> dict[str, object]: return {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}], **extra} - @pytest.mark.asyncio - async def test_modified_response_names_a_guardrail_the_failure_hook_drops(self): - async def _modify_response(**kwargs: object) -> None: - data: Final = cast(dict[str, dict[str, object]], kwargs["data"]) # cast-ok: the hook data the handler built - data["metadata"]["applied_guardrails"] = ["key-guard"] - raise ModifyResponseException( - message="replaced", model="gpt-4o", request_data=data, guardrail_name="key-guard" - ) - - async def _drop_guardrail_fields(**kwargs: object) -> None: - cast(dict[str, dict[str, object]], kwargs["request_data"])["metadata"].pop("applied_guardrails", None) # cast-ok: payload - - response, _, _ = await self._run_with_response( - self._chat_body(), - inherit=True, - user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", metadata={"guardrails": ["key-guard"]}), - guardrails=(self._guardrail("key-guard", GuardrailEventHooks.post_call),), - post_call_side_effect=_modify_response, - failure_side_effect=_drop_guardrail_fields, - ) - - assert response.status_code == 200 - assert response.headers["x-litellm-applied-guardrails"] == "key-guard" - @pytest.mark.asyncio async def test_key_team_and_project_guardrails_reach_pre_and_post_call(self): auth: Final = UserAPIKeyAuth( From 4ec3750fb99a2235dd0f47ec3e8a4701e62180c9 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 14:44:12 -0800 Subject: [PATCH 16/27] test(proxy): keep only the raising call inside pytest.raises --- .../test_pass_through_endpoints.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 6d22cb8f739..492d0d9c4c2 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 @@ -532,15 +532,15 @@ async def test_guardrail_proxy_exception_carries_the_guardrails_that_ran(): patch( # test-quality-ok: proxy_logging_obj is a proxy_server module global read inside pass_through_request "litellm.proxy.proxy_server.proxy_logging_obj", ProxyLogging(user_api_key_cache=DualCache()) ), - pytest.raises(ProxyException) as exc, ): litellm_patch.setattr(litellm, "callbacks", [guardrail]) - await pass_through_request( - request=mock_request, - target="http://test.com/v1/chat/completions", - custom_headers={}, - user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), - ) + with pytest.raises(ProxyException) as exc: + await pass_through_request( + request=mock_request, + target="http://test.com/v1/chat/completions", + custom_headers={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"), + ) assert exc.value.headers["x-litellm-applied-guardrails"] == "passed-guard" From cee2c79a979941a61225778db9c90a65bac340e9 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 15:05:03 -0800 Subject: [PATCH 17/27] fix(proxy): annotate the guardrail headers kept across upstream failure logging --- .../proxy/pass_through_endpoints/pass_through_endpoints.py | 4 ++-- 1 file changed, 2 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 7b28b25ffc6..451d2ae22af 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1446,8 +1446,8 @@ async def _log_passthrough_upstream_failure_keeping_guardrail_headers( """Log an upstream error like ``_log_passthrough_upstream_failure`` and return the guardrail response headers, read before the failure hooks rebuild ``kwargs``' metadata without the guardrail fields. A successful response has no failure hooks to read around, so it returns none.""" - guardrail_headers: Final = ( - _guardrail_response_headers(parsed_body, kwargs) if response.status_code >= 400 else MappingProxyType({}) + guardrail_headers: Final[Mapping[str, str]] = ( + _guardrail_response_headers(parsed_body, kwargs) if response.status_code >= 400 else {} ) await _log_passthrough_upstream_failure( response=response, From 21fbb82cf8a32f9819cfda5760de4d1e765b5453 Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 17:44:46 -0800 Subject: [PATCH 18/27] 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"]}}) == {} From a9670e88dbb2aa783dba2424eb8b4b9e405b8c2e Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 23:02:16 -0800 Subject: [PATCH 19/27] 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"]}}) == {} From 3b7d942bad373ec655901b4490c0e015a6012c7c Mon Sep 17 00:00:00 2001 From: yucheng Date: Mon, 2 Nov 2026 23:06:48 -0800 Subject: [PATCH 20/27] refactor(proxy): drop a docstring that repeats the pass-through header try block --- litellm/proxy/pass_through_endpoints/pass_through_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 1384884c8ca..9e29c63b551 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -332,7 +332,6 @@ def _recorded_guardrail_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: From ef3b24fc14ed2ebfbad7340c4f6fe014d75851ec Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 04:19:18 -0800 Subject: [PATCH 21/27] fix(proxy): keep guardrail headers from pushing a response past a client's header limit --- .../pass_through_endpoints.py | 39 +++++++++++++++---- .../test_pass_through_endpoints.py | 37 ++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 62568420f37..b73089db6c8 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -230,6 +230,10 @@ _JOINED_HEADER_METADATA_FIELDS: Final = frozenset( _SENDABLE_HEADER_VALUE: Final = re.compile(rb"(?:[\x21-\x7e\x80-\xff]+(?:[ \t]+[\x21-\x7e\x80-\xff]+)*)?") # Half of the 16 KB response header limit in Node.js, the smallest common client limit, leaving room for the others. _GUARDRAIL_HEADERS_MAX_BYTES: Final = 8192 +# Node.js's limit for a whole response, less 1 KB for the status line and what the server adds. +_RESPONSE_HEADERS_MAX_BYTES: Final = 15360 +# The ": " and the line ending around each header on the wire. +_HEADER_FRAMING_BYTES: Final = 4 def _is_mergeable_client_metadata(key: str, value: object) -> bool: @@ -285,9 +289,29 @@ def _is_sendable_header_value(value: str) -> bool: ) -def _with_header_in_budget(kept: Mapping[str, str], header: tuple[str, str]) -> Mapping[str, str]: +def _with_header_in_budget( + kept: Mapping[str, str], header: tuple[str, str], budget: int = _GUARDRAIL_HEADERS_MAX_BYTES +) -> Mapping[str, str]: size: Final = sum(len(name) + len(value) for name, value in (*kept.items(), header)) - return MappingProxyType({**kept, header[0]: header[1]}) if size <= _GUARDRAIL_HEADERS_MAX_BYTES else kept + return MappingProxyType({**kept, header[0]: header[1]}) if size <= budget else kept + + +def _guardrail_headers_with_room( + guardrail_headers: Mapping[str, str], response_headers: Mapping[str, str] +) -> Mapping[str, str]: + """``guardrail_headers``, without one the response's other headers leave no room for, so adding them can't cost a + client the response""" + others: Final = sum( + len(name) + len(value) + _HEADER_FRAMING_BYTES + for name, value in response_headers.items() + if name not in guardrail_headers + ) + no_headers: Final[Mapping[str, str]] = MappingProxyType({}) + return reduce( + partial(_with_header_in_budget, budget=_RESPONSE_HEADERS_MAX_BYTES - others), + guardrail_headers.items(), + no_headers, + ) def _recorded_guardrail_headers( @@ -595,13 +619,12 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils): } return_headers.update(sanitized_custom_headers) guardrail_headers: Final = _guardrail_response_headers(*guardrail_request_data) - return_headers.update( - reduce( - _with_header_in_budget, - (header for header in earlier_guardrail_headers.items() if header[0] not in guardrail_headers), - guardrail_headers, - ) + combined_guardrail_headers: Final = reduce( + _with_header_in_budget, + (header for header in earlier_guardrail_headers.items() if header[0] not in guardrail_headers), + guardrail_headers, ) + return_headers.update(_guardrail_headers_with_room(combined_guardrail_headers, return_headers)) return return_headers 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 6cf010e67d0..9a0ab4d3d99 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 @@ -13851,6 +13851,43 @@ class TestGuardrailResponseHeaders: assert headers == {"x-litellm-applied-policies": "tag-policy"} assert h11.Response(status_code=200, headers=[*headers.items()]).status_code == 200 + @pytest.mark.parametrize( + ("upstream_bytes", "names", "kept"), + [(12000, 500, False), (12000, 1, True), (8000, 500, True), (15340, 1, False)], + ids=["large-and-large", "large-and-small", "both-within-the-limit", "upstream-at-the-limit"], + ) + def test_guardrail_headers_leave_the_whole_response_within_a_clients_limit(self, upstream_bytes, names, kept): + upstream: Final = "u" * upstream_bytes + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"x-upstream": upstream}), + guardrail_request_data=( + {}, + {"litellm_params": {"metadata": {"applied_guardrails": [f"guardrail-{i:03d}" for i in range(names)]}}}, + ), + ) + + assert headers["x-upstream"] == upstream + assert ("x-litellm-applied-guardrails" in headers) is kept + assert sum(len(name) + len(value) for name, value in headers.items()) <= 15360 or not kept + + def test_many_small_upstream_headers_count_with_their_framing(self): + upstream: Final = httpx.Headers({f"x-{index:04d}": "v" for index in range(1_500)}) + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=upstream, + guardrail_request_data=({}, {"litellm_params": {"metadata": {"applied_guardrails": ["guard"]}}}), + ) + + assert "x-litellm-applied-guardrails" not in headers + assert len(headers) == 1_500 + + def test_an_upstream_guardrail_header_the_proxy_replaces_takes_no_room(self): + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers({"x-litellm-applied-guardrails": "u" * 15_400}), + guardrail_request_data=({}, {"litellm_params": {"metadata": {"applied_guardrails": ["guard"]}}}), + ) + + assert headers["x-litellm-applied-guardrails"] == "guard" + def test_guardrail_header_past_the_size_budget_is_left_out(self): names: Final = [f"g{index}" for index in range(2_000)] From 0c07a613a7a14c8795894de9ed9134214f40a25d Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 04:53:59 -0800 Subject: [PATCH 22/27] fix(proxy): leave room for callback headers on streamed pass-through responses --- .../pass_through_endpoints.py | 33 +++++++++++- ...t_passthrough_applied_guardrails_header.py | 53 ++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index f202e2bd407..a03b436d47c 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -234,6 +234,15 @@ _GUARDRAIL_HEADERS_MAX_BYTES: Final = 8192 _RESPONSE_HEADERS_MAX_BYTES: Final = 15360 # The ": " and the line ending around each header on the wire. _HEADER_FRAMING_BYTES: Final = 4 +_GUARDRAIL_HEADER_NAMES: Final = frozenset( + ( + "x-litellm-applied-guardrails", + "x-litellm-applied-policies", + "x-litellm-guardrail-scan-id", + "x-litellm-guardrail-scan-metadata", + "x-litellm-policy-sources", + ) +) def _is_mergeable_client_metadata(key: str, value: object) -> bool: @@ -314,6 +323,20 @@ def _guardrail_headers_with_room( ) +def _with_callback_headers( + response_headers: Mapping[str, str], callback_headers: Mapping[str, str] +) -> dict[str, str]: # mutable-ok: the starlette response takes a plain dict + """``response_headers`` with a callback's headers added, less a guardrail header they then leave no room for""" + merged: Final = {**response_headers, **callback_headers} + guardrail_headers: Final = { + name: value + for name, value in response_headers.items() + if name in _GUARDRAIL_HEADER_NAMES and name not in callback_headers + } + kept: Final = _guardrail_headers_with_room(guardrail_headers, merged) + return {name: value for name, value in merged.items() if name not in guardrail_headers or name in kept} + + def _recorded_guardrail_headers( parsed_body: object = None, kwargs: object = None, post_call_data: object = None ) -> Mapping[str, str]: @@ -2129,7 +2152,10 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers.update(callback_headers) + _response_headers = _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept + _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict + callback_headers, + ) return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( @@ -2234,7 +2260,10 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers.update(callback_headers) + _response_headers = _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept + _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict + callback_headers, + ) return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( diff --git a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py index df6fefd26a4..43ee159095f 100644 --- a/tests/integration/authorization/test_passthrough_applied_guardrails_header.py +++ b/tests/integration/authorization/test_passthrough_applied_guardrails_header.py @@ -28,6 +28,23 @@ HEADER: Final = "x-litellm-applied-guardrails" OPENAI_ROUTE: Final = "/openai/v1/chat/completions" ANTHROPIC_ROUTE: Final = "/anthropic/v1/messages" GEMINI_ROUTE: Final = "/gemini/v1beta/models/gemini-2.5-flash:generateContent" +CALLBACK_HEADERS_MODEL: Final = "passthrough-header-callback-headers" +UNREQUESTED_STREAM_MODEL: Final = "passthrough-header-callback-headers-unrequested-stream" +CALLBACK_HEADER: Final = "x-callback-padding" +CALLBACK_HEADER_VALUE: Final = "c" * 12_000 +_CALLBACK_HEADERS_MODULE: Final = f""" +from litellm.integrations.custom_logger import CustomLogger + + +class LargeHeadersHook(CustomLogger): + async def async_post_call_response_headers_hook(self, data, user_api_key_dict, response, *args, **kwargs): + if str(data.get("model")).startswith("{CALLBACK_HEADERS_MODEL}"): + return {{"{CALLBACK_HEADER}": "{CALLBACK_HEADER_VALUE}"}} + return None + + +proxy_handler_instance = LargeHeadersHook() +""" _CONFIG: Final = Path(__file__).resolve().parents[1] / "proxy_config.yaml" _JSON: Final = TypeAdapter(dict[str, JsonValue]) @@ -61,7 +78,12 @@ def _openai_reply(request: Request) -> Reply: status=404, body=json.dumps({"error": {"message": "model not found", "type": "invalid_request_error"}}).encode(), ) - return chat_reply("chatcmpl-passthrough-header", "gpt-5.6", "Hello", stream=payload.get("stream") is True) + return chat_reply( + "chatcmpl-passthrough-header", + "gpt-5.6", + "Hello", + stream=payload.get("stream") is True or payload.get("model") == UNREQUESTED_STREAM_MODEL, + ) def _anthropic_reply(_request: Request) -> Reply: @@ -100,11 +122,16 @@ def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> I } ) config: Final = _JSON.validate_python(yaml.safe_load(_CONFIG.read_text())) + (directory / "passthrough_large_headers_hook.py").write_text(_CALLBACK_HEADERS_MODULE) path: Final = directory / "passthrough-applied-guardrails-header.yaml" path.write_text( yaml.safe_dump( { **config, + "litellm_settings": { + **_JSON.validate_python(config.get("litellm_settings", {})), + "callbacks": ["passthrough_large_headers_hook.proxy_handler_instance"], + }, "guardrails": [ { "guardrail_name": name, @@ -294,6 +321,30 @@ def test_header_value_with_a_line_break_is_left_out(rig: HeaderRig, suffix: str) assert "x-injected" not in response.headers +@pytest.mark.parametrize( + ("model", "stream"), + [(CALLBACK_HEADERS_MODEL, True), (UNREQUESTED_STREAM_MODEL, False)], + ids=["requested-stream", "unrequested-stream"], +) +@pytest.mark.parametrize(("names", "kept"), [(300, False), (100, True)], ids=["no-room", "room"]) +def test_a_callback_header_on_a_stream_leaves_room_or_costs_the_guardrail_header( + rig: HeaderRig, model: str, stream: bool, names: int, kept: bool +) -> None: + gateway: Final = rig.owned.gateway + body: Final[dict[str, JsonValue]] = { + **_route_body(OPENAI_ROUTE), + "model": model, + "stream": stream, + "guardrails": [f"caller-named-{index}" for index in range(names)], + } + with gateway.scenario() as scenario: + response: Final = _send(gateway, scenario, OPENAI_ROUTE, body) + + assert response.status_code == 200, response.text + assert response.headers.get(CALLBACK_HEADER) == CALLBACK_HEADER_VALUE + assert (HEADER in response.headers) is kept + + @pytest.mark.parametrize("stream", [False, True]) def test_response_to_a_body_naming_thousands_of_guardrails_stays_readable(rig: HeaderRig, stream: bool) -> None: gateway: Final = rig.owned.gateway From 819abb47a666fff5b8933b24329623070f43f188 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 04:57:59 -0800 Subject: [PATCH 23/27] style(proxy): format the callback header call sites --- .../pass_through_endpoints.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index a03b436d47c..81f7176ba68 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2152,9 +2152,11 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers = _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept - _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict - callback_headers, + _response_headers = ( + _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept + _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict + callback_headers, + ) ) return StreamingResponse( @@ -2260,9 +2262,11 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers = _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept - _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict - callback_headers, + _response_headers = ( + _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept + _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict + callback_headers, + ) ) return StreamingResponse( From 263012bd9670ceb0c176f8afb140c9e785d0064f Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 05:01:43 -0800 Subject: [PATCH 24/27] refactor(proxy): add callback headers in place on streamed pass-through responses --- .../pass_through_endpoints.py | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 81f7176ba68..f029065cc41 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -323,18 +323,20 @@ def _guardrail_headers_with_room( ) -def _with_callback_headers( - response_headers: Mapping[str, str], callback_headers: Mapping[str, str] -) -> dict[str, str]: # mutable-ok: the starlette response takes a plain dict - """``response_headers`` with a callback's headers added, less a guardrail header they then leave no room for""" - merged: Final = {**response_headers, **callback_headers} +def _add_callback_headers( + response_headers: dict[str, str], # mutable-ok: adds to the response's headers in place, like the dict's update + callback_headers: Mapping[str, str], +) -> None: + """Add a callback's headers to ``response_headers``, less a guardrail header they then leave no room for""" guardrail_headers: Final = { name: value for name, value in response_headers.items() if name in _GUARDRAIL_HEADER_NAMES and name not in callback_headers } - kept: Final = _guardrail_headers_with_room(guardrail_headers, merged) - return {name: value for name, value in merged.items() if name not in guardrail_headers or name in kept} + response_headers.update(callback_headers) + kept: Final = _guardrail_headers_with_room(guardrail_headers, response_headers) + for name in guardrail_headers.keys() - kept.keys(): + response_headers.pop(name) def _recorded_guardrail_headers( @@ -2152,12 +2154,7 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers = ( - _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept - _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict - callback_headers, - ) - ) + _add_callback_headers(_response_headers, callback_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( @@ -2262,12 +2259,7 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _response_headers = ( - _with_callback_headers( # rebind-ok: replaces this branch's headers with the room kept - _response_headers, # pyright: ignore[reportUnknownArgumentType] # untyped header dict - callback_headers, - ) - ) + _add_callback_headers(_response_headers, callback_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( From e89c462d7a359c4c6450b30238a010f03ac5fc17 Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 05:11:38 -0800 Subject: [PATCH 25/27] fix(proxy): count an upstream guardrail header the proxy can't replace toward the header limit --- .../pass_through_endpoints.py | 34 ++++++++----------- .../test_pass_through_endpoints.py | 14 ++++++++ 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index f029065cc41..1484ec744d2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -298,29 +298,23 @@ def _is_sendable_header_value(value: str) -> bool: ) -def _with_header_in_budget( - kept: Mapping[str, str], header: tuple[str, str], budget: int = _GUARDRAIL_HEADERS_MAX_BYTES -) -> Mapping[str, str]: +def _with_header_in_budget(kept: Mapping[str, str], header: tuple[str, str]) -> Mapping[str, str]: size: Final = sum(len(name) + len(value) for name, value in (*kept.items(), header)) - return MappingProxyType({**kept, header[0]: header[1]}) if size <= budget else kept + return MappingProxyType({**kept, header[0]: header[1]}) if size <= _GUARDRAIL_HEADERS_MAX_BYTES else kept + + +def _with_header_in_response_limit(headers: Mapping[str, str], header: tuple[str, str]) -> Mapping[str, str]: + merged: Final = {**headers, header[0]: header[1]} + size: Final = sum(len(name) + len(value) + _HEADER_FRAMING_BYTES for name, value in merged.items()) + return merged if size <= _RESPONSE_HEADERS_MAX_BYTES else headers def _guardrail_headers_with_room( guardrail_headers: Mapping[str, str], response_headers: Mapping[str, str] ) -> Mapping[str, str]: - """``guardrail_headers``, without one the response's other headers leave no room for, so adding them can't cost a - client the response""" - others: Final = sum( - len(name) + len(value) + _HEADER_FRAMING_BYTES - for name, value in response_headers.items() - if name not in guardrail_headers - ) - no_headers: Final[Mapping[str, str]] = MappingProxyType({}) - return reduce( - partial(_with_header_in_budget, budget=_RESPONSE_HEADERS_MAX_BYTES - others), - guardrail_headers.items(), - no_headers, - ) + """``response_headers`` with each of ``guardrail_headers`` that fits, so adding them can't cost a client the + response. One that doesn't fit leaves the header of that name as it was.""" + return reduce(_with_header_in_response_limit, guardrail_headers.items(), response_headers) def _add_callback_headers( @@ -333,10 +327,10 @@ def _add_callback_headers( for name, value in response_headers.items() if name in _GUARDRAIL_HEADER_NAMES and name not in callback_headers } - response_headers.update(callback_headers) - kept: Final = _guardrail_headers_with_room(guardrail_headers, response_headers) - for name in guardrail_headers.keys() - kept.keys(): + for name in guardrail_headers: response_headers.pop(name) + response_headers.update(callback_headers) + response_headers.update(_guardrail_headers_with_room(guardrail_headers, response_headers)) def _recorded_guardrail_headers( 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 9b30baf84d2..fa83297bb05 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 @@ -13896,6 +13896,20 @@ class TestGuardrailResponseHeaders: assert "x-litellm-applied-guardrails" not in headers assert len(headers) == 1_500 + def test_an_upstream_guardrail_header_a_larger_one_cannot_replace_still_counts(self): + names: Final = [f"guardrail-{index:03d}" for index in range(280)] + upstream: Final = {"x-upstream": "u" * 10_000, "x-litellm-applied-policies": "p" * 4_000} + headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( + headers=httpx.Headers(upstream), + guardrail_request_data=( + {}, + {"litellm_params": {"metadata": {"applied_guardrails": names, "applied_policies": names}}}, + ), + ) + + assert sum(len(name) + len(value) + 4 for name, value in headers.items()) <= 15360 + assert headers["x-upstream"] == upstream["x-upstream"] + def test_an_upstream_guardrail_header_the_proxy_replaces_takes_no_room(self): headers: Final = HttpPassThroughEndpointHelpers.get_response_headers( headers=httpx.Headers({"x-litellm-applied-guardrails": "u" * 15_400}), From 8f3c1d20a74c2e75f525342a275d77d9179a787d Mon Sep 17 00:00:00 2001 From: yucheng Date: Tue, 3 Nov 2026 10:42:05 -0800 Subject: [PATCH 26/27] fix(proxy): combine recorded guardrail names without encoding each one --- .../pass_through_endpoints/pass_through_endpoints.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 2bbd74314d9..76049974610 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -282,6 +282,10 @@ def _joinable_header_metadata(recorded: Mapping[str, object]) -> Mapping[str, ob } +def _metadata_entry_identity(entry: object) -> object: + return entry if isinstance(entry, str) else (json.dumps(entry, sort_keys=True, default=str),) + + 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.""" @@ -292,9 +296,7 @@ def _merged_metadata_entry(prior: object, value: object) -> object: if not isinstance(prior, list | tuple) or not isinstance(value, list | tuple): return value entries: Final = (*_GUARDRAIL_ENTRIES.validate_python(prior), *_GUARDRAIL_ENTRIES.validate_python(value)) - return tuple( - MappingProxyType({json.dumps(entry, sort_keys=True, default=str): entry for entry in entries}).values() - ) + return tuple({_metadata_entry_identity(entry): entry for entry in entries}.values()) def _is_sendable_header_value(value: str) -> bool: From 7c488f9f443d7439a034160e3530507f3bf5cf63 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 5 Nov 2026 01:20:49 -0800 Subject: [PATCH 27/27] fix(proxy): keep an upstream's guardrail headers beside a streamed callback header Only a guardrail header this proxy recorded gives way to a callback header that leaves it no room, so a stream whose proxy recorded nothing passes the upstream's headers through unchanged, as the buffered path does. --- .../pass_through_endpoints.py | 27 +++++++--------- .../test_pass_through_endpoints.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 3b8751fe2c6..9de2c7b9d23 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -251,15 +251,6 @@ _GUARDRAIL_HEADERS_MAX_BYTES: Final = 8192 _RESPONSE_HEADERS_MAX_BYTES: Final = 15360 # The ": " and the line ending around each header on the wire. _HEADER_FRAMING_BYTES: Final = 4 -_GUARDRAIL_HEADER_NAMES: Final = frozenset( - ( - "x-litellm-applied-guardrails", - "x-litellm-applied-policies", - "x-litellm-guardrail-scan-id", - "x-litellm-guardrail-scan-metadata", - "x-litellm-policy-sources", - ) -) def _is_mergeable_client_metadata(key: str, value: object) -> bool: @@ -339,12 +330,14 @@ def _guardrail_headers_with_room( def _add_callback_headers( response_headers: dict[str, str], # mutable-ok: adds to the response's headers in place, like the dict's update callback_headers: Mapping[str, str], + own_guardrail_headers: Mapping[str, str], ) -> None: - """Add a callback's headers to ``response_headers``, less a guardrail header they then leave no room for""" + """Add a callback's headers to ``response_headers``, less a guardrail header this proxy set, ``own_guardrail_headers``, + that they then leave no room for. The upstream's guardrail headers are kept as they came.""" guardrail_headers: Final = { name: value - for name, value in response_headers.items() - if name in _GUARDRAIL_HEADER_NAMES and name not in callback_headers + for name, value in own_guardrail_headers.items() + if response_headers.get(name) == value and name not in callback_headers } for name in guardrail_headers: response_headers.pop(name) @@ -2238,10 +2231,11 @@ async def pass_through_request( # Built before the failure hooks rebuild kwargs' metadata without the guardrail fields. The relay # response differs only in body-framing headers, which get_response_headers drops anyway. + streamed_guardrail_headers: Final = _guardrail_response_headers(_parsed_body, kwargs) # pyright: ignore[reportUnknownArgumentType] # untyped request dicts _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, litellm_call_id=litellm_call_id, - guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + earlier_guardrail_headers=streamed_guardrail_headers, ) relay_response: Final = await _log_passthrough_upstream_failure( response=response, @@ -2267,7 +2261,7 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _add_callback_headers(_response_headers, callback_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict + _add_callback_headers(_response_headers, callback_headers, streamed_guardrail_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( @@ -2343,10 +2337,11 @@ async def pass_through_request( # Built before the failure hooks rebuild kwargs' metadata without the guardrail fields. The relay # response differs only in body-framing headers, which get_response_headers drops anyway. + detected_stream_guardrail_headers: Final = _guardrail_response_headers(_parsed_body, kwargs) # pyright: ignore[reportUnknownArgumentType] # untyped request dicts _response_headers = HttpPassThroughEndpointHelpers.get_response_headers( headers=response.headers, litellm_call_id=litellm_call_id, - guardrail_request_data=(_parsed_body, kwargs), # pyright: ignore[reportUnknownArgumentType] # untyped request dicts + earlier_guardrail_headers=detected_stream_guardrail_headers, ) detected_relay_response: Final = await _log_passthrough_upstream_failure( response=response, @@ -2372,7 +2367,7 @@ async def pass_through_request( request_headers=dict(redact_credential_headers(request.headers)), ) if callback_headers: - _add_callback_headers(_response_headers, callback_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict + _add_callback_headers(_response_headers, callback_headers, detected_stream_guardrail_headers) # pyright: ignore[reportUnknownArgumentType] # untyped header dict return StreamingResponse( wrap_passthrough_sse_bytes_with_keepalive_pings( 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 0f507c80fb1..c98983598f4 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 @@ -68,6 +68,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( WebsocketFrameModelVerdict, _build_passthrough_failure_request_payload, _registered_pass_through_routes, + _add_callback_headers, _guardrail_response_headers, _log_passthrough_upstream_failure_keeping_guardrail_headers, _strip_client_guardrail_fields, @@ -14087,6 +14088,36 @@ class TestGuardrailResponseHeaders: assert headers["x-litellm-applied-guardrails"] == "guard" + @staticmethod + def _upstream_with_guardrail_headers() -> dict[str, str]: + return { + "x-litellm-applied-guardrails": "upstream-guard", + "x-litellm-guardrail-scan-id": "upstream-scan", + "x-upstream-0": "o" * 8_000, + "x-upstream-1": "o" * 8_000, + } + + def test_upstream_guardrail_headers_survive_a_callback_header_when_the_proxy_recorded_nothing(self): + headers: Final = self._upstream_with_guardrail_headers() + + _add_callback_headers(headers, {"x-callback": "c"}, {}) + + assert headers == {**self._upstream_with_guardrail_headers(), "x-callback": "c"} + + def test_the_proxys_guardrail_header_gives_way_to_a_callback_header_with_no_room(self): + headers: Final = {"x-litellm-applied-guardrails": "guard", "x-upstream": "o" * 15_000} + + _add_callback_headers(headers, {"x-callback": "c" * 300}, {"x-litellm-applied-guardrails": "guard"}) + + assert headers == {"x-upstream": "o" * 15_000, "x-callback": "c" * 300} + + def test_the_proxys_guardrail_header_stays_beside_a_callback_header_with_room(self): + headers: Final = {"x-litellm-applied-guardrails": "guard", "x-upstream": "o"} + + _add_callback_headers(headers, {"x-callback": "c"}, {"x-litellm-applied-guardrails": "guard"}) + + assert headers == {"x-litellm-applied-guardrails": "guard", "x-upstream": "o", "x-callback": "c"} + def test_guardrail_header_past_the_size_budget_is_left_out(self): names: Final = [f"g{index}" for index in range(2_000)]