diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index 7d988fe3f4b..deb641d18ca 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -1859,36 +1859,31 @@ class ContentFilterGuardrail(CustomGuardrail): """ Whether this guardrail is configured to scan response content. - Returns True when ``event_hook`` selects a response-side hook - (``post_call`` or ``during_call``). For ``pre_call`` / - ``realtime_input_transcription`` / unset, the streaming iterator must - not modify the response — otherwise a pre_call regex would silently - also redact output, contradicting the documented mode contract. + Only ``post_call`` is a response-side hook. ``during_call`` is + dispatched by the framework as ``async_moderation_hook`` (runs in + parallel to the LLM call on the *input* side) and never reaches + ``async_post_call_streaming_iterator_hook``. ``pre_call`` / + ``realtime_input_transcription`` / unset are input-only as well. Supports all three accepted ``event_hook`` shapes: - single ``GuardrailEventHooks`` value - - ``list`` of ``GuardrailEventHooks`` (opt-in if any member matches) - - ``Mode`` (tag-routed): opt-in if any tag value or the default - resolves to a response hook, since the configured routing could - send matching requests to a response-side mode + - ``list`` of ``GuardrailEventHooks`` + - ``Mode`` (tag-routed) """ - response_hook_values = { - GuardrailEventHooks.post_call.value, - GuardrailEventHooks.during_call.value, - } + post_call_value = GuardrailEventHooks.post_call.value hook = self.event_hook if isinstance(hook, list): - return any(getattr(h, "value", h) in response_hook_values for h in hook) + return any(getattr(h, "value", h) == post_call_value for h in hook) if isinstance(hook, Mode): candidates: List[Union[str, List[str]]] = list(hook.tags.values()) if hook.default is not None: candidates.append(hook.default) for value in candidates: items = value if isinstance(value, list) else [value] - if any(v in response_hook_values for v in items): + if any(v == post_call_value for v in items): return True return False - return getattr(hook, "value", hook) in response_hook_values + return getattr(hook, "value", hook) == post_call_value async def async_post_call_streaming_iterator_hook( self, diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index d9e05313c12..5ed85c72df6 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -408,7 +408,7 @@ class TestContentFilterGuardrail: guardrail = ContentFilterGuardrail( guardrail_name="test-streaming-mask", patterns=patterns, - event_hook=GuardrailEventHooks.during_call, + event_hook=GuardrailEventHooks.post_call, ) # Create mock streaming chunks that split an email @@ -473,7 +473,7 @@ class TestContentFilterGuardrail: guardrail = ContentFilterGuardrail( guardrail_name="test-streaming-block", patterns=patterns, - event_hook=GuardrailEventHooks.during_call, + event_hook=GuardrailEventHooks.post_call, ) # Create mock streaming chunks with SSN @@ -2060,10 +2060,11 @@ class TestTracingFieldsE2E: class TestStreamingHookRespectsEventHook: """ The streaming iterator hook must only scan response content when the - guardrail is configured for post_call or during_call. With pre_call - (request-only), the stream must pass through unchanged — otherwise a - request-scoped regex silently also redacts LLM output, contradicting - the documented contract of ``mode: pre_call``. + guardrail is configured for ``post_call``. ``during_call`` is + dispatched by the framework as ``async_moderation_hook`` (input-side, + parallel to the LLM call) and never reaches the streaming iterator. + ``pre_call`` is request-only. In all non-``post_call`` modes the + stream must pass through unchanged. """ def _patterns(self): @@ -2139,18 +2140,18 @@ class TestStreamingHookRespectsEventHook: assert "[EMAIL_REDACTED]" in content @pytest.mark.asyncio - async def test_during_call_masks_response(self): - """during_call guards mask matches in streamed response text (regression).""" + async def test_during_call_does_not_mask_response(self): + """during_call is input-side (async_moderation_hook) — must not scan response.""" guardrail = ContentFilterGuardrail( - guardrail_name="test-during-call-masks", + guardrail_name="test-during-call-no-response-scan", patterns=self._patterns(), event_hook=GuardrailEventHooks.during_call, ) content = await self._collect_stream(guardrail) - assert "test@example.com" not in content - assert "[EMAIL_REDACTED]" in content + assert "test@example.com" in content + assert "[EMAIL_REDACTED]" not in content @pytest.mark.asyncio async def test_default_event_hook_does_not_mask_response(self):