mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
b8d79d1e0c
commit
78a7d0b210
2 changed files with 136 additions and 1 deletions
|
|
@ -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"]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue