mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
8544faec91
commit
5df0e12e0f
15 changed files with 386 additions and 60 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) ---------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)": [
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue