feat(guardrails): surface OpenAI moderation violation_categories on guardrail traces (#30659)

The OpenAI moderation guardrail (and the ai-platform-moderation guardrail
built on it) stamped the whole moderation model response into the guardrail
trace as guardrail_response. That blob carries the full category_scores map
plus categories and category_applied_input_types, which on OTEL backends that
index span attributes (for example ELK, which caps indexed attribute values at
1024 chars) overflows the limit and gets truncated, so the violated categories
cannot be reliably searched.

Extract the flagged category names from the moderation response and pass them
through tracing_detail to add_standard_logging_guardrail_information_to_request_data,
mirroring the Bedrock hook. Both the legacy and v2 OTEL integrations already
read violation_categories off the standard logging guardrail information and
emit it as a short, queryable guardrail_violation_categories attribute, so
dashboards can group and filter by violation category without parsing the large
guardrail_response blob.

Resolves LIT-3801
This commit is contained in:
Yassin Kortam 2026-06-17 09:44:19 -07:00 • committed by GitHub
parent b8d79d1e0c
commit 78a7d0b210
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 136 additions and 1 deletions

View file

@ -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"]]:
"""

View file

@ -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."""