diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260907000000_add_autorouter_classifier_cost/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260907000000_add_autorouter_classifier_cost/migration.sql
new file mode 100644
index 00000000000..5503167ce09
--- /dev/null
+++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260907000000_add_autorouter_classifier_cost/migration.sql
@@ -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;
diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
index 06ac177cca4..ccbab0fef10 100644
--- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
+++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma
@@ -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])
diff --git a/litellm/proxy/db/autorouter_session_rollup.py b/litellm/proxy/db/autorouter_session_rollup.py
index b33bffbaaa1..b866ecc741f 100644
--- a/litellm/proxy/db/autorouter_session_rollup.py
+++ b/litellm/proxy/db/autorouter_session_rollup.py
@@ -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,
diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py
index 0f0323b45f8..bbc914a772a 100644
--- a/litellm/proxy/management_endpoints/auto_router_endpoints.py
+++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py
@@ -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),
)
diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma
index 06ac177cca4..ccbab0fef10 100644
--- a/litellm/proxy/schema.prisma
+++ b/litellm/proxy/schema.prisma
@@ -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])
diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py
index 50c3515cf01..6306658ad0b 100644
--- a/litellm/types/management_endpoints/auto_router_endpoints.py
+++ b/litellm/types/management_endpoints/auto_router_endpoints.py
@@ -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"
diff --git a/schema.prisma b/schema.prisma
index 06ac177cca4..ccbab0fef10 100644
--- a/schema.prisma
+++ b/schema.prisma
@@ -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])
diff --git a/tests/proxy_behavior/spend/test_autorouter_session_rollup.py b/tests/proxy_behavior/spend/test_autorouter_session_rollup.py
index c2272f3d20d..9ac6476a03c 100644
--- a/tests/proxy_behavior/spend/test_autorouter_session_rollup.py
+++ b/tests/proxy_behavior/spend/test_autorouter_session_rollup.py
@@ -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,
diff --git a/tests/test_litellm/proxy/db/test_autorouter_session_rollup.py b/tests/test_litellm/proxy/db/test_autorouter_session_rollup.py
index 2ed4f843711..4507892bd0f 100644
--- a/tests/test_litellm/proxy/db/test_autorouter_session_rollup.py
+++ b/tests/test_litellm/proxy/db/test_autorouter_session_rollup.py
@@ -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():
diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
index 35e76c96c14..dc18e0f7d4a 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py
@@ -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(
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
index e7c6adc478c..006da4f2725 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx
@@ -68,6 +68,7 @@ const totals = (overrides: Partial
-
+
);
@@ -70,7 +74,9 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => {
Total estimated savings
{usd(stats.saved_spend)}
++ {usd(stats.saved_spend)} +
+ Breakdown unavailable because some usage predates classification-cost tracking. +
+ )}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.