From 24962ef054e6bd5bbeec80bd539158da110a3ccb Mon Sep 17 00:00:00 2001 From: shivam Date: Fri, 4 Sep 2026 02:46:31 +0000 Subject: [PATCH] fix(guardrails): stop logging the request payload as guardrail_response on pre_call hooks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/custom_guardrail.py | 39 ++++++++++++----- .../guardrail_hooks/azure/prompt_shield.py | 9 ++-- .../integrations/test_custom_guardrail.py | 43 +++++++++++++++++++ 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 9bb613654e4..4eca77b77c5 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -1235,17 +1235,12 @@ class CustomGuardrail(CustomLogger): This gets logged on downsteam Langfuse, DataDog, etc. """ - # Convert None to empty dict to satisfy type requirements - guardrail_response: dict[str, object] | str = {} if response is None else response - - # For apply_guardrail functions in custom_code_guardrail scenario, - # simplify the logged response to "allow", "deny", or "mask" - if original_inputs is not None and isinstance(response, dict): - # Check if inputs were modified by comparing them - if self._inputs_were_modified(original_inputs, response): - guardrail_response = "mask" - else: - guardrail_response = "allow" + guardrail_response: Final = self._summarize_guardrail_response( + response=response, + request_data=request_data, + event_type=event_type, + original_inputs=original_inputs, + ) verbose_logger.debug("Guardrail response: %s", response) @@ -1260,6 +1255,28 @@ class CustomGuardrail(CustomLogger): ) return response + def _summarize_guardrail_response( + self, + response: dict | None, # mutable-ok: matches _process_response signature + request_data: dict, # mutable-ok: matches _process_response signature + event_type: GuardrailEventHooks | None, + original_inputs: dict | None, # mutable-ok: matches _process_response signature + ) -> dict | str: # mutable-ok: returns the caller's response unchanged + """Reduce a hook's return value to what is safe to log as ``guardrail_response``. + + ``apply_guardrail`` returns the (possibly masked) inputs and ``async_pre_call_hook`` + returns the (possibly modified) request payload. Neither is a provider verdict, and + logging them verbatim ships the user's prompt to every logging sink (OTEL spans, + Datadog, spend logs), so both collapse to ``"allow"`` / ``"mask"``. + """ + if response is None: + return {} + if original_inputs is not None: + return "mask" if self._inputs_were_modified(original_inputs, response) else "allow" + if event_type == GuardrailEventHooks.pre_call: + return "mask" if self._inputs_were_modified(request_data, response) else "allow" + return response + @staticmethod def _is_guardrail_intervention(e: Exception) -> bool: """Retained spelling for existing callers; prefer ``is_guardrail_intervention``.""" diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py b/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py index 6e29d44662e..b54be6526ea 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/prompt_shield.py @@ -338,10 +338,11 @@ class AzureContentSafetyPromptShieldGuardrail(AzureGuardrailBase, CustomGuardrai estimated cost) and the ``azure`` provider label to the recorded guardrail information. Follows the OpenAI moderation override pattern (openai/moderations.py).""" - guardrail_response: Final[dict | str] = ( # mutable-ok: mirrors CustomGuardrail._process_response - ("mask" if self._inputs_were_modified(original_inputs, response) else "allow") - if original_inputs is not None and isinstance(response, dict) - else ({} if response is None else response) # mutable-ok: empty placeholder, never mutated + guardrail_response: Final = self._summarize_guardrail_response( + response=response, + request_data=request_data, + event_type=event_type, + original_inputs=original_inputs, ) self.add_standard_logging_guardrail_information_to_request_data( guardrail_json_response=guardrail_response, diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 6edc2c9bf77..6c3e42d95e7 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -2506,3 +2506,46 @@ class TestCustomGuardrailPostCallSuccessDeploymentHook: ) assert result is replacement + + +class TestPreCallHookResponseIsNotLoggedVerbatim: + """Regression for LIT-6935: a pre_call hook returning the request payload leaked the prompt + into ``guardrail_response`` and from there onto OTEL guardrail spans.""" + + @staticmethod + def _logged_response(request_data: dict) -> object: + entries = request_data["litellm_metadata"]["standard_logging_guardrail_information"] + assert len(entries) == 1 + return entries[0]["guardrail_response"] + + @pytest.mark.asyncio + async def test_pre_call_hook_returning_request_logs_allow(self): + class PassthroughGuardrail(CustomGuardrail): + @log_guardrail_information + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + return data + + guardrail = PassthroughGuardrail(guardrail_name="g") + data = {"model": "gpt-4.1-mini", "input": "SECRET_PROMPT", "litellm_metadata": {}} + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="aresponses" + ) + + assert self._logged_response(data) == "allow" + + @pytest.mark.asyncio + async def test_pre_call_hook_returning_modified_copy_logs_mask(self): + class MaskingGuardrail(CustomGuardrail): + @log_guardrail_information + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): + return {**data, "input": "[MASKED]"} + + guardrail = MaskingGuardrail(guardrail_name="g") + data = {"model": "gpt-4.1-mini", "input": "SECRET_PROMPT", "litellm_metadata": {}} + + await guardrail.async_pre_call_hook( + user_api_key_dict=UserAPIKeyAuth(), cache=None, data=data, call_type="aresponses" + ) + + assert self._logged_response(data) == "mask"