From f7658ec06ab726ec77b5eafb9c62bb3bc1471beb Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Wed, 11 Mar 2026 22:48:55 -0700 Subject: [PATCH] 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 --- .../litellm_content_filter/content_filter.py | 12 ++- .../guardrail_hooks/test_tone_detector.py | 90 +++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) 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 842dffd920d..b25c8546898 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 @@ -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] diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py index 5ff9f525230..46c514ce820 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_tone_detector.py @@ -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"