feat(guardrails): roll up Bedrock guardrail cost per usage counter

The daily guardrail usage rollup stored billable units per counter but no
cost, so the usage endpoints could only report units. The Bedrock hook now
stamps guardrail_cost_by_unit next to guardrail_usage, the spend-log
aggregator sums it into a new nullable cost column on
LiteLLM_DailyGuardrailUsageUnits, and /guardrails/usage/overview and
/guardrails/usage/detail/{id} return cost, totalCost and cost_by_unit /
cost_by_team / cost_by_key alongside the existing unit breakdowns.

Cost is nullable on purpose. Rows written before this migration, and rows
whose hook had no pricing entry, read as null rather than $0, and a single
unpriced increment keeps that row's cost unknown instead of partial.
guardrail_cost and the spend/budget path are untouched.

Claude-Session: https://claude.ai/code/session_01EX13mWex6RaBo9PYnkAtFW
This commit is contained in:
ryan-crabbe-berri 2026-09-01 14:45:12 -07:00
parent 8bc862f52c
commit 4914914801
17 changed files with 605 additions and 56 deletions

View file

@ -93,7 +93,7 @@
"limit": 181
},
"reportTypedDictNotRequiredAccess": {
"limit": 24
"limit": 22
},
"reportUndefinedVariable": {
"limit": 0

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_DailyGuardrailUsageUnits" ADD COLUMN IF NOT EXISTS "cost" DOUBLE PRECISION;

View file

@ -1123,6 +1123,7 @@ model LiteLLM_DailyGuardrailUsageUnits {
api_key String // hashed virtual key; empty string when unknown
usage_unit String // provider counter name, e.g. Bedrock's contentPolicyUnits
units BigInt @default(0)
cost Float? // USD billed for these units; null when any contributing increment was unpriced
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -1,8 +1,8 @@
import math
from collections.abc import Mapping
from typing import Final
from typing import Annotated, Final
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger
@ -30,6 +30,30 @@ class GuardrailCostEntry(BaseModel):
_GUARDRAIL_COST_ENTRY_ADAPTER: Final[TypeAdapter[GuardrailCostEntry]] = TypeAdapter(GuardrailCostEntry)
class GuardrailCostByUnitEntry(BaseModel):
"""The rollup-side view of a ``guardrail_information`` entry, validated apart from
``GuardrailCostEntry`` so a forged per-counter map can never zero the spend path."""
model_config = ConfigDict(extra="ignore", frozen=True)
guardrail_cost_by_unit: Mapping[str, Annotated[float, Field(ge=0, allow_inf_nan=False)]] | None = None
guardrail_cost_in_spend: bool | None = True
_GUARDRAIL_COST_BY_UNIT_ADAPTER: Final[TypeAdapter[GuardrailCostByUnitEntry]] = TypeAdapter(GuardrailCostByUnitEntry)
def billed_guardrail_cost_by_unit(raw: object) -> Mapping[str, float] | None:
"""Per-counter USD the daily rollup may record for one raw ``guardrail_information``
entry; None when the entry is unpriced, report-only, or malformed."""
try:
entry: Final = _GUARDRAIL_COST_BY_UNIT_ADAPTER.validate_python(raw)
except ValidationError as e:
verbose_logger.warning("Ignoring malformed guardrail_information entry for guardrail cost rollup: %s", e)
return None
return None if entry.guardrail_cost_in_spend is False else entry.guardrail_cost_by_unit
def _bedrock_guardrail_pricing(aws_region_name: str | None) -> GuardrailPricing | None:
regional_key: Final = f"bedrock/{aws_region_name}/guardrails" if aws_region_name else None
for key in (regional_key, BEDROCK_GUARDRAIL_PRICING_KEY):
@ -42,11 +66,24 @@ def _bedrock_guardrail_pricing(aws_region_name: str | None) -> GuardrailPricing
return None
def bedrock_guardrail_cost(usage_units: Mapping[str, int], aws_region_name: str | None) -> float:
def bedrock_guardrail_cost_by_unit(
usage_units: Mapping[str, int], aws_region_name: str | None
) -> Mapping[str, float] | None:
"""USD per counter, keyed like ``usage_units``; None when no pricing entry exists."""
pricing: Final = _bedrock_guardrail_pricing(aws_region_name)
if pricing is None:
return 0.0
return sum(units * pricing.guardrail_cost_per_unit.get(counter, 0.0) for counter, units in usage_units.items())
return None
return { # mutable-ok: stamped into guardrail_information, which safe_dumps only serializes as a plain dict
counter: units * pricing.guardrail_cost_per_unit.get(counter, 0.0) for counter, units in usage_units.items()
}
def guardrail_cost_total(cost_by_unit: Mapping[str, float] | None) -> float:
return sum(cost_by_unit.values()) if cost_by_unit is not None else 0.0
def bedrock_guardrail_cost(usage_units: Mapping[str, int], aws_region_name: str | None) -> float:
return guardrail_cost_total(bedrock_guardrail_cost_by_unit(usage_units, aws_region_name))
AZURE_PROMPT_SHIELD_TEXT_RECORD_UNIT: Final = "text_records"

View file

@ -13039,6 +13039,59 @@
],
"title": "Avgscore"
},
"cost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cost"
},
"cost_by_key": {
"additionalProperties": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"title": "Cost By Key",
"type": "object"
},
"cost_by_team": {
"additionalProperties": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"title": "Cost By Team",
"type": "object"
},
"cost_by_unit": {
"additionalProperties": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"title": "Cost By Unit",
"type": "object"
},
"description": {
"anyOf": [
{
@ -13140,7 +13193,11 @@
"usage_units",
"usage_units_daily",
"usage_units_by_team",
"usage_units_by_key"
"usage_units_by_key",
"cost",
"cost_by_unit",
"cost_by_team",
"cost_by_key"
],
"title": "UsageDetailResponse",
"type": "object"
@ -13295,6 +13352,17 @@
"title": "Totalblocked",
"type": "integer"
},
"totalCost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Totalcost"
},
"totalRequests": {
"title": "Totalrequests",
"type": "integer"
@ -13313,7 +13381,8 @@
"totalRequests",
"totalBlocked",
"passRate",
"totalUsageUnits"
"totalUsageUnits",
"totalCost"
],
"title": "UsageOverviewResponse",
"type": "object"
@ -13342,6 +13411,17 @@
],
"title": "Avgscore"
},
"cost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cost"
},
"failRate": {
"title": "Failrate",
"type": "number"
@ -13393,13 +13473,25 @@
"avgLatency",
"status",
"trend",
"usageUnits"
"usageUnits",
"cost"
],
"title": "UsageOverviewRow",
"type": "object"
},
"UsageUnitsDailyPoint": {
"properties": {
"cost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cost"
},
"date": {
"title": "Date",
"type": "string"
@ -13414,7 +13506,8 @@
},
"required": [
"date",
"units"
"units",
"cost"
],
"title": "UsageUnitsDailyPoint",
"type": "object"
@ -28773,6 +28866,17 @@
"title": "Totalblocked",
"type": "integer"
},
"totalCost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Totalcost"
},
"totalRequests": {
"title": "Totalrequests",
"type": "integer"
@ -28791,7 +28895,8 @@
"totalRequests",
"totalBlocked",
"passRate",
"totalUsageUnits"
"totalUsageUnits",
"totalCost"
],
"title": "UsageOverviewResponse",
"type": "object"
@ -28820,6 +28925,17 @@
],
"title": "Avgscore"
},
"cost": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Cost"
},
"failRate": {
"title": "Failrate",
"type": "number"
@ -28871,7 +28987,8 @@
"avgLatency",
"status",
"trend",
"usageUnits"
"usageUnits",
"cost"
],
"title": "UsageOverviewRow",
"type": "object"

View file

@ -35,7 +35,10 @@ from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_rege
from litellm.litellm_core_utils.litellm_logging import (
_get_masked_values, # pyright: ignore[reportPrivateUsage] # the shared header-masking helper has no public name
)
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import bedrock_guardrail_cost
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
bedrock_guardrail_cost_by_unit,
guardrail_cost_total,
)
from litellm.llms.anthropic.chat.guardrail_translation.handler import AnthropicMessagesHandler
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_scan_only_tool_results_for_guardrail,
@ -109,6 +112,7 @@ _BEDROCK_TOO_LARGE_ERROR_SUBSTRINGS: Final = (
_BEDROCK_APPLY_GUARDRAIL_MAX_THROTTLE_RETRIES: Final = 3
_BEDROCK_APPLY_GUARDRAIL_BASE_BACKOFF_SECONDS: Final = 0.5
_BEDROCK_WHITESPACE: Final = re.compile(r"\s")
_NO_TRACING_DETAIL: Final[GuardrailTracingDetail] = {}
# Resource-less, detect-only InvokeGuardrailChecks API (no guardrail resource required).
_BEDROCK_INVOKE_GUARDRAIL_CHECKS_PATH: Final = "/guardrail-checks/invoke"
# InvokeGuardrailChecks accepts at most 10 content blocks per message. A message with
@ -2147,25 +2151,37 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
OTEL integration can expose it as a queryable span attribute
without re-parsing the redacted guardrail_response blob.
"""
tracing_detail: Final[GuardrailTracingDetail] = {}
violation_categories: Final = self._extract_violation_category_names(response)
if violation_categories:
tracing_detail["violation_categories"] = violation_categories
bedrock_action: Final = response.get("action")
if isinstance(bedrock_action, str):
tracing_detail["guardrail_action"] = bedrock_action
usage: Final = response.get("usage")
if isinstance(usage, dict):
usage_units: Final = { # mutable-ok: json.dumps'd into spend log metadata downstream
key: value for key, value in usage.items() if isinstance(value, int)
}
if usage_units:
tracing_detail["guardrail_usage"] = usage_units
tracing_detail["guardrail_cost"] = bedrock_guardrail_cost(
usage_units=usage_units, aws_region_name=aws_region_name
)
categories_detail: Final[GuardrailTracingDetail] = {"violation_categories": violation_categories}
action_detail: Final[GuardrailTracingDetail] = {"guardrail_action": bedrock_action}
tracing_detail: Final[GuardrailTracingDetail] = {
**(categories_detail if violation_categories else _NO_TRACING_DETAIL),
**(action_detail if isinstance(bedrock_action, str) else _NO_TRACING_DETAIL),
**self._usage_tracing_detail(response.get("usage"), aws_region_name),
}
return tracing_detail
@staticmethod
def _usage_tracing_detail(
usage: BedrockGuardrailUsage | None, aws_region_name: str | None
) -> GuardrailTracingDetail:
if not isinstance(usage, dict):
return _NO_TRACING_DETAIL
usage_units: Final = { # mutable-ok: json.dumps'd into spend log metadata downstream
key: value for key, value in usage.items() if isinstance(value, int)
}
if not usage_units:
return _NO_TRACING_DETAIL
cost_by_unit: Final = bedrock_guardrail_cost_by_unit(usage_units=usage_units, aws_region_name=aws_region_name)
priced_detail: Final[GuardrailTracingDetail] = {"guardrail_cost_by_unit": cost_by_unit}
usage_detail: Final[GuardrailTracingDetail] = {
"guardrail_usage": usage_units,
"guardrail_cost": guardrail_cost_total(cost_by_unit),
**(priced_detail if cost_by_unit is not None else _NO_TRACING_DETAIL),
}
return usage_detail
def _extract_violation_category_names(self, response: BedrockGuardrailResponse) -> list[str]:
"""
Flatten the BLOCKED assessments into a list of human-readable category

View file

@ -169,6 +169,20 @@ def _units_by(
return MappingProxyType({key: _sum_counter_units(group) for key, group in groupby(ordered, key=key_of)})
def _sum_tracked_cost(rows: "Iterable[prisma_models.LiteLLM_DailyGuardrailUsageUnits]") -> float | None:
"""Sum over rows with a tracked cost; None when no row has one (pre-migration or unpriced)."""
tracked: Final = tuple(r.cost for r in rows if r.cost is not None)
return sum(tracked) if tracked else None
def _cost_by(
rows: "Sequence[prisma_models.LiteLLM_DailyGuardrailUsageUnits]",
key_of: "Callable[[prisma_models.LiteLLM_DailyGuardrailUsageUnits], str]",
) -> Mapping[str, float | None]:
ordered: Final = sorted(rows, key=key_of)
return MappingProxyType({key: _sum_tracked_cost(group) for key, group in groupby(ordered, key=key_of)})
# --- Response models ---
@ -218,6 +232,8 @@ class UsageOverviewRow(BaseModel):
status: str # healthy | warning | critical
trend: str # up | down | stable
usageUnits: Mapping[str, int]
cost: float | None
"""USD billed for usageUnits over the window, summed over days with tracked cost; null when none have it."""
class UsageOverviewResponse(BaseModel):
@ -227,11 +243,18 @@ class UsageOverviewResponse(BaseModel):
totalBlocked: int
passRate: float
totalUsageUnits: Mapping[str, int]
totalCost: float | None
_EMPTY_OVERVIEW: Final = UsageOverviewResponse(
rows=[], chart=[], totalRequests=0, totalBlocked=0, passRate=100.0, totalUsageUnits=_EMPTY_UNITS, totalCost=None
)
class UsageUnitsDailyPoint(BaseModel):
date: str
units: Mapping[str, int]
cost: float | None
class UsageDetailResponse(BaseModel):
@ -251,6 +274,10 @@ class UsageDetailResponse(BaseModel):
usage_units_daily: Sequence[UsageUnitsDailyPoint]
usage_units_by_team: Mapping[str, Mapping[str, int]]
usage_units_by_key: Mapping[str, Mapping[str, int]]
cost: float | None
cost_by_unit: Mapping[str, float | None]
cost_by_team: Mapping[str, float | None]
cost_by_key: Mapping[str, float | None]
class UsageLogEntry(BaseModel):
@ -367,6 +394,7 @@ def _guardrail_overview_rows(
agg: Mapping[str, _MetricTotals],
prev_agg: Mapping[str, float],
units_agg: Mapping[str, Mapping[str, int]],
cost_agg: Mapping[str, float | None],
) -> list[UsageOverviewRow]:
rows: Final[list[UsageOverviewRow]] = []
covered_keys: Final[set[str]] = set()
@ -393,6 +421,7 @@ def _guardrail_overview_rows(
break
trend = _trend_from_comparison(fail_rate, prev_fail)
row_units: Mapping[str, int] = next((units_agg[k] for k in lookup_keys if k in units_agg), _EMPTY_UNITS)
row_cost: float | None = next((cost_agg[k] for k in lookup_keys if k in cost_agg), None)
rows.append(
UsageOverviewRow(
id=gid,
@ -406,6 +435,7 @@ def _guardrail_overview_rows(
status=_status_from_fail_rate(fail_rate),
trend=trend,
usageUnits=row_units,
cost=row_cost,
)
)
# Add rows for guardrails with metrics but not in guardrails table (e.g. MCP, config)
@ -429,6 +459,7 @@ def _guardrail_overview_rows(
status=_status_from_fail_rate(fail_rate),
trend=trend,
usageUnits=units_agg.get(agg_key, _EMPTY_UNITS),
cost=cost_agg.get(agg_key),
)
)
return rows
@ -459,6 +490,7 @@ def _policy_overview_rows(
status=_status_from_fail_rate(fail_rate),
trend=trend,
usageUnits=_EMPTY_UNITS,
cost=None,
)
)
return rows
@ -479,9 +511,7 @@ async def guardrails_usage_overview(
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return UsageOverviewResponse(
rows=[], chart=[], totalRequests=0, totalBlocked=0, passRate=100.0, totalUsageUnits=_EMPTY_UNITS
)
return _EMPTY_OVERVIEW
start, end = _resolve_usage_window(start_date, end_date)
@ -516,11 +546,12 @@ async def guardrails_usage_overview(
agg: Final = _aggregate_daily_metrics(metrics, "guardrail_id")
prev_agg: Final = _prev_fail_rates(metrics_prev, "guardrail_id")
units_agg: Final = _units_by(units_rows, lambda r: r.guardrail_id)
cost_agg: Final = _cost_by(units_rows, lambda r: r.guardrail_id)
chart: Final = _chart_from_metrics(metrics)
total_requests: Final = sum(a["requests"] for a in agg.values())
total_blocked: Final = sum(a["blocked"] for a in agg.values())
pass_rate: Final = (100.0 * (total_requests - total_blocked) / total_requests) if total_requests else 100.0
rows: Final = _guardrail_overview_rows(guardrails, agg, prev_agg, units_agg)
rows: Final = _guardrail_overview_rows(guardrails, agg, prev_agg, units_agg, cost_agg)
return UsageOverviewResponse(
rows=rows,
chart=chart,
@ -528,6 +559,7 @@ async def guardrails_usage_overview(
totalBlocked=total_blocked,
passRate=round(pass_rate, 1),
totalUsageUnits=_sum_counter_units(units_rows),
totalCost=_sum_tracked_cost(units_rows),
)
except Exception as e:
from litellm.proxy.utils import handle_exception_on_proxy
@ -619,7 +651,10 @@ async def guardrails_usage_detail(
guardrail_info: Final = _to_dict(_get_guardrail_field(guardrail, "guardrail_info"))
_guardrail_name: Final = _get_guardrail_field(guardrail, "guardrail_name")
daily_unit_sums: Final = sorted(_units_by(units_rows, lambda r: r.date).items())
units_daily: Final = tuple(UsageUnitsDailyPoint(date=d, units=units) for d, units in daily_unit_sums)
daily_cost: Final = _cost_by(units_rows, lambda r: r.date)
units_daily: Final = tuple(
UsageUnitsDailyPoint(date=d, units=units, cost=daily_cost.get(d)) for d, units in daily_unit_sums
)
return UsageDetailResponse(
guardrail_id=guardrail_id,
@ -638,6 +673,10 @@ async def guardrails_usage_detail(
usage_units_daily=units_daily,
usage_units_by_team=_units_by(units_rows, lambda r: r.team_id),
usage_units_by_key=_units_by(units_rows, lambda r: r.api_key),
cost=_sum_tracked_cost(units_rows),
cost_by_unit=_cost_by(units_rows, _counter_name),
cost_by_team=_cost_by(units_rows, lambda r: r.team_id),
cost_by_key=_cost_by(units_rows, lambda r: r.api_key),
)
@ -857,9 +896,7 @@ async def policies_usage_overview(
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
return UsageOverviewResponse(
rows=[], chart=[], totalRequests=0, totalBlocked=0, passRate=100.0, totalUsageUnits=_EMPTY_UNITS
)
return _EMPTY_OVERVIEW
start, end = _resolve_usage_window(start_date, end_date)
@ -891,6 +928,7 @@ async def policies_usage_overview(
totalBlocked=total_blocked,
passRate=round(pass_rate, 1),
totalUsageUnits=_EMPTY_UNITS,
totalCost=None,
)
except Exception as e:
from litellm.proxy.utils import handle_exception_on_proxy

View file

@ -6,7 +6,7 @@ insert into SpendLogGuardrailIndex when spend logs are written.
import asyncio
import json
from collections import defaultdict
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence
from datetime import datetime, timezone
from functools import partial
from itertools import groupby
@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, Final, NamedTuple, TypeVar
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import billed_guardrail_cost_by_unit
from litellm.proxy._types import DB_RETRY_SAFE_ERROR_TYPES
from litellm.proxy.utils import PrismaClient
from litellm.repositories.table_repositories import (
@ -44,6 +45,11 @@ class _UsageUnitKey(NamedTuple):
usage_unit: str
class _UsageUnitIncrement(NamedTuple):
units: int
cost: float | None
class _MetricsKey(NamedTuple):
guardrail_id: str
date: str
@ -67,22 +73,38 @@ class PendingRollups:
def __init__(self) -> None:
self.lock: Final = asyncio.Lock()
self.metrics: Mapping[_MetricsKey, Mapping[str, int]] = MappingProxyType({})
self.units: Mapping[_UsageUnitKey, int] = MappingProxyType({})
self.units: Mapping[_UsageUnitKey, _UsageUnitIncrement] = MappingProxyType({})
_PENDING_ROLLUPS: Final = PendingRollups()
_NO_COUNTERS: Final[Mapping[str, int]] = MappingProxyType({})
_NO_INCREMENT: Final = _UsageUnitIncrement(units=0, cost=0.0)
def _merged_keys(base: Mapping[_RowKey, object], extra: Mapping[_RowKey, object]) -> tuple[_RowKey, ...]:
return (*base, *(key for key in extra if key not in base))
def _summed_increments(increments: Iterable[_UsageUnitIncrement]) -> _UsageUnitIncrement:
"""Units add; cost adds too unless any increment was unpriced, which makes the sum unknown."""
materialized: Final = tuple(increments)
costs: Final = tuple(i.cost for i in materialized)
return _UsageUnitIncrement(
units=sum(i.units for i in materialized),
cost=None if any(c is None for c in costs) else sum(c for c in costs if c is not None),
)
def _merged_unit_rows(
base: Mapping[_UsageUnitKey, int], extra: Mapping[_UsageUnitKey, int]
) -> Mapping[_UsageUnitKey, int]:
return MappingProxyType({key: base.get(key, 0) + extra.get(key, 0) for key in _merged_keys(base, extra)})
base: Mapping[_UsageUnitKey, _UsageUnitIncrement], extra: Mapping[_UsageUnitKey, _UsageUnitIncrement]
) -> Mapping[_UsageUnitKey, _UsageUnitIncrement]:
return MappingProxyType(
{
key: _summed_increments((base.get(key, _NO_INCREMENT), extra.get(key, _NO_INCREMENT)))
for key in _merged_keys(base, extra)
}
)
def _merged_metric_rows(
@ -209,7 +231,9 @@ def _parse_payload_start_time(payload: Mapping[str, Any]) -> datetime | None:
return None
def _iter_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) -> Iterator[tuple[_UsageUnitKey, int]]:
def _iter_usage_unit_increments(
logs_to_process: Sequence[Mapping[str, Any]],
) -> Iterator[tuple[_UsageUnitKey, _UsageUnitIncrement]]:
for payload in logs_to_process:
start_time = _parse_payload_start_time(payload)
if not payload.get("request_id") or start_time is None:
@ -222,26 +246,37 @@ def _iter_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) ->
usage = entry.get("guardrail_usage")
if not guardrail_id or not isinstance(usage, dict):
continue
cost_by_unit = billed_guardrail_cost_by_unit(entry)
for unit_name, units in usage.items():
if isinstance(units, int) and not isinstance(units, bool) and units > 0:
yield _UsageUnitKey(guardrail_id, date_key, team_id, api_key, str(unit_name)), units
key = _UsageUnitKey(guardrail_id, date_key, team_id, api_key, str(unit_name))
cost = cost_by_unit.get(str(unit_name)) if cost_by_unit is not None else None
yield key, _UsageUnitIncrement(units=units, cost=cost)
def _sum_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) -> Mapping[_UsageUnitKey, int]:
def _sum_usage_unit_increments(
logs_to_process: Sequence[Mapping[str, Any]],
) -> Mapping[_UsageUnitKey, _UsageUnitIncrement]:
ordered: Final = sorted(_iter_usage_unit_increments(logs_to_process), key=itemgetter(0))
return MappingProxyType(
{key: sum(units for _, units in group) for key, group in groupby(ordered, key=itemgetter(0))}
{
key: _summed_increments(increment for _, increment in group)
for key, group in groupby(ordered, key=itemgetter(0))
}
)
async def _upsert_usage_unit_row(prisma_client: PrismaClient, key: _UsageUnitKey, units: int) -> None:
async def _upsert_usage_unit_row(
prisma_client: PrismaClient, key: _UsageUnitKey, increment: _UsageUnitIncrement
) -> None:
row: Final[prisma_types.LiteLLM_DailyGuardrailUsageUnitsCreateInput] = {
"guardrail_id": key.guardrail_id,
"date": key.date,
"team_id": key.team_id,
"api_key": key.api_key,
"usage_unit": key.usage_unit,
"units": units,
"units": increment.units,
"cost": increment.cost,
}
where: Final[_UsageUnitWhereUnique] = {
"guardrail_id_date_team_id_api_key_usage_unit": {
@ -252,9 +287,13 @@ async def _upsert_usage_unit_row(prisma_client: PrismaClient, key: _UsageUnitKey
"usage_unit": key.usage_unit,
}
}
# NULL + x stays NULL in SQL, so an unknown cost stays unknown; writing NULL outright makes it so
data: Final[prisma_types.LiteLLM_DailyGuardrailUsageUnitsUpsertInput] = {
"create": row,
"update": {"units": {"increment": units}},
"update": {
"units": {"increment": increment.units},
"cost": {"increment": increment.cost} if increment.cost is not None else None,
},
}
await DailyGuardrailUsageUnitsRepository(prisma_client).table.upsert(where=where, data=data)

View file

@ -1123,6 +1123,7 @@ model LiteLLM_DailyGuardrailUsageUnits {
api_key String // hashed virtual key; empty string when unknown
usage_unit String // provider counter name, e.g. Bedrock's contentPolicyUnits
units BigInt @default(0)
cost Float? // USD billed for these units; null when any contributing increment was unpriced
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -3142,6 +3142,11 @@ class StandardLoggingGuardrailInformation(TypedDict, total=False):
provider hook. Summed into the request's ``response_cost`` so it counts against
spend and budgets like token cost, unless ``guardrail_cost_in_spend`` is False."""
guardrail_cost_by_unit: ReadOnly[Mapping[str, float] | None]
"""``guardrail_cost`` split per ``guardrail_usage`` counter, so the daily
per-counter usage rollup can carry cost at its own grain. Absent when the
hook had no pricing for the invocation."""
guardrail_cost_in_spend: ReadOnly[bool | None]
"""Whether ``guardrail_cost`` participates in the request's ``response_cost`` and
the spend/budget aggregates built from it. Absent, None, or True keeps the default
@ -3193,6 +3198,7 @@ class GuardrailTracingDetail(TypedDict, total=False):
guardrail_action: str | None
guardrail_usage: ReadOnly[Mapping[str, int] | None]
guardrail_cost: ReadOnly[float | None]
guardrail_cost_by_unit: ReadOnly[Mapping[str, float] | None]
guardrail_cost_in_spend: ReadOnly[bool | None]

View file

@ -1123,6 +1123,7 @@ model LiteLLM_DailyGuardrailUsageUnits {
api_key String // hashed virtual key; empty string when unknown
usage_unit String // provider counter name, e.g. Bedrock's contentPolicyUnits
units BigInt @default(0)
cost Float? // USD billed for these units; null when any contributing increment was unpriced
created_at DateTime @default(now())
updated_at DateTime @updatedAt

View file

@ -5,6 +5,8 @@ import pytest
import litellm
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
bedrock_guardrail_cost,
bedrock_guardrail_cost_by_unit,
billed_guardrail_cost_by_unit,
cost_breakdown_with_guardrail,
guardrail_information_cost,
)
@ -56,6 +58,58 @@ def test_bedrock_guardrail_cost_no_pricing_entry(monkeypatch):
assert bedrock_guardrail_cost(usage_units={"contentPolicyUnits": 1}, aws_region_name="us-east-1") == 0.0
def test_bedrock_guardrail_cost_by_unit_prices_every_counter_it_was_given(synthetic_cost_map):
"""LIT-5652: the daily rollup stores one row per counter, so pricing must come
back at that grain, keyed exactly like the usage (free and unknown counters
included at 0.0) and summing to the scalar the spend path bills."""
usage = {"contentPolicyUnits": 2, "topicPolicyUnits": 1, "wordPolicyUnits": 5, "someFutureCounter": 3}
by_unit = bedrock_guardrail_cost_by_unit(usage_units=usage, aws_region_name="us-east-1")
assert by_unit is not None
assert by_unit.keys() == usage.keys()
assert by_unit["contentPolicyUnits"] == pytest.approx(0.0003)
assert by_unit["topicPolicyUnits"] == pytest.approx(0.00015)
assert (by_unit["wordPolicyUnits"], by_unit["someFutureCounter"]) == (0.0, 0.0)
assert sum(by_unit.values()) == pytest.approx(
bedrock_guardrail_cost(usage_units=usage, aws_region_name="us-east-1")
)
def test_bedrock_guardrail_cost_by_unit_is_none_without_pricing_so_unpriced_is_not_free(monkeypatch):
"""The scalar keeps returning 0.0 for the spend path; the per-unit view must
say "unknown" instead so the rollup stores NULL rather than a $0 that would
hide the exact silent-spend problem this feature exists to surface."""
monkeypatch.setattr(litellm, "model_cost", {})
assert bedrock_guardrail_cost_by_unit(usage_units={"contentPolicyUnits": 1}, aws_region_name="us-east-1") is None
def test_billed_guardrail_cost_by_unit_reads_the_hook_stamp():
entry = {"guardrail_name": "bedrock", "guardrail_cost_by_unit": {"contentPolicyUnits": 0.15, "wordPolicyUnits": 0}}
assert billed_guardrail_cost_by_unit(entry) == {"contentPolicyUnits": 0.15, "wordPolicyUnits": 0.0}
@pytest.mark.parametrize(
"entry",
[
{"guardrail_name": "no-pricing", "guardrail_usage": {"contentPolicyUnits": 1}},
{"guardrail_cost_by_unit": {"text_records": 0.5}, "guardrail_cost_in_spend": False},
{"guardrail_cost_by_unit": {"contentPolicyUnits": -0.5}},
{"guardrail_cost_by_unit": {"contentPolicyUnits": float("nan")}},
{"guardrail_cost_by_unit": {"contentPolicyUnits": float("inf")}},
{"guardrail_cost_by_unit": {"contentPolicyUnits": "bad"}},
{"guardrail_cost_by_unit": "not-a-map"},
{"guardrail_cost_by_unit": {"contentPolicyUnits": 0.1}, "guardrail_cost_in_spend": "maybe"},
"not-an-entry",
],
)
def test_billed_guardrail_cost_by_unit_is_none_when_unpriced_report_only_or_forged(entry):
assert billed_guardrail_cost_by_unit(entry) is None
def test_billed_guardrail_cost_by_unit_treats_none_in_spend_as_billed():
entry = {"guardrail_cost_by_unit": {"contentPolicyUnits": 0.15}, "guardrail_cost_in_spend": None}
assert billed_guardrail_cost_by_unit(entry) == {"contentPolicyUnits": 0.15}
def test_shipped_bedrock_guardrail_prices_match_aws_pricing_page(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")

View file

@ -2961,9 +2961,7 @@ async def test_streaming_hook_reraises_guardrail_service_failures():
guardrail = _sse_guardrail()
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
mock_api.side_effect = HTTPException(
status_code=500, detail="Bedrock guardrail throttle retries exhausted"
)
mock_api.side_effect = HTTPException(status_code=500, detail="Bedrock guardrail throttle retries exhausted")
with pytest.raises(HTTPException) as exc:
await _drain_streaming_hook(guardrail)
@ -5104,6 +5102,26 @@ def test_build_tracing_detail_surfaces_usage_counters_and_cost(monkeypatch):
assert detail["guardrail_usage"] == {"topicPolicyUnits": 1, "contentPolicyUnits": 2, "wordPolicyUnits": 0}
assert detail["guardrail_cost"] == pytest.approx(0.00045)
by_unit = detail["guardrail_cost_by_unit"]
assert by_unit is not None and by_unit.keys() == detail["guardrail_usage"].keys()
assert by_unit["topicPolicyUnits"] == pytest.approx(0.00015)
assert by_unit["contentPolicyUnits"] == pytest.approx(0.0003)
assert by_unit["wordPolicyUnits"] == 0.0
def test_build_tracing_detail_omits_cost_by_unit_when_unpriced_but_keeps_scalar_zero(monkeypatch):
"""LIT-5652: without a cost-map entry the spend path still bills 0.0, but the
per-counter stamp must be absent so the rollup records NULL, not $0."""
monkeypatch.setattr(litellm, "model_cost", {})
guardrail = BedrockGuardrail(guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT")
detail = guardrail._build_tracing_detail(
{"action": "NONE", "usage": {"contentPolicyUnits": 5}}, aws_region_name="us-east-1"
)
assert detail["guardrail_usage"] == {"contentPolicyUnits": 5}
assert detail["guardrail_cost"] == 0.0
assert "guardrail_cost_by_unit" not in detail
def test_build_tracing_detail_omits_guardrail_usage_when_bedrock_reports_none():
@ -5115,6 +5133,7 @@ def test_build_tracing_detail_omits_guardrail_usage_when_bedrock_reports_none():
):
assert "guardrail_usage" not in detail
assert "guardrail_cost" not in detail
assert "guardrail_cost_by_unit" not in detail
@pytest.mark.asyncio
@ -5478,7 +5497,7 @@ async def test_unbuffered_end_of_stream_hook_yields_chunks_before_scan():
scan_index = events.index("scan")
chunk_events = [e for e in events if e != "scan"]
assert events.count("scan") == 1
assert [e for e in events[:scan_index] if e != "scan"] == chunk_events[: scan_index]
assert [e for e in events[:scan_index] if e != "scan"] == chunk_events[:scan_index]
assert ("chunk", "Hello") in events[:scan_index]
assert ("chunk", " world") in events[:scan_index]
assert len(chunk_events) == 3

View file

@ -85,6 +85,7 @@ def _units_row(
api_key: str = "",
usage_unit: str = "contentPolicyUnits",
units: int = 1,
cost: float | None = None,
) -> Any:
r = MagicMock()
r.guardrail_id = guardrail_id
@ -93,6 +94,7 @@ def _units_row(
r.api_key = api_key
r.usage_unit = usage_unit
r.units = units
r.cost = cost
return r
@ -279,8 +281,8 @@ async def test_detail_breaks_units_down_by_day_team_and_key():
)
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}},
{"date": "2026-04-24", "units": {"topicPolicyUnits": 1}, "cost": None},
{"date": "2026-04-25", "units": {"contentPolicyUnits": 3}, "cost": None},
]
assert resp.usage_units_by_team == {
"team-a": {"contentPolicyUnits": 2, "topicPolicyUnits": 1},
@ -311,6 +313,73 @@ async def test_overview_degrades_units_to_empty_when_units_table_is_missing():
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, {})
assert (row.cost, resp.totalCost) == (None, None)
@pytest.mark.asyncio
async def test_overview_reports_cost_per_row_and_total_summing_only_tracked_days():
"""LIT-5652: cost rides the units rollup. Rows written before the cost column
(or by an unpriced hook) carry NULL and must drop out of the sum rather than
read as $0, and a guardrail with only NULL rows reports None, not 0.0."""
prisma = _prisma(
find_many=[],
metrics=[_metric("yaml-pii", requests=4, passed=3, blocked=1)],
units=[
_units_row("yaml-pii", usage_unit="contentPolicyUnits", units=1000, cost=0.15),
_units_row("yaml-pii", team_id="team-a", usage_unit="contentPolicyUnits", units=2000, cost=0.3),
_units_row("yaml-pii", date="2026-04-24", usage_unit="contentPolicyUnits", units=5000, cost=None),
_units_row("legacy-guard", usage_unit="topicPolicyUnits", units=7, cost=None),
],
)
handler = _config_handler(
_yaml_guardrail(guardrail_id="yaml-uuid", name="yaml-pii"),
_yaml_guardrail(guardrail_id="legacy-uuid", name="legacy-guard"),
)
p1, p2 = _patches(prisma, handler)
with p1, p2:
resp = await guardrails_usage_overview(start_date=START, end_date=END, user_api_key_dict=ADMIN)
by_id = {r.id: r for r in resp.rows}
assert by_id["yaml-uuid"].cost == pytest.approx(0.45)
assert by_id["legacy-uuid"].cost is None
assert resp.totalCost == pytest.approx(0.45)
@pytest.mark.asyncio
async def test_detail_breaks_cost_down_by_unit_day_team_and_key():
"""Every cost breakdown keeps the same keys as its units twin so the UI can
render them side by side, with None where that group has no tracked cost."""
prisma = _prisma(
find_unique=None,
units=[
_units_row("yaml-pii", date="2026-04-25", team_id="team-a", api_key="hash-1", units=1000, cost=0.15),
_units_row("yaml-pii", date="2026-04-25", team_id="", api_key="hash-2", units=200, cost=0.03),
_units_row(
"yaml-pii",
date="2026-04-24",
team_id="team-a",
api_key="hash-1",
usage_unit="topicPolicyUnits",
units=10,
cost=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.cost == pytest.approx(0.18)
assert resp.cost_by_unit == {"contentPolicyUnits": pytest.approx(0.18), "topicPolicyUnits": None}
assert [p.model_dump() for p in resp.usage_units_daily] == [
{"date": "2026-04-24", "units": {"topicPolicyUnits": 10}, "cost": None},
{"date": "2026-04-25", "units": {"contentPolicyUnits": 1200}, "cost": pytest.approx(0.18)},
]
assert resp.cost_by_team == {"team-a": pytest.approx(0.15), "": pytest.approx(0.03)}
assert resp.cost_by_key == {"hash-1": pytest.approx(0.15), "hash-2": pytest.approx(0.03)}
assert resp.cost_by_team.keys() == resp.usage_units_by_team.keys()
assert resp.cost_by_key.keys() == resp.usage_units_by_key.keys()
@pytest.mark.asyncio
@ -330,6 +399,7 @@ async def test_detail_degrades_units_to_empty_when_units_table_is_missing():
{},
{},
)
assert (resp.cost, resp.cost_by_unit, resp.cost_by_team, resp.cost_by_key) == (None, {}, {}, {})
# ---- logs -------------------------------------------------------------------

View file

@ -30,6 +30,8 @@ def _payload(
api_key: str = "hashed-key-1",
usage: dict[str, Any] | None = None,
guardrail_status: str = "success",
cost_by_unit: dict[str, Any] | None = None,
cost_in_spend: bool | None = None,
) -> dict[str, Any]:
entry: dict[str, Any] = {
"guardrail_id": "bedrock-guard",
@ -37,6 +39,10 @@ def _payload(
}
if usage is not None:
entry["guardrail_usage"] = usage
if cost_by_unit is not None:
entry["guardrail_cost_by_unit"] = cost_by_unit
if cost_in_spend is not None:
entry["guardrail_cost_in_spend"] = cost_in_spend
return {
"request_id": request_id,
"startTime": datetime(2026, 8, 17, 12, 0, tzinfo=timezone.utc),
@ -58,6 +64,18 @@ def _units_upserts(prisma: MagicMock) -> dict[tuple, int]:
return out
def _cost_upserts(prisma: MagicMock) -> dict[str, tuple[float | None, object]]:
"""usage_unit -> (cost written on create, cost clause sent on update)."""
calls = prisma.db.litellm_dailyguardrailusageunits.upsert.call_args_list
return {
c.kwargs["data"]["create"]["usage_unit"]: (
c.kwargs["data"]["create"]["cost"],
c.kwargs["data"]["update"]["cost"],
)
for c in calls
}
@pytest.mark.asyncio
async def test_usage_units_rolled_up_by_guardrail_team_key_and_date():
"""
@ -181,7 +199,9 @@ async def test_retry_exhausted_rows_are_requeued_and_land_on_the_next_flush():
down, [_payload("r1", usage={"topicPolicyUnits": 2})], sleep=sleep, pending=pending
)
assert dict(pending.units) == {("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "topicPolicyUnits"): 2}
assert dict(pending.units) == {
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "topicPolicyUnits"): (2, None)
}
recovered = _prisma()
await process_spend_logs_guardrail_usage(
@ -320,3 +340,111 @@ async def test_payload_without_request_id_is_skipped_like_the_metrics_path():
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "topicPolicyUnits"): 1,
}
assert prisma.db.litellm_dailyguardrailmetrics.upsert.call_args.kwargs["data"]["create"]["requests_evaluated"] == 1
@pytest.mark.asyncio
async def test_cost_rolled_up_per_counter_alongside_units():
"""LIT-5652: the hook's per-counter cost lands on the same daily row as the
units it priced, summed across payloads exactly like the units are, and the
update path increments it so a second flush on the same day keeps adding."""
prisma = _prisma()
logs = [
_payload(
"r1",
usage={"contentPolicyUnits": 1000, "wordPolicyUnits": 50},
cost_by_unit={"contentPolicyUnits": 0.15, "wordPolicyUnits": 0.0},
),
_payload(
"r2",
usage={"contentPolicyUnits": 2000, "wordPolicyUnits": 10},
cost_by_unit={"contentPolicyUnits": 0.3, "wordPolicyUnits": 0.0},
),
]
await process_spend_logs_guardrail_usage(prisma, logs)
assert _units_upserts(prisma) == {
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "contentPolicyUnits"): 3000,
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "wordPolicyUnits"): 60,
}
costs = _cost_upserts(prisma)
assert costs["contentPolicyUnits"][0] == pytest.approx(0.45)
assert costs["contentPolicyUnits"][1] == {"increment": pytest.approx(0.45)}
assert costs["wordPolicyUnits"] == (0.0, {"increment": 0.0})
@pytest.mark.asyncio
async def test_unpriced_increment_makes_the_rows_cost_unknown_not_partial():
"""A payload with usage but no per-counter cost (a hook without pricing, a
pre-upgrade proxy in a mixed fleet) must poison that row's cost to NULL on
both create and update. Keeping the priced part would understate the day
while looking exact."""
prisma = _prisma()
logs = [
_payload("r1", usage={"contentPolicyUnits": 1000}, cost_by_unit={"contentPolicyUnits": 0.15}),
_payload("r2", usage={"contentPolicyUnits": 1000}),
]
await process_spend_logs_guardrail_usage(prisma, logs)
assert _units_upserts(prisma) == {
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "contentPolicyUnits"): 2000,
}
assert _cost_upserts(prisma) == {"contentPolicyUnits": (None, None)}
@pytest.mark.asyncio
async def test_report_only_and_forged_costs_are_not_rolled_up_but_units_are():
"""guardrail_cost_in_spend=False (Azure Prompt Shield) keeps its cost out of
spend, so the rollup must not record it either or the dashboard would show
a number the budget never charged. A negative or non-finite per-counter cost
is treated the same way rather than subtracting from the day."""
prisma = _prisma()
logs = [
_payload("r1", usage={"text_records": 3}, cost_by_unit={"text_records": 0.5}, cost_in_spend=False),
_payload("r2", usage={"contentPolicyUnits": 10}, cost_by_unit={"contentPolicyUnits": -0.5}),
_payload("r3", usage={"topicPolicyUnits": 10}, cost_by_unit={"topicPolicyUnits": float("inf")}),
]
await process_spend_logs_guardrail_usage(prisma, logs)
assert _units_upserts(prisma) == {
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "text_records"): 3,
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "contentPolicyUnits"): 10,
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "topicPolicyUnits"): 10,
}
assert _cost_upserts(prisma) == {
"text_records": (None, None),
"contentPolicyUnits": (None, None),
"topicPolicyUnits": (None, None),
}
@pytest.mark.asyncio
async def test_requeued_cost_is_added_to_the_next_flush():
"""Cost must survive the connection-error requeue the same way units do, or
a DB blip would silently drop dollars while keeping the units they bought."""
pending = PendingRollups()
down = _prisma()
down.db.litellm_dailyguardrailmetrics.upsert.side_effect = httpx.ConnectError("db down")
down.db.litellm_dailyguardrailusageunits.upsert.side_effect = httpx.ConnectError("db down")
sleep, _ = _fake_sleep()
await process_spend_logs_guardrail_usage(
down,
[_payload("r1", usage={"contentPolicyUnits": 1000}, cost_by_unit={"contentPolicyUnits": 0.15})],
sleep=sleep,
pending=pending,
)
recovered = _prisma()
await process_spend_logs_guardrail_usage(
recovered,
[_payload("r2", usage={"contentPolicyUnits": 2000}, cost_by_unit={"contentPolicyUnits": 0.3})],
sleep=sleep,
pending=pending,
)
assert _units_upserts(recovered) == {
("bedrock-guard", "2026-08-17", "team-a", "hashed-key-1", "contentPolicyUnits"): 3000,
}
assert _cost_upserts(recovered)["contentPolicyUnits"][0] == pytest.approx(0.45)

View file

@ -3,7 +3,7 @@
"limit": 22367
},
"LIT002": {
"limit": 26777
"limit": 26775
},
"LIT003": {
"limit": 269

View file

@ -37791,6 +37791,20 @@ export interface components {
avgLatency: number | null;
/** Avgscore */
avgScore: number | null;
/** Cost */
cost: number | null;
/** Cost By Key */
cost_by_key: {
[key: string]: number | null;
};
/** Cost By Team */
cost_by_team: {
[key: string]: number | null;
};
/** Cost By Unit */
cost_by_unit: {
[key: string]: number | null;
};
/** Description */
description: string | null;
/** Failrate */
@ -37872,6 +37886,8 @@ export interface components {
rows: components["schemas"]["UsageOverviewRow"][];
/** Totalblocked */
totalBlocked: number;
/** Totalcost */
totalCost: number | null;
/** Totalrequests */
totalRequests: number;
/** Totalusageunits */
@ -37885,6 +37901,8 @@ export interface components {
avgLatency: number | null;
/** Avgscore */
avgScore: number | null;
/** Cost */
cost: number | null;
/** Failrate */
failRate: number;
/** Id */
@ -37908,6 +37926,8 @@ export interface components {
};
/** UsageUnitsDailyPoint */
UsageUnitsDailyPoint: {
/** Cost */
cost: number | null;
/** Date */
date: string;
/** Units */