Fix streaming tone detection gap and logging helper misclassification

- Add tone checker to async_post_call_streaming_iterator_hook so tone
  violations are detected in during_call (streaming) mode, not silently
  skipped
- Fix _get_detection_methods: tone detections now report "regex" instead
  of falling through to "keyword"
- Fix _build_match_details: add "tone" case with detection_method, category,
  and action_taken fields for correct observability logging
- Add tests: streaming blocks/passes, detection method labels, match details

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia 2026-03-11 22:48:55 -07:00
parent 8304b3ae75
commit f7658ec06a
2 changed files with 101 additions and 1 deletions

View file

@ -1626,6 +1626,11 @@ class ContentFilterGuardrail(CustomGuardrail):
detail["detection_method"] = "intent"
detail["snippet"] = detection.get("intent", "")
detail["confidence"] = detection.get("confidence")
elif detection["type"] == "tone":
detail["detection_method"] = "regex"
tone_det = cast(ToneDetection, detection)
detail["category"] = tone_det.get("category", "")
detail["action_taken"] = "BLOCK"
match_details.append(detail)
return match_details
@ -1633,7 +1638,7 @@ class ContentFilterGuardrail(CustomGuardrail):
"""Get comma-separated detection methods used."""
methods: set = set()
for detection in detections:
if detection["type"] == "pattern":
if detection["type"] in ("pattern", "tone"):
methods.add("regex")
elif detection["type"] == "competitor_intent":
methods.add("intent")
@ -1956,6 +1961,11 @@ class ContentFilterGuardrail(CustomGuardrail):
text_to_check += " "
try:
# Tone detection on streaming responses (during_call is always a response)
if self._tone_checker and text_to_check:
tone_result = self._tone_checker.run(text_to_check)
if tone_result is not None:
self._apply_tone_detection_policy(tone_result, [])
masked_text = self._filter_single_text(text_to_check)
if is_final and masked_text.endswith(" "):
masked_text = masked_text[:-1]

View file

@ -12,6 +12,7 @@ Covers:
import os
import sys
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -22,6 +23,7 @@ from fastapi import HTTPException
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.utils import ModelResponseStream
# ---------------------------------------------------------------------------
# Helpers
@ -495,3 +497,91 @@ class TestInputTypeGuard:
{},
"response",
)
# ---------------------------------------------------------------------------
# STREAMING — tone detection in during_call (streaming) mode
# ---------------------------------------------------------------------------
def _make_stream_chunk(content, finish_reason=None):
"""Build a minimal ModelResponseStream chunk."""
delta = MagicMock()
delta.content = content
choice = MagicMock()
choice.delta = delta
choice.finish_reason = finish_reason
chunk = MagicMock(spec=ModelResponseStream)
chunk.choices = [choice]
return chunk
async def _iter_chunks(chunks):
for c in chunks:
yield c
class TestStreamingToneDetection:
@pytest.mark.asyncio
async def test_streaming_blocks_tone_violation(self):
"""Tone checker should fire in streaming mode and raise on violation."""
g = _make_guardrail()
chunks = [
_make_stream_chunk("That's not my problem.", finish_reason="stop"),
]
with pytest.raises(HTTPException) as exc_info:
async for _ in g.async_post_call_streaming_iterator_hook(
MagicMock(), _iter_chunks(chunks), {},
):
pass
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_streaming_passes_clean_text(self):
"""Clean text should stream through without raising."""
g = _make_guardrail()
chunks = [
_make_stream_chunk("Thank you for reaching out!", finish_reason="stop"),
]
yielded = []
async for item in g.async_post_call_streaming_iterator_hook(
MagicMock(), _iter_chunks(chunks), {},
):
yielded.append(item)
assert len(yielded) > 0
# ---------------------------------------------------------------------------
# LOGGING HELPERS — tone detections should be correctly labeled
# ---------------------------------------------------------------------------
class TestLoggingHelpers:
def test_get_detection_methods_tone(self):
"""Tone detections should report 'regex' as detection method."""
g = _make_guardrail()
detections = [{"type": "tone", "category": "dismissive", "matched_text": "x"}]
result = g._get_detection_methods(detections)
assert result == "regex"
def test_get_detection_methods_tone_and_keyword(self):
"""Mixed detections should report both methods."""
g = _make_guardrail()
detections = [
{"type": "tone", "category": "dismissive", "matched_text": "x"},
{"type": "blocked_word", "keyword": "bad", "action": "BLOCK", "description": None},
]
result = g._get_detection_methods(detections)
assert "regex" in result
assert "keyword" in result
def test_build_match_details_tone(self):
"""Tone detections should produce correct match_details entries."""
g = _make_guardrail()
detections = [{"type": "tone", "category": "impatience", "matched_text": "x"}]
details = g._build_match_details(detections)
assert len(details) == 1
assert details[0]["type"] == "tone"
assert details[0]["detection_method"] == "regex"
assert details[0]["category"] == "impatience"
assert details[0]["action_taken"] == "BLOCK"