diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index d21f64157ff..d671d28d7e2 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1143,10 +1143,15 @@ async def pass_through_request( passthrough_guardrails_config=guardrails_config, ) - if guardrails_to_run and uninspectable_body is not None: - # Guardrails read the parsed body, which for a non-object payload carries none of - # the caller's content, while the bytes forwarded upstream carry all of it. Running - # them would report "inspected" on content nobody looked at, so refuse instead. + if ( + guardrails_to_run + and uninspectable_body is not None + and PassthroughGuardrailHandler.any_inspects_the_request_body(guardrails_to_run) + ): + # A request-inspecting guardrail reads the parsed body, which for a non-object + # payload carries none of the caller's content, while the bytes forwarded upstream + # carry all of it. Running it would report "inspected" on content nobody looked at, + # so refuse instead. A post_call guardrail reads the response, so it does not gate. raise ProxyException( message=( "Guardrails are configured for this route and cannot inspect a JSON body " diff --git a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py index de9b0acf081..6f219e0ae9f 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_guardrails.py @@ -7,15 +7,37 @@ Handles guardrail execution for passthrough endpoints with: - Automatic inheritance from org/team/key levels when enabled """ +from collections.abc import Collection from typing import Any, Final +import litellm from litellm._logging import verbose_proxy_logger +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import ( PassThroughGuardrailsConfig, PassThroughGuardrailSettings, UserAPIKeyAuth, ) from litellm.proxy.pass_through_endpoints.jsonpath_extractor import JsonPathExtractor +from litellm.types.guardrails import GuardrailEventHooks + +# The hooks that read the request body. ``post_call`` and ``logging_only`` read the response. +_REQUEST_INSPECTING_EVENT_HOOKS: Final = ( + GuardrailEventHooks.pre_call, + GuardrailEventHooks.during_call, +) + + +def _reads_the_request_body(callback: object, guardrail_names: Collection[str]) -> bool: + if not isinstance(callback, CustomGuardrail) or callback.guardrail_name not in guardrail_names: + return False + # Same private predicate common_request_processing.py uses to ask a callback which + # lifecycle hooks it is configured for, rather than re-deriving the matching rules. + return any( + callback._event_hook_is_event_type(hook) # pyright: ignore[reportPrivateUsage] # no public equivalent + for hook in _REQUEST_INSPECTING_EVENT_HOOKS + ) + # Type for raw guardrails config input (before normalization) # Can be a list of names or a dict with settings @@ -273,6 +295,16 @@ class PassthroughGuardrailHandler: return guardrails_to_run if guardrails_to_run else None + @staticmethod + def any_inspects_the_request_body(guardrail_names: Collection[str]) -> bool: + """Whether any of these guardrails reads the request body. + + A ``post_call`` or ``logging_only`` guardrail inspects the response, so a request + body it never reads is no reason to turn the request away. A name with no + initialized callback inspects nothing either, since there is nothing to run. + """ + return any(_reads_the_request_body(callback, guardrail_names) for callback in litellm.callbacks) + @staticmethod def get_field_targeted_text( data: dict, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index ee008dc8e9e..57859e27bc9 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -24,6 +24,7 @@ from starlette.datastructures import UploadFile as StarletteUploadFile import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_REQUEST_TIMEOUT_SECONDS +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.proxy._types import ProxyException, UserAPIKeyAuth @@ -49,6 +50,7 @@ from litellm.proxy.pass_through_endpoints.success_handler import ( ) from litellm.proxy.route_llm_request import ProxyModelNotFoundError from litellm.types import utils as types_utils +from litellm.types.guardrails import GuardrailEventHooks from litellm.types.passthrough_endpoints.pass_through_endpoints import ( LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, @@ -7771,6 +7773,18 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): return error.value +@contextmanager +def _registered_guardrail(name: str, mode: GuardrailEventHooks): + """Register a real guardrail the way the proxy does, so the code under test resolves its + event mode instead of being told the answer.""" + guardrail = CustomGuardrail(guardrail_name=name, event_hook=mode) + litellm.callbacks.append(guardrail) + try: + yield + finally: + litellm.callbacks.remove(guardrail) + + @pytest.mark.asyncio @pytest.mark.parametrize( "body", @@ -7780,17 +7794,37 @@ async def _run_guarded_passthrough(body: bytes, guardrails: list[str] | None): pytest.param(b"123", id="number"), ], ) -async def test_guarded_route_refuses_a_body_its_guardrails_cannot_read(body: bytes): - """Guardrails inspect the parsed body, which for a non-object payload carries none of the - caller's content, while the bytes forwarded upstream carry all of it. Running them would - report "inspected" on content nobody looked at, so the request must be refused instead.""" - raised = await _run_guarded_passthrough(body, guardrails=["my-guard"]) +async def test_route_with_a_request_guardrail_refuses_a_body_it_cannot_read(body: bytes): + """A pre_call guardrail inspects the parsed body, which for a non-object payload carries + none of the caller's content, while the bytes forwarded upstream carry all of it. Running + it would report "inspected" on content nobody looked at, so refuse instead.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + raised = await _run_guarded_passthrough(body, guardrails=["precall-guard"]) assert raised.code == "400" assert raised.type == "invalid_request_error" assert "cannot inspect a JSON body that is not an object" in raised.message +@pytest.mark.asyncio +@pytest.mark.parametrize( + "mode", + [ + pytest.param(GuardrailEventHooks.post_call, id="post_call"), + pytest.param(GuardrailEventHooks.logging_only, id="logging_only"), + ], +) +async def test_a_response_only_guardrail_does_not_gate_the_request_body(mode: GuardrailEventHooks): + """A guardrail that inspects the response never reads the request body, so a body it was + never going to look at is no reason to turn the request away.""" + body = b'[{"role": "user", "content": "hi"}]' + + with _registered_guardrail("response-guard", mode): + upstream = await _capture_upstream_request(body, guardrails=["response-guard"]) + + assert upstream.content == body + + @pytest.mark.asyncio async def test_unguarded_route_still_forwards_a_non_object_body(): """The refusal is scoped to routes that actually configured guardrails: passthrough is @@ -7801,10 +7835,13 @@ async def test_unguarded_route_still_forwards_a_non_object_body(): @pytest.mark.asyncio -async def test_guarded_route_accepts_an_object_body(): - """An object body is fully readable by guardrails, so guardrails being configured must not - turn it away: it still reaches the provider.""" - upstream = await _capture_upstream_request(b'{"model": "claude-sonnet-4-5"}', guardrails=["my-guard"]) +async def test_a_request_guardrail_still_accepts_an_object_body(): + """An object body is fully readable, so a pre_call guardrail being configured must not turn + it away: it still reaches the provider.""" + with _registered_guardrail("precall-guard", GuardrailEventHooks.pre_call): + upstream = await _capture_upstream_request( + b'{"model": "claude-sonnet-4-5"}', guardrails=["precall-guard"] + ) assert json.loads(upstream.content)["model"] == "claude-sonnet-4-5"