fix(guardrails): stop readers from scoring not_run evaluations as passed

This commit is contained in:
Yucheng Zhu 2026-09-01 01:09:01 -07:00
parent 45ff658c6f
commit 26fc1ff221
8 changed files with 147 additions and 14 deletions

View file

@ -26,7 +26,8 @@ class ComplianceChecker:
def __init__(self, data: ComplianceCheckRequest):
self.data = data
self.guardrails = data.guardrail_information or []
# a not_run entry records a guardrail that never evaluated the request, so it cannot evidence compliance
self.guardrails = [g for g in (data.guardrail_information or []) if g.get("guardrail_status") != "not_run"]
def _get_guardrails_by_mode(self, mode: str) -> list[dict]:
"""

View file

@ -256,7 +256,7 @@ class UsageDetailResponse(BaseModel):
class UsageLogEntry(BaseModel):
id: str
timestamp: str
action: str # blocked | passed | flagged
action: str # blocked | passed | flagged | not_run
score: float | None
latency_ms: float | None
model: str | None
@ -691,7 +691,9 @@ def _usage_log_entry_from_row(
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:
if st == "not_run":
action_val = "not_run"
elif "intervened" in st or "block" in st:
action_val = "blocked"
elif "fail" in st or "error" in st:
action_val = "flagged"

View file

@ -9,12 +9,10 @@ orphans), and logs missed their logical-name alias.
"""
from datetime import datetime
from typing import Any, Optional
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from prisma.errors import TableNotFoundError
@ -47,7 +45,7 @@ def _yaml_guardrail(
guardrail_id: str = "yaml-1",
name: str = "yaml-pii",
provider: str = "presidio",
info: Optional[dict] = None,
info: dict | None = None,
) -> Guardrail:
return Guardrail(
guardrail_id=guardrail_id,
@ -433,3 +431,67 @@ async def test_detail_prev_trend_query_is_bounded():
prev_wheres = [w for w in wheres if "lt" in w.get("date", {})]
assert prev_wheres
assert all("gte" in w["date"] for w in prev_wheres)
@pytest.mark.asyncio
async def test_logs_report_not_run_entries_as_not_run_not_passed():
"""LIT-6314: a guardrail that never scanned must not be reported as a pass in the drill-down."""
index_row = MagicMock()
index_row.request_id = "req-nr"
index_row.guardrail_id = "db-1"
index_row.start_time = datetime(2026, 4, 22)
spend_log = MagicMock()
spend_log.request_id = "req-nr"
spend_log.model = "gpt-4o-mini"
spend_log.startTime = datetime(2026, 4, 22)
spend_log.metadata = {
"guardrail_information": [
{"guardrail_name": "db-1", "guardrail_status": "not_run", "duration": 0.0},
]
}
prisma = _prisma(find_unique=_db_row(), index_find_many=[index_row])
prisma.db.litellm_spendlogs.find_many = AsyncMock(return_value=[spend_log])
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_logs(
guardrail_id="db-1",
policy_id=None,
page=1,
page_size=50,
action=None,
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
assert [log.action for log in resp.logs] == ["not_run"]
@pytest.mark.asyncio
async def test_logs_action_passed_filter_excludes_not_run_entries():
"""LIT-6314: filtering the drill-down for passes must not return unscanned requests."""
index_row = MagicMock()
index_row.request_id = "req-nr"
index_row.guardrail_id = "db-1"
index_row.start_time = datetime(2026, 4, 22)
spend_log = MagicMock()
spend_log.request_id = "req-nr"
spend_log.model = "gpt-4o-mini"
spend_log.startTime = datetime(2026, 4, 22)
spend_log.metadata = {"guardrail_information": [{"guardrail_name": "db-1", "guardrail_status": "not_run"}]}
prisma = _prisma(find_unique=_db_row(), index_find_many=[index_row])
prisma.db.litellm_spendlogs.find_many = AsyncMock(return_value=[spend_log])
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_logs(
guardrail_id="db-1",
policy_id=None,
page=1,
page_size=50,
action="passed",
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
assert resp.logs == []

View file

@ -2,10 +2,8 @@
Unit tests for compliance check endpoints (EU AI Act and GDPR).
"""
import pytest
from litellm.proxy.compliance_checks import ComplianceChecker
from litellm.types.proxy.compliance_endpoints import ComplianceCheckRequest
@ -591,3 +589,37 @@ class TestModeMatching:
continue
if matched:
assert mode in _guaranteed_modes(g_mode), (g_mode, mode)
class TestNotRunGuardrails:
"""LIT-6314 logs a not_run entry for a guardrail that message scoping left nothing to scan."""
def test_not_run_alone_never_evidences_compliance(self):
data = ComplianceCheckRequest(
request_id="req-601",
user_id="user-1",
model="gpt-4",
timestamp="2026-02-17T00:00:00Z",
guardrail_information=[
{"guardrail_name": "pii_detection", "guardrail_status": "not_run", "guardrail_mode": "pre_call"},
],
)
results = {c.check_name: c.passed for c in ComplianceChecker(data).check_eu_ai_act()}
assert results["Guardrails applied"] is False
assert results["Content screened before LLM"] is False
assert results["Audit record complete"] is False
def test_not_run_sibling_does_not_fail_a_passing_request(self):
data = ComplianceCheckRequest(
request_id="req-602",
user_id="user-1",
model="gpt-4",
timestamp="2026-02-17T00:00:00Z",
pii_detected=True,
guardrail_information=[
{"guardrail_name": "pii_detection", "guardrail_status": "success", "guardrail_mode": "pre_call"},
{"guardrail_name": "system_only", "guardrail_status": "not_run", "guardrail_mode": "pre_call"},
],
)
results = {c.check_name: c.passed for c in ComplianceChecker(data).check_gdpr()}
assert results["Sensitive data protected"] is True

View file

@ -1,4 +1,4 @@
import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react";
import { CircleCheck, ChevronDown, MinusCircle, TriangleAlert, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import moment from "moment";
import React, { useState } from "react";
@ -10,9 +10,16 @@ import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/column
import type { LogEntry } from "./mockData";
const actionConfig: Record<
"blocked" | "passed" | "flagged",
"blocked" | "passed" | "flagged" | "not_run",
{ icon: React.ElementType; color: string; bg: string; border: string; label: string }
> = {
not_run: {
icon: MinusCircle,
color: "text-muted-foreground",
bg: "bg-muted",
border: "border-border",
label: "Not run",
},
blocked: {
icon: X,
color: "text-destructive",

View file

@ -43,7 +43,7 @@ export interface LogEntry {
input_snippet?: string;
output_snippet?: string;
score?: number;
action: "blocked" | "passed" | "flagged";
action: "blocked" | "passed" | "flagged" | "not_run";
model?: string;
reason?: string;
latency_ms?: number;

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,30 @@ describe("LogDetailContent", () => {
expect(within(descriptions).getByText("-")).toBeInTheDocument();
});
});
describe("GuardrailJumpLink", () => {
it("does not render a not_run entry as a failure", () => {
render(
<GuardrailJumpLink
guardrailEntries={[
{ guardrail_name: "pii-rail", guardrail_status: "success" },
{ guardrail_name: "system-only", guardrail_status: "not_run" },
]}
/>,
);
expect(screen.getByText(/2 guardrails/)).toHaveTextContent("✓");
expect(screen.getByText(/2 guardrails/)).not.toHaveTextContent("✗");
});
it("still renders a real failure as failed", () => {
render(
<GuardrailJumpLink
guardrailEntries={[
{ guardrail_name: "pii-rail", guardrail_status: "guardrail_intervened" },
{ guardrail_name: "system-only", guardrail_status: "not_run" },
]}
/>,
);
expect(screen.getByText(/2 guardrails/)).toHaveTextContent("✗");
});
});

View file

@ -636,7 +636,9 @@ function RequestResponseSection({
}
export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) {
const allPassed = guardrailEntries.every((e) => {
// a not_run entry never evaluated the request, so it neither passes nor fails the banner
const evaluated = guardrailEntries.filter((e) => (e?.guardrail_status || e?.status) !== "not_run");
const allPassed = evaluated.every((e) => {
const status = e?.guardrail_status || e?.status;
return status === "pass" || status === "passed" || status === "success";
});