feat(spend): add a per-session auto-router benchmarks rollup

Fold each auto-routed turn into a per-(api_key, session, auto-router) row when it
happens, and serve the benchmarks dashboard by summing those rows. Nothing in the
feature reads LiteLLM_SpendLogs.

The row carries what each tier the session used left in the prompt cache, so a turn's
bucket is a question about one model's own record and the upsert answers it against the
row it is already writing. Absent from that record means a first visit; present means
warm or expired, on one comparison of the idle gap against the TTL the entry was written
with. Nothing is read before the write, so there is no state to load or validate, and the
statement is atomic.
This commit is contained in:
Tin Chi Lo 2026-08-04 14:59:28 -07:00
parent 5045a576ad
commit 2e2b2624ed
17 changed files with 1602 additions and 7 deletions

View file

@ -44,6 +44,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
"/router/",
"/router_settings",
"/adaptive_router/",
"/auto_router/",
"/fallback",
"/fallbacks",
"/cache_settings",

View file

@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS "LiteLLM_AutoRouterSession" (
api_key TEXT NOT NULL,
session_id TEXT NOT NULL,
model_group TEXT NOT NULL,
router_kind TEXT NOT NULL,
baseline_model TEXT,
turns INTEGER NOT NULL DEFAULT 0,
turns_with_usage INTEGER NOT NULL DEFAULT 0,
total_tokens BIGINT NOT NULL DEFAULT 0,
spend DOUBLE PRECISION NOT NULL DEFAULT 0,
baseline_spend DOUBLE PRECISION NOT NULL DEFAULT 0,
first_visit_turns INTEGER NOT NULL DEFAULT 0,
first_visit_hits INTEGER NOT NULL DEFAULT 0,
warm_turns INTEGER NOT NULL DEFAULT 0,
warm_hits INTEGER NOT NULL DEFAULT 0,
expired_turns INTEGER NOT NULL DEFAULT 0,
expired_hits INTEGER NOT NULL DEFAULT 0,
unordered_turns INTEGER NOT NULL DEFAULT 0,
unordered_hits INTEGER NOT NULL DEFAULT 0,
ephemeral_1h_turns INTEGER NOT NULL DEFAULT 0,
tiers JSONB NOT NULL DEFAULT '{}',
first_turn_at TIMESTAMP(3) NOT NULL,
last_turn_at TIMESTAMP(3) NOT NULL,
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_AutoRouterSession_pkey" PRIMARY KEY (api_key, session_id, model_group)
);
CREATE INDEX IF NOT EXISTS "idx_auto_router_session_started"
ON "LiteLLM_AutoRouterSession" (first_turn_at);
CREATE INDEX IF NOT EXISTS "idx_auto_router_session_activity"
ON "LiteLLM_AutoRouterSession" (last_turn_at);

View file

@ -1391,6 +1391,41 @@ model LiteLLM_AdaptiveRouterSession {
@@index([last_activity_at], map: "idx_adaptive_router_session_activity")
}
model LiteLLM_AutoRouterSession {
api_key String
session_id String
model_group String
router_kind String
baseline_model String?
turns Int @default(0)
turns_with_usage Int @default(0)
total_tokens BigInt @default(0)
spend Float @default(0)
baseline_spend Float @default(0)
first_visit_turns Int @default(0)
first_visit_hits Int @default(0)
warm_turns Int @default(0)
warm_hits Int @default(0)
expired_turns Int @default(0)
expired_hits Int @default(0)
unordered_turns Int @default(0)
unordered_hits Int @default(0)
ephemeral_1h_turns Int @default(0)
tiers Json @default("{}")
first_turn_at DateTime
last_turn_at DateTime
updated_at DateTime @default(now()) @updatedAt
@@id([api_key, session_id, model_group])
@@index([first_turn_at], map: "idx_auto_router_session_started")
@@index([last_turn_at], map: "idx_auto_router_session_activity")
}
// ---------------------------------------------------------------------------
// Workflow Run Tracking
//

View file

@ -51,6 +51,7 @@ from litellm.proxy.db.db_transaction_queue.tool_discovery_queue import (
ToolDiscoveryQueue,
)
from litellm.proxy.route_llm_request import ROUTE_ENDPOINT_MAPPING
from litellm.proxy.spend_tracking.auto_router_sessions import AutoRouterSessionQueue, build_turn_facts
from litellm.proxy.spend_tracking.compression_savings import (
extract_compression_saved_tokens,
)
@ -132,6 +133,46 @@ class DBSpendUpdateWriter:
self.daily_agent_spend_update_queue = DailySpendUpdateQueue()
self.daily_org_spend_update_queue = DailySpendUpdateQueue()
self.daily_tag_spend_update_queue = DailySpendUpdateQueue()
self.auto_router_session_queue = AutoRouterSessionQueue()
def _enqueue_auto_router_turn(self, payload: SpendLogsPayload) -> None:
"""Stage one auto-routed turn for the benchmarks rollup.
Runs once per request rather than beside the daily transactions, which are built
per entity type and would count every turn six times over. Independent of
``disable_spend_logs``, because the rollup is what the benchmarks dashboard reads
now. Never raises, and never blocks: a full queue drops the turn rather than
applying backpressure to spend tracking.
"""
try:
metadata: Final[SpendLogsMetadata] = json.loads(payload["metadata"])
if not metadata.get("routing_decision"):
return
usage_raw: Final = metadata.get("usage_object")
usage_obj: Final = usage_raw if isinstance(usage_raw, dict) else None
cache_read_tokens: Final = _extract_cache_read_tokens(usage_obj) if usage_obj is not None else 0
savings: Final = compute_savings_spend(
model=payload.get("model", None),
custom_llm_provider=payload.get("custom_llm_provider", None),
compression_saved_tokens=extract_compression_saved_tokens(metadata),
cache_read_input_tokens=cache_read_tokens,
routing_decision=metadata.get("routing_decision"),
model_id=payload.get("model_id"),
llm_router=_get_llm_router,
usage_object=usage_obj,
cost_breakdown=metadata.get("cost_breakdown"),
)
turn: Final = build_turn_facts(
payload=payload,
metadata=metadata,
autorouter_savings=savings.autorouter,
cache_read_tokens=cache_read_tokens,
cache_creation_tokens=_extract_cache_creation_tokens(usage_obj) if usage_obj is not None else 0,
)
if turn is not None:
self.auto_router_session_queue.update_queue.put_nowait(turn)
except Exception as e: # noqa: BLE001 # a dashboard rollup must never fail spend tracking
verbose_proxy_logger.debug("auto_router_sessions: could not stage turn (%s)", e)
async def update_database(
# LiteLLM management object fields
@ -209,6 +250,8 @@ class DBSpendUpdateWriter:
"disable_spend_logs=True. Skipping writing spend logs to db. Other spend updates - Key/User/Team table will still occur."
)
self._enqueue_auto_router_turn(payload=payload)
# Single task replaces 11 create_task() calls
asyncio.create_task(
self._batch_database_updates(
@ -809,6 +852,9 @@ class DBSpendUpdateWriter:
proxy_logging_obj=proxy_logging_obj,
)
################## Auto-Router Benchmarks Rollup ##################
await self.auto_router_session_queue.flush(prisma_client=prisma_client)
async def _commit_spend_updates_to_db_with_redis(
self,
prisma_client: PrismaClient,

View file

@ -186,6 +186,20 @@ class SpendLogCleanup:
time_column="start_time",
)
async def _delete_old_auto_router_sessions(self, prisma_client: PrismaClient, cutoff_date: datetime) -> int:
"""Expire auto-router rollups on the spend-log cutoff.
Keyed on last activity rather than session start, so a conversation still running
when the cutoff passes is not pruned out from under itself.
"""
return await self._delete_old_rows_batched(
prisma_client,
cutoff_date,
table_name="LiteLLM_AutoRouterSession",
key_columns=("api_key", "session_id", "model_group"),
time_column="last_turn_at",
)
async def cleanup_old_spend_logs(self, prisma_client: PrismaClient) -> None:
"""
Main cleanup function. Deletes old spend logs in batches.
@ -245,6 +259,9 @@ class SpendLogCleanup:
index_deleted: Final = await self._delete_old_tool_index_rows(prisma_client, cutoff_date)
verbose_proxy_logger.info("Deleted %s expired tool index rows", index_deleted)
sessions_deleted: Final = await self._delete_old_auto_router_sessions(prisma_client, cutoff_date)
verbose_proxy_logger.info("Deleted %s expired auto-router session rollups", sessions_deleted)
except Exception as e:
# .exception() captures the traceback; str(e) alone on a Prisma/DB
# timeout is often empty and gives operators no signal to diagnose.

View file

@ -16,10 +16,11 @@ import time
import traceback
import warnings
from collections.abc import AsyncGenerator, Callable, Mapping
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from types import UnionType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Final,
Literal,
@ -530,6 +531,10 @@ from litellm.proxy.response_api_endpoints.endpoints import router as response_ro
from litellm.proxy.route_llm_request import route_request
from litellm.proxy.search_endpoints.endpoints import router as search_router
from litellm.proxy.shutdown.graceful_shutdown_manager import GracefulShutdownManager
from litellm.proxy.spend_tracking.auto_router_benchmarks import (
AutoRouterBenchmarksResponse,
fetch_benchmarks,
)
from litellm.proxy.spend_tracking.budget_reservation import get_budget_window_start
from litellm.proxy.spend_tracking.spend_management_endpoints import (
router as spend_management_router,
@ -16477,6 +16482,50 @@ async def get_adaptive_router_state(
return {"routers": snapshots}
def _auto_router_error(message: str) -> dict[str, str]:
return {"error": message} # mutable-ok: HTTPException serializes its detail from a real dict
@router.get(
"/auto_router/benchmarks",
tags=["auto_router"], # mutable-ok: FastAPI types tags as List[str]
response_model=AutoRouterBenchmarksResponse,
)
async def get_auto_router_benchmarks(
start_date: date,
end_date: date,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
model_group: str | None = None,
):
"""Savings, session shape and prompt-cache behaviour for every auto-router.
Admin-only. Reads the per-session rollup only; no per-request table is scanned.
`start_date` and `end_date` are inclusive calendar dates, clamped to the most recent
30 days. Pass `model_group` to scope every figure to one auto-router.
"""
if not _user_has_admin_view(user_api_key_dict):
raise HTTPException(
status_code=403,
detail=_auto_router_error(CommonProxyErrors.not_allowed_access.value),
)
if end_date < start_date:
raise HTTPException(
status_code=400,
detail=_auto_router_error("end_date must not be earlier than start_date."),
)
if prisma_client is None:
raise HTTPException(
status_code=500,
detail=_auto_router_error(CommonProxyErrors.db_not_connected_error.value),
)
return await fetch_benchmarks(
prisma_client=prisma_client,
start_date=start_date,
end_date=end_date,
model_group=model_group,
)
@router.get("/routes", dependencies=[Depends(user_api_key_auth)])
async def get_routes():
"""

View file

@ -1391,6 +1391,41 @@ model LiteLLM_AdaptiveRouterSession {
@@index([last_activity_at], map: "idx_adaptive_router_session_activity")
}
model LiteLLM_AutoRouterSession {
api_key String
session_id String
model_group String
router_kind String
baseline_model String?
turns Int @default(0)
turns_with_usage Int @default(0)
total_tokens BigInt @default(0)
spend Float @default(0)
baseline_spend Float @default(0)
first_visit_turns Int @default(0)
first_visit_hits Int @default(0)
warm_turns Int @default(0)
warm_hits Int @default(0)
expired_turns Int @default(0)
expired_hits Int @default(0)
unordered_turns Int @default(0)
unordered_hits Int @default(0)
ephemeral_1h_turns Int @default(0)
tiers Json @default("{}")
first_turn_at DateTime
last_turn_at DateTime
updated_at DateTime @default(now()) @updatedAt
@@id([api_key, session_id, model_group])
@@index([first_turn_at], map: "idx_auto_router_session_started")
@@index([last_turn_at], map: "idx_auto_router_session_activity")
}
// ---------------------------------------------------------------------------
// Workflow Run Tracking
//

View file

@ -0,0 +1,258 @@
"""Read path for the auto-router benchmarks dashboard.
Sums pre-folded rows from ``LiteLLM_AutoRouterSession`` and nothing else. ``summarize``
produces both the per-router view and the totals, since averaging per-router percentages is
wrong. Every miss has one of four causes on one denominator: cold, prefix changed, aged out, or a turn whose cache state could not be established.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import astuple, dataclass, fields
from datetime import date, datetime, time, timedelta, timezone
from functools import reduce
from typing import TYPE_CHECKING, Final
from pydantic import BaseModel
from litellm.proxy.spend_tracking.auto_router_sessions import CACHE_TTL_1H_SECONDS, CACHE_TTL_5M_SECONDS
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
MAX_WINDOW_DAYS: Final = 30
@dataclass(frozen=True, slots=True)
class _Counters:
sessions: int = 0
turns: int = 0
turns_with_usage: int = 0
total_tokens: int = 0
total_session_seconds: float = 0.0
spend: float = 0.0
baseline_spend: float = 0.0
first_visit_turns: int = 0
first_visit_hits: int = 0
warm_turns: int = 0
warm_hits: int = 0
expired_turns: int = 0
expired_hits: int = 0
unordered_turns: int = 0
unordered_hits: int = 0
ephemeral_1h_turns: int = 0
class AutoRouterCacheBenchmark(BaseModel):
ttl_seconds: float
turns: int
hits: int
misses: int
hit_rate_pct: float
coverage_pct: float
first_visit_turns: int
first_visit_hits: int
first_visit_hit_rate_pct: float
warm_turns: int
warm_hits: int
warm_hit_rate_pct: float
expired_turns: int
expired_hits: int
expired_hit_rate_pct: float
unordered_turns: int
unordered_hits: int
cold_misses: int
prefix_change_misses: int
expired_misses: int
unattributed_misses: int
cold_miss_pct: float
prefix_change_miss_pct: float
expired_miss_pct: float
unattributed_miss_pct: float
class AutoRouterBenchmark(BaseModel):
sessions: int
turns: int
total_tokens: int
spend: float
baseline_spend: float
savings: float
savings_pct: float
saved_per_session: float
avg_turns_per_session: float
avg_session_seconds: float
avg_tokens_per_session: float
cache: AutoRouterCacheBenchmark | None
class AutoRouterGroupBenchmark(BaseModel):
model_group: str
router_kind: str
baseline_model: str | None
benchmark: AutoRouterBenchmark
class AutoRouterBenchmarksResponse(BaseModel):
start_date: date
end_date: date
routers_in_scope: int
totals: AutoRouterBenchmark
groups: tuple[AutoRouterGroupBenchmark, ...]
_DERIVED_COLUMNS: Final = ("sessions", "total_session_seconds")
_SUM_COLUMNS: Final = tuple(field.name for field in fields(_Counters) if field.name not in _DERIVED_COLUMNS)
_AGGREGATE_SQL: Final = f"""
SELECT
model_group,
MAX(router_kind) AS router_kind,
MAX(baseline_model) AS baseline_model,
COUNT(*) AS sessions,
COALESCE(SUM(EXTRACT(EPOCH FROM (last_turn_at - first_turn_at))), 0) AS total_session_seconds,
{", ".join(f"COALESCE(SUM({column}), 0) AS {column}" for column in _SUM_COLUMNS)}
FROM "LiteLLM_AutoRouterSession"
WHERE first_turn_at >= $1::timestamptz AT TIME ZONE \'UTC\'
AND first_turn_at < $2::timestamptz AT TIME ZONE \'UTC\'
AND ($3::text IS NULL OR model_group = $3::text)
GROUP BY model_group
ORDER BY SUM(spend) DESC
"""
def _pct(numerator: float, denominator: float) -> float:
return 100.0 * numerator / denominator if denominator else 0.0
def _per(numerator: float, denominator: float) -> float:
return numerator / denominator if denominator else 0.0
def _number(value: object) -> float:
return float(value) if isinstance(value, (int, float)) else 0.0
def _counters_from_row(row: Mapping[str, object]) -> _Counters:
return _Counters(*(_number(row.get(field.name)) for field in fields(_Counters)))
def _combine(left: _Counters, right: _Counters) -> _Counters:
return _Counters(*(a + b for a, b in zip(astuple(left), astuple(right))))
def _summarize_cache(counters: _Counters) -> AutoRouterCacheBenchmark | None:
"""The cache view, or ``None`` when no turn in scope reported cache behaviour."""
if counters.turns_with_usage == 0:
return None
covered: Final = counters.turns_with_usage
hits: Final = counters.first_visit_hits + counters.warm_hits + counters.expired_hits + counters.unordered_hits
misses: Final = covered - hits
cold: Final = counters.first_visit_turns - counters.first_visit_hits
prefix_changed: Final = counters.warm_turns - counters.warm_hits
savable: Final = counters.expired_turns - counters.expired_hits
unattributed: Final = counters.unordered_turns - counters.unordered_hits
return AutoRouterCacheBenchmark(
ttl_seconds=(
CACHE_TTL_1H_SECONDS if counters.ephemeral_1h_turns * 2 > counters.turns else CACHE_TTL_5M_SECONDS
),
turns=covered,
hits=hits,
misses=misses,
hit_rate_pct=_pct(hits, covered),
coverage_pct=_pct(counters.turns_with_usage, counters.turns),
first_visit_turns=counters.first_visit_turns,
first_visit_hits=counters.first_visit_hits,
first_visit_hit_rate_pct=_pct(counters.first_visit_hits, counters.first_visit_turns),
warm_turns=counters.warm_turns,
warm_hits=counters.warm_hits,
warm_hit_rate_pct=_pct(counters.warm_hits, counters.warm_turns),
expired_turns=counters.expired_turns,
expired_hits=counters.expired_hits,
expired_hit_rate_pct=_pct(counters.expired_hits, counters.expired_turns),
unordered_turns=counters.unordered_turns,
unordered_hits=counters.unordered_hits,
cold_misses=cold,
prefix_change_misses=prefix_changed,
expired_misses=savable,
unattributed_misses=unattributed,
cold_miss_pct=_pct(cold, misses),
prefix_change_miss_pct=_pct(prefix_changed, misses),
expired_miss_pct=_pct(savable, misses),
unattributed_miss_pct=_pct(unattributed, misses),
)
def summarize(counters: _Counters) -> AutoRouterBenchmark:
"""One benchmark from raw counters, used for a single router and for the totals alike."""
savings: Final = counters.baseline_spend - counters.spend
return AutoRouterBenchmark(
sessions=counters.sessions,
turns=counters.turns,
total_tokens=counters.total_tokens,
spend=counters.spend,
baseline_spend=counters.baseline_spend,
savings=savings,
savings_pct=_pct(savings, counters.baseline_spend),
saved_per_session=_per(savings, counters.sessions),
avg_turns_per_session=_per(counters.turns, counters.sessions),
avg_session_seconds=_per(counters.total_session_seconds, counters.sessions),
avg_tokens_per_session=_per(counters.total_tokens, counters.sessions),
cache=_summarize_cache(counters),
)
def clamp_window(start_date: date, end_date: date) -> tuple[datetime, datetime]:
"""The half-open UTC interval to read, clamped to ``MAX_WINDOW_DAYS``; ``end_date`` is
inclusive to the caller, so the upper bound is the start of the following day."""
span_start: Final = max(start_date, end_date - timedelta(days=MAX_WINDOW_DAYS - 1))
return (
datetime.combine(span_start, time.min, tzinfo=timezone.utc),
datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=timezone.utc),
)
def build_response(
rows: Sequence[Mapping[str, object]],
start_date: date,
end_date: date,
) -> AutoRouterBenchmarksResponse:
per_group: Final = tuple((row, _counters_from_row(row)) for row in rows)
totals: Final = reduce(_combine, (counters for _, counters in per_group), _Counters())
return AutoRouterBenchmarksResponse(
start_date=start_date,
end_date=end_date,
routers_in_scope=len(per_group),
totals=summarize(totals),
groups=tuple(
AutoRouterGroupBenchmark(
model_group=str(row.get("model_group") or ""),
router_kind=str(row.get("router_kind") or ""),
baseline_model=row.get("baseline_model") if isinstance(row.get("baseline_model"), str) else None,
benchmark=summarize(counters),
)
for row, counters in per_group
),
)
async def fetch_benchmarks(
prisma_client: PrismaClient,
start_date: date,
end_date: date,
model_group: str | None = None,
) -> AutoRouterBenchmarksResponse:
"""Benchmarks for the window actually read; sessions are attributed to the window they
started in, and the response echoes the clamped dates rather than the requested ones."""
window_start, window_end = clamp_window(start_date, end_date)
rows: Final = await prisma_client.db.query_raw(
_AGGREGATE_SQL,
window_start.isoformat(),
window_end.isoformat(),
model_group,
)
return build_response(
rows=rows,
start_date=window_start.date(),
end_date=window_end.date() - timedelta(days=1),
)

View file

@ -0,0 +1,273 @@
"""Per-(api_key, session, auto-router) rollup behind the auto-router benchmarks dashboard.
A turn enters the cache view only when it touched the cache, so a model with caching off
contributes nothing to the buckets or the hit rate. ``tiers`` holds
``{model: [refreshed_at, ttl, prefix_tokens]}``, so a turn's bucket is
a question about one model's own record and the upsert answers it against the row it is
already writing: absent or nothing live is a first visit, an earlier start is unordered, and
otherwise warm or expired on the idle gap against the TTL the entry was written with.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Final
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.proxy.db.db_transaction_queue.base_update_queue import BaseUpdateQueue
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
CACHE_TTL_5M_SECONDS: Final = 300.0
CACHE_TTL_1H_SECONDS: Final = 3600.0
@dataclass(frozen=True, slots=True)
class TurnFacts:
"""One auto-routed turn, priced and reduced to what the rollup needs."""
api_key: str
session_id: str
model_group: str
router_kind: str
baseline_model: str | None
model: str
started_at: float
total_tokens: int
spend: float
baseline_spend: float
cache_hit: bool
cache_creation_tokens: int
cached_prefix_tokens: int
ttl_seconds: float
PARAM_NAMES: Final = (
"api_key",
"session_id",
"model_group",
"router_kind",
"baseline_model",
"model",
"started_at",
"total_tokens",
"spend",
"baseline_spend",
"cache_hit",
"written_tokens",
"prefix_tokens",
"one_hour",
"ttl",
)
(
_API_KEY,
_SESSION_ID,
_MODEL_GROUP,
_ROUTER_KIND,
_BASELINE_MODEL,
_MODEL,
_STARTED_AT,
_TOTAL_TOKENS,
_SPEND,
_BASELINE_SPEND,
_CACHE_HIT,
_WRITTEN_TOKENS,
_PREFIX_TOKENS,
_ONE_HOUR,
_TTL,
) = (f"${position}" for position in range(1, len(PARAM_NAMES) + 1))
def bind(turn: TurnFacts) -> tuple[object, ...]:
"""This turn's values, in ``PARAM_NAMES`` order."""
return (
turn.api_key,
turn.session_id,
turn.model_group,
turn.router_kind,
turn.baseline_model,
turn.model,
turn.started_at,
turn.total_tokens,
turn.spend,
turn.baseline_spend,
int(turn.cache_hit),
turn.cache_creation_tokens,
turn.cached_prefix_tokens,
int(turn.ttl_seconds >= CACHE_TTL_1H_SECONDS),
turn.ttl_seconds,
)
_SEEN: Final = f"t.tiers ? {_MODEL}"
_CACHED_AT: Final = f"(t.tiers -> {_MODEL} ->> 0)::float8"
_CACHED_TTL: Final = f"GREATEST((t.tiers -> {_MODEL} ->> 1)::float8, 1)"
_CACHED_TOKENS: Final = f"(t.tiers -> {_MODEL} ->> 2)::float8"
_IDLE: Final = f"({_STARTED_AT}::float8 - {_CACHED_AT})"
_COVERED: Final = f"{_PREFIX_TOKENS}::float8 > 0"
_LIVE: Final = f"{_SEEN} AND {_CACHED_TOKENS} > 0"
_UNORDERED: Final = f"{_COVERED} AND {_LIVE} AND {_IDLE} < 0"
_WARM: Final = f"{_COVERED} AND {_LIVE} AND {_IDLE} >= 0 AND {_IDLE} <= {_CACHED_TTL}"
_EXPIRED: Final = f"{_COVERED} AND {_LIVE} AND {_IDLE} > {_CACHED_TTL}"
_REFRESHED: Final = f"NOT ({_SEEN}) OR ({_PREFIX_TOKENS}::float8 > 0 AND {_IDLE} >= 0)"
_REWROTE: Final = f"NOT ({_SEEN}) OR ({_WRITTEN_TOKENS}::float8 > 0 AND {_IDLE} >= 0)"
_UPSERT_SQL: Final = f"""
INSERT INTO "LiteLLM_AutoRouterSession" AS t (
api_key, session_id, model_group, router_kind, baseline_model,
turns, turns_with_usage, total_tokens, spend, baseline_spend,
first_visit_turns, first_visit_hits, ephemeral_1h_turns,
tiers, first_turn_at, last_turn_at, updated_at
)
VALUES (
{_API_KEY}, {_SESSION_ID}, {_MODEL_GROUP}, {_ROUTER_KIND}, {_BASELINE_MODEL},
1, CASE WHEN {_COVERED} THEN 1 ELSE 0 END, {_TOTAL_TOKENS}::bigint, {_SPEND}, {_BASELINE_SPEND},
CASE WHEN {_COVERED} THEN 1 ELSE 0 END,
CASE WHEN {_COVERED} THEN {_CACHE_HIT} ELSE 0 END,
{_ONE_HOUR},
jsonb_build_object({_MODEL}, jsonb_build_array(
{_STARTED_AT}::float8, {_TTL}::float8, {_PREFIX_TOKENS}::float8
)),
to_timestamp({_STARTED_AT}::float8) AT TIME ZONE 'UTC',
to_timestamp({_STARTED_AT}::float8) AT TIME ZONE 'UTC',
NOW()
)
ON CONFLICT (api_key, session_id, model_group) DO UPDATE SET
turns = t.turns + 1,
turns_with_usage = t.turns_with_usage + CASE WHEN {_COVERED} THEN 1 ELSE 0 END,
total_tokens = t.total_tokens + {_TOTAL_TOKENS}::bigint,
spend = t.spend + {_SPEND},
baseline_spend = t.baseline_spend + {_BASELINE_SPEND},
first_visit_turns = t.first_visit_turns + CASE WHEN {_COVERED} AND NOT ({_LIVE}) THEN 1 ELSE 0 END,
first_visit_hits = t.first_visit_hits + CASE WHEN {_COVERED} AND NOT ({_LIVE}) THEN {_CACHE_HIT} ELSE 0 END,
unordered_turns = t.unordered_turns + CASE WHEN {_UNORDERED} THEN 1 ELSE 0 END,
unordered_hits = t.unordered_hits + CASE WHEN {_UNORDERED} THEN {_CACHE_HIT} ELSE 0 END,
warm_turns = t.warm_turns + CASE WHEN {_WARM} THEN 1 ELSE 0 END,
warm_hits = t.warm_hits + CASE WHEN {_WARM} THEN {_CACHE_HIT} ELSE 0 END,
expired_turns = t.expired_turns + CASE WHEN {_EXPIRED} THEN 1 ELSE 0 END,
expired_hits = t.expired_hits + CASE WHEN {_EXPIRED} THEN {_CACHE_HIT} ELSE 0 END,
ephemeral_1h_turns = t.ephemeral_1h_turns + {_ONE_HOUR},
baseline_model = COALESCE(t.baseline_model, {_BASELINE_MODEL}),
tiers = t.tiers || jsonb_build_object({_MODEL}, jsonb_build_array(
CASE WHEN {_REFRESHED} THEN {_STARTED_AT}::float8 ELSE {_CACHED_AT} END,
CASE WHEN {_REWROTE} THEN {_TTL}::float8 ELSE {_CACHED_TTL} END,
CASE WHEN {_REFRESHED} THEN {_PREFIX_TOKENS}::float8 ELSE {_CACHED_TOKENS} END
)),
first_turn_at = LEAST(t.first_turn_at, to_timestamp({_STARTED_AT}::float8) AT TIME ZONE 'UTC'),
last_turn_at = GREATEST(t.last_turn_at, to_timestamp({_STARTED_AT}::float8) AT TIME ZONE 'UTC'),
updated_at = NOW()
"""
class AutoRouterSessionQueue(BaseUpdateQueue):
"""Stages turns in memory; writes an interval as one ordered batch on the spend flush.
Batched because a busy interval drains up to ``MAX_IN_MEMORY_QUEUE_FLUSH_COUNT`` turns.
Ordered because a session's classification depends on the turn before it. Never raises.
"""
async def flush(self, prisma_client: PrismaClient) -> None:
staged: Final[Sequence[TurnFacts]] = await self.flush_all_updates_from_in_memory_queue()
if not staged:
return
try:
async with prisma_client.db.batch_() as batcher:
for turn in sorted(staged, key=lambda staged_turn: staged_turn.started_at):
batcher.execute_raw(_UPSERT_SQL, *bind(turn))
except Exception as e: # noqa: BLE001 # a dashboard rollup must never fail spend tracking
verbose_proxy_logger.warning("auto_router_sessions: dropped %d turns (%s)", len(staged), e)
def ttl_seconds(usage_obj: Mapping[str, object] | None) -> float:
"""The cache tier this turn wrote against; no one-hour evidence means the 5m default."""
if usage_obj is None:
return CACHE_TTL_5M_SECONDS
details: Final = usage_obj.get("cache_creation_token_details")
if not isinstance(details, Mapping):
return CACHE_TTL_5M_SECONDS
if int(details.get("ephemeral_1h_input_tokens") or 0) > 0:
return CACHE_TTL_1H_SECONDS
return CACHE_TTL_5M_SECONDS
def _as_utc(moment: datetime) -> float:
return (moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment).timestamp()
def _started_at(start_time: object) -> float | None:
if isinstance(start_time, datetime):
return _as_utc(start_time)
if isinstance(start_time, str):
try:
return _as_utc(datetime.fromisoformat(start_time))
except ValueError:
return None
return None
def _str_or_none(value: object) -> str | None:
return value if isinstance(value, str) else None
def build_turn_facts(
payload: Mapping[str, object],
metadata: Mapping[str, object],
autorouter_savings: float,
cache_read_tokens: int,
cache_creation_tokens: int,
) -> TurnFacts | None:
"""One spend-log payload as a rollup turn, or ``None`` if it was not auto-routed.
A recorded ``routing_decision`` is what says the request was auto-routed, and names the kind.
"""
decision: Final = metadata.get("routing_decision")
if not isinstance(decision, Mapping):
return None
router_kind: Final = decision.get("router_type")
api_key: Final = payload.get("api_key")
session_id: Final = payload.get("session_id")
model_group: Final = payload.get("model_group")
model: Final = payload.get("model")
if not (
isinstance(router_kind, str)
and isinstance(api_key, str)
and isinstance(session_id, str)
and isinstance(model_group, str)
and isinstance(model, str)
and api_key
and session_id
and model_group
and model
):
return None
started_at: Final = _started_at(payload.get("startTime"))
if started_at is None:
return None
usage_raw: Final = metadata.get("usage_object")
usage_obj: Final = usage_raw if isinstance(usage_raw, Mapping) else None
spend: Final = float(payload.get("spend") or 0.0)
return TurnFacts(
api_key=api_key,
session_id=session_id,
model_group=model_group,
router_kind=router_kind,
baseline_model=litellm.autorouter_savings_baseline_model,
model=model,
started_at=started_at,
total_tokens=int(payload.get("prompt_tokens") or 0) + int(payload.get("completion_tokens") or 0),
spend=spend,
baseline_spend=spend + autorouter_savings,
cache_hit=cache_read_tokens > 0,
cache_creation_tokens=cache_creation_tokens,
cached_prefix_tokens=max(cache_read_tokens, 0) + max(cache_creation_tokens, 0),
ttl_seconds=ttl_seconds(usage_obj),
)

View file

@ -1391,6 +1391,41 @@ model LiteLLM_AdaptiveRouterSession {
@@index([last_activity_at], map: "idx_adaptive_router_session_activity")
}
model LiteLLM_AutoRouterSession {
api_key String
session_id String
model_group String
router_kind String
baseline_model String?
turns Int @default(0)
turns_with_usage Int @default(0)
total_tokens BigInt @default(0)
spend Float @default(0)
baseline_spend Float @default(0)
first_visit_turns Int @default(0)
first_visit_hits Int @default(0)
warm_turns Int @default(0)
warm_hits Int @default(0)
expired_turns Int @default(0)
expired_hits Int @default(0)
unordered_turns Int @default(0)
unordered_hits Int @default(0)
ephemeral_1h_turns Int @default(0)
tiers Json @default("{}")
first_turn_at DateTime
last_turn_at DateTime
updated_at DateTime @default(now()) @updatedAt
@@id([api_key, session_id, model_group])
@@index([first_turn_at], map: "idx_auto_router_session_started")
@@index([last_turn_at], map: "idx_auto_router_session_activity")
}
// ---------------------------------------------------------------------------
// Workflow Run Tracking
//

View file

View file

@ -0,0 +1,36 @@
"""A real Postgres connection for the rollup's SQL.
The auto-router rollup classifies each turn inside its upsert, against the row's own
stored state, so the classification only exists when a real database evaluates it.
"""
from dataclasses import dataclass
import pytest_asyncio
from prisma import Prisma
@dataclass(frozen=True)
class PrismaClientShim:
"""What the rollup writer needs from litellm's PrismaClient: a connected `db`."""
db: Prisma
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def prisma_db():
db = Prisma()
await db.connect()
try:
yield db
finally:
await db.disconnect()
@pytest_asyncio.fixture(loop_scope="session")
async def rollup_client(prisma_db):
await prisma_db.execute_raw('DELETE FROM "LiteLLM_AutoRouterSession"')
try:
yield PrismaClientShim(db=prisma_db)
finally:
await prisma_db.execute_raw('DELETE FROM "LiteLLM_AutoRouterSession"')

View file

@ -0,0 +1,237 @@
"""How the auto-router rollup buckets a turn, evaluated by a real Postgres.
The upsert classifies each turn against the session's own cache record, so these assertions
are about SQL. They cover what the dashboard depends on: the three buckets partition every
turn, a tier that aged out is told from one that is still warm, the warming estimate is
priced on the prefix that was actually cached, and one caller cannot write into another's
rollup by reusing a session id.
"""
import datetime as dt
from dataclasses import replace
import pytest
from litellm.proxy.spend_tracking.auto_router_sessions import AutoRouterSessionQueue, TurnFacts
pytestmark = pytest.mark.asyncio(loop_scope="session")
HAIKU = "anthropic/claude-haiku-4-5"
OPUS = "anthropic/claude-opus-4-8"
T0 = dt.datetime(2026, 8, 3, 12, 0, tzinfo=dt.timezone.utc).timestamp()
TURN = TurnFacts(
api_key="key-a",
session_id="sess-1",
model_group="claude-auto",
router_kind="complexity",
baseline_model=OPUS,
model=HAIKU,
started_at=T0,
total_tokens=1000,
spend=0.01,
baseline_spend=0.05,
cache_hit=False,
cache_creation_tokens=2000,
cached_prefix_tokens=2000,
ttl_seconds=300.0,
)
async def _flush(client, turns) -> None:
queue = AutoRouterSessionQueue()
for turn in turns:
await queue.update_queue.put(turn)
await queue.flush(prisma_client=client)
async def _rows(db) -> list[dict]:
return await db.query_raw('SELECT * FROM "LiteLLM_AutoRouterSession" ORDER BY api_key, session_id')
def _epoch_of(stored: str) -> float:
parsed = dt.datetime.fromisoformat(stored)
return (parsed.replace(tzinfo=dt.timezone.utc) if parsed.tzinfo is None else parsed).timestamp()
async def test_a_sessions_opening_turn_on_a_tier_is_a_first_visit(rollup_client, prisma_db):
await _flush(rollup_client, [TURN])
row = (await _rows(prisma_db))[0]
assert (row["turns"], row["first_visit_turns"], row["warm_turns"], row["expired_turns"]) == (1, 1, 0, 0)
assert row["tiers"] == {HAIKU: [T0, 300.0, 2000]}
async def test_a_second_tier_is_its_own_first_visit(rollup_client, prisma_db):
await _flush(rollup_client, [TURN, replace(TURN, model=OPUS, started_at=T0 + 10)])
row = (await _rows(prisma_db))[0]
assert (row["turns"], row["first_visit_turns"]) == (2, 2)
assert set(row["tiers"]) == {HAIKU, OPUS}
async def test_a_tier_used_again_inside_its_ttl_is_warm(rollup_client, prisma_db):
await _flush(rollup_client, [TURN, replace(TURN, started_at=T0 + 60, cache_hit=True)])
row = (await _rows(prisma_db))[0]
assert (row["warm_turns"], row["warm_hits"], row["expired_turns"]) == (1, 1, 0)
async def test_a_tier_used_again_past_its_ttl_has_expired(rollup_client, prisma_db):
await _flush(rollup_client, [TURN, replace(TURN, started_at=T0 + 900)])
row = (await _rows(prisma_db))[0]
assert (row["warm_turns"], row["expired_turns"], row["expired_hits"]) == (0, 1, 0)
async def test_expiry_measures_against_the_ttl_the_cache_was_written_with(rollup_client, prisma_db):
"""A one-hour entry is still warm at 30 minutes, even though the turn reading it
reports no one-hour evidence of its own and so carries the five minute default."""
await _flush(
rollup_client,
[
replace(TURN, ttl_seconds=3600.0, cache_creation_tokens=2000),
replace(TURN, started_at=T0 + 1800, ttl_seconds=300.0, cache_hit=True, cache_creation_tokens=0),
],
)
row = (await _rows(prisma_db))[0]
assert (row["warm_turns"], row["expired_turns"]) == (1, 0)
async def test_the_three_buckets_partition_every_turn(rollup_client, prisma_db):
await _flush(
rollup_client,
[
TURN,
replace(TURN, started_at=T0 + 10),
replace(TURN, model=OPUS, started_at=T0 + 20),
replace(TURN, started_at=T0 + 5000),
],
)
row = (await _rows(prisma_db))[0]
assert row["first_visit_turns"] + row["warm_turns"] + row["expired_turns"] == row["turns"] == 4
async def test_a_turn_that_only_read_the_cache_leaves_the_written_terms_alone(rollup_client, prisma_db):
await _flush(
rollup_client,
[
replace(TURN, ttl_seconds=3600.0, cache_creation_tokens=5000, cached_prefix_tokens=5000),
replace(TURN, started_at=T0 + 10, cache_hit=True, cache_creation_tokens=0, cached_prefix_tokens=5000),
],
)
row = (await _rows(prisma_db))[0]
assert row["tiers"][HAIKU] == [T0 + 10, 3600.0, 5000]
async def test_counters_accumulate_across_flushes(rollup_client, prisma_db):
await _flush(rollup_client, [TURN])
await _flush(rollup_client, [replace(TURN, started_at=T0 + 10)])
row = (await _rows(prisma_db))[0]
assert row["turns"] == 2
assert row["spend"] == pytest.approx(0.02)
assert row["baseline_spend"] == pytest.approx(0.10)
assert row["total_tokens"] == 2000
assert _epoch_of(row["first_turn_at"]) == pytest.approx(T0, abs=0.001)
assert _epoch_of(row["last_turn_at"]) == pytest.approx(T0 + 10, abs=0.001)
async def test_a_late_turn_cannot_rewind_the_session(rollup_client, prisma_db):
await _flush(rollup_client, [replace(TURN, started_at=T0 + 600)])
await _flush(rollup_client, [replace(TURN, started_at=T0)])
row = (await _rows(prisma_db))[0]
assert row["turns"] == 2
assert row["tiers"][HAIKU][0] == T0 + 600
assert _epoch_of(row["last_turn_at"]) == pytest.approx(T0 + 600, abs=0.001)
assert _epoch_of(row["first_turn_at"]) == pytest.approx(T0, abs=0.001)
async def test_two_callers_reusing_one_session_id_keep_separate_rollups(rollup_client, prisma_db):
await _flush(
rollup_client,
[
replace(TURN, api_key="key-a", model=HAIKU),
replace(TURN, api_key="key-b", model=OPUS, started_at=T0 + 10),
],
)
rows = await _rows(prisma_db)
assert [row["api_key"] for row in rows] == ["key-a", "key-b"]
assert all(row["turns"] == 1 and row["first_visit_turns"] == 1 for row in rows)
async def test_a_turn_arriving_before_an_already_recorded_one_is_not_called_warm(rollup_client, prisma_db):
"""Its cache state at its own time is unknowable, so it abstains rather than being
guessed at; a negative idle gap is not evidence of warmth."""
await _flush(rollup_client, [replace(TURN, started_at=T0 + 600)])
await _flush(rollup_client, [replace(TURN, started_at=T0)])
row = (await _rows(prisma_db))[0]
assert row["turns"] == 2
assert row["unordered_turns"] == 1
assert row["warm_turns"] == 0
assert row["expired_turns"] == 0
assert row["first_visit_turns"] == 1
assert row["tiers"][HAIKU][0] == T0 + 600
async def test_every_turn_lands_in_exactly_one_of_the_four_buckets(rollup_client, prisma_db):
await _flush(
rollup_client,
[
TURN,
replace(TURN, started_at=T0 + 10),
replace(TURN, model=OPUS, started_at=T0 + 20),
replace(TURN, started_at=T0 + 5000),
],
)
await _flush(rollup_client, [replace(TURN, started_at=T0 + 5)])
row = (await _rows(prisma_db))[0]
buckets = row["first_visit_turns"] + row["warm_turns"] + row["expired_turns"] + row["unordered_turns"]
assert buckets == row["turns"] == 5
assert row["unordered_turns"] == 1
async def test_a_first_visit_to_a_new_tier_keeps_its_own_ttl_even_with_no_cache_write(rollup_client, prisma_db):
await _flush(
rollup_client,
[
TURN,
replace(
TURN,
model=OPUS,
started_at=T0 + 10,
ttl_seconds=3600.0,
cache_creation_tokens=0,
cached_prefix_tokens=0,
),
],
)
row = (await _rows(prisma_db))[0]
assert row["tiers"][OPUS] == [T0 + 10, 3600.0, 0]
async def test_a_growing_conversation_records_the_whole_live_prefix(rollup_client, prisma_db):
"""A warm turn on a growing prompt writes only the new segment, so recording
cache_creation_tokens alone would shrink the prefix and under-price later replays.
The live prefix is what was read plus what was written."""
await _flush(
rollup_client,
[
replace(TURN, cache_creation_tokens=2000, cached_prefix_tokens=2000),
replace(TURN, started_at=T0 + 60, cache_hit=True, cache_creation_tokens=500, cached_prefix_tokens=2500),
],
)
row = (await _rows(prisma_db))[0]
assert row["warm_turns"] == 1
assert row["tiers"][HAIKU] == [T0 + 60, 300.0, 2500]
async def test_a_turn_that_touched_no_cache_is_left_out_of_the_cache_view(rollup_client, prisma_db):
"""A model with caching off would otherwise read as a wall of first visits and drag the
hit rate down; it counts as a turn and nothing else."""
await _flush(
rollup_client,
[
replace(TURN, cache_creation_tokens=0, cached_prefix_tokens=0),
replace(TURN, started_at=T0 + 10, cache_creation_tokens=0, cached_prefix_tokens=0),
],
)
row = (await _rows(prisma_db))[0]
assert row["turns"] == 2
assert row["turns_with_usage"] == 0
assert row["first_visit_turns"] + row["warm_turns"] + row["expired_turns"] + row["unordered_turns"] == 0

View file

@ -0,0 +1,246 @@
"""Read-path derivations for the auto-router benchmarks dashboard.
Every figure the dashboard shows is a pure function of the rollup counters, so all of it
is exercised here without a database.
"""
import datetime as dt
from dataclasses import fields
import pytest
from litellm.proxy.spend_tracking.auto_router_benchmarks import (
MAX_WINDOW_DAYS,
_Counters,
build_response,
clamp_window,
summarize,
)
def _row(model_group: str, **overrides: object) -> dict[str, object]:
"""Every counter at zero unless the case under test says otherwise."""
return {
"model_group": model_group,
"router_kind": "complexity",
"baseline_model": "anthropic/claude-opus-4-8",
**{field.name: 0 for field in fields(_Counters)},
"sessions": 1,
**overrides,
}
class TestSummarize:
def test_savings_are_the_difference_between_the_two_arms(self):
result = summarize(_Counters(sessions=4, turns=40, spend=364.59, baseline_spend=414.63))
assert result.savings == pytest.approx(50.04)
assert result.savings_pct == pytest.approx(100 * 50.04 / 414.63)
assert result.saved_per_session == pytest.approx(50.04 / 4)
assert result.avg_turns_per_session == pytest.approx(10.0)
def test_a_cache_thrashing_router_reports_a_signed_loss(self):
result = summarize(_Counters(sessions=1, turns=1, spend=5.0, baseline_spend=4.0))
assert result.savings == pytest.approx(-1.0)
assert result.savings_pct < 0
def test_session_shape_averages_over_sessions_not_turns(self):
result = summarize(
_Counters(sessions=2, turns=64, total_tokens=10_000, total_session_seconds=7200.0)
)
assert result.avg_turns_per_session == pytest.approx(32.0)
assert result.avg_session_seconds == pytest.approx(3600.0)
assert result.avg_tokens_per_session == pytest.approx(5000.0)
def test_an_empty_window_divides_by_nothing(self):
result = summarize(_Counters())
assert result.sessions == 0
assert result.savings_pct == 0.0
assert result.saved_per_session == 0.0
assert result.avg_turns_per_session == 0.0
assert result.cache is None
class TestCacheView:
def test_hit_rate_is_weighted_by_turn_count_not_averaged_across_buckets(self):
cache = summarize(
_Counters(
sessions=1,
turns=3145,
turns_with_usage=3145,
warm_turns=2560,
warm_hits=2491,
first_visit_turns=146,
first_visit_hits=15,
expired_turns=439,
expired_hits=348,
)
).cache
assert cache is not None
hits = 2491 + 15 + 348
assert cache.hit_rate_pct == pytest.approx(100 * hits / 3145)
mean_of_bucket_rates = (
cache.warm_hit_rate_pct + cache.first_visit_hit_rate_pct + cache.expired_hit_rate_pct
) / 3
assert cache.hit_rate_pct != pytest.approx(mean_of_bucket_rates)
assert cache.hit_rate_pct > mean_of_bucket_rates
def test_traffic_that_never_touched_the_cache_is_left_out_of_the_hit_rate(self):
"""A model with caching off would otherwise read as a wall of misses. It is absent
from the buckets, and coverage says how much of the traffic that was."""
cache = summarize(
_Counters(sessions=1, turns=100, turns_with_usage=40, warm_turns=40, warm_hits=36)
).cache
assert cache is not None
assert cache.turns == 40
assert cache.coverage_pct == pytest.approx(40.0)
assert cache.hit_rate_pct == pytest.approx(90.0)
assert cache.misses == 4
def test_the_three_buckets_partition_every_turn(self):
counters = _Counters(
sessions=1, turns=10, turns_with_usage=10, warm_turns=6, first_visit_turns=2, expired_turns=2
)
cache = summarize(counters).cache
assert cache is not None
assert cache.warm_turns + cache.first_visit_turns + cache.expired_turns == cache.turns
def test_every_miss_has_one_cause_and_they_stack_to_the_whole(self):
"""A miss is cold by design, a changed prefix, an expiry, or a turn whose cache
state could not be established."""
cache = summarize(
_Counters(
sessions=1,
turns=24,
turns_with_usage=24,
first_visit_turns=5,
first_visit_hits=1,
warm_turns=10,
warm_hits=8,
expired_turns=5,
expired_hits=2,
unordered_turns=4,
unordered_hits=3,
)
).cache
assert cache is not None
assert cache.hits == 14
assert cache.misses == 10
assert (cache.cold_misses, cache.prefix_change_misses, cache.expired_misses) == (4, 2, 3)
assert cache.unattributed_misses == 1
assert (
cache.cold_misses + cache.prefix_change_misses + cache.expired_misses + cache.unattributed_misses
== cache.misses
)
assert (
cache.cold_miss_pct
+ cache.prefix_change_miss_pct
+ cache.expired_miss_pct
+ cache.unattributed_miss_pct
== pytest.approx(100.0)
)
def test_an_unordered_turn_still_counts_toward_the_headline_hit_rate(self):
"""Its cause is unknowable, but the provider still said whether it hit, so the
rate a reader looks at stays exact and only the attribution abstains."""
cache = summarize(
_Counters(sessions=1, turns=10, turns_with_usage=10, warm_turns=6, warm_hits=6, unordered_turns=4, unordered_hits=2)
).cache
assert cache is not None
assert cache.hits == 8
assert cache.hit_rate_pct == pytest.approx(80.0)
def test_a_router_with_no_cache_evidence_has_no_cache_view(self):
assert summarize(_Counters(sessions=1, turns=5, turns_with_usage=0)).cache is None
def test_ttl_follows_the_tier_the_majority_of_turns_used(self):
five_minute = summarize(_Counters(sessions=1, turns=10, turns_with_usage=10, ephemeral_1h_turns=4)).cache
one_hour = summarize(_Counters(sessions=1, turns=10, turns_with_usage=10, ephemeral_1h_turns=6)).cache
assert five_minute is not None and one_hour is not None
assert five_minute.ttl_seconds == 300.0
assert one_hour.ttl_seconds == 3600.0
class TestTotals:
def test_totals_sum_the_counters_rather_than_averaging_group_rates(self):
"""A big cheap router and a small expensive one must not be weighted equally."""
response = build_response(
rows=[
_row(
"big",
turns=1000,
turns_with_usage=1000,
warm_turns=1000,
warm_hits=900,
first_visit_turns=0,
first_visit_hits=0,
expired_turns=0,
expired_hits=0,
),
_row(
"small",
turns=10,
turns_with_usage=10,
warm_turns=10,
warm_hits=1,
first_visit_turns=0,
first_visit_hits=0,
expired_turns=0,
expired_hits=0,
),
],
start_date=dt.date(2026, 7, 5),
end_date=dt.date(2026, 8, 3),
)
assert response.routers_in_scope == 2
assert response.totals.cache is not None
assert response.totals.cache.hit_rate_pct == pytest.approx(100 * 901 / 1010)
group_rates = [g.benchmark.cache.hit_rate_pct for g in response.groups if g.benchmark.cache]
assert response.totals.cache.hit_rate_pct != pytest.approx(sum(group_rates) / len(group_rates))
def test_totals_dollars_are_the_sum_of_every_router(self):
response = build_response(
rows=[_row("a", spend=3.0, baseline_spend=5.0), _row("b", spend=1.0, baseline_spend=9.0)],
start_date=dt.date(2026, 7, 5),
end_date=dt.date(2026, 8, 3),
)
assert response.totals.spend == pytest.approx(4.0)
assert response.totals.baseline_spend == pytest.approx(14.0)
assert response.totals.savings == pytest.approx(10.0)
assert response.totals.sessions == 2
def test_the_response_echoes_the_window_actually_read(self):
"""A caller asking for a year is told it got a month, not handed month-sized
numbers under year-sized dates."""
response = build_response(rows=[], start_date=dt.date(2026, 7, 5), end_date=dt.date(2026, 8, 3))
assert (response.start_date, response.end_date) == (dt.date(2026, 7, 5), dt.date(2026, 8, 3))
def test_an_empty_window_still_answers_with_zeroed_totals(self):
response = build_response(rows=[], start_date=dt.date(2026, 8, 1), end_date=dt.date(2026, 8, 3))
assert response.routers_in_scope == 0
assert response.groups == ()
assert response.totals.turns == 0
def test_group_identity_is_carried_through(self):
response = build_response(
rows=[_row("claude-auto")], start_date=dt.date(2026, 8, 1), end_date=dt.date(2026, 8, 3)
)
assert response.groups[0].model_group == "claude-auto"
assert response.groups[0].router_kind == "complexity"
assert response.groups[0].baseline_model == "anthropic/claude-opus-4-8"
class TestWindow:
def test_end_date_is_inclusive(self):
start, end = clamp_window(dt.date(2026, 8, 3), dt.date(2026, 8, 3))
assert start == dt.datetime(2026, 8, 3, tzinfo=dt.timezone.utc)
assert end == dt.datetime(2026, 8, 4, tzinfo=dt.timezone.utc)
def test_a_wider_request_is_clamped_to_the_cap_measured_in_dates_spanned(self):
start, end = clamp_window(dt.date(2020, 1, 1), dt.date(2026, 8, 3))
assert (end.date() - start.date()).days == MAX_WINDOW_DAYS
assert start == dt.datetime(2026, 7, 5, tzinfo=dt.timezone.utc)
def test_a_window_inside_the_cap_is_left_alone(self):
start, end = clamp_window(dt.date(2026, 8, 1), dt.date(2026, 8, 3))
assert start == dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc)
assert end == dt.datetime(2026, 8, 4, tzinfo=dt.timezone.utc)

View file

@ -0,0 +1,124 @@
"""Reducing one spend-log payload to a rollup turn.
Bucketing a turn is done by the upsert against the session's own cache record, so it is
covered in tests/proxy_behavior/spend against a real Postgres. What is pure, and covered
here, is deciding whether a request is an auto-routed turn at all and what it contributes.
"""
import datetime as dt
import pytest
from litellm.proxy.spend_tracking.auto_router_sessions import (
CACHE_TTL_1H_SECONDS,
CACHE_TTL_5M_SECONDS,
build_turn_facts,
ttl_seconds,
)
T0 = dt.datetime(2026, 8, 3, 12, 0, tzinfo=dt.timezone.utc)
def _payload(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"api_key": "hashed-key",
"session_id": "sess-1",
"model_group": "claude-auto",
"model": "claude-haiku-4-5",
"custom_llm_provider": "anthropic",
"startTime": T0.isoformat(),
"spend": 0.25,
"prompt_tokens": 900,
"completion_tokens": 100,
}
return {**base, **overrides}
def _metadata(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"routing_decision": {"router_type": "complexity"},
"usage_object": {"cache_read_input_tokens": 500, "cache_creation_input_tokens": 0},
}
return {**base, **overrides}
def _build(payload=None, metadata=None, **kwargs):
return build_turn_facts(
payload=payload if payload is not None else _payload(),
metadata=metadata if metadata is not None else _metadata(),
autorouter_savings=kwargs.get("autorouter_savings", 0.75),
cache_read_tokens=kwargs.get("cache_read_tokens", 500),
cache_creation_tokens=kwargs.get("cache_creation_tokens", 0),
)
class TestNotAutoRouted:
def test_a_request_with_no_routing_decision_is_not_a_turn(self):
assert _build(metadata={"usage_object": {}}) is None
def test_a_routing_decision_without_a_kind_is_not_a_turn(self):
assert _build(metadata=_metadata(routing_decision={})) is None
@pytest.mark.parametrize("field", ["api_key", "session_id", "model_group", "model"])
def test_a_turn_missing_any_identity_field_is_dropped(self, field: str):
assert _build(payload=_payload(**{field: ""})) is None
assert _build(payload=_payload(**{field: None})) is None
def test_an_unparseable_start_time_is_dropped(self):
assert _build(payload=_payload(startTime="not-a-timestamp")) is None
class TestTurnFacts:
def test_the_router_kind_is_read_from_the_decision_the_router_recorded(self):
built = _build()
assert built is not None and built.router_kind == "complexity"
def test_the_baseline_arm_is_this_turn_plus_what_the_router_saved(self):
built = _build(autorouter_savings=0.75)
assert built is not None
assert (built.spend, built.baseline_spend) == (pytest.approx(0.25), pytest.approx(1.0))
def test_a_route_that_lost_money_carries_a_baseline_below_what_was_paid(self):
built = _build(autorouter_savings=-0.10)
assert built is not None and built.baseline_spend == pytest.approx(0.15)
def test_tokens_are_the_whole_turn(self):
built = _build()
assert built is not None and built.total_tokens == 1000
def test_a_naive_start_time_is_read_as_utc(self):
naive = _build(payload=_payload(startTime=T0.replace(tzinfo=None)))
aware = _build(payload=_payload(startTime=T0))
assert naive is not None and aware is not None
assert naive.started_at == aware.started_at == T0.timestamp()
def test_a_cache_read_is_a_hit(self):
built = _build(cache_read_tokens=1)
assert built is not None and built.cache_hit is True
def test_no_cache_read_is_a_miss(self):
built = _build(cache_read_tokens=0)
assert built is not None and built.cache_hit is False
class TestCacheEvidence:
def test_the_live_prefix_is_what_was_read_plus_what_was_written(self):
built = _build(cache_read_tokens=500, cache_creation_tokens=200)
assert built is not None and built.cached_prefix_tokens == 700
def test_a_turn_that_touched_no_cache_has_no_prefix(self):
"""Coverage keys off this: a model with caching off is absent from the cache view
rather than counted as a miss."""
built = _build(cache_read_tokens=0, cache_creation_tokens=0)
assert built is not None and built.cached_prefix_tokens == 0
def test_a_turn_with_no_one_hour_evidence_is_scored_against_the_five_minute_tier(self):
assert ttl_seconds({"cache_creation_input_tokens": 10}) == CACHE_TTL_5M_SECONDS
assert ttl_seconds(None) == CACHE_TTL_5M_SECONDS
def test_one_hour_cache_writes_are_scored_against_the_one_hour_tier(self):
usage = {
"cache_creation_input_tokens": 10,
"cache_creation_token_details": {"ephemeral_1h_input_tokens": 10},
}
assert ttl_seconds(usage) == CACHE_TTL_1H_SECONDS

View file

@ -158,8 +158,8 @@ async def test_cleanup_old_spend_logs_batch_deletion():
mock_db = MagicMock()
# Mock execute_raw to return deleted counts (3 spend-log batches, then the
# tool-index cleanup's first batch returning 0)
mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0, 0])
# tool-index and auto-router-session cleanups each returning 0 on their first batch)
mock_db.execute_raw = AsyncMock(side_effect=[1000, 500, 0, 0, 0])
# Wire up mocks
mock_prisma_client.db = mock_db
@ -179,7 +179,7 @@ async def test_cleanup_old_spend_logs_batch_deletion():
await cleaner.cleanup_old_spend_logs(mock_prisma_client)
# Validate batching and deletion via raw SQL
assert mock_db.execute_raw.call_count == 4
assert mock_db.execute_raw.call_count == 5
# Check the first call argument
call_args_sql = mock_db.execute_raw.call_args_list[0][0][0]
@ -193,6 +193,12 @@ async def test_cleanup_old_spend_logs_batch_deletion():
tool_index_sql = mock_db.execute_raw.call_args_list[3][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in tool_index_sql
# The auto-router rollup expires on the same cutoff, keyed on last activity so a
# conversation still running when the cutoff passes is not pruned mid-session
session_sql = mock_db.execute_raw.call_args_list[4][0][0]
assert 'DELETE FROM "LiteLLM_AutoRouterSession"' in session_sql
assert '"last_turn_at" <' in session_sql
# The LiteLLM_DailyToolSpend rollup must outlive spend-log retention: it is
# the only copy of tool spend history once its per-request sources expire,
# so spend-log cleanup must never touch it.
@ -285,7 +291,7 @@ async def test_cleanup_uses_delete_when_partitioning_not_enabled():
from unittest.mock import AsyncMock, MagicMock
mock_prisma_client = MagicMock()
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0])
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0, 0])
partition_manager = MagicMock()
partition_manager.is_partitioned = AsyncMock(return_value=True)
@ -316,7 +322,7 @@ async def test_cleanup_uses_delete_when_not_partitioned():
from unittest.mock import AsyncMock, MagicMock
mock_prisma_client = MagicMock()
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0])
mock_prisma_client.db.execute_raw = AsyncMock(side_effect=[10, 0, 0, 0])
partition_manager = MagicMock()
partition_manager.is_partitioned = AsyncMock(return_value=False)
@ -335,7 +341,7 @@ async def test_cleanup_uses_delete_when_not_partitioned():
await cleaner.cleanup_old_spend_logs(mock_prisma_client)
partition_manager.drop_partitions_older_than.assert_not_awaited()
assert mock_prisma_client.db.execute_raw.await_count == 3
assert mock_prisma_client.db.execute_raw.await_count == 4
delete_sql = mock_prisma_client.db.execute_raw.call_args_list[0][0][0]
assert 'DELETE FROM "LiteLLM_SpendLogs"' in delete_sql

