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:
Benjamin Bachmann 2026-04-16 08:39:26 +02:00
parent 1ea8c86914
commit 65ea886bc1
2 changed files with 23 additions and 27 deletions

View file

@ -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,

View file

@ -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):