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 = {}): 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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx index 39e6b0fd390..33ad1bfe555 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx @@ -52,10 +52,14 @@ const Metric: React.FC<{ label: string; value: string; hint?: string }> = ({ lab ); -const SpendRow: React.FC<{ label: string; value: string }> = ({ label, value }) => ( -
-
{label}
-
{value}
+const SpendRow: React.FC<{ label: string; value: string; subdued?: boolean }> = ({ label, value, subdued }) => ( +
+
{label}
+
+ {value} +
); @@ -70,7 +74,9 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => { Total estimated savings

-

{usd(stats.saved_spend)}

+

+ {usd(stats.saved_spend)} +

= ({ view }) => {
+
+ + +
+ {stats.classifier_cost == null && ( +

+ Breakdown unavailable because some usage predates classification-cost tracking. +

+ )}
@@ -254,8 +277,9 @@ const BenchmarksBody: React.FC = ({ isPending, error, data,

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.

diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts index 9a70fd9289a..22d6336e86f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/autoRouterBenchmarks.test.ts @@ -37,6 +37,7 @@ const totals = (overrides: Partial = {}) => ({ 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, diff --git a/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx index 8cfd9d1941e..be95c0e600d 100644 --- a/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeyAutoRouterUsageTab.integration.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 29b0cdfb73a..3540d2f6aea 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -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