feat(guardrails): add non-blocking flag() verdict to custom code guardrails (#39728)

Custom code guardrails could only allow(), block(reason) or modify(). This adds flag(reason, metadata={}) which lets the request or response through unchanged and records a guardrail_flagged entry carrying the guardrail name, configured mode, evaluated input_type (request or response), reason and structured metadata. The new status is threaded through the request-level guardrail_status aggregation, the Guardrails Monitor rollup (flagged_count), Request Logs (action=flagged, most severe phase wins when a guardrail runs pre and post call) and the Request Logs detail view in the dashboard, which now renders FLAGGED with warning styling instead of falling into FAILED.

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-05 13:08:03 -07:00 • committed by GitHub
parent 8544faec91
commit 5df0e12e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 386 additions and 60 deletions

View file

@ -5881,6 +5881,7 @@ def _get_status_fields(
# Mapping for legacy guardrail status values to new GuardrailStatus values
GUARDRAIL_STATUS_MAP: Final[dict[str, GuardrailStatus]] = {
"success": "success",
"guardrail_flagged": "guardrail_flagged",
"blocked": "guardrail_intervened", # legacy
"guardrail_intervened": "guardrail_intervened", # direct
"failure": "guardrail_failed_to_respond", # legacy
@ -5902,6 +5903,7 @@ def _get_status_fields(
GUARDRAIL_STATUS_SEVERITY: Final[tuple[GuardrailStatus, ...]] = (
"not_run",
"success",
"guardrail_flagged",
"guardrail_failed_to_respond",
"guardrail_intervened",
)

View file

@ -36,6 +36,7 @@ Example: block when response rejects the user (input_type response only):
import asyncio
import threading
import time
from collections.abc import Callable, Mapping
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast
@ -93,6 +94,7 @@ class CustomCodeGuardrail(CustomGuardrail):
that returns one of:
- allow() - let the request/response through
- block(reason) - reject with a message
- flag(reason) - let it through but log a non-blocking violation
- modify(texts=...) - transform the content
Example:
@ -227,6 +229,7 @@ class CustomCodeGuardrail(CustomGuardrail):
raise CustomCodeExecutionError(f"Custom code guardrail not compiled: {self._compile_error}")
raise CustomCodeExecutionError("Custom code guardrail not compiled")
start_time: Final = time.time()
try:
# Prepare inputs dict for the function
@ -245,6 +248,7 @@ class CustomCodeGuardrail(CustomGuardrail):
inputs=inputs,
request_data=request_data,
input_type=input_type,
start_time=start_time,
)
except HTTPException:
@ -290,6 +294,7 @@ class CustomCodeGuardrail(CustomGuardrail):
inputs: GenericGuardrailAPIInputs,
request_data: dict[str, object],
input_type: Literal["request", "response"],
start_time: float,
) -> GenericGuardrailAPIInputs:
"""
Process the result from the custom code function.
@ -299,6 +304,7 @@ class CustomCodeGuardrail(CustomGuardrail):
inputs: The original inputs
request_data: The request data
input_type: "request" or "response"
start_time: Unix timestamp of when the guardrail started running, used for the flagged log entry
Returns:
GenericGuardrailAPIInputs - possibly modified
@ -348,6 +354,27 @@ class CustomCodeGuardrail(CustomGuardrail):
},
)
elif action == "flag":
flag_reason: Final = result.get("reason", "Flagged by custom code guardrail")
verbose_proxy_logger.info(
"Custom code guardrail '%s': Flagging %s - %s", self.guardrail_name, input_type, flag_reason
)
end_time: Final = time.time()
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={ # mutable-ok: logging helper requires a dict
"action": "flag",
"reason": flag_reason,
"input_type": input_type,
"metadata": result.get("metadata") or {}, # mutable-ok: logging helper requires a dict
},
request_data=request_data,
guardrail_status="guardrail_flagged",
start_time=start_time,
end_time=end_time,
duration=end_time - start_time,
)
return inputs
elif action == "modify":
verbose_proxy_logger.debug("Custom code guardrail '%s': Modifying %s", self.guardrail_name, input_type)

View file

@ -8,7 +8,7 @@ and provide safe, sandboxed functionality for common guardrail operations.
import json
import re
from collections.abc import Mapping, Sequence
from typing import Final
from typing import Final, Literal
from urllib.parse import urlparse
import httpx
@ -51,6 +51,31 @@ def block(reason: str, detection_info: Mapping[str, object] | None = None) -> di
return result
class FlagResult(TypedDict):
action: ReadOnly[Literal["flag"]]
reason: ReadOnly[str]
metadata: ReadOnly[Mapping[str, object]]
def flag(reason: str, metadata: Mapping[str, object] | None = None) -> FlagResult:
"""
Let the request/response proceed unchanged but record a non-blocking violation.
Args:
reason: Human-readable reason for flagging
metadata: Optional structured metadata stored alongside the reason
Returns:
Dict indicating the request should be flagged but allowed
"""
result: Final[FlagResult] = {
"action": "flag",
"reason": reason,
"metadata": metadata if metadata is not None else {},
}
return result
def modify(
texts: Sequence[str] | None = None,
images: Sequence[object] | None = None,
@ -787,6 +812,7 @@ def get_custom_code_primitives() -> dict[str, object]:
# Result types
"allow": allow,
"block": block,
"flag": flag,
"modify": modify,
# Regex
"regex_match": regex_match,

View file

@ -17,6 +17,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.guardrails.usage_tracking import guardrail_status_to_action
from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
DailyGuardrailMetricsRepository,
@ -41,6 +42,7 @@ if TYPE_CHECKING:
router: Final = APIRouter()
_EMPTY_UNITS: Final[Mapping[str, int]] = MappingProxyType({})
_ACTION_SEVERITY: Final[Mapping[str, int]] = MappingProxyType({"passed": 0, "flagged": 1, "blocked": 2})
_T = TypeVar("_T")
@ -759,21 +761,17 @@ def _usage_log_entry_from_row(
except Exception:
meta = {}
guardrail_info_list: Final[Sequence[_GuardrailRunInfo]] = (meta or {}).get("guardrail_information") or []
entry_for_guardrail: _GuardrailRunInfo | None = None
for gi in guardrail_info_list:
if (gi.get("guardrail_id") or gi.get("guardrail_name")) == r.guardrail_id:
entry_for_guardrail = gi
break
entry_for_guardrail: Final[_GuardrailRunInfo | None] = max(
(gi for gi in guardrail_info_list if (gi.get("guardrail_id") or gi.get("guardrail_name")) == r.guardrail_id),
key=lambda gi: _ACTION_SEVERITY[guardrail_status_to_action(gi.get("guardrail_status"))],
default=None,
)
action_val = "passed"
score_val = None
latency_val = None
reason_val = None
if entry_for_guardrail:
st: Final = (entry_for_guardrail.get("guardrail_status") or "").lower()
if "intervened" in st or "block" in st:
action_val = "blocked"
elif "fail" in st or "error" in st:
action_val = "flagged"
action_val = guardrail_status_to_action(entry_for_guardrail.get("guardrail_status"))
duration: Final = entry_for_guardrail.get("duration")
if duration is not None:
latency_val = round(float(duration) * 1000, 0)

View file

@ -190,14 +190,14 @@ async def _upsert_rows_with_retry(
return await _upsert_rows_with_retry(retryable, upsert_row, label, sleep, retries_left - 1)
def _guardrail_status_to_action(status: str | None) -> str:
def guardrail_status_to_action(status: str | None) -> str:
"""Map StandardLogging guardrail_status to blocked/passed/flagged."""
if not status:
return "passed"
s: Final = (status or "").lower()
if "intervened" in s or "block" in s:
return "blocked"
if "fail" in s or "error" in s:
if "flagged" in s or "fail" in s or "error" in s:
return "flagged"
return "passed"
@ -367,7 +367,7 @@ async def process_spend_logs_guardrail_usage(
continue
key = _MetricsKey(guardrail_id, date_key)
daily_guardrail[key]["requests_evaluated"] += 1
action = _guardrail_status_to_action(entry.get("guardrail_status"))
action = guardrail_status_to_action(entry.get("guardrail_status"))
if action == "passed":
daily_guardrail[key]["passed_count"] += 1
elif action == "blocked":

View file

@ -3078,7 +3078,9 @@ class GuardrailMode(TypedDict, total=False):
default: str | list[str] | None
GuardrailStatus = Literal["success", "guardrail_intervened", "guardrail_failed_to_respond", "not_run"]
GuardrailStatus = Literal[
"success", "guardrail_flagged", "guardrail_intervened", "guardrail_failed_to_respond", "not_run"
]
# Fields on a guardrail record whose values can quote the caller's prompt: the payload sent to the
# guardrail, the provider response that echoes it back, and the two first-party hooks that inline
@ -3320,6 +3322,7 @@ class StandardLoggingPayloadStatusFields(TypedDict, total=False):
"""
Status of guardrail execution:
- 'success': Guardrail ran and allowed content through
- 'guardrail_flagged': Guardrail allowed content through but recorded a non-blocking violation
- 'guardrail_intervened': Guardrail blocked or modified content
- 'guardrail_failed_to_respond': Guardrail had technical failure
- 'not_run': No guardrail was run

View file

@ -16,7 +16,10 @@ from litellm._logging import session_id_var, trace_id_var
from litellm.constants import SENTRY_DENYLIST, SENTRY_PII_DENYLIST
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
from litellm.litellm_core_utils.litellm_logging import set_callbacks
from litellm.litellm_core_utils.litellm_logging import (
_get_status_fields,
set_callbacks,
)
from litellm.types.utils import ModelResponse, TextCompletionResponse
@ -6441,3 +6444,16 @@ def test_passthrough_embeddings_result_swapped_for_callbacks():
assert isinstance(swapped_result, EmbeddingResponse)
assert swapped_result.data[0]["embedding"] == [0.1, 0.2, 0.3]
def test_get_status_fields_ranks_guardrail_flagged_between_success_and_intervened():
"""LIT-6894: a non-blocking flagged verdict must outrank success in the
request-level guardrail_status but never mask an intervention."""
flagged = {"guardrail_status": "guardrail_flagged"}
assert _get_status_fields(
"success", [{"guardrail_status": "success"}, flagged], None
)["guardrail_status"] == "guardrail_flagged"
assert _get_status_fields(
"success", [flagged, {"guardrail_status": "guardrail_intervened"}], None
)["guardrail_status"] == "guardrail_intervened"

View file

@ -197,6 +197,71 @@ async def test_custom_code_post_call_block_raises_http_400():
}
FLAG_CODE = (
"def apply_guardrail(inputs, request_data, input_type):\n"
' return flag("audit hit", metadata={"category": "topic"})\n'
)
@pytest.mark.asyncio
@pytest.mark.parametrize("input_type", ["request", "response"])
async def test_custom_code_flag_passes_content_through_and_records_flagged_entry(input_type):
"""LIT-6894: flag() must not raise, must return the content unchanged and must log
exactly one guardrail_flagged entry (the decorator must not add a second "success")."""
guardrail = CustomCodeGuardrail(custom_code=FLAG_CODE, guardrail_name="t", event_hook=["pre_call", "post_call"])
request_data = {"model": "test-model", "litellm_metadata": {}}
result = await guardrail.apply_guardrail(
inputs={"texts": ["hello"]},
request_data=request_data,
input_type=input_type,
)
assert result == {"texts": ["hello"]}
entries = request_data["litellm_metadata"]["standard_logging_guardrail_information"]
assert len(entries) == 1
entry = entries[0]
assert entry["guardrail_status"] == "guardrail_flagged"
assert entry["guardrail_name"] == "t"
assert entry["guardrail_mode"] == ["pre_call", "post_call"]
assert entry["guardrail_response"] == {
"action": "flag",
"reason": "audit hit",
"input_type": input_type,
"metadata": {"category": "topic"},
}
assert entry["duration"] is not None and entry["duration"] >= 0
@pytest.mark.asyncio
async def test_custom_code_flag_default_reason_and_empty_metadata():
code = "def apply_guardrail(inputs, request_data, input_type):\n return flag('just a note')\n"
guardrail = _compile(code)
request_data = {"model": "m", "litellm_metadata": {}}
await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data, input_type="request")
entry = request_data["litellm_metadata"]["standard_logging_guardrail_information"][0]
assert entry["guardrail_response"] == {
"action": "flag",
"reason": "just a note",
"input_type": "request",
"metadata": {},
}
@pytest.mark.asyncio
async def test_custom_code_allow_still_records_success_not_flagged():
code = "def apply_guardrail(inputs, request_data, input_type):\n return allow()\n"
guardrail = _compile(code)
request_data = {"model": "m", "litellm_metadata": {}}
await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data, input_type="request")
entries = request_data["litellm_metadata"]["standard_logging_guardrail_information"]
assert [e["guardrail_status"] for e in entries] == ["success"]
def test_typical_sync_guardrail_still_works():
code = (
"def apply_guardrail(inputs, request_data, input_type):\n"

View file

@ -477,6 +477,103 @@ async def test_logs_resolves_config_guardrail_logical_name():
assert where["guardrail_id"] == {"in": ["yaml-uuid", "yaml-pii"]}
def _index_row(request_id: str, guardrail_id: str = "cc-flag") -> Any:
r = MagicMock(spec=["request_id", "guardrail_id", "policy_id", "start_time"])
r.request_id = request_id
r.guardrail_id = guardrail_id
return r
def _spend_log(request_id: str, *guardrail_statuses: str, guardrail_id: str = "cc-flag") -> Any:
sl = MagicMock(spec=["request_id", "metadata", "startTime", "model", "messages", "response"])
sl.request_id = request_id
sl.startTime = datetime(2026, 4, 25, 12, 0)
sl.model = "gpt-4o-mini"
sl.messages = [{"role": "user", "content": "hi"}]
sl.response = "ok"
sl.metadata = {
"guardrail_information": [
{
"guardrail_name": guardrail_id,
"guardrail_status": status,
"guardrail_response": (
{"action": "flag", "reason": "audit hit"} if status == "guardrail_flagged" else "allow"
),
"duration": 0.002,
}
for status in guardrail_statuses
]
}
return sl
@pytest.mark.asyncio
async def test_logs_reports_flagged_action_for_guardrail_flagged_status():
"""LIT-6894: Request Logs surface a custom code flag() verdict as flagged with its reason."""
prisma = _prisma(index_find_many=[_index_row("r-flag"), _index_row("r-pass"), _index_row("r-block")])
prisma.db.litellm_spendlogs.find_many = AsyncMock(
return_value=[
_spend_log("r-flag", "guardrail_flagged"),
_spend_log("r-pass", "success"),
_spend_log("r-block", "guardrail_intervened"),
]
)
p1, p2 = _patches(prisma, _config_handler())
with p1, p2:
resp = await guardrails_usage_logs(
guardrail_id="cc-flag",
policy_id=None,
page=1,
page_size=50,
action=None,
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
flagged_only = await guardrails_usage_logs(
guardrail_id="cc-flag",
policy_id=None,
page=1,
page_size=50,
action="flagged",
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
assert [(log.id, log.action) for log in resp.logs] == [
("r-flag", "flagged"),
("r-pass", "passed"),
("r-block", "blocked"),
]
assert resp.logs[0].reason == "{'action': 'flag', 'reason': 'audit hit'}"
assert [log.id for log in flagged_only.logs] == ["r-flag"]
@pytest.mark.asyncio
async def test_logs_reports_post_call_flag_when_pre_call_allowed():
"""LIT-6894: a guardrail on mode [pre_call, post_call] that allows the request but flags the response
shows as flagged, not hidden behind the pre_call allow entry."""
prisma = _prisma(index_find_many=[_index_row("r-post-flag")])
prisma.db.litellm_spendlogs.find_many = AsyncMock(
return_value=[_spend_log("r-post-flag", "success", "guardrail_flagged")]
)
p1, p2 = _patches(prisma, _config_handler())
with p1, p2:
resp = await guardrails_usage_logs(
guardrail_id="cc-flag",
policy_id=None,
page=1,
page_size=50,
action=None,
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
assert [(log.id, log.action, log.reason) for log in resp.logs] == [
("r-post-flag", "flagged", "{'action': 'flag', 'reason': 'audit hit'}")
]
# ---- date window cap (LIT-5762) ---------------------------------------------

View file

@ -105,6 +105,27 @@ async def test_usage_units_rolled_up_by_guardrail_team_key_and_date():
}
@pytest.mark.asyncio
async def test_flagged_status_counts_as_flagged_not_passed_or_blocked():
"""LIT-6894: a custom code flag() verdict lands in flagged_count on the Monitor rollup."""
prisma = _prisma()
logs = [
_payload("r1", guardrail_status="success"),
_payload("r2", guardrail_status="guardrail_flagged"),
_payload("r3", guardrail_status="guardrail_intervened"),
]
await process_spend_logs_guardrail_usage(prisma, logs)
create = prisma.db.litellm_dailyguardrailmetrics.upsert.call_args.kwargs["data"]["create"]
assert (create["requests_evaluated"], create["passed_count"], create["flagged_count"], create["blocked_count"]) == (
3,
1,
1,
1,
)
def _fake_sleep() -> tuple[AsyncMock, list[float]]:
delays: list[float] = []
sleep = AsyncMock(side_effect=lambda delay: delays.append(delay))

View file

@ -112,6 +112,7 @@ const PRIMITIVES = {
"Return Values": [
{ name: "allow()", desc: "Let request/response through" },
{ name: "block(reason)", desc: "Reject with message" },
{ name: "flag(reason, metadata={})", desc: "Let through, record a non-blocking violation" },
{ name: "modify(texts=[], images=[], tool_calls=[])", desc: "Transform content" },
],
"HTTP Requests (async)": [

View file

@ -33,6 +33,22 @@ describe("GuardrailViewer", () => {
expect(screen.getByText("1235ms")).toBeInTheDocument();
});
it("renders guardrail_flagged as FLAGGED (warning), not FAILED", () => {
const data = makeGuardrailInformation({
guardrail_name: "cc-flag",
guardrail_status: "guardrail_flagged",
guardrail_provider: "custom_code",
});
renderWithProviders(<GuardrailViewer data={data} />);
expect(screen.getByText(/0 Passed/)).toBeInTheDocument();
expect(screen.getByText(/1 Flagged/)).toBeInTheDocument();
const badges = screen.getAllByText("FLAGGED");
expect(badges.length).toBeGreaterThan(0);
expect(badges[0]).toHaveClass("text-warning");
expect(screen.queryByText("FAILED")).not.toBeInTheDocument();
});
it("calculates and displays masked entity totals", async () => {
const user = userEvent.setup();
const data = makeGuardrailInformation({

View file

@ -133,8 +133,27 @@ const getTotalMasked = (entry: GuardrailInformation): number => {
);
};
const isEntrySuccess = (entry: GuardrailInformation): boolean => {
return (entry.guardrail_status ?? "").toLowerCase() === "success";
type EntryOutcome = "passed" | "flagged" | "failed";
const getEntryOutcome = (entry: GuardrailInformation): EntryOutcome => {
const status = (entry.guardrail_status ?? "").toLowerCase();
if (status === "success") return "passed";
if (status === "guardrail_flagged") return "flagged";
return "failed";
};
const isEntrySuccess = (entry: GuardrailInformation): boolean => getEntryOutcome(entry) === "passed";
const OUTCOME_LABEL: Record<EntryOutcome, string> = {
passed: "PASSED",
flagged: "FLAGGED",
failed: "FAILED",
};
const OUTCOME_BADGE_CLASS: Record<EntryOutcome, string> = {
passed: "bg-success/15 text-success border border-success/20",
flagged: "bg-warning/15 text-warning border border-warning/20",
failed: "bg-destructive/15 text-destructive border border-destructive/20",
};
const getRiskColor = (score: number): string => {
@ -202,6 +221,19 @@ const FailCircleIcon = ({ className }: { className?: string }) => (
</svg>
);
const FlagCircleIcon = ({ className }: { className?: string }) => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" className={className}>
<circle cx="11" cy="11" r="10" stroke="#D97706" strokeWidth="1.5" fill="#FFFBEB" />
<path d="M11 6.5v5M11 14.5v.5" stroke="#D97706" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
const OutcomeIcon = ({ outcome }: { outcome: EntryOutcome }) => {
if (outcome === "passed") return <CheckCircleIcon />;
if (outcome === "flagged") return <FlagCircleIcon />;
return <FailCircleIcon />;
};
const PlayCircleIcon = () => (
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="10" stroke="#3B82F6" strokeWidth="1.5" fill="#EFF6FF" />
@ -318,8 +350,7 @@ interface TimelineEntry {
type: "request" | "guardrail" | "llm" | "response";
label: string;
offsetMs: number;
status?: string;
isSuccess?: boolean;
outcome?: EntryOutcome;
}
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
@ -348,8 +379,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
type: "guardrail",
label: `Pre-call guardrail: ${getDisplayName(e)}`,
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
isSuccess: isEntrySuccess(e),
outcome: getEntryOutcome(e),
});
}
@ -372,8 +402,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
type: "guardrail",
label: `During-call guardrail: ${getDisplayName(e)}`,
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
isSuccess: isEntrySuccess(e),
outcome: getEntryOutcome(e),
});
}
@ -384,8 +413,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
type: "guardrail",
label: `Post-call guardrail: ${getDisplayName(e)}`,
offsetMs,
status: isEntrySuccess(e) ? "PASSED" : "FAILED",
isSuccess: isEntrySuccess(e),
outcome: getEntryOutcome(e),
});
}
@ -410,10 +438,8 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
<GrayDotIcon />
) : item.type === "llm" ? (
<PlayCircleIcon />
) : item.isSuccess ? (
<CheckCircleIcon />
) : (
<FailCircleIcon />
<OutcomeIcon outcome={item.outcome ?? "failed"} />
)}
</div>
{idx < timeline.length - 1 && <div className="w-0.5 bg-border grow" style={{ minHeight: "24px" }} />}
@ -425,13 +451,11 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
<span className={`text-sm ${item.type === "llm" ? "text-info font-medium" : "text-foreground"}`}>
{item.label}
</span>
{item.status && (
{item.outcome && (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${
item.isSuccess ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"
}`}
className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${OUTCOME_BADGE_CLASS[item.outcome]}`}
>
{item.status}
{OUTCOME_LABEL[item.outcome]}
</span>
)}
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">T+{item.offsetMs}ms</span>
@ -455,7 +479,7 @@ const formatGuardrailCost = (cost: number): string => {
const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
const [expanded, setExpanded] = useState(false);
const success = isEntrySuccess(entry);
const outcome = getEntryOutcome(entry);
const totalMasked = getTotalMasked(entry);
const displayName = getDisplayName(entry);
const durationStr = formatDurationMs(entry.duration);
@ -490,7 +514,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
onClick={() => setExpanded(!expanded)}
>
{/* Status icon */}
<div className="shrink-0">{success ? <CheckCircleIcon /> : <FailCircleIcon />}</div>
<div className="shrink-0">
<OutcomeIcon outcome={outcome} />
</div>
{/* Name + badges */}
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
@ -501,13 +527,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
</span>
<span
className={`px-2 py-0.5 rounded text-[11px] font-semibold uppercase shrink-0 ${
success
? "bg-success/15 text-success border border-success/20"
: "bg-destructive/15 text-destructive border border-destructive/20"
}`}
className={`px-2 py-0.5 rounded text-[11px] font-semibold uppercase shrink-0 ${OUTCOME_BADGE_CLASS[outcome]}`}
>
{success ? "PASSED" : "FAILED"}
{OUTCOME_LABEL[outcome]}
</span>
{matchCountStr && (
@ -528,7 +550,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
</span>
)}
{riskScore != null && success && (
{riskScore != null && outcome === "passed" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
@ -673,7 +695,13 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
}, [data]);
const passedCount = guardrailEntries.filter(isEntrySuccess).length;
const flaggedCount = guardrailEntries.filter((e) => getEntryOutcome(e) === "flagged").length;
const allPassed = passedCount === guardrailEntries.length;
const headerOutcome: EntryOutcome = allPassed
? "passed"
: passedCount + flaggedCount === guardrailEntries.length
? "flagged"
: "failed";
const totalOverheadMs = useMemo(() => {
return Math.round(guardrailEntries.reduce((sum, e) => sum + (e.duration ?? 0), 0) * 1000);
@ -709,11 +737,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
</span>
<span className="text-muted-foreground">|</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${
allPassed
? "bg-success/10 text-success border border-success/20"
: "bg-destructive/10 text-destructive border border-destructive/20"
}`}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${OUTCOME_BADGE_CLASS[headerOutcome]}`}
>
{allPassed ? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
@ -728,6 +752,13 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps)
) : null}
{passedCount} Passed
</span>
{flaggedCount > 0 && (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${OUTCOME_BADGE_CLASS.flagged}`}
>
{flaggedCount} Flagged
</span>
)}
</div>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LogDetailContent } from "./LogDetailContent";
import { GuardrailJumpLink, LogDetailContent } from "./LogDetailContent";
import type { LogEntry } from "../columns";
vi.mock("../GuardrailViewer/GuardrailViewer", () => ({
@ -489,3 +489,17 @@ describe("LogDetailContent", () => {
expect(within(descriptions).getByText("-")).toBeInTheDocument();
});
});
describe("GuardrailJumpLink", () => {
it.each([
[["success", "success"], "text-success", "\u2713"],
[["success", "guardrail_flagged"], "text-warning", "\u26A0"],
[["guardrail_flagged", "guardrail_intervened"], "text-destructive", "\u2717"],
])("styles %j as %s", (statuses, expectedClass, glyph) => {
render(<GuardrailJumpLink guardrailEntries={statuses.map((s) => ({ guardrail_status: s }))} />);
const pill = screen.getByText(/2 guardrails evaluated/);
expect(pill).toHaveClass(expectedClass);
expect(pill).toHaveTextContent(glyph);
});
});

View file

@ -635,11 +635,24 @@ function RequestResponseSection({
);
}
const GUARDRAIL_JUMP_LINK_STYLE = {
passed: { className: "border border-success/20 bg-success/10 text-success", glyph: "\u2713" },
flagged: { className: "border border-warning/20 bg-warning/10 text-warning", glyph: "\u26A0" },
failed: { className: "border border-destructive/20 bg-destructive/10 text-destructive", glyph: "\u2717" },
} as const;
const isPassedStatus = (status: unknown) => status === "pass" || status === "passed" || status === "success";
const isFlaggedStatus = (status: unknown) => status === "flagged" || status === "guardrail_flagged";
const guardrailJumpLinkOutcome = (statuses: unknown[]): keyof typeof GUARDRAIL_JUMP_LINK_STYLE => {
if (statuses.every(isPassedStatus)) return "passed";
if (statuses.every((s) => isPassedStatus(s) || isFlaggedStatus(s))) return "flagged";
return "failed";
};
export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) {
const allPassed = guardrailEntries.every((e) => {
const status = e?.guardrail_status || e?.status;
return status === "pass" || status === "passed" || status === "success";
});
const outcome = guardrailJumpLinkOutcome(guardrailEntries.map((e) => e?.guardrail_status || e?.status));
const { className, glyph } = GUARDRAIL_JUMP_LINK_STYLE[outcome];
const handleClick = () => {
const el = document.getElementById("guardrail-section");
@ -650,11 +663,7 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
<div style={{ textAlign: "left", marginBottom: 12 }}>
<div
onClick={handleClick}
className={
allPassed
? "border border-success/20 bg-success/10 text-success"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}
className={className}
style={{
display: "inline-flex",
alignItems: "center",
@ -666,8 +675,8 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
fontWeight: 500,
}}
>
{allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""}{" "}
evaluated
{glyph} {guardrailEntries.length} guardrail
{guardrailEntries.length !== 1 ? "s" : ""} evaluated
<span style={{ fontSize: 11, opacity: 0.7 }}>{"\u2193"}</span>
</div>
</div>