diff --git a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py index 7e6f3dac008..b3b8fbdb2a5 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py +++ b/litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py @@ -25,7 +25,11 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus +from litellm.types.utils import ( + GenericGuardrailAPIInputs, + GuardrailStatus, + GuardrailTracingDetail, +) from .base import OpenAIGuardrailBase @@ -287,6 +291,7 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): start_time=start_time, end_time=end_time, event_type=event_type, + tracing_detail=self._build_tracing_detail(guardrail_response), ) return response @@ -328,9 +333,36 @@ class OpenAIModerationGuardrail(OpenAIGuardrailBase, CustomGuardrail): start_time=start_time, end_time=end_time, event_type=event_type, + tracing_detail=self._build_tracing_detail(guardrail_response), ) raise e + @staticmethod + def _build_tracing_detail( + guardrail_response: Union[dict, str, Exception], + ) -> Optional[GuardrailTracingDetail]: + """ + Pull the flagged category names out of the moderation response so trace + backends can index a short, queryable ``guardrail_violation_categories`` + attribute instead of the full ``guardrail_response`` blob, whose + ``category_scores`` map (one float per category) blows past indexed-field + length limits on backends like ELK (1024 chars). + """ + if not isinstance(guardrail_response, dict): + return None + + results = guardrail_response.get("results") or [] + violation_categories = [ + category + for result in results + if isinstance(result, dict) + for category, is_flagged in (result.get("categories") or {}).items() + if is_flagged + ] + if not violation_categories: + return None + return GuardrailTracingDetail(violation_categories=violation_categories) + @staticmethod def get_config_model() -> Optional[Type["GuardrailConfigModel"]]: """ diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py index 16b5cbe8589..fe6cb98d1f5 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py @@ -2,6 +2,7 @@ """ Test OpenAI Moderation Guardrail """ + import os import sys @@ -822,6 +823,108 @@ def test_openai_moderation_process_error_metadata_none_edge_case(): assert "_openai_moderation_response" not in request_data["metadata"] +@pytest.mark.asyncio +async def test_openai_moderation_logs_violation_categories_harmful_content(): + """Flagged content surfaces only the violated category names in + StandardLoggingGuardrailInformation.violation_categories, so OTEL can index + a short ``guardrail_violation_categories`` attribute instead of the full + response blob (LIT-3801).""" + from fastapi import HTTPException + + from litellm.types.utils import GenericGuardrailAPIInputs + + with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}): + guardrail = OpenAIModerationGuardrail(guardrail_name="test-openai-moderation") + + mock_response = OpenAIModerationResponse( + id="modr-violations", + model="omni-moderation-latest", + results=[ + OpenAIModerationResult( + flagged=True, + categories={ + "sexual": False, + "hate": False, + "self-harm": True, + "self-harm/intent": True, + "violence": True, + }, + category_scores={ + "sexual": 0.0001, + "hate": 0.0001, + "self-harm": 0.97, + "self-harm/intent": 0.98, + "violence": 0.35, + }, + category_applied_input_types={}, + ) + ], + ) + + with patch.object(guardrail, "async_make_request", return_value=mock_response): + request_data = {"metadata": {}} + with pytest.raises(HTTPException): + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + structured_messages=[{"role": "user", "content": "harmful"}] + ), + request_data=request_data, + input_type="request", + ) + + info = request_data["metadata"]["standard_logging_guardrail_information"][0] + + # Only the flagged categories, never the unflagged ones or the scores + assert info["violation_categories"] == [ + "self-harm", + "self-harm/intent", + "violence", + ] + + +@pytest.mark.asyncio +async def test_openai_moderation_no_violation_categories_safe_content(): + """Safe content carries no violation_categories key, so the short attribute + is absent rather than empty on allowed requests (LIT-3801).""" + from litellm.types.utils import GenericGuardrailAPIInputs + + with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}): + guardrail = OpenAIModerationGuardrail(guardrail_name="test-openai-moderation") + + mock_response = OpenAIModerationResponse( + id="modr-safe", + model="omni-moderation-latest", + results=[ + OpenAIModerationResult( + flagged=False, + categories={"hate": False, "violence": False}, + category_scores={"hate": 0.001, "violence": 0.002}, + category_applied_input_types={}, + ) + ], + ) + + with patch.object(guardrail, "async_make_request", return_value=mock_response): + request_data = {"metadata": {}} + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs( + structured_messages=[{"role": "user", "content": "hi"}] + ), + request_data=request_data, + input_type="request", + ) + + info = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert "violation_categories" not in info + + +def test_openai_moderation_build_tracing_detail_non_dict_responses(): + """Non-dict guardrail responses (the "allow" sentinel, a raw Exception) yield + no tracing detail so logging never crashes when no moderation call ran.""" + assert OpenAIModerationGuardrail._build_tracing_detail("allow") is None + assert OpenAIModerationGuardrail._build_tracing_detail(ValueError("boom")) is None + + @pytest.mark.asyncio async def test_openai_moderation_guardrail_streaming_defaults(): """Defaults match the unified dispatcher: sampled in-stream, every 5th chunk."""