Merge pull request #38835 from BerriAI/litellm_38816_classifier_cost_savings

fix(proxy): count auto-router classifier cost in savings and benchmarks
This commit is contained in:
yuneng-jiang 2026-08-29 16:23:50 -07:00 committed by GitHub
commit e4ae1c1f2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 8 deletions

View file

@ -1377,6 +1377,8 @@ def _classifier_cost_from_request_data(request_data: Mapping[str, object] | None
routes and in `metadata` on chat-style routes, so both buckets are consulted, in the same
precedence `get_or_create_metadata_bucket` writes them.
"""
from litellm.proxy.spend_tracking.savings import classifier_cost_from_decision
data: Final = request_data or {}
for metadata_key in ("litellm_metadata", "metadata"):
metadata = data.get(metadata_key)
@ -1385,10 +1387,10 @@ def _classifier_cost_from_request_data(request_data: Mapping[str, object] | None
decision = metadata.get("routing_decision")
if not isinstance(decision, dict):
continue
cost = decision.get("classifier_cost")
if isinstance(cost, bool) or not isinstance(cost, (int, float)):
cost = classifier_cost_from_decision(decision)
if cost is None:
continue
return float(cost)
return cost
return None

View file

@ -185,8 +185,10 @@ def build_autorouter_turn_transaction(
of a request through the router) are excluded by their internal_call_origin stamp:
they are not traffic a user sent, so counting them would manufacture sessions and
savings in the adoption metrics. Failed requests served nothing and are excluded.
Cache facts are derived from the payload's own usage record through the savings
owner, never handed in beside it.
The classifier's charge still lands here exactly once, via the decision's own
classifier_cost folded into this turn's spend: the excluded classifier row is how
it was billed, the decision is how it is attributed. Cache facts are derived from
the payload's own usage record through the savings owner, never handed in beside it.
"""
if payload.get("status") != "success":
return None
@ -204,9 +206,12 @@ def build_autorouter_turn_transaction(
turn_at: Final = _turn_time_utc(str(payload.get("startTime") or ""))
if turn_at is None:
return None
from litellm.proxy.spend_tracking.savings import classifier_cost_from_decision
usage_object_raw: Final = metadata.get("usage_object")
cache: Final = turn_cache_facts(usage_object_raw if isinstance(usage_object_raw, Mapping) else None)
tier_raw: Final = routing_decision.get("tier")
classifier_cost: Final = classifier_cost_from_decision(routing_decision)
return AutoRouterTurnTransaction(
api_key=api_key,
session_id=_bounded_session_id(session_id),
@ -216,7 +221,7 @@ def build_autorouter_turn_transaction(
model=model,
turn_at=turn_at,
total_tokens=int(payload.get("prompt_tokens") or 0) + int(payload.get("completion_tokens") or 0),
spend=float(payload.get("spend") or 0.0),
spend=float(payload.get("spend") or 0.0) + (classifier_cost or 0.0),
saved_spend=saved_spend,
covered=cache.covered,
cache_hit=cache.read_tokens > 0,

View file

@ -481,6 +481,20 @@ def _numeric_savings(value: object) -> float | None:
return float(value)
def classifier_cost_from_decision(routing_decision: Mapping[str, object] | None) -> float | None:
"""The LLM-classifier cost a routing decision recorded, or ``None`` when it holds none.
``None`` covers the decision-less request, the heuristic short-circuit that never
called a classifier, the unpriced classifier model, and a malformed value alike:
in every one of those cases there is no dollar figure to move, so callers treat
``None`` as zero rather than as an error. The one owner of that reading, shared by
the savings netting, the session rollup and the response header, so the three can
never disagree about what counts as a classifier charge.
"""
decision: Final = routing_decision if isinstance(routing_decision, Mapping) else {}
return _numeric_savings(decision.get("classifier_cost"))
def autorouter_savings_for_request(
model: str | None,
custom_llm_provider: str | None,
@ -490,7 +504,8 @@ def autorouter_savings_for_request(
llm_router: "Callable[[], Router | None] | None" = None,
cost_breakdown: Mapping[str, object] | None = None,
) -> float | None:
"""Auto-router savings for one request, or ``None`` when the driver is off.
"""Auto-router savings for one request, net of the classifier call that routed it,
or ``None`` when the driver is off.
``None`` and ``0.0`` are different facts: ``None`` means this request cannot carry a
figure at all (no routing decision, no baseline, unusable usage), while ``0.0`` is a
@ -498,6 +513,11 @@ def autorouter_savings_for_request(
Never raises: pricing failures inside degrade to zero, and the driver-off cases
return ``None``, so this is safe on the logging path where a raise would fail the
request's logging.
The classifier deduction lives here, at the figure's one computation owner, rather
than in any reader: the stamped ``autorouter_savings`` is then already net, so the
session rollup, the daily tables and every logging consumer agree without each
re-deriving the deduction, and the recorded-figure-wins path cannot deduct twice.
"""
usage: Final = _usage_from_spend_log(usage_object)
if usage is None or not model:
@ -510,7 +530,7 @@ def autorouter_savings_for_request(
if not decision or not baseline_model:
return None
router_instance: Final = llm_router() if llm_router else None
return compute_autorouter_savings(
gross: Final = compute_autorouter_savings(
baseline_model=baseline_model,
selected_model=model,
selected_provider=custom_llm_provider,
@ -522,6 +542,8 @@ def autorouter_savings_for_request(
baseline_info=_effective_model_info(router_instance, baseline_id, baseline_model or ""),
cost_breakdown=cost_breakdown,
)
classifier_cost: Final = classifier_cost_from_decision(decision)
return gross if classifier_cost is None else gross - classifier_cost
def autorouter_savings_for_logging_payload(

View file

@ -106,6 +106,28 @@ class TestBuildTransaction:
transaction = _build()
assert transaction is not None and transaction.tier is None
def test_a_priced_classifier_rides_the_turns_spend(self):
"""The classifier row is excluded from the rollup, so its charge lands here,
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)
@pytest.mark.parametrize(
"decision_extra", [{}, {"classifier_cost": 0.0}, {"classifier_cost": "bogus"}, {"classifier_cost": True}]
)
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)
def test_every_turn_carries_its_own_classifier_charge(self):
first = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "classifier_cost": 0.005}))
second = _build(
payload=_payload(startTime="2026-08-01T12:01:00", spend=0.02),
metadata=_metadata(routing_decision={**ROUTING_DECISION, "classifier_cost": 0.007}),
)
assert first is not None and first.spend == pytest.approx(0.015)
assert second is not None and second.spend == pytest.approx(0.027)
def test_router_name_falls_back_to_the_payload_model_group(self):
transaction = _build(metadata=_metadata(routing_decision={"router_type": "complexity"}))
assert transaction is not None and transaction.router_name == "live-auto"

View file

@ -1151,6 +1151,61 @@ def test_logging_payload_never_stamps_internal_calls():
assert internal is None
def test_savings_are_net_of_a_priced_classifier():
"""The classifier call is part of what routing cost, so the per-request figure
deducts it; a charge big enough to outweigh the model saving goes negative,
since the figure is signed on purpose (GH #38816)."""
from litellm.proxy.spend_tracking.savings import autorouter_savings_for_request
gross = autorouter_savings_for_request(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
routing_decision=_routed_decision(),
usage_object=_cached_usage_object(),
)
net = autorouter_savings_for_request(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
routing_decision={**_routed_decision(), "classifier_cost": 0.005},
usage_object=_cached_usage_object(),
)
assert gross is not None and net == pytest.approx(gross - 0.005)
@pytest.mark.parametrize("classifier_cost", [0.0, "bogus", True])
def test_an_unpriced_classifier_deducts_nothing(classifier_cost: object):
from litellm.proxy.spend_tracking.savings import autorouter_savings_for_request
gross = autorouter_savings_for_request(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
routing_decision=_routed_decision(),
usage_object=_cached_usage_object(),
)
with_cost_field = autorouter_savings_for_request(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
routing_decision={**_routed_decision(), "classifier_cost": classifier_cost},
usage_object=_cached_usage_object(),
)
assert with_cost_field == gross
def test_recorded_savings_are_already_net_and_not_deducted_again():
"""The deduction lives at the figure's computation owner, so a stamped figure is
net by construction; the recorded-wins path must not subtract a second time."""
result = compute_savings_spend(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
compression_saved_tokens=0,
gateway_injected_cache=False,
routing_decision={**_routed_decision(), "classifier_cost": 0.005},
usage_object=_cached_usage_object(),
recorded_autorouter_savings=0.5,
)
assert result.autorouter == 0.5
def test_caching_savings_require_a_gateway_injected_breakpoint():
"""The same cached usage is attributed to the gateway only when it added a breakpoint.