merge: bring litellm_internal_staging into litellm_fix_agent_mcp_grants again

Staging moved by the auto-router classifier cost change (#40168) between the
first merge and its push; this merge picks it up so the PR merges cleanly
This commit is contained in:
mateo-berri 2026-09-07 16:35:41 -07:00
commit e01bb98960
15 changed files with 282 additions and 46 deletions

View file

@ -0,0 +1,3 @@
ALTER TABLE "LiteLLM_AutoRouterSession"
ADD COLUMN IF NOT EXISTS "classifier_cost" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "classifier_cost_recorded_turns" INTEGER NOT NULL DEFAULT 0;

View file

@ -1509,6 +1509,8 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
classifier_cost Float @default(0)
classifier_cost_recorded_turns Int @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])

View file

@ -75,6 +75,8 @@ SELECT
COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens,
COALESCE(SUM(spend), 0)::float8 AS spend,
COALESCE(SUM(saved_spend), 0)::float8 AS saved_spend,
COALESCE(SUM(classifier_cost), 0)::float8 AS classifier_cost,
COALESCE(SUM(classifier_cost_recorded_turns), 0)::int AS classifier_cost_recorded_turns,
COALESCE(SUM(EXTRACT(EPOCH FROM (last_turn_at - first_turn_at))), 0)::float8 AS session_seconds
FROM windowed
GROUP BY router_name, router_type
@ -95,6 +97,7 @@ class AutoRouterTurnTransaction:
total_tokens: int
spend: float
saved_spend: float
classifier_cost: float
covered: bool
cache_hit: bool
cache_ttl_seconds: int | None
@ -225,6 +228,7 @@ def build_autorouter_turn_transaction(
total_tokens=int(payload.get("prompt_tokens") or 0) + int(payload.get("completion_tokens") or 0),
spend=float(payload.get("spend") or 0.0) + (classifier_cost or 0.0),
saved_spend=saved_spend,
classifier_cost=classifier_cost or 0.0,
covered=cache.covered,
cache_hit=cache.read_tokens > 0,
cache_ttl_seconds=cache.write_ttl_seconds,
@ -266,7 +270,7 @@ INSERT INTO "LiteLLM_AutoRouterSession" AS t (
last_model, models, turns, unordered_turns, covered_turns, cache_hits,
same_model_turns, same_model_hits, first_visit_turns, first_visit_hits,
return_turns, return_hits, return_expired_misses, return_within_ttl_misses,
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend, tier_turns
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend, classifier_cost, classifier_cost_recorded_turns, tier_turns
)
VALUES (
{_p("api_key")}, {_p("session_id")}, {_p("router_name")}, {_p("router_type")}, {_TURN_AT}::timestamp, {_TURN_AT}::timestamp,
@ -277,13 +281,15 @@ VALUES (
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_5M_SECONDS} THEN 1 ELSE 0 END),
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_1H_SECONDS} THEN 1 ELSE 0 END),
{_p("total_tokens")}::bigint, {_p("spend")}::float8, {_p("saved_spend")}::float8,
{_TIER_DELTA}
{_p("classifier_cost")}::float8, 1, {_TIER_DELTA}
)
ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
turns = t.turns + 1,
total_tokens = t.total_tokens + EXCLUDED.total_tokens,
spend = t.spend + EXCLUDED.spend,
saved_spend = t.saved_spend + EXCLUDED.saved_spend,
classifier_cost = t.classifier_cost + EXCLUDED.classifier_cost,
classifier_cost_recorded_turns = t.classifier_cost_recorded_turns + 1,
covered_turns = t.covered_turns + EXCLUDED.covered_turns,
cache_hits = t.cache_hits + EXCLUDED.cache_hits,
ttl_5m_turns = t.ttl_5m_turns + EXCLUDED.ttl_5m_turns,

View file

@ -484,6 +484,8 @@ class _SessionAggRow(BaseModel):
total_tokens: int
spend: float
saved_spend: float
classifier_cost: float
classifier_cost_recorded_turns: int
session_seconds: float
@ -520,6 +522,7 @@ def _benchmark_totals(row: _SessionAggRow) -> AutoRouterBenchmarkTotals:
avg_tokens_per_session=row.total_tokens / sessions if sessions else 0.0,
spend=row.spend,
saved_spend=row.saved_spend,
classifier_cost=row.classifier_cost if row.classifier_cost_recorded_turns == row.turns else None,
baseline_spend=baseline_spend,
saved_pct=_pct(row.saved_spend, baseline_spend),
saved_per_session=row.saved_spend / sessions if sessions else 0.0,
@ -552,6 +555,7 @@ def _benchmark_group(row: _SessionAggRow) -> AutoRouterBenchmarkGroup:
avg_tokens_per_session=totals.avg_tokens_per_session,
spend=totals.spend,
saved_spend=totals.saved_spend,
classifier_cost=totals.classifier_cost,
baseline_spend=totals.baseline_spend,
saved_pct=totals.saved_pct,
saved_per_session=totals.saved_per_session,
@ -582,6 +586,8 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
total_tokens=sum(row.total_tokens for row in rows),
spend=sum(row.spend for row in rows),
saved_spend=sum(row.saved_spend for row in rows),
classifier_cost=sum(row.classifier_cost for row in rows),
classifier_cost_recorded_turns=sum(row.classifier_cost_recorded_turns for row in rows),
session_seconds=sum(row.session_seconds for row in rows),
)

View file

@ -1509,6 +1509,8 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
classifier_cost Float @default(0)
classifier_cost_recorded_turns Int @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])

View file

@ -195,6 +195,10 @@ class AutoRouterBenchmarkTotals(BaseModel):
avg_session_seconds: float
avg_tokens_per_session: float
spend: float = Field(description="What the routed traffic actually cost")
classifier_cost: float | None = Field(
description="Recorded LLM classifier cost already included in spend; null when any session turns predate "
"subtotal recording, and zero for an empty window"
)
saved_spend: float = Field(
description="Signed dollars saved versus each router's savings baseline (derived from its hardest "
"tier, or the configured override), from the same per-request savings record the usage tab reads"

View file

@ -1509,6 +1509,8 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
classifier_cost Float @default(0)
classifier_cost_recorded_turns Int @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])

View file

@ -40,12 +40,26 @@ async def _turn(
tokens: int = 100,
spend: float = 0.01,
saved: float = 0.02,
classifier_cost: float = 0.0,
tier: "str | None" = None,
) -> None:
touched: Final = 1 if (hit or ttl is not None or not covered) else 0
await db.execute_raw(
UPSERT_AUTOROUTER_SESSION_SQL,
key, session_id, router, router_type, model, at.isoformat(), tokens, spend, saved, covered, hit, ttl, touched,
key,
session_id,
router,
router_type,
model,
at.isoformat(),
tokens,
spend,
saved,
classifier_cost,
covered,
hit,
ttl,
touched,
tier,
)
@ -53,7 +67,9 @@ async def _turn(
async def _row(db, key: str, session_id: str = "s1", router: str = "auto-1") -> dict:
rows = await db.query_raw(
'SELECT * FROM "LiteLLM_AutoRouterSession" WHERE api_key = $1 AND session_id = $2 AND router_name = $3',
key, session_id, router,
key,
session_id,
router,
)
assert len(rows) == 1
return rows[0]
@ -143,9 +159,9 @@ async def test_out_of_order_turns_do_not_rewind_the_session(db):
async def test_concurrent_writers_compose_without_losing_turns(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0)
await _turn(db, key, "A", T0, classifier_cost=0.001)
await asyncio.gather(
*(_turn(db, key, "A", T0 + timedelta(seconds=1 + offset), hit=1) for offset in range(30))
*(_turn(db, key, "A", T0 + timedelta(seconds=1 + offset), hit=1, classifier_cost=0.002) for offset in range(30))
)
row = await _row(db, key)
assert row["turns"] == 31
@ -154,6 +170,51 @@ async def test_concurrent_writers_compose_without_losing_turns(db):
== row["turns"]
)
assert row["spend"] == pytest.approx(0.31)
assert row["saved_spend"] == pytest.approx(0.62)
assert row["classifier_cost"] == pytest.approx(0.061)
assert row["classifier_cost_recorded_turns"] == 31
async def _legacy_turn(db, key: str, at: datetime, session_id: str = "s1", router: str = "auto-1") -> None:
await db.execute_raw(
"""INSERT INTO "LiteLLM_AutoRouterSession" AS t (
api_key, session_id, router_name, router_type, first_turn_at, last_turn_at, last_model, turns, spend, saved_spend
) VALUES ($1, $2, $3, 'complexity', $4::timestamp, $4::timestamp, 'A', 1, 0.01, 0.02)
ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
turns = t.turns + 1, spend = t.spend + EXCLUDED.spend, saved_spend = t.saved_spend + EXCLUDED.saved_spend,
last_turn_at = EXCLUDED.last_turn_at""",
key,
session_id,
router,
at.isoformat(),
)
@pytest.mark.parametrize("writers", [(False,), (True,), (False, True), (True, False)])
async def test_subtotal_coverage_survives_legacy_and_rolling_writers(db, writers: tuple[bool, ...]):
key: Final = f"k-{uuid.uuid4()}"
for offset, records_cost in enumerate(writers):
at: Final = T0 + timedelta(seconds=offset)
if records_cost:
await _turn(db, key, "A", at, classifier_cost=0.004)
else:
await _legacy_turn(db, key, at)
row: Final = await _row(db, key)
assert row["turns"] == len(writers)
assert row["spend"] == pytest.approx(0.01 * len(writers))
assert row["saved_spend"] == pytest.approx(0.02 * len(writers))
assert row["classifier_cost"] == pytest.approx(0.004 * sum(writers))
assert row["classifier_cost_recorded_turns"] == sum(writers)
groups: Final = await db.query_raw(
AUTOROUTER_BENCHMARKS_SQL, T0.isoformat(), (T0 + timedelta(days=1)).isoformat(), key
)
assert len(groups) == 1
assert groups[0]["classifier_cost"] == row["classifier_cost"]
assert groups[0]["classifier_cost_recorded_turns"] == sum(writers)
assert groups[0]["turns"] == len(writers)
assert groups[0]["spend"] == row["spend"]
assert groups[0]["saved_spend"] == row["saved_spend"]
async def test_the_benchmarks_aggregate_reads_only_overlapping_sessions(db):
@ -161,9 +222,19 @@ async def test_the_benchmarks_aggregate_reads_only_overlapping_sessions(db):
router = f"r-{uuid.uuid4()}"
in_window = f"s-{uuid.uuid4()}"
out_of_window = f"s-{uuid.uuid4()}"
await _turn(db, key, "A", T0, session_id=in_window, router=router, saved=0.5, spend=0.25)
await _turn(db, key, "B", T0 + timedelta(seconds=60), session_id=in_window, router=router, saved=0.5, spend=0.25)
await _turn(db, key, "A", T0 - timedelta(days=40), session_id=out_of_window, router=router)
await _turn(
db,
key,
"B",
T0 + timedelta(seconds=60),
session_id=in_window,
router=router,
saved=0.5,
spend=0.25,
classifier_cost=0.01,
)
await _turn(db, key, "A", T0, session_id=in_window, router=router, saved=0.5, spend=0.25, classifier_cost=0.02)
await _turn(db, key, "A", T0 - timedelta(days=40), session_id=out_of_window, router=router, classifier_cost=9.0)
rows = await db.query_raw(
AUTOROUTER_BENCHMARKS_SQL,
@ -179,6 +250,9 @@ async def test_the_benchmarks_aggregate_reads_only_overlapping_sessions(db):
assert grouped["turns"] == 2
assert grouped["spend"] == pytest.approx(0.5)
assert grouped["saved_spend"] == pytest.approx(1.0)
assert grouped["classifier_cost"] == pytest.approx(0.03)
assert grouped["classifier_cost_recorded_turns"] == 2
assert grouped["unordered_turns"] == 1
assert grouped["session_seconds"] == pytest.approx(60.0)
@ -186,8 +260,8 @@ async def test_the_benchmarks_aggregate_can_filter_to_one_key(db):
router = f"r-{uuid.uuid4()}"
first_key = f"k-{uuid.uuid4()}"
second_key = f"k-{uuid.uuid4()}"
await _turn(db, first_key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, saved=0.5)
await _turn(db, second_key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, saved=9.0)
await _turn(db, first_key, "A", T0, router=router, saved=0.5, classifier_cost=0.01)
await _turn(db, second_key, "A", T0, router=router, saved=9.0, classifier_cost=0.09)
rows = await db.query_raw(
AUTOROUTER_BENCHMARKS_SQL,
@ -199,6 +273,8 @@ async def test_the_benchmarks_aggregate_can_filter_to_one_key(db):
assert len(matching) == 1
assert matching[0]["sessions"] == 1
assert matching[0]["saved_spend"] == pytest.approx(0.5)
assert matching[0]["classifier_cost"] == pytest.approx(0.01)
assert matching[0]["classifier_cost_recorded_turns"] == 1
unknown_key_rows = await db.query_raw(
AUTOROUTER_BENCHMARKS_SQL,
@ -213,7 +289,9 @@ async def test_a_reconfigured_alias_reports_each_router_type_as_its_own_group(db
key = f"k-{uuid.uuid4()}"
router = f"r-{uuid.uuid4()}"
await _turn(db, key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, router_type="complexity")
await _turn(db, key, "A", T0 + timedelta(seconds=10), session_id=f"s-{uuid.uuid4()}", router=router, router_type="quality")
await _turn(
db, key, "A", T0 + timedelta(seconds=10), session_id=f"s-{uuid.uuid4()}", router=router, router_type="quality"
)
rows = await db.query_raw(
AUTOROUTER_BENCHMARKS_SQL,

View file

@ -9,13 +9,15 @@ request-time transaction builder and the flush contract with an injected fake cl
import asyncio
import json
from datetime import datetime
from types import SimpleNamespace
from typing import Final
import httpx
import pytest
from litellm.proxy.db.autorouter_session_rollup import (
AutoRouterTurnTransaction,
UPSERT_AUTOROUTER_SESSION_SQL,
AutoRouterTurnTransaction,
build_autorouter_turn_transaction,
flush_autorouter_turn_transactions,
)
@ -70,6 +72,7 @@ class TestBuildTransaction:
total_tokens=100,
spend=0.01,
saved_spend=0.02,
classifier_cost=0.0,
covered=True,
cache_hit=True,
cache_ttl_seconds=300,
@ -111,6 +114,9 @@ class TestBuildTransaction:
folded once into the turn that paid for it (GH #38816)."""
transaction = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "classifier_cost": 0.005}))
assert transaction is not None and transaction.spend == pytest.approx(0.015)
assert transaction.classifier_cost == 0.005
assert transaction.spend - transaction.classifier_cost == pytest.approx(0.01)
assert transaction.saved_spend == 0.02
@pytest.mark.parametrize(
"decision_extra", [{}, {"classifier_cost": 0.0}, {"classifier_cost": "bogus"}, {"classifier_cost": True}]
@ -118,6 +124,8 @@ class TestBuildTransaction:
def test_an_unpriced_classifier_leaves_the_spend_alone(self, decision_extra: dict):
transaction = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, **decision_extra}))
assert transaction is not None and transaction.spend == pytest.approx(0.01)
assert transaction.classifier_cost == 0.0
assert transaction.saved_spend == 0.02
def test_every_turn_carries_its_own_classifier_charge(self):
first = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "classifier_cost": 0.005}))
@ -127,6 +135,7 @@ class TestBuildTransaction:
)
assert first is not None and first.spend == pytest.approx(0.015)
assert second is not None and second.spend == pytest.approx(0.027)
assert (first.classifier_cost, second.classifier_cost) == (0.005, 0.007)
def test_router_name_falls_back_to_the_payload_model_group(self):
transaction = _build(metadata=_metadata(routing_decision={"router_type": "complexity"}))
@ -217,6 +226,7 @@ def _transaction(
total_tokens=100,
spend=0.01,
saved_spend=0.02,
classifier_cost=0.005,
covered=True,
cache_hit=False,
cache_ttl_seconds=None,
@ -249,6 +259,7 @@ class TestFlush:
100,
0.01,
0.02,
0.005,
1,
0,
None,
@ -279,26 +290,31 @@ class TestFlush:
class TestEnqueueSeam:
@pytest.mark.asyncio
async def test_update_database_seam_enqueues_only_auto_routed_success(self, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("classifier_cost", [0.005, 0.0, None])
async def test_update_database_seam_enqueues_only_auto_routed_success(self, classifier_cost: float | None):
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
from litellm.proxy.utils import PrismaClient
monkeypatch.setattr(PrismaClient, "autorouter_turn_transactions", [])
writer = DBSpendUpdateWriter()
fake_prisma = type("P", (), {})()
fake_prisma._autorouter_turn_transactions_lock = asyncio.Lock()
fake_prisma.autorouter_turn_transactions = []
writer: Final = DBSpendUpdateWriter()
fake_prisma: Final = SimpleNamespace(
_autorouter_turn_transactions_lock=asyncio.Lock(), autorouter_turn_transactions=[]
)
metadata: Final = _metadata(
routing_decision={**ROUTING_DECISION, "classifier_cost": classifier_cost}, autorouter_savings=-0.003
)
for payload in (
_payload(metadata=json.dumps(metadata)),
_payload(metadata=json.dumps({"usage_object": {"prompt_tokens": 9}})),
_payload(status="failure", metadata=json.dumps(metadata)),
_payload(metadata=json.dumps({**metadata, "internal_call_origin": "autorouter_classifier"})),
):
await writer._enqueue_autorouter_turn_transaction(payload=payload, prisma_client=fake_prisma)
routed = _payload()
routed["metadata"] = json.dumps(_metadata())
await writer._enqueue_autorouter_turn_transaction(payload=routed, prisma_client=fake_prisma)
plain = _payload()
plain["metadata"] = json.dumps({"usage_object": {"prompt_tokens": 9}})
await writer._enqueue_autorouter_turn_transaction(payload=plain, prisma_client=fake_prisma)
assert [t.router_name for t in fake_prisma.autorouter_turn_transactions] == ["live-auto"]
assert fake_prisma.autorouter_turn_transactions[0].saved_spend == 0.0
assert len(fake_prisma.autorouter_turn_transactions) == 1
transaction: Final = fake_prisma.autorouter_turn_transactions[0]
assert transaction.router_name == "live-auto"
assert transaction.spend == pytest.approx(0.01 + (classifier_cost or 0.0))
assert transaction.classifier_cost == (classifier_cost or 0.0)
assert transaction.saved_spend == -0.003
def test_every_drain_trigger_reads_the_one_queue_census_owner():

View file

@ -10,7 +10,6 @@ import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from litellm.proxy._types import (
LitellmUserRoles,
ProxyErrorTypes,
@ -21,11 +20,11 @@ from litellm.proxy.management_endpoints.auto_router_endpoints import (
preview_auto_router_routing,
)
from litellm.router import Router
from litellm.types.utils import Choices, Message, ModelResponse
from litellm.types.management_endpoints.auto_router_endpoints import (
AutoRouterBenchmarksResponse,
AutoRouterRoutingTestRequest,
)
from litellm.types.utils import Choices, Message, ModelResponse
ADMIN = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test", user_id="admin")
@ -529,6 +528,8 @@ class TestAutoRouterBenchmarks:
total_tokens=4000,
spend=10.0,
saved_spend=30.0,
classifier_cost=0.4,
classifier_cost_recorded_turns=40,
session_seconds=400.0,
)
@ -567,6 +568,7 @@ class TestAutoRouterBenchmarks:
totals = _benchmark_totals(losing)
assert totals.baseline_spend == 5.0
assert totals.saved_pct == -100.0
assert totals.classifier_cost == 0.4
def test_an_empty_window_folds_to_zeros(self):
from litellm.proxy.management_endpoints.auto_router_endpoints import (
@ -579,6 +581,7 @@ class TestAutoRouterBenchmarks:
assert totals.turns == 0
assert totals.saved_pct == 0.0
assert totals.cache.hit_rate_pct == 0.0
assert totals.classifier_cost == 0.0
def test_totals_sum_counters_across_groups_before_deriving_ratios(self):
from litellm.proxy.management_endpoints.auto_router_endpoints import (
@ -660,6 +663,38 @@ class TestAutoRouterBenchmarks:
assert response.routers_in_scope == 1
assert response.groups[0].router_name == "live-auto"
assert response.groups[0].saved_pct == response.totals.saved_pct == 75.0
assert response.groups[0].classifier_cost == response.totals.classifier_cost == 0.4
assert response.totals.spend - response.totals.classifier_cost == pytest.approx(9.6)
@pytest.mark.asyncio
@pytest.mark.parametrize("recorded_turns", [0, 3, 10])
async def test_classifier_subtotals_require_every_included_turn_to_be_recorded(
self, recorded_turns: int, monkeypatch: pytest.MonkeyPatch
):
other: Final = self.ROW.model_copy(
update={
"router_name": "other-auto",
"sessions": 1,
"turns": 10,
"spend": 2.0,
"saved_spend": -0.5,
"classifier_cost": recorded_turns * 0.02,
"classifier_cost_recorded_turns": recorded_turns,
}
)
response: Final = await self._benchmarks(
monkeypatch, rows=[self.ROW.model_dump(), other.model_dump()], model_list=[]
)
wire: Final = response.model_dump()
assert wire["groups"][0]["classifier_cost"] == 0.4
assert wire["groups"][1]["classifier_cost"] == (pytest.approx(0.2) if recorded_turns == 10 else None)
assert wire["totals"]["classifier_cost"] == (pytest.approx(0.6) if recorded_turns == 10 else None)
assert response.totals.turns == 50
assert response.totals.spend == 12.0
assert response.totals.saved_spend == 29.5
assert response.totals.baseline_spend == 41.5
assert response.totals.saved_pct == 71.1
assert response.totals.saved_per_session == 5.9
@pytest.mark.asyncio
@pytest.mark.parametrize(
@ -723,6 +758,7 @@ class TestAutoRouterBenchmarks:
assert (idle.cache.hit_rate_pct, idle.cache.coverage_pct) == (0.0, 0.0)
assert idle.cache.same_model.turns == idle.cache.return_to_tier.hits == 0
assert idle.tier_turns == {}
assert idle.classifier_cost == 0.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
@ -799,7 +835,6 @@ class TestAutoRouterBenchmarks:
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy.management_endpoints.auto_router_endpoints import (
get_shadow_eval_job,
list_shadow_eval_jobs,
@ -1134,7 +1169,9 @@ async def test_start_shadow_eval_writes_one_leg_per_key_in_one_statement(monkeyp
assert (
len(
{
frozenset((k, tuple(v) if isinstance(v, list) else v) for k, v in row.items() if k not in ("target_id", "id"))
frozenset(
(k, tuple(v) if isinstance(v, list) else v) for k, v in row.items() if k not in ("target_id", "id")
)
for row in rows
}
)
@ -1261,8 +1298,8 @@ async def test_start_shadow_eval_accepts_an_sdk_judge_when_anthropic_secret_look
monkeypatch: pytest.MonkeyPatch,
) -> None:
import litellm
from litellm.integrations.custom_secret_manager import CustomSecretManager
import litellm.proxy.proxy_server as proxy_server
from litellm.integrations.custom_secret_manager import CustomSecretManager
from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem
class AnthropicSecretManager(CustomSecretManager):
@ -1824,9 +1861,10 @@ async def test_list_shadow_eval_jobs_rejects_a_lone_filter_half(monkeypatch: pyt
@pytest.mark.asyncio
async def test_start_shadow_eval_concurrent_unique_violation_is_a_409(monkeypatch: pytest.MonkeyPatch):
import litellm.proxy.proxy_server as proxy_server
from prisma.errors import UniqueViolationError
import litellm.proxy.proxy_server as proxy_server
_configure_anthropic_sdk_judge(monkeypatch)
prisma = _shadow_prisma()
prisma.db.litellm_shadowevaljob.create_many = AsyncMock(

View file

@ -68,6 +68,7 @@ const totals = (overrides: Partial<Totals> = {}): Totals => ({
avg_session_seconds: 7560,
avg_tokens_per_session: 5_300_000,
spend: 359.86,
classifier_cost: 6.146,
saved_spend: 2174.59,
baseline_spend: 2534.45,
saved_pct: 85.8,
@ -99,6 +100,7 @@ const zeroTotals: Totals = {
avg_session_seconds: 0,
avg_tokens_per_session: 0,
spend: 0,
classifier_cost: 0,
saved_spend: 0,
baseline_spend: 0,
saved_pct: 0,
@ -184,6 +186,37 @@ describe("AutoRouterBenchmarksTab", () => {
expect(screen.getByText("5.3M")).toBeInTheDocument();
});
it.each([
{ spend: 20665.28, classifier_cost: 342.18, turns: 140815, llm: "$20,323.10", cost: "$342.18" },
{ spend: 0, classifier_cost: 0, turns: 0, llm: "$0.00", cost: "$0.00" },
{ spend: 0.002, classifier_cost: 0.0004, turns: 100, llm: "$0.0016", cost: "$0.0004" },
])("shows total classification cost across $turns turns without a per-turn rate", ({ llm, cost, ...values }) => {
const stats = totals({ ...values, saved_spend: 10126.28, baseline_spend: values.spend + 10126.28 });
mockHook({ data: response([group(stats)], stats) });
renderTab();
expect(
screen
.getAllByRole("definition")
.map((node) => node.textContent)
.slice(1, 3),
).toEqual([llm, cost]);
expect(screen.queryByText(/1K turns/)).not.toBeInTheDocument();
expect(screen.getAllByText("$10,126.28").length).toBeGreaterThan(0);
});
it.each([null, undefined])("keeps totals when the classification breakdown is %s", (classifier_cost) => {
const stats = totals({ classifier_cost });
mockHook({ data: response([group(stats)], stats) });
renderTab();
expect(screen.getAllByText("Unavailable")).toHaveLength(2);
expect(screen.queryByText(/\/ 1K turns/)).not.toBeInTheDocument();
expect(screen.getByText("$359.86")).toBeInTheDocument();
expect(screen.getByText("$2,174.59")).toBeInTheDocument();
expect(screen.getByText(/some usage predates classification-cost tracking/)).toBeInTheDocument();
});
it("pairs the savings with the session count it was earned over, in its own tile", () => {
mockHook({ data: response([group(), group({ router_name: "gpt-auto" })]) });
renderTab();
@ -201,8 +234,13 @@ describe("AutoRouterBenchmarksTab", () => {
const terms = screen.getAllByRole("term").map((node) => node.textContent);
const values = screen.getAllByRole("definition").map((node) => node.textContent);
expect(terms).toEqual(["Actual auto-router spend", "Estimated spend at highest-tier model"]);
expect(values).toEqual(["$359.86", "$2,534.45"]);
expect(terms).toEqual([
"Actual auto-router spend",
"LLM spend",
"Classification cost",
"Estimated spend at highest-tier model",
]);
expect(values).toEqual(["$359.86", "$353.71", "$6.15", "$2,534.45"]);
});
it("lets both hero columns shrink below their content so a large total cannot clip", () => {
@ -339,7 +377,7 @@ describe("AutoRouterBenchmarksTab", () => {
renderTab();
expect(screen.getByText("Total estimated savings")).toBeInTheDocument();
expect(screen.getAllByText("$0.00")).toHaveLength(4);
expect(screen.getAllByText("$0.00")).toHaveLength(6);
expect(screen.getByText("· 0 sessions")).toBeInTheDocument();
expect(screen.getByText("0s")).toBeInTheDocument();
expect(screen.getByText(/turns measured/)).toBeInTheDocument();

View file

@ -52,10 +52,14 @@ const Metric: React.FC<{ label: string; value: string; hint?: string }> = ({ lab
</Card>
);
const SpendRow: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<dl className="flex items-baseline justify-between gap-6 py-3">
<dt className="text-sm text-muted-foreground">{label}</dt>
<dd className="text-base font-semibold tabular-nums text-foreground">{value}</dd>
const SpendRow: React.FC<{ label: string; value: string; subdued?: boolean }> = ({ label, value, subdued }) => (
<dl className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 py-2">
<dt className="min-w-0 text-sm text-muted-foreground">{label}</dt>
<dd
className={`min-w-0 break-all tabular-nums ${subdued ? "text-sm font-normal text-muted-foreground" : "text-base font-semibold text-foreground"}`}
>
{value}
</dd>
</dl>
);
@ -70,7 +74,9 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
Total estimated savings
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<p className="text-6xl font-semibold tracking-tight text-foreground">{usd(stats.saved_spend)}</p>
<p className="min-w-0 break-all text-center text-4xl font-semibold tracking-tight text-foreground xl:text-6xl">
{usd(stats.saved_spend)}
</p>
<Badge
variant="secondary"
className={`h-6 px-2.5 text-sm ${cheaper ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"}`}
@ -83,6 +89,23 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
<div className="flex flex-col justify-center border-t p-6 md:border-t-0 md:border-l">
<SpendRow label="Actual auto-router spend" value={usd(stats.spend)} />
<div className="mb-3 border-l-2 pl-4">
<SpendRow
subdued
label="LLM spend"
value={stats.classifier_cost == null ? "Unavailable" : usd(stats.spend - stats.classifier_cost)}
/>
<SpendRow
subdued
label="Classification cost"
value={stats.classifier_cost == null ? "Unavailable" : usd(stats.classifier_cost)}
/>
</div>
{stats.classifier_cost == null && (
<p className="mb-3 text-xs text-muted-foreground">
Breakdown unavailable because some usage predates classification-cost tracking.
</p>
)}
<Separator />
<SpendRow label="Estimated spend at highest-tier model" value={usd(stats.baseline_spend)} />
</div>
@ -254,8 +277,9 @@ const BenchmarksBody: React.FC<BenchmarksBodyProps> = ({ isPending, error, data,
<p className="text-xs text-muted-foreground">
Compares your actual routed spend with the estimated cost of using only the most expensive model configured in
the auto-router. It accounts for both the cache savings from staying on one model and the added cache costs from
switching models. The range counts whole sessions that overlap it, so totals can differ slightly from the
Overall tab, which buckets savings by UTC day.
switching models. Savings are net of recorded LLM classification cost, which is included in actual spend. The
range counts whole sessions that overlap it, so totals can differ slightly from the Overall tab, which buckets
savings by UTC day.
</p>
<div className="space-y-4">

View file

@ -37,6 +37,7 @@ const totals = (overrides: Partial<AutoRouterBenchmarkGroup> = {}) => ({
avg_session_seconds: 7560,
avg_tokens_per_session: 5_300_000,
spend: 359.86,
classifier_cost: 6.146,
saved_spend: 2174.59,
baseline_spend: 2534.45,
saved_pct: 85.8,

View file

@ -32,6 +32,7 @@ const stats = {
avg_session_seconds: 30,
avg_tokens_per_session: 100,
spend: 1.25,
classifier_cost: 0.25,
saved_spend: 8.75,
baseline_spend: 10,
saved_pct: 87.5,
@ -84,6 +85,11 @@ describe("KeyAutoRouterUsageTab", () => {
expect(await screen.findByText("$8.75")).toBeInTheDocument();
expect(screen.getByText("Actual auto-router spend")).toBeInTheDocument();
expect(screen.getByText("$1.25")).toBeInTheDocument();
expect(screen.getByText("LLM spend")).toBeInTheDocument();
expect(screen.getByText("$1.00")).toBeInTheDocument();
expect(screen.getByText("Classification cost")).toBeInTheDocument();
expect(screen.getByText("$0.2500")).toBeInTheDocument();
expect(screen.queryByText(/1K turns/)).not.toBeInTheDocument();
expect(screen.getByText("Estimated spend at highest-tier model")).toBeInTheDocument();
expect(screen.getByText("$10.00")).toBeInTheDocument();
expect(screen.getByText("Auto-router prompt caching")).toBeInTheDocument();

View file

@ -23305,6 +23305,11 @@ export interface components {
*/
baseline_spend: number;
cache: components["schemas"]["AutoRouterCacheStats"];
/**
* Classifier Cost
* @description Recorded LLM classifier cost already included in spend; null when any session turns predate subtotal recording, and zero for an empty window
*/
classifier_cost: number | null;
/**
* Router Name
* @description The auto-router alias requests were sent to
@ -23361,6 +23366,11 @@ export interface components {
*/
baseline_spend: number;
cache: components["schemas"]["AutoRouterCacheStats"];
/**
* Classifier Cost
* @description Recorded LLM classifier cost already included in spend; null when any session turns predate subtotal recording, and zero for an empty window
*/
classifier_cost: number | null;
/**
* Saved Pct
* @description saved_spend over baseline_spend, as a percentage