From 0f96b5a1b755f9d70cb7d7375e79234de076470a Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 16 Feb 2026 18:44:27 -0800 Subject: [PATCH] feat: guardrail tracing UI - policy, detection method, match details (#21349) * feat: add GuardrailTracingDetail TypedDict and tracing fields to StandardLoggingGuardrailInformation * feat: add policy_template field to Guardrail config TypedDict * feat: accept GuardrailTracingDetail in base guardrail logging method * feat: populate tracing fields in content filter guardrail * test: add tracing fields tests for custom guardrail base class * test: add tracing fields e2e tests for content filter guardrail * feat: add guardrail tracing UI - policy badges, match details, timeline * feat: redesign GuardrailViewer to Guardrails & Policy Compliance layout Two-column layout with request lifecycle timeline on the left and compact evaluation detail cards on the right. Header shows guardrail count, pass/fail status, total overhead, policy info, and an export button. * feat: add clickable guardrail link in metrics + show policy names * feat: add risk_score field to StandardLoggingGuardrailInformation * feat: compute risk_score in content filter guardrail * feat: display backend risk_score badge on evaluation cards * fix: fallback to frontend risk score when backend doesn't provide one --- .../litellm_content_filter/content_filter.py | 159 +-- litellm/types/utils.py | 4 + .../GuardrailViewer/GuardrailViewer.tsx | 932 +++++++++++------- .../LogDetailsDrawer/LogDetailContent.tsx | 23 +- 4 files changed, 684 insertions(+), 434 deletions(-) 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 9d1c254d1a7..55746e5e527 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 @@ -329,10 +329,10 @@ class ContentFilterGuardrail(CustomGuardrail): action if action else category_config_obj.default_action ) - # Handle conditional categories (with identifier_words + inherit_from OR identifier_words + additional_block_words) - if category_config_obj.identifier_words and ( - category_config_obj.inherit_from - or category_config_obj.additional_block_words + # Handle conditional categories (with identifier_words + inherit_from) + if ( + category_config_obj.identifier_words + and category_config_obj.inherit_from ): self._load_conditional_category( category_name, @@ -387,81 +387,64 @@ class ContentFilterGuardrail(CustomGuardrail): categories_dir: str, ) -> None: """ - Load a conditional category that uses identifier_words + block_words. - - Supports two patterns: - 1. Inherit + additional: identifier_words + inherit_from + optional additional_block_words - 2. Standalone: identifier_words + additional_block_words (no inheritance) + Load a conditional category that uses identifier_words + inherited block_words. Args: category_name: Name of the category - category_config_obj: CategoryConfig object with identifier_words and either inherit_from or additional_block_words + category_config_obj: CategoryConfig object with identifier_words and inherit_from category_action: Action to take when match is found severity_threshold: Minimum severity threshold categories_dir: Directory containing category files """ - block_words = [] + # Load the inherited category to get block words inherit_from = category_config_obj.inherit_from + if not inherit_from: + return - # Pattern 1: Load inherited category to get base block words - if inherit_from: - # Remove .json or .yaml extension if included - inherit_base = inherit_from.replace(".json", "").replace(".yaml", "") + # Remove .json or .yaml extension if included + inherit_base = inherit_from.replace(".json", "").replace(".yaml", "") - # Find the inherited category file - inherit_yaml_path = os.path.join(categories_dir, f"{inherit_base}.yaml") - inherit_json_path = os.path.join(categories_dir, f"{inherit_base}.json") + # Find the inherited category file + inherit_yaml_path = os.path.join(categories_dir, f"{inherit_base}.yaml") + inherit_json_path = os.path.join(categories_dir, f"{inherit_base}.json") - if os.path.exists(inherit_yaml_path): - inherit_file_path = inherit_yaml_path - elif os.path.exists(inherit_json_path): - inherit_file_path = inherit_json_path - else: - verbose_proxy_logger.warning( - f"Category {category_name}: inherit_from '{inherit_from}' file not found at {categories_dir}" - ) - verbose_proxy_logger.debug( - f"Tried paths: {inherit_yaml_path}, {inherit_json_path}" - ) - return - - try: - # Load the inherited category - inherited_category = self._load_category_file(inherit_file_path) - - # Extract block words from inherited category that meet severity threshold - for keyword_data in inherited_category.keywords: - keyword = keyword_data["keyword"].lower() - severity = keyword_data["severity"] - if self._should_apply_severity(severity, severity_threshold): - block_words.append(keyword) - except Exception as e: - verbose_proxy_logger.error( - f"Error loading inherited category for {category_name}: {e}" - ) - return - - # Pattern 2 or supplement to Pattern 1: Add additional block words - if category_config_obj.additional_block_words: - block_words.extend(category_config_obj.additional_block_words) - - # Ensure we have block words before storing - if not block_words: + if os.path.exists(inherit_yaml_path): + inherit_file_path = inherit_yaml_path + elif os.path.exists(inherit_json_path): + inherit_file_path = inherit_json_path + else: verbose_proxy_logger.warning( - f"Category {category_name}: no block words found (check inherit_from or additional_block_words)" + f"Category {category_name}: inherit_from '{inherit_from}' file not found at {categories_dir}" + ) + verbose_proxy_logger.debug( + f"Tried paths: {inherit_yaml_path}, {inherit_json_path}" ) return - # Store the conditional category configuration - self.conditional_categories[category_name] = { - "identifier_words": category_config_obj.identifier_words, - "block_words": block_words, - "action": category_action, - "severity": "high", # Combinations are always high severity - } + try: + # Load the inherited category + inherited_category = self._load_category_file(inherit_file_path) + + # Extract block words from inherited category that meet severity threshold + block_words = [] + for keyword_data in inherited_category.keywords: + keyword = keyword_data["keyword"].lower() + severity = keyword_data["severity"] + if self._should_apply_severity(severity, severity_threshold): + block_words.append(keyword) + + # Add additional block words specific to this category + if category_config_obj.additional_block_words: + block_words.extend(category_config_obj.additional_block_words) + + # Store the conditional category configuration + self.conditional_categories[category_name] = { + "identifier_words": category_config_obj.identifier_words, + "block_words": block_words, + "action": category_action, + "severity": "high", # Combinations are always high severity + } - # Log different messages based on pattern - if inherit_from and category_config_obj.additional_block_words: verbose_proxy_logger.info( f"Loaded conditional category {category_name}: " f"{len(category_config_obj.identifier_words)} identifiers + " @@ -469,17 +452,9 @@ class ContentFilterGuardrail(CustomGuardrail): f"({len(category_config_obj.additional_block_words)} additional + " f"{len(block_words) - len(category_config_obj.additional_block_words)} from {inherit_from})" ) - elif inherit_from: - verbose_proxy_logger.info( - f"Loaded conditional category {category_name}: " - f"{len(category_config_obj.identifier_words)} identifiers + " - f"{len(block_words)} block words (from {inherit_from})" - ) - else: - verbose_proxy_logger.info( - f"Loaded conditional category {category_name}: " - f"{len(category_config_obj.identifier_words)} identifiers + " - f"{len(block_words)} block words (standalone)" + except Exception as e: + verbose_proxy_logger.error( + f"Error loading inherited category for {category_name}: {e}" ) def _load_category_file(self, file_path: str) -> CategoryConfig: @@ -1398,6 +1373,41 @@ class ContentFilterGuardrail(CustomGuardrail): names = [cat.description or cat.category_name for cat in self.loaded_categories.values()] return ", ".join(names) if names else None + def _compute_risk_score( + self, + detections: List[ContentFilterDetection], + masked_entity_count: Dict[str, int], + status: "GuardrailStatus", + ) -> float: + """ + Compute a risk score from 0-10 for this guardrail evaluation. + + Factors: + - Match ratio: how many patterns matched vs total checked + - Number of entities masked + - Whether the guardrail blocked the request (max risk) + """ + if status == "guardrail_intervened": + return 10.0 + + total_masked = sum(masked_entity_count.values()) if masked_entity_count else 0 + patterns_checked = self._get_patterns_checked_count() + + # Match ratio contribution (0-7 points) + match_ratio = total_masked / patterns_checked if patterns_checked > 0 else 0.0 + ratio_score = match_ratio * 7.0 + + # Detection count contribution (0-3 points, capped) + detection_score = min(len(detections), 5) * 0.6 + + score = ratio_score + detection_score + + # Floor: if anything matched, minimum risk is 2 + if total_masked > 0 and score < 2.0: + score = 2.0 + + return round(min(10.0, score), 1) + def _log_guardrail_information( self, request_data: dict, @@ -1444,6 +1454,7 @@ class ContentFilterGuardrail(CustomGuardrail): 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(), + risk_score=self._compute_risk_score(detections, masked_entity_count, status), ), ) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5fbfd23b2db..5f8798c7712 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2644,6 +2644,9 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False): alert_recipients: Optional[List[str]] """Email addresses that were notified""" + risk_score: Optional[float] + """Risk score 0-10 indicating how risky the request was (higher = riskier). Computed by the guardrail provider.""" + class GuardrailTracingDetail(TypedDict, total=False): """ @@ -2661,6 +2664,7 @@ class GuardrailTracingDetail(TypedDict, total=False): match_details: Optional[List[dict]] patterns_checked: Optional[int] alert_recipients: Optional[List[str]] + risk_score: Optional[float] StandardLoggingPayloadStatus = Literal["success", "failure"] 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 8f9869e4dd5..35828922012 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -1,11 +1,13 @@ -import React, { useState } from "react"; -import { Tooltip, Collapse } from "antd"; +import React, { useState, useMemo } from "react"; +import { Tooltip } from "antd"; import PresidioDetectedEntities from "./PresidioDetectedEntities"; import BedrockGuardrailDetails, { BedrockGuardrailResponse, } from "@/components/view_logs/GuardrailViewer/BedrockGuardrailDetails"; import ContentFilterDetails from "./ContentFilterDetails"; +// ── Interfaces ────────────────────────────────────────────────────────────── + interface RecognitionMetadata { recognizer_name: string; recognizer_identifier: string; @@ -51,45 +53,204 @@ interface GuardrailInformation { match_details?: MatchDetail[]; patterns_checked?: number; alert_recipients?: string[]; + risk_score?: number; } interface GuardrailViewerProps { data: GuardrailInformation | GuardrailInformation[]; } -interface GuardrailDetailsProps { - entry: GuardrailInformation; - index: number; - total: number; -} +// ── Helpers ───────────────────────────────────────────────────────────────── -const formatTime = (timestamp: number) => { - const date = new Date(timestamp * 1000); - return date.toLocaleString(); +const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set([ + "presidio", + "bedrock", + "litellm_content_filter", +]); + +const formatMode = (mode: string): string => { + return mode.replace(/_/g, "-").toUpperCase(); }; -// Providers with custom renderers -const PROVIDERS_WITH_CUSTOM_RENDERERS = new Set(["presidio", "bedrock", "litellm_content_filter"]); +const formatDurationMs = (seconds: number): string => { + const ms = Math.round(seconds * 1000); + return `${ms}ms`; +}; + +const getTotalMasked = (entry: GuardrailInformation): number => { + return Object.values(entry.masked_entity_count || {}).reduce( + (sum, count) => sum + (typeof count === "number" ? count : 0), + 0, + ); +}; + +const isEntrySuccess = (entry: GuardrailInformation): boolean => { + return (entry.guardrail_status ?? "").toLowerCase() === "success"; +}; + +const getRiskColor = (score: number): string => { + if (score <= 3) return "text-green-600 bg-green-50 border-green-200"; + if (score <= 6) return "text-amber-600 bg-amber-50 border-amber-200"; + return "text-red-600 bg-red-50 border-red-200"; +}; + +const getRiskScore = (entry: GuardrailInformation): number | null => { + if (!isEntrySuccess(entry)) return null; + + // Prefer backend-computed score + if (entry.risk_score != null) return entry.risk_score; + + // Fallback: compute from available data + const totalMasked = getTotalMasked(entry); + const patternsChecked = entry.patterns_checked ?? 0; + const confidence = entry.confidence_score ?? 0; + + if (patternsChecked === 0 && confidence === 0) return 0; + + const matchRatio = patternsChecked > 0 ? totalMasked / patternsChecked : 0; + let score = matchRatio * 7 + confidence * 3; + if (totalMasked > 0 && score < 2) score = 2; + return Math.min(10, Math.round(score * 10) / 10); +}; + +const getDisplayName = (entry: GuardrailInformation): string => { + return entry.policy_template || entry.guardrail_name; +}; + +// ── Icons (inline SVGs) ───────────────────────────────────────────────────── + +const ShieldIcon = () => ( + + + + + +); + +const CheckCircleIcon = ({ className }: { className?: string }) => ( + + + + +); + +const FailCircleIcon = ({ className }: { className?: string }) => ( + + + + +); + +const PlayCircleIcon = () => ( + + + + +); + +const GrayDotIcon = () => ( + + + +); + +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const ExternalLinkIcon = () => ( + + + +); + +// ── Sub-components ────────────────────────────────────────────────────────── + +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 GenericGuardrailResponse = ({ response }: { response: any }) => { const [showRaw, setShowRaw] = useState(false); return ( -
+
setShowRaw(!showRaw)} >
- - - -
Raw Guardrail Response
+ +
Raw Guardrail Response
{showRaw && ( @@ -104,189 +265,162 @@ 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; +// ── Timeline entry types ──────────────────────────────────────────────────── - 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} -
- )} -
-
+interface TimelineEntry { + type: "request" | "guardrail" | "llm" | "response"; + label: string; + offsetMs: number; + status?: string; + isSuccess?: boolean; +} + +const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { + const sorted = useMemo( + () => [...entries].sort((a, b) => (a.start_time ?? 0) - (b.start_time ?? 0)), + [entries], ); -}; -const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => { - if (!matchDetails || matchDetails.length === 0) return null; + const timeline = useMemo(() => { + if (sorted.length === 0) return []; + + const baseTime = sorted[0].start_time; + const items: TimelineEntry[] = []; + + // Request received + items.push({ type: "request", label: "Request received", offsetMs: 0 }); + + // Pre-call guardrails + const preCalls = sorted.filter((e) => e.guardrail_mode === "pre_call"); + const postCalls = sorted.filter((e) => e.guardrail_mode === "post_call" || e.guardrail_mode === "logging_only"); + const duringCalls = sorted.filter((e) => e.guardrail_mode === "during_call"); + + for (const e of preCalls) { + const offsetMs = Math.round((e.end_time - baseTime) * 1000); + items.push({ + type: "guardrail", + label: `Pre-call guardrail: ${getDisplayName(e)}`, + offsetMs, + status: isEntrySuccess(e) ? "PASSED" : "FAILED", + isSuccess: isEntrySuccess(e), + }); + } + + // LLM call — infer from gap between pre-call end and post-call start + const lastPreEnd = preCalls.length > 0 ? Math.max(...preCalls.map((e) => e.end_time)) : baseTime; + const firstPostStart = postCalls.length > 0 ? Math.min(...postCalls.map((e) => e.start_time)) : undefined; + const llmEndTime = firstPostStart ?? (lastPreEnd + 1); + const llmOffsetMs = Math.round((llmEndTime - baseTime) * 1000); + + items.push({ + type: "llm", + label: "LLM call", + offsetMs: llmOffsetMs, + }); + + // During-call guardrails (rare) + for (const e of duringCalls) { + const offsetMs = Math.round((e.end_time - baseTime) * 1000); + items.push({ + type: "guardrail", + label: `During-call guardrail: ${getDisplayName(e)}`, + offsetMs, + status: isEntrySuccess(e) ? "PASSED" : "FAILED", + isSuccess: isEntrySuccess(e), + }); + } + + // Post-call guardrails + for (const e of postCalls) { + const offsetMs = Math.round((e.end_time - baseTime) * 1000); + items.push({ + type: "guardrail", + label: `Post-call guardrail: ${getDisplayName(e)}`, + offsetMs, + status: isEntrySuccess(e) ? "PASSED" : "FAILED", + isSuccess: isEntrySuccess(e), + }); + } + + // Response returned + const maxEnd = Math.max(...sorted.map((e) => e.end_time)); + const responseOffsetMs = Math.round((maxEnd - baseTime) * 1000) + 1; + items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs }); + + return items; + }, [sorted]); 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} - +
+

+ Request Lifecycle +

+
+ {timeline.map((item, idx) => ( +
+ {/* Vertical line */} +
+
+ {item.type === "request" || item.type === "response" ? ( + + ) : item.type === "llm" ? ( + + ) : item.isSuccess ? ( + + ) : ( + )}
+ {idx < timeline.length - 1 && ( +
+ )}
- ); - })} + + {/* Content */} +
+
+ + {item.label} + + {item.status && ( + + {item.status} + + )} + + T+{item.offsetMs}ms + +
+
+
+ ))}
); }; -const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => { - const guardrailProvider = entry.guardrail_provider ?? "presidio"; - const statusLabel = entry.guardrail_status ?? "unknown"; - const isSuccess = statusLabel.toLowerCase() === "success"; - const maskedEntityCount = entry.masked_entity_count || {}; - const totalMaskedEntities = Object.values(maskedEntityCount).reduce( - (sum, count) => sum + (typeof count === "number" ? count : 0), - 0, - ); +// ── Evaluation Card ───────────────────────────────────────────────────────── +const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { + const [expanded, setExpanded] = useState(false); + const success = isEntrySuccess(entry); + const totalMasked = getTotalMasked(entry); + const displayName = getDisplayName(entry); + const durationStr = formatDurationMs(entry.duration); + const modeStr = formatMode(entry.guardrail_mode); + const riskScore = getRiskScore(entry); + + const guardrailProvider = entry.guardrail_provider ?? "presidio"; const guardrailResponse = entry.guardrail_response; const presidioEntities = Array.isArray(guardrailResponse) ? guardrailResponse : []; const bedrockResponse = @@ -297,201 +431,287 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => { ? (guardrailResponse as BedrockGuardrailResponse) : undefined; - return ( -
- {total > 1 && ( -
-

- Guardrail #{index + 1} - {entry.guardrail_name} -

- - {guardrailProvider} - -
- )} + // Match count string: "X/Y matched" or "X matched" + const matchCountStr = + entry.patterns_checked != null + ? `${totalMasked}/${entry.patterns_checked} matched` + : totalMasked > 0 + ? `${totalMasked} matched` + : null; -
-
-
- Guardrail Name: - {entry.guardrail_name} -
- {entry.guardrail_id && entry.guardrail_id !== entry.guardrail_name && ( -
- Guardrail ID: - {entry.guardrail_id} -
+ return ( +
+ {/* Collapsed header row */} +
setExpanded(!expanded)} + > + {/* Status icon */} +
+ {success ? : } +
+ + {/* Name + badges */} +
+ {displayName} + + + {modeStr} + + + + {success ? "PASSED" : "FAILED"} + + + {matchCountStr && ( + + {matchCountStr} + )} -
- Mode: - {entry.guardrail_mode} -
-
- Status: - - - {statusLabel} + + {entry.confidence_score != null && ( + + {(entry.confidence_score * 100).toFixed(0)}% conf + + )} + + {riskScore != null && success && ( + + + Risk {riskScore}/10 -
+ )}
-
-
- Start Time: - {formatTime(entry.start_time)} -
-
- End Time: - {formatTime(entry.end_time)} -
-
- Duration: - {entry.duration.toFixed(4)}s -
+ {/* Right side: duration + method + chevron */} +
+ {durationStr} + {entry.detection_method && ( + + {entry.detection_method.split(",")[0].trim()} + + )} +
- {/* Policy, detection method, confidence, patterns checked */} - + {/* Expanded details */} + {expanded && ( +
+ {/* View Policy Configuration link */} + {entry.policy_template && ( + + )} - {/* Classification details (LLM-judge) */} - {entry.classification && } + {/* Classification details for llm-judge */} + {entry.classification && ( +
+
Classification
+ {entry.classification.category && ( +
+ Category: + {entry.classification.category} +
+ )} + {entry.classification.article_reference && ( +
+ Reference: + {entry.classification.article_reference} +
+ )} + {entry.classification.confidence != null && ( +
+ Confidence: + {(entry.classification.confidence * 100).toFixed(0)}% +
+ )} + {entry.classification.reason && ( +
+ Reason: + {entry.classification.reason} +
+ )} +
+ )} - {/* Match details table */} - {entry.match_details && entry.match_details.length > 0 && ( - - )} + {/* Match details table */} + {entry.match_details && entry.match_details.length > 0 && ( + + )} - {totalMaskedEntities > 0 && ( -
-
Masked Entity Summary
-
- {Object.entries(maskedEntityCount).map(([entityType, count]) => ( - - {entityType}: {count} - - ))} -
+ {/* Masked entity summary */} + {totalMasked > 0 && ( +
+
Masked Entities
+
+ {Object.entries(entry.masked_entity_count || {}).map(([entityType, count]) => ( + + {entityType}: {count} + + ))} +
+
+ )} + + {/* Provider-specific details */} + {guardrailProvider === "presidio" && presidioEntities.length > 0 && ( +
+ +
+ )} + {guardrailProvider === "bedrock" && bedrockResponse && ( +
+ +
+ )} + {guardrailProvider === "litellm_content_filter" && guardrailResponse && ( +
+ +
+ )} + {guardrailProvider && + !PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) && + guardrailResponse && }
)} - - {guardrailProvider === "presidio" && presidioEntities.length > 0 && ( -
- -
- )} - - {guardrailProvider === "bedrock" && bedrockResponse && ( -
- -
- )} - - {guardrailProvider === "litellm_content_filter" && guardrailResponse && ( -
- -
- )} - - {/* Generic fallback for unknown guardrail providers */} - {guardrailProvider && - !PROVIDERS_WITH_CUSTOM_RENDERERS.has(guardrailProvider) && - guardrailResponse && }
); }; +// ── Main Component ────────────────────────────────────────────────────────── + const GuardrailViewer = ({ data }: GuardrailViewerProps) => { - const guardrailEntries = Array.isArray(data) - ? data.filter((entry): entry is GuardrailInformation => Boolean(entry)) - : data - ? [data] - : []; + const guardrailEntries = useMemo(() => { + return Array.isArray(data) + ? data.filter((entry): entry is GuardrailInformation => Boolean(entry)) + : data + ? [data] + : []; + }, [data]); - const primaryName = - guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`; - const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status))); - const allSucceeded = statuses.every((status) => (status ?? "").toLowerCase() === "success"); - const aggregatedStatus = allSucceeded ? "success" : "failure"; - const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => { - return ( - sum + - Object.values(entry.masked_entity_count || {}).reduce( - (acc, count) => acc + (typeof count === "number" ? count : 0), - 0, - ) - ); - }, 0); + const passedCount = guardrailEntries.filter(isEntrySuccess).length; + const allPassed = passedCount === guardrailEntries.length; - const policyTemplates = Array.from( - new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean)) - ); + const totalOverheadMs = useMemo(() => { + return Math.round(guardrailEntries.reduce((sum, e) => sum + (e.duration ?? 0), 0) * 1000); + }, [guardrailEntries]); - const tooltipTitle = allSucceeded ? null : "Guardrail failed to run."; + const policyTemplates = useMemo(() => { + return Array.from(new Set(guardrailEntries.map((e) => e.policy_template).filter(Boolean))); + }, [guardrailEntries]); if (guardrailEntries.length === 0) { return null; } + const handleExport = () => { + const blob = new Blob([JSON.stringify(guardrailEntries, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `guardrail-compliance-log-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( -
- -

Guardrail Information

+
+ {/* ── Header ─────────────────────────────────────────────── */} +
+
+ +
+

+ Guardrails & Policy Compliance +

+
+ + {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""} evaluated + + | + + {allPassed ? ( + + + + ) : null} + {passedCount} Passed + +
+
+
- - - {aggregatedStatus} - - - - {primaryName} - - {totalMaskedEntities > 0 && ( - - {totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"} - - )} - - {policyTemplates.map((pt) => ( - - {pt} - - ))} +
+
+
+ Total: {totalOverheadMs}ms overhead +
+ {policyTemplates.length > 0 && ( +
+ Policy: {policyTemplates.join(" / ")}
- ), - children: ( -
- - {guardrailEntries.map((entry, index) => ( - - ))} -
- ), - }, - ]} - /> + )} +
+ + +
+
+ + {/* ── Body: two columns ──────────────────────────────────── */} +
+ {/* Left column: Request Lifecycle */} +
+ +
+ + {/* Right column: Evaluation Details */} +
+

+ Evaluation Details +

+
+ {guardrailEntries.map((entry, index) => ( + + ))} +
+
+
); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index 9081219d5be..f0f4041531c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -66,6 +66,9 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = const hasGuardrailData = guardrailEntries.length > 0; const totalMaskedEntities = calculateTotalMaskedEntities(guardrailEntries); const primaryGuardrailLabel = getGuardrailLabel(guardrailEntries); + const guardrailPolicyNames = Array.from( + new Set(guardrailEntries.map((e: any) => e?.policy_template).filter(Boolean)) + ) as string[]; // Vector store data const hasVectorStoreData = checkHasVectorStoreData(metadata); @@ -124,7 +127,7 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = )} {hasGuardrailData && ( - + )} @@ -164,7 +167,11 @@ export function LogDetailContent({ logEntry, onOpenSettings, isLoadingDetails = )} {/* Guardrail Data */} - {hasGuardrailData && } + {hasGuardrailData && ( +
+ +
+ )} {/* Vector Store Data */} {hasVectorStoreData && } @@ -218,15 +225,23 @@ function TagsSection({ tags }: { tags: Record }) { ); } -function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: number }) { +function GuardrailLabel({ label, maskedCount, policyNames }: { label: string; maskedCount: number; policyNames: string[] }) { + const handleClick = () => { + const el = document.getElementById("guardrail-section"); + if (el) el.scrollIntoView({ behavior: "smooth" }); + }; + return ( - {label} + {label} {maskedCount > 0 && ( {maskedCount} masked )} + {policyNames.map((name) => ( + {name} + ))} ); }