litellm/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py
yuneng-jiang 6a0d03914c
test: drop the cwd-relative sys.path.insert calls from the test suite (#37802)
* test: drop the cwd-relative sys.path.insert calls from the test suite

TQ003 stands at 1,077 across 1,058 files, and 1,015 of them are the same shape:
sys.path.insert(0, os.path.abspath("../..")) and its deeper siblings. The
argument resolves against the working directory rather than the file, so from
the repo root, where every job runs pytest, it inserts the directory two levels
above the checkout. It has never pointed at litellm. The package is installed
into the environment anyway, which is what actually makes the import work, and
what the rule's message has said all along.

Removing them leaves 1,634 imports of sys and os with no remaining reference,
and those go too, except where another test module imports the name back out of
the file. The rest of TQ003 is 62 call sites that resolve against __file__ or a
variable, which are a different question and are left alone.

Collection is identical either way: 45,871 tests and the same 51 pre-existing
collection errors before and after, and ruff reports no new undefined name.

* test: drop the duplicate imports the sys.path sweep exposed to F811

* test(pre-call-utils): restore the os import the new bedrock tests need
2026-08-22 09:25:58 -07:00

435 lines
17 KiB
Python

"""
Tests for the /guardrails/usage/* endpoints backing the dashboard Guardrail Monitor.
Regression (LIT-2529): guardrails defined in config.yaml live only in
IN_MEMORY_GUARDRAIL_HANDLER, so the monitor's overview/detail/logs endpoints —
which read the litellm_guardrailstable Prisma table — could not see them:
detail 404'd, overview omitted them (or rendered them as Custom/Guardrail
orphans), and logs missed their logical-name alias.
"""
from datetime import datetime
from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from prisma.errors import TableNotFoundError
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_registry import InMemoryGuardrailHandler
from litellm.proxy.guardrails.usage_endpoints import (
guardrails_usage_detail,
guardrails_usage_logs,
guardrails_usage_overview,
policies_usage_overview,
)
from litellm.types.guardrails import Guardrail, LitellmParams
ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
# Query() defaults don't resolve to None when the handler is called directly.
START, END = "2026-04-20", "2026-04-27"
def _config_handler(*guardrails: Guardrail) -> InMemoryGuardrailHandler:
"""A real handler seeded with config-sourced YAML guardrails (no callbacks)."""
handler = InMemoryGuardrailHandler()
for g in guardrails:
gid = g["guardrail_id"]
handler.IN_MEMORY_GUARDRAILS[gid] = g
handler._sources[gid] = "config"
return handler
def _yaml_guardrail(
guardrail_id: str = "yaml-1",
name: str = "yaml-pii",
provider: str = "presidio",
info: Optional[dict] = None,
) -> Guardrail:
return Guardrail(
guardrail_id=guardrail_id,
guardrail_name=name,
litellm_params=LitellmParams(guardrail=provider, mode="pre_call"),
guardrail_info=info if info is not None else {"type": "PII", "description": "yaml-defined"},
)
def _db_row(guardrail_id: str = "db-1", name: str = "db-guard", provider: str = "aim") -> Any:
"""A Prisma-style row: attribute access, litellm_params/guardrail_info as plain dicts."""
row = MagicMock(spec=["guardrail_id", "guardrail_name", "litellm_params", "guardrail_info"])
row.guardrail_id = guardrail_id
row.guardrail_name = name
row.litellm_params = {"guardrail": provider, "mode": "pre_call"}
row.guardrail_info = {"type": "ContentSafety", "description": "db-defined"}
return row
def _metric(guardrail_id: str, date: str = "2026-04-25", requests: int = 10, passed: int = 8, blocked: int = 2) -> Any:
m = MagicMock()
m.guardrail_id = guardrail_id
m.date = date
m.requests_evaluated = requests
m.passed_count = passed
m.blocked_count = blocked
m.flagged_count = 0
return m
def _units_row(
guardrail_id: str,
date: str = "2026-04-25",
team_id: str = "",
api_key: str = "",
usage_unit: str = "contentPolicyUnits",
units: int = 1,
) -> Any:
r = MagicMock()
r.guardrail_id = guardrail_id
r.date = date
r.team_id = team_id
r.api_key = api_key
r.usage_unit = usage_unit
r.units = units
return r
def _prisma(
*,
find_many=None,
find_unique=None,
metrics=None,
index_find_many=None,
units=None,
) -> MagicMock:
client = MagicMock()
db = client.db
db.litellm_guardrailstable.find_many = AsyncMock(return_value=find_many or [])
db.litellm_guardrailstable.find_unique = AsyncMock(return_value=find_unique)
db.litellm_dailyguardrailmetrics.find_many = AsyncMock(return_value=metrics or [])
db.litellm_dailyguardrailusageunits.find_many = AsyncMock(return_value=units or [])
db.litellm_spendlogguardrailindex.find_many = AsyncMock(return_value=index_find_many or [])
db.litellm_spendlogguardrailindex.count = AsyncMock(return_value=0)
db.litellm_spendlogs.find_many = AsyncMock(return_value=[])
return client
def _patches(prisma: MagicMock, handler: InMemoryGuardrailHandler):
return (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", handler),
)
# ---- detail -----------------------------------------------------------------
@pytest.mark.asyncio
async def test_detail_returns_yaml_guardrail_when_db_misses():
prisma = _prisma(find_unique=None)
handler = _config_handler(_yaml_guardrail())
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_detail(
guardrail_id="yaml-1", start_date=START, end_date=END, user_api_key_dict=ADMIN
)
assert resp.guardrail_id == "yaml-1"
assert resp.guardrail_name == "yaml-pii"
assert resp.provider == "presidio" # coerced from the LitellmParams pydantic model
assert resp.type == "PII" # from guardrail_info
assert resp.description == "yaml-defined"
@pytest.mark.asyncio
async def test_detail_404_when_neither_db_nor_config():
prisma = _prisma(find_unique=None)
handler = _config_handler() # empty
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_detail(guardrail_id="ghost", start_date=START, end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_detail_does_not_surface_db_sourced_in_memory_entry():
"""A stale in-memory entry (source=db, gone from DB) must 404, not resurface."""
prisma = _prisma(find_unique=None)
handler = InMemoryGuardrailHandler()
stale = _yaml_guardrail(guardrail_id="stale-1", name="stale")
handler.IN_MEMORY_GUARDRAILS["stale-1"] = stale
handler._sources["stale-1"] = "db"
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_detail(guardrail_id="stale-1", start_date=START, end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_detail_db_row_still_resolves():
prisma = _prisma(find_unique=_db_row(guardrail_id="db-1", provider="aim"))
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_detail(
guardrail_id="db-1", start_date=START, end_date=END, user_api_key_dict=ADMIN
)
assert resp.provider == "aim"
assert resp.type == "ContentSafety"
# ---- overview ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_overview_includes_yaml_guardrail_with_no_metrics():
"""The core bug: a YAML guardrail with zero metrics must still appear as a row."""
prisma = _prisma(find_many=[]) # no DB guardrails
handler = _config_handler(_yaml_guardrail())
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
rows = [r for r in resp.rows if r.id == "yaml-1"]
assert len(rows) == 1
assert rows[0].name == "yaml-pii"
assert rows[0].provider == "presidio"
assert rows[0].type == "PII"
assert rows[0].requestsEvaluated == 0
@pytest.mark.asyncio
async def test_overview_yaml_metrics_matched_by_logical_name():
"""Daily metrics are keyed by logical name; the YAML row must pick them up."""
prisma = _prisma(
find_many=[],
metrics=[_metric("yaml-pii", requests=10, blocked=2)], # keyed by name, not uuid
)
handler = _config_handler(_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii"))
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
rows = [r for r in resp.rows if r.id == "yaml-uuid"]
assert len(rows) == 1
assert rows[0].requestsEvaluated == 10
assert rows[0].failRate == 20.0
# must not also emit an orphan row keyed by the logical name
assert [r for r in resp.rows if r.id == "yaml-pii"] == []
@pytest.mark.asyncio
async def test_overview_excludes_db_sourced_in_memory_entry():
"""union must not resurrect a stale db-sourced in-memory guardrail."""
prisma = _prisma(find_many=[])
handler = InMemoryGuardrailHandler()
handler.IN_MEMORY_GUARDRAILS["cfg"] = _yaml_guardrail(guardrail_id="cfg", name="cfg-guard")
handler._sources["cfg"] = "config"
handler.IN_MEMORY_GUARDRAILS["stale"] = _yaml_guardrail(guardrail_id="stale", name="stale-guard")
handler._sources["stale"] = "db"
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
ids = {r.id for r in resp.rows}
assert "cfg" in ids
assert "stale" not in ids
@pytest.mark.asyncio
async def test_overview_reports_usage_units_per_row_and_total():
"""LIT-5650: billable units must surface per guardrail row (matched by
logical name like the daily metrics) and as a response-level total."""
prisma = _prisma(
find_many=[],
metrics=[_metric("yaml-pii", requests=4, passed=3, blocked=1)],
units=[
_units_row("yaml-pii", usage_unit="topicPolicyUnits", units=4),
_units_row("yaml-pii", usage_unit="contentPolicyUnits", units=3),
_units_row("yaml-pii", team_id="team-a", usage_unit="contentPolicyUnits", units=2),
_units_row("other-guard", usage_unit="topicPolicyUnits", units=7),
],
)
handler = _config_handler(_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii"))
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
row = next(r for r in resp.rows if r.id == "yaml-uuid")
assert row.usageUnits == {"topicPolicyUnits": 4, "contentPolicyUnits": 5}
assert resp.totalUsageUnits == {"topicPolicyUnits": 11, "contentPolicyUnits": 5}
units_where = prisma.db.litellm_dailyguardrailusageunits.find_many.call_args.kwargs["where"]
assert units_where == {"date": {"gte": START, "lte": END}}
@pytest.mark.asyncio
async def test_detail_breaks_units_down_by_day_team_and_key():
prisma = _prisma(
find_unique=None,
units=[
_units_row("yaml-pii", date="2026-04-25", team_id="team-a", api_key="hash-1", units=2),
_units_row("yaml-pii", date="2026-04-25", team_id="", api_key="hash-2", units=1),
_units_row(
"yaml-pii", date="2026-04-24", team_id="team-a", api_key="hash-1", usage_unit="topicPolicyUnits"
),
],
)
handler = _config_handler(_yaml_guardrail())
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_detail(
guardrail_id="yaml-1", start_date=START, end_date=END, user_api_key_dict=ADMIN
)
assert resp.usage_units == {"contentPolicyUnits": 3, "topicPolicyUnits": 1}
assert [p.model_dump() for p in resp.usage_units_daily] == [
{"date": "2026-04-24", "units": {"topicPolicyUnits": 1}},
{"date": "2026-04-25", "units": {"contentPolicyUnits": 3}},
]
assert resp.usage_units_by_team == {
"team-a": {"contentPolicyUnits": 2, "topicPolicyUnits": 1},
"": {"contentPolicyUnits": 1},
}
assert resp.usage_units_by_key == {
"hash-1": {"contentPolicyUnits": 2, "topicPolicyUnits": 1},
"hash-2": {"contentPolicyUnits": 1},
}
units_where = prisma.db.litellm_dailyguardrailusageunits.find_many.call_args.kwargs["where"]
assert units_where == {"guardrail_id": {"in": ["yaml-pii", "yaml-1"]}, "date": {"gte": START, "lte": END}}
def _units_table_missing() -> TableNotFoundError:
return TableNotFoundError(
data={"user_facing_error": {"meta": {"table": "public.LiteLLM_DailyGuardrailUsageUnits"}}}
)
@pytest.mark.asyncio
async def test_overview_degrades_units_to_empty_when_units_table_is_missing():
prisma = _prisma(metrics=[_metric("yaml-pii", requests=4, passed=3, blocked=1)])
prisma.db.litellm_dailyguardrailusageunits.find_many = AsyncMock(side_effect=_units_table_missing())
handler = _config_handler(_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii"))
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
row = next(r for r in resp.rows if r.id == "yaml-uuid")
assert (row.requestsEvaluated, row.usageUnits) == (4, {})
assert (resp.totalRequests, resp.totalBlocked, resp.totalUsageUnits) == (4, 1, {})
@pytest.mark.asyncio
async def test_detail_degrades_units_to_empty_when_units_table_is_missing():
prisma = _prisma(metrics=[_metric("yaml-pii", requests=4, passed=3, blocked=1)])
prisma.db.litellm_dailyguardrailusageunits.find_many = AsyncMock(side_effect=_units_table_missing())
handler = _config_handler(_yaml_guardrail())
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_detail(
guardrail_id="yaml-1", start_date=START, end_date=END, user_api_key_dict=ADMIN
)
assert (resp.requestsEvaluated, resp.failRate) == (4, 25.0)
assert (resp.usage_units, list(resp.usage_units_daily), resp.usage_units_by_team, resp.usage_units_by_key) == (
{},
[],
{},
{},
)
# ---- logs -------------------------------------------------------------------
@pytest.mark.asyncio
async def test_logs_resolves_config_guardrail_logical_name():
"""The index query must include the YAML guardrail's logical name alias."""
prisma = _prisma(find_unique=None)
handler = _config_handler(_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii"))
p1, p2 = _patches(prisma, handler)
with p1, p2:
await guardrails_usage_logs(
guardrail_id="yaml-uuid",
policy_id=None,
page=1,
page_size=50,
action=None,
start_date=START,
end_date=END,
user_api_key_dict=ADMIN,
)
where = prisma.db.litellm_spendlogguardrailindex.find_many.call_args.kwargs["where"]
assert where["guardrail_id"] == {"in": ["yaml-uuid", "yaml-pii"]}
# ---- date window cap (LIT-5762) ---------------------------------------------
@pytest.mark.asyncio
async def test_overview_rejects_range_over_max_days():
prisma = _prisma()
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_overview(start_date="2020-01-01", end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 400
assert "366" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_overview_accepts_range_at_exactly_max_days():
prisma = _prisma()
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date="2025-04-26", end_date="2026-04-27", user_api_key_dict=ADMIN)
assert resp.totalRequests == 0
@pytest.mark.asyncio
async def test_overview_rejects_malformed_dates():
prisma = _prisma()
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_overview(start_date="not-a-date", end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_overview_rejects_non_canonical_date_format():
prisma = _prisma()
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_overview(start_date="20260420", end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 400
assert "YYYY-MM-DD" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_detail_rejects_reversed_dates():
prisma = _prisma(find_unique=_db_row())
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await guardrails_usage_detail(guardrail_id="db-1", start_date=END, end_date=START, user_api_key_dict=ADMIN)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_policies_overview_rejects_range_over_max_days():
prisma = _prisma()
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2, pytest.raises(HTTPException) as exc:
await policies_usage_overview(start_date="2020-01-01", end_date=END, user_api_key_dict=ADMIN)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_detail_prev_trend_query_is_bounded():
"""Regression: the trend query scanned every metrics row before start_date."""
prisma = _prisma(find_unique=_db_row())
handler = _config_handler()
p1, p2 = _patches(prisma, handler)
with p1, p2:
await guardrails_usage_detail(guardrail_id="db-1", start_date=START, end_date=END, user_api_key_dict=ADMIN)
wheres = [c.kwargs["where"] for c in prisma.db.litellm_dailyguardrailmetrics.find_many.await_args_list]
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)