mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
fix(guardrails): stop readers from scoring not_run evaluations as passed
This commit is contained in:
parent
45ff658c6f
commit
26fc1ff221
8 changed files with 147 additions and 14 deletions
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("✗");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue