mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
commit
e01bb98960
15 changed files with 282 additions and 46 deletions
|
|
@ -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;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue