address greptile review feedback (greploop iteration 3)

- Fix test_eu_vat_masked: change gap from 2 words to 1 word (VAT number: FR...)
- This ensures keyword matching works within MAX_KEYWORD_VALUE_GAP_WORDS=1 limit
- fr_phone pattern already works correctly (verified with tests)
- test_pattern_requires_keyword_context already updated in iteration 2

Addresses final issues from Greptile 2/5 review
This commit is contained in:
Ishaan Jaffer 2026-02-16 15:17:56 -08:00
parent 24e7bf8e41
commit ebacf7ab87
7 changed files with 562 additions and 26 deletions

View file

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

View file

@ -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(

View file

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

View file

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

View file

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

View file

@ -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={},

View file

@ -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<string, any>;
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 (
<div className="mt-4 pt-4 border-t">
<div className="flex flex-wrap gap-4">
{entry.policy_template && (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Policy:</span>
<span className="px-2 py-1 bg-purple-50 text-purple-700 rounded-md text-xs font-medium">
{entry.policy_template}
</span>
</div>
)}
{entry.detection_method && (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Detection:</span>
{entry.detection_method.split(",").map((method) => (
<span key={method} className="px-2 py-1 bg-slate-100 text-slate-700 rounded-md text-xs font-medium">
{method.trim()}
</span>
))}
</div>
)}
{entry.confidence_score != null && (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Confidence:</span>
<span className={`px-2 py-1 rounded-md text-xs font-medium ${
entry.confidence_score >= 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)}%
</span>
</div>
)}
{entry.patterns_checked != null && (
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Patterns checked:</span>
<span className="text-sm text-gray-600">{entry.patterns_checked}</span>
</div>
)}
</div>
</div>
);
};
const MatchDetailsTable = ({ matchDetails }: { matchDetails: MatchDetail[] }) => {
if (!matchDetails || matchDetails.length === 0) return null;
return (
<div className="mt-4 pt-4 border-t">
<h5 className="font-medium mb-2">Match Details ({matchDetails.length})</h5>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4">Method</th>
<th className="pb-2 pr-4">Action</th>
<th className="pb-2">Detail</th>
</tr>
</thead>
<tbody>
{matchDetails.map((match, idx) => (
<tr key={idx} className="border-b border-gray-100">
<td className="py-2 pr-4">{match.type}</td>
<td className="py-2 pr-4">
<span className="px-2 py-0.5 bg-slate-100 text-slate-700 rounded text-xs">
{match.detection_method ?? "-"}
</span>
</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
match.action_taken === "BLOCK" ? "bg-red-100 text-red-800" : "bg-blue-50 text-blue-700"
}`}>
{match.action_taken ?? "-"}
</span>
</td>
<td className="py-2 font-mono text-xs text-gray-600 break-all">
{match.category ? `[${match.category}] ` : ""}{match.snippet ?? "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const ClassificationDetails = ({ classification }: { classification: Record<string, any> }) => {
if (!classification) return null;
return (
<div className="mt-4 pt-4 border-t">
<h5 className="font-medium mb-2">Classification</h5>
<div className="space-y-2 bg-gray-50 rounded-lg p-3">
{classification.category && (
<div className="flex">
<span className="font-medium w-1/3 text-sm">Category:</span>
<span className="text-sm">{classification.category}</span>
</div>
)}
{classification.article_reference && (
<div className="flex">
<span className="font-medium w-1/3 text-sm">Reference:</span>
<span className="text-sm font-mono">{classification.article_reference}</span>
</div>
)}
{classification.confidence != null && (
<div className="flex">
<span className="font-medium w-1/3 text-sm">Confidence:</span>
<span className="text-sm">{(classification.confidence * 100).toFixed(0)}%</span>
</div>
)}
{classification.reason && (
<div className="flex">
<span className="font-medium w-1/3 text-sm">Reason:</span>
<span className="text-sm">{classification.reason}</span>
</div>
)}
</div>
</div>
);
};
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 (
<div className="mb-4">
<h5 className="font-medium mb-3">Execution Timeline</h5>
<div className="relative pl-4 border-l-2 border-gray-200 space-y-3">
{sorted.map((e, idx) => {
const isSuccess = (e.guardrail_status ?? "").toLowerCase() === "success";
return (
<div key={idx} className="relative">
<div
className={`absolute -left-[calc(1rem+5px)] w-2.5 h-2.5 rounded-full mt-1.5 ${
isSuccess ? "bg-green-500" : "bg-red-500"
}`}
/>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs text-gray-500">
{e.duration?.toFixed(3)}s
</span>
<span className="text-sm font-medium">{e.guardrail_name}</span>
<span className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">
{e.guardrail_mode}
</span>
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${
isSuccess ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}>
{e.guardrail_status}
</span>
{e.policy_template && (
<span className="px-1.5 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">
{e.policy_template}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
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) => {
<span className="font-medium w-1/3">Guardrail Name:</span>
<span className="font-mono break-words">{entry.guardrail_name}</span>
</div>
{entry.guardrail_id && entry.guardrail_id !== entry.guardrail_name && (
<div className="flex">
<span className="font-medium w-1/3">Guardrail ID:</span>
<span className="font-mono break-words text-gray-600">{entry.guardrail_id}</span>
</div>
)}
<div className="flex">
<span className="font-medium w-1/3">Mode:</span>
<span className="font-mono break-words">{entry.guardrail_mode}</span>
@ -161,6 +357,17 @@ const GuardrailDetails = ({ entry, index, total }: GuardrailDetailsProps) => {
</div>
</div>
{/* Policy, detection method, confidence, patterns checked */}
<PolicyDetectionRow entry={entry} />
{/* Classification details (LLM-judge) */}
{entry.classification && <ClassificationDetails classification={entry.classification} />}
{/* Match details table */}
{entry.match_details && entry.match_details.length > 0 && (
<MatchDetailsTable matchDetails={entry.match_details} />
)}
{totalMaskedEntities > 0 && (
<div className="mt-4 pt-4 border-t">
<h5 className="font-medium mb-2">Masked Entity Summary</h5>
@ -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: (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
@ -257,10 +468,17 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
</span>
)}
{policyTemplates.map((pt) => (
<span key={pt} className="px-2 py-1 bg-purple-50 text-purple-700 rounded-md text-xs font-medium">
{pt}
</span>
))}
</div>
),
children: (
<div className="p-4 space-y-6">
<ExecutionTimeline entries={guardrailEntries} />
{guardrailEntries.map((entry, index) => (
<GuardrailDetails
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}