diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 93d0077f233..a8f1ba7ced0 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -26,6 +26,7 @@ from litellm.types.utils import ( CallTypes, GenericGuardrailAPIInputs, GuardrailStatus, + GuardrailTracingDetail, LLMResponseTypes, StandardLoggingGuardrailInformation, ) @@ -520,17 +521,15 @@ class CustomGuardrail(CustomLogger): masked_entity_count: Optional[Dict[str, int]] = None, guardrail_provider: Optional[str] = None, event_type: Optional[GuardrailEventHooks] = None, - guardrail_id: Optional[str] = None, - policy_template: Optional[str] = None, - detection_method: Optional[str] = None, - confidence_score: Optional[float] = None, - classification: Optional[dict] = None, - match_details: Optional[List[dict]] = None, - patterns_checked: Optional[int] = None, - alert_recipients: Optional[List[str]] = None, + tracing_detail: Optional[GuardrailTracingDetail] = None, ) -> None: """ Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc. + + Args: + tracing_detail: Optional typed dict with provider-specific tracing fields + (guardrail_id, policy_template, detection_method, confidence_score, + classification, match_details, patterns_checked, alert_recipients). """ if isinstance(guardrail_json_response, Exception): guardrail_json_response = str(guardrail_json_response) @@ -567,14 +566,7 @@ class CustomGuardrail(CustomLogger): end_time=end_time, duration=duration, masked_entity_count=masked_entity_count, - guardrail_id=guardrail_id, - policy_template=policy_template, - detection_method=detection_method, - confidence_score=confidence_score, - classification=classification, - match_details=match_details, - patterns_checked=patterns_checked, - alert_recipients=alert_recipients, + **(tracing_detail or {}), ) def _append_guardrail_info(container: dict) -> None: 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 245a16d8ad2..1f2c7fed793 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 @@ -31,7 +31,7 @@ from litellm import Router from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import ModelResponseStream +from litellm.types.utils import GuardrailTracingDetail, ModelResponseStream if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -133,6 +133,8 @@ class ContentFilterGuardrail(CustomGuardrail): def __init__( self, guardrail_name: Optional[str] = None, + guardrail_id: Optional[str] = None, + policy_template: Optional[str] = None, patterns: Optional[List[ContentFilterPattern]] = None, blocked_words: Optional[List[BlockedWord]] = None, blocked_words_file: Optional[str] = None, @@ -177,6 +179,8 @@ class ContentFilterGuardrail(CustomGuardrail): ) self.guardrail_provider = "litellm_content_filter" + self.config_guardrail_id = guardrail_id + self.config_policy_template = policy_template self.pattern_redaction_format = ( pattern_redaction_format or self.PATTERN_REDACTION_FORMAT ) @@ -1409,11 +1413,13 @@ class ContentFilterGuardrail(CustomGuardrail): end_time=datetime.now().timestamp(), duration=(datetime.now() - start_time).total_seconds(), masked_entity_count=masked_entity_count, - guardrail_id=self.guardrail_name, - policy_template=self._get_policy_templates(), - detection_method=self._get_detection_methods(detections) if detections else None, - match_details=self._build_match_details(detections) if detections else None, - patterns_checked=self._get_patterns_checked_count(), + tracing_detail=GuardrailTracingDetail( + guardrail_id=self.config_guardrail_id or self.guardrail_name, + policy_template=self.config_policy_template or self._get_policy_templates(), + detection_method=self._get_detection_methods(detections) if detections else None, + match_details=self._build_match_details(detections) if detections else None, + patterns_checked=self._get_patterns_checked_count(), + ), ) async def apply_guardrail( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d26550ed3f2..5fbfd23b2db 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2645,6 +2645,24 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False): """Email addresses that were notified""" +class GuardrailTracingDetail(TypedDict, total=False): + """ + Typed fields for guardrail tracing metadata. + + Passed to add_standard_logging_guardrail_information_to_request_data() + to enrich the StandardLoggingGuardrailInformation with provider-specific details. + """ + + guardrail_id: Optional[str] + policy_template: Optional[str] + detection_method: Optional[str] + confidence_score: Optional[float] + classification: Optional[dict] + match_details: Optional[List[dict]] + patterns_checked: Optional[int] + alert_recipients: Optional[List[str]] + + StandardLoggingPayloadStatus = Literal["success", "failure"] diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 00557c10b5d..e7317b40ca9 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -723,3 +723,93 @@ class TestEventTypeLogging: logged_info = request_data["metadata"]["standard_logging_guardrail_information"] assert len(logged_info) == 1 assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call + + +class TestTracingFieldsPopulation: + """Verify add_standard_logging_guardrail_information_to_request_data passes new tracing fields via **extra_fields.""" + + def test_new_fields_set_on_slg(self): + cg = CustomGuardrail(guardrail_name="test-rail") + request_data = {"metadata": {}} + cg.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={"result": "ok"}, + request_data=request_data, + guardrail_status="success", + guardrail_id="rail-123", + policy_template="EU AI Act Article 5", + detection_method="regex", + confidence_score=0.95, + match_details=[{"type": "pattern", "action_taken": "BLOCK"}], + patterns_checked=12, + alert_recipients=["admin@example.com"], + ) + slg_list = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(slg_list) == 1 + slg = slg_list[0] + assert slg["guardrail_id"] == "rail-123" + assert slg["policy_template"] == "EU AI Act Article 5" + assert slg["detection_method"] == "regex" + assert slg["confidence_score"] == 0.95 + assert slg["patterns_checked"] == 12 + assert slg["alert_recipients"] == ["admin@example.com"] + assert len(slg["match_details"]) == 1 + + def test_new_fields_default_to_absent(self): + """When extra_fields are not passed, new fields are absent from the SLG dict.""" + cg = CustomGuardrail(guardrail_name="test-rail") + request_data = {"metadata": {}} + cg.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response="ok", + request_data=request_data, + guardrail_status="success", + ) + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg.get("guardrail_id") is None + assert slg.get("policy_template") is None + assert slg.get("confidence_score") is None + + def test_multiple_guardrails_with_different_policies(self): + """One request, multiple guardrails each with own policy_template.""" + cg1 = CustomGuardrail(guardrail_name="rail-1") + cg2 = CustomGuardrail(guardrail_name="rail-2") + request_data = {"metadata": {}} + + cg1.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response="ok", + request_data=request_data, + guardrail_status="success", + policy_template="GDPR", + ) + cg2.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response="blocked", + request_data=request_data, + guardrail_status="guardrail_intervened", + policy_template="EU AI Act Article 5", + ) + + slg_list = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(slg_list) == 2 + assert slg_list[0]["policy_template"] == "GDPR" + assert slg_list[1]["policy_template"] == "EU AI Act Article 5" + + def test_classification_field_passed_through(self): + """Classification dict for LLM-judge guardrails is passed through.""" + cg = CustomGuardrail(guardrail_name="judge-rail") + request_data = {"metadata": {}} + classification = { + "flagged": True, + "category": "workplace_emotion_recognition", + "article_reference": "Article 5(1)(f)", + "confidence": 0.94, + "reason": "Request asks to analyze employee sentiment", + } + cg.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response="blocked", + request_data=request_data, + guardrail_status="guardrail_intervened", + classification=classification, + detection_method="llm-judge", + ) + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["classification"] == classification + assert slg["detection_method"] == "llm-judge" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index bebc6ff80d5..a1d3eb152bb 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -23,6 +23,9 @@ from litellm.types.guardrails import ( ContentFilterPattern, GuardrailEventHooks, ) +from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( + ContentFilterCategoryConfig, +) class TestContentFilterGuardrail: @@ -1842,3 +1845,212 @@ class TestContentFilterGuardrail: ) # Should pass - 'Indian' in sentence 1, 'lazy' in sentence 2 assert len(result["texts"]) == 1 + + +class TestTracingFieldsE2E: + """E2E tests for new tracing fields (guardrail_id, policy_template, detection_method, match_details, patterns_checked).""" + + @pytest.mark.asyncio + async def test_tracing_fields_populated_on_mask_detection(self): + """New tracing fields are populated in SpendLog metadata when content is masked.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + blocked_words = [ + BlockedWord( + keyword="secret", + action=ContentFilterAction.MASK, + description="Secret keyword", + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="tracing-test", + guardrail_id="gd-tracing-001", + policy_template="Test Policy Template", + patterns=patterns, + blocked_words=blocked_words, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + await guardrail.apply_guardrail( + inputs={"texts": ["Email me at user@test.com, it's a secret"]}, + request_data=request_data, + input_type="request", + ) + + slg_list = request_data["metadata"]["standard_logging_guardrail_information"] + assert len(slg_list) == 1 + slg = slg_list[0] + + # New tracing fields + assert slg["guardrail_id"] == "gd-tracing-001" + assert slg["policy_template"] == "Test Policy Template" + assert slg["detection_method"] == "keyword,regex" + assert slg["patterns_checked"] >= 2 # at least 1 pattern + 1 keyword + + # match_details + assert isinstance(slg["match_details"], list) + assert len(slg["match_details"]) >= 2 + methods = {d["detection_method"] for d in slg["match_details"]} + assert "regex" in methods + assert "keyword" in methods + + @pytest.mark.asyncio + async def test_tracing_fields_fallback_when_no_config_id(self): + """guardrail_id falls back to guardrail_name when config id not provided.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="fallback-test", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + await guardrail.apply_guardrail( + inputs={"texts": ["SSN: 123-45-6789"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "fallback-test" + assert slg.get("policy_template") is None # no categories loaded + assert slg["detection_method"] == "regex" + assert slg["patterns_checked"] >= 1 + + @pytest.mark.asyncio + async def test_tracing_fields_with_category_keywords(self): + """Tracing fields populated correctly when category keywords trigger detections.""" + categories = [ + ContentFilterCategoryConfig( + category="harm_toxic_abuse", + enabled=True, + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="category-tracing", + guardrail_id="gd-cat-001", + categories=categories, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + # Use a word from the harm_toxic_abuse category + await guardrail.apply_guardrail( + inputs={"texts": ["You are an idiot and stupid"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-cat-001" + assert slg["patterns_checked"] >= 1 # category keywords counted + + if slg.get("match_details"): + # If detections happened, verify category info + cat_matches = [d for d in slg["match_details"] if d.get("category")] + for m in cat_matches: + assert m["detection_method"] == "keyword" + + @pytest.mark.asyncio + async def test_tracing_fields_on_blocked_request(self): + """Tracing fields populated even when request is blocked.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="us_ssn", + action=ContentFilterAction.BLOCK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="block-tracing", + guardrail_id="gd-block-001", + policy_template="SSN Protection", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + with pytest.raises(HTTPException): + await guardrail.apply_guardrail( + inputs={"texts": ["SSN: 123-45-6789"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-block-001" + assert slg["policy_template"] == "SSN Protection" + assert slg["guardrail_status"] == "guardrail_intervened" + assert slg["patterns_checked"] >= 1 + + @pytest.mark.asyncio + async def test_tracing_fields_no_detections(self): + """When no detections occur, tracing fields still populated with metadata.""" + patterns = [ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name="email", + action=ContentFilterAction.MASK, + ), + ] + + guardrail = ContentFilterGuardrail( + guardrail_name="clean-tracing", + guardrail_id="gd-clean-001", + policy_template="Email Protection", + patterns=patterns, + ) + + request_data = { + "messages": [{"role": "user", "content": "Test"}], + "model": "gpt-4o", + "metadata": {}, + } + + await guardrail.apply_guardrail( + inputs={"texts": ["Hello world, no sensitive content here"]}, + request_data=request_data, + input_type="request", + ) + + slg = request_data["metadata"]["standard_logging_guardrail_information"][0] + assert slg["guardrail_id"] == "gd-clean-001" + assert slg["policy_template"] == "Email Protection" + assert slg["guardrail_status"] == "success" + assert slg["patterns_checked"] >= 1 + # No detections, so these should be None + assert slg.get("detection_method") is None + assert slg.get("match_details") is None diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py index a8d91495c80..d762a204f22 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_gdpr_policy_e2e.py @@ -155,8 +155,8 @@ class TestGDPRPolicyE2E: """ guardrail = self.setup_gdpr_guardrail() - # Include VAT keyword for contextual matching - text = "Company VAT number is FR12345678901" + # Include VAT keyword for contextual matching (max 1 word gap) + text = "Company VAT number: FR12345678901" guardrailed_inputs = await guardrail.apply_guardrail( inputs={"texts": [text]}, request_data={}, diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index ed2198ba859..8f9869e4dd5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -24,6 +24,15 @@ interface MaskedEntityCount { [key: string]: number; } +interface MatchDetail { + type: string; + detection_method?: string; + action_taken?: string; + snippet?: string; + category?: string; + position?: number; +} + interface GuardrailInformation { duration: number; end_time: number; @@ -33,7 +42,15 @@ interface GuardrailInformation { guardrail_status: string; guardrail_response: GuardrailEntity[] | BedrockGuardrailResponse | any; masked_entity_count: MaskedEntityCount; - guardrail_provider?: string; // "presidio" | "bedrock" | "litellm_content_filter" | other providers + guardrail_provider?: string; + guardrail_id?: string; + policy_template?: string; + detection_method?: string; + confidence_score?: number; + classification?: Record; + match_details?: MatchDetail[]; + patterns_checked?: number; + alert_recipients?: string[]; } interface GuardrailViewerProps { @@ -87,6 +104,179 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => { ); }; +const PolicyDetectionRow = ({ entry }: { entry: GuardrailInformation }) => { + const hasData = entry.policy_template || entry.detection_method || entry.confidence_score != null || entry.patterns_checked != null; + if (!hasData) return null; + + return ( +
+
+ {entry.policy_template && ( +
+ Policy: + + {entry.policy_template} + +
+ )} + {entry.detection_method && ( +
+ Detection: + {entry.detection_method.split(",").map((method) => ( + + {method.trim()} + + ))} +
+ )} + {entry.confidence_score != null && ( +
+ Confidence: + = 0.8 ? "bg-red-100 text-red-800" : + entry.confidence_score >= 0.5 ? "bg-amber-100 text-amber-800" : + "bg-green-100 text-green-800" + }`}> + {(entry.confidence_score * 100).toFixed(0)}% + +
+ )} + {entry.patterns_checked != null && ( +
+ Patterns checked: + {entry.patterns_checked} +
+ )} +
+
+ ); +}; + +const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => { + if (!matchDetails || matchDetails.length === 0) return null; + + return ( +
+
Match Details ({matchDetails.length})
+
+ + + + + + + + + + + {matchDetails.map((match, idx) => ( + + + + + + + ))} + +
TypeMethodActionDetail
{match.type} + + {match.detection_method ?? "-"} + + + + {match.action_taken ?? "-"} + + + {match.category ? `[${match.category}] ` : ""}{match.snippet ?? "-"} +
+
+
+ ); +}; + +const ClassificationDetails = ({ classification }: { classification: Record }) => { + if (!classification) return null; + + return ( +
+
Classification
+
+ {classification.category && ( +
+ Category: + {classification.category} +
+ )} + {classification.article_reference && ( +
+ Reference: + {classification.article_reference} +
+ )} + {classification.confidence != null && ( +
+ Confidence: + {(classification.confidence * 100).toFixed(0)}% +
+ )} + {classification.reason && ( +
+ Reason: + {classification.reason} +
+ )} +
+
+ ); +}; + +const ExecutionTimeline = ({ entries }: { entries: GuardrailInformation[] }) => { + if (entries.length <= 1) return null; + + const sorted = [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0)); + + return ( +
+
Execution Timeline
+
+ {sorted.map((e, idx) => { + const isSuccess = (e.guardrail_status ?? "").toLowerCase() === "success"; + return ( +
+
+
+ + {e.duration?.toFixed(3)}s + + {e.guardrail_name} + + {e.guardrail_mode} + + + {e.guardrail_status} + + {e.policy_template && ( + + {e.policy_template} + + )} +
+
+ ); + })} +
+
+ ); +}; + const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => { const guardrailProvider = entry.guardrail_provider ?? "presidio"; const statusLabel = entry.guardrail_status ?? "unknown"; @@ -127,6 +317,12 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => { Guardrail Name: {entry.guardrail_name}
+ {entry.guardrail_id && entry.guardrail_id !== entry.guardrail_name && ( +
+ Guardrail ID: + {entry.guardrail_id} +
+ )}
Mode: {entry.guardrail_mode} @@ -161,6 +357,17 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
+ {/* Policy, detection method, confidence, patterns checked */} + + + {/* Classification details (LLM-judge) */} + {entry.classification && } + + {/* Match details table */} + {entry.match_details && entry.match_details.length > 0 && ( + + )} + {totalMaskedEntities > 0 && (
Masked Entity Summary
@@ -222,6 +429,10 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => { ); }, 0); + const policyTemplates = Array.from( + new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean)) + ); + const tooltipTitle = allSucceeded ? null : "Guardrail failed to run."; if (guardrailEntries.length === 0) { @@ -237,7 +448,7 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => { { key: "1", label: ( -
+

Guardrail Information

@@ -257,10 +468,17 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => { {totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"} )} + + {policyTemplates.map((pt) => ( + + {pt} + + ))}
), children: (
+ {guardrailEntries.map((entry, index) => (