View file

@ -760,6 +760,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/auto_router/benchmarks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Auto Router Benchmarks
* @description Savings, session shape and prompt-cache behaviour for every auto-router.
*
* Admin-only. Reads the per-session rollup only; no per-request table is scanned.
* `start_date` and `end_date` are inclusive calendar dates, clamped to the most recent
* 30 days. Pass `model_group` to scope every figure to one auto-router.
*/
get: operations["get_auto_router_benchmarks_auto_router_benchmarks_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/azure/{endpoint}": {
parameters: {
query?: never;
@ -21062,6 +21086,113 @@ export interface components {
[key: string]: unknown;
} | null;
};
/** AutoRouterBenchmark */
AutoRouterBenchmark: {
/** Avg Session Seconds */
avg_session_seconds: number;
/** Avg Tokens Per Session */
avg_tokens_per_session: number;
/** Avg Turns Per Session */
avg_turns_per_session: number;
/** Baseline Spend */
baseline_spend: number;
cache: components["schemas"]["AutoRouterCacheBenchmark"] | null;
/** Saved Per Session */
saved_per_session: number;
/** Savings */
savings: number;
/** Savings Pct */
savings_pct: number;
/** Sessions */
sessions: number;
/** Spend */
spend: number;
/** Total Tokens */
total_tokens: number;
/** Turns */
turns: number;
};
/** AutoRouterBenchmarksResponse */
AutoRouterBenchmarksResponse: {
/**
* End Date
* Format: date
*/
end_date: string;
/** Groups */
groups: components["schemas"]["AutoRouterGroupBenchmark"][];
/** Routers In Scope */
routers_in_scope: number;
/**
* Start Date
* Format: date
*/
start_date: string;
totals: components["schemas"]["AutoRouterBenchmark"];
};
/** AutoRouterCacheBenchmark */
AutoRouterCacheBenchmark: {
/** Cold Miss Pct */
cold_miss_pct: number;
/** Cold Misses */
cold_misses: number;
/** Coverage Pct */
coverage_pct: number;
/** Expired Hit Rate Pct */
expired_hit_rate_pct: number;
/** Expired Hits */
expired_hits: number;
/** Expired Miss Pct */
expired_miss_pct: number;
/** Expired Misses */
expired_misses: number;
/** Expired Turns */
expired_turns: number;
/** First Visit Hit Rate Pct */
first_visit_hit_rate_pct: number;
/** First Visit Hits */
first_visit_hits: number;
/** First Visit Turns */
first_visit_turns: number;
/** Hit Rate Pct */
hit_rate_pct: number;
/** Hits */
hits: number;
/** Misses */
misses: number;
/** Prefix Change Miss Pct */
prefix_change_miss_pct: number;
/** Prefix Change Misses */
prefix_change_misses: number;
/** Ttl Seconds */
ttl_seconds: number;
/** Turns */
turns: number;
/** Unattributed Miss Pct */
unattributed_miss_pct: number;
/** Unattributed Misses */
unattributed_misses: number;
/** Unordered Hits */
unordered_hits: number;
/** Unordered Turns */
unordered_turns: number;
/** Warm Hit Rate Pct */
warm_hit_rate_pct: number;
/** Warm Hits */
warm_hits: number;
/** Warm Turns */
warm_turns: number;
};
/** AutoRouterGroupBenchmark */
AutoRouterGroupBenchmark: {
/** Baseline Model */
baseline_model: string | null;
benchmark: components["schemas"]["AutoRouterBenchmark"];
/** Model Group */
model_group: string;
/** Router Kind */
router_kind: string;
};
/** BaseLitellmParams */
"BaseLitellmParams-Input": {
/**
@ -35698,6 +35829,39 @@ export interface operations {
};
};
};
get_auto_router_benchmarks_auto_router_benchmarks_get: {
parameters: {
query: {
start_date: string;
end_date: string;
model_group?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AutoRouterBenchmarksResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
azure_proxy_route_azure__endpoint__get: {
parameters: {
query?: never;