From ef6d14fbe4acfbcb69ba7b1405333982b61d6d09 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Sat, 11 Jul 2026 11:52:23 -0700 Subject: [PATCH] fix(guardrails): backfill resolved hook onto self-recorded guardrail entries Guardrails like Presidio record their guardrail entry inside apply_guardrail (without event_type), so the decorator's resolved hook never reached the logged entry. Backfill the resolved event_type onto entries recorded during the invocation when they carry the raw-config fallback mode. --- litellm/integrations/custom_guardrail.py | 42 ++++++++++++++++++ .../integrations/test_custom_guardrail.py | 44 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 5859e7f3693..d9a7d093d66 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -1148,6 +1148,44 @@ def log_guardrail_information(func): total += len(entries) return total + def _backfill_event_type_on_recorded_entries( + guardrail: "CustomGuardrail", + request_data: dict, + entries_before: int, + event_type: Optional[GuardrailEventHooks], + ) -> None: + """Overwrite the raw-config fallback mode on entries the wrapped + function recorded itself. + + Guardrails like Presidio call + ``add_standard_logging_guardrail_information_to_request_data`` + internally (without ``event_type``), so those entries fall back to + the configured ``event_hook``. The wrapper is the only place that + knows the concrete hook that fired, so patch it onto the entries + added during this invocation. Entries whose mode differs from the + fallback were set deliberately and are left untouched. + """ + if event_type is None: + return + fallback_mode = guardrail._resolve_logged_guardrail_mode(None) + seen = 0 + for container_key in ("metadata", "litellm_metadata"): + container = request_data.get(container_key) + if not isinstance(container, dict): + continue + entries = container.get("standard_logging_guardrail_information") + if not isinstance(entries, list): + continue + for entry in entries: + seen += 1 + if seen <= entries_before or not isinstance(entry, dict): + continue + if ( + entry.get("guardrail_name") == guardrail.guardrail_name + and entry.get("guardrail_mode") == fallback_mode + ): + entry["guardrail_mode"] = event_type + @functools.wraps(func) async def async_wrapper(*args, **kwargs): start_time = datetime.now() # Move start_time inside the wrapper @@ -1165,6 +1203,7 @@ def log_guardrail_information(func): try: response = await func(*args, **kwargs) if _count_recorded_guardrail_entries(request_data) > entries_before: + _backfill_event_type_on_recorded_entries(self, request_data, entries_before, event_type) return response return self._process_response( response=response, @@ -1177,6 +1216,7 @@ def log_guardrail_information(func): ) except Exception as e: if _count_recorded_guardrail_entries(request_data) > entries_before: + _backfill_event_type_on_recorded_entries(self, request_data, entries_before, event_type) raise return self._process_error( e=e, @@ -1206,6 +1246,7 @@ def log_guardrail_information(func): try: response = func(*args, **kwargs) if _count_recorded_guardrail_entries(request_data) > entries_before: + _backfill_event_type_on_recorded_entries(self, request_data, entries_before, event_type) return response return self._process_response( response=response, @@ -1216,6 +1257,7 @@ def log_guardrail_information(func): ) except Exception as e: if _count_recorded_guardrail_entries(request_data) > entries_before: + _backfill_event_type_on_recorded_entries(self, request_data, entries_before, event_type) raise return self._process_error( e=e, diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 2bae20a517d..8bc74c61bc3 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1513,6 +1513,50 @@ class TestEventTypeLogging: assert "pre_call" in mode assert "post_call" in mode + @pytest.mark.asyncio + async def test_self_recording_guardrail_gets_resolved_mode_backfilled(self): + """Guardrails like Presidio record their own guardrail entry inside + apply_guardrail (without event_type), so the entry falls back to the + raw config. The decorator must backfill the resolved hook onto those + entries.""" + from litellm.integrations.custom_guardrail import log_guardrail_information + from litellm.types.guardrails import GuardrailEventHooks + + class SelfRecordingGuardrail(CustomGuardrail): + def __init__(self): + super().__init__( + guardrail_name="test_self_recording", + event_hook=[ + GuardrailEventHooks.pre_call, + GuardrailEventHooks.post_call, + ], + ) + + @log_guardrail_information + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + # mimic presidio's check_pii: record the entry internally, + # with no event_type available at this depth + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={"result": "ok"}, + request_data=request_data, + guardrail_status="success", + event_type=None, + ) + return inputs + + guardrail = SelfRecordingGuardrail() + request_data = {"metadata": {}} + + await guardrail.apply_guardrail( + inputs={"texts": ["hi"]}, + request_data=request_data, + input_type="request", + ) + + logged_info = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(logged_info) == 1 + assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call + class TestTracingFieldsPopulation: """Verify add_standard_logging_guardrail_information_to_request_data passes tracing_detail fields."""