mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
fix(guardrails): during_call is input-only — only post_call scans response
Per maintainer review: during_call maps to async_moderation_hook (runs in parallel to the LLM call on the input side) and the framework never dispatches it to the streaming iterator. Only post_call is a response-side hook. Update _runs_on_response() to gate on post_call only. Update both existing streaming tests (test_streaming_hook_mask/block) and the new test_during_call test to reflect the correct semantics.
This commit is contained in:
parent
1ea8c86914
commit
65ea886bc1
2 changed files with 23 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue