feat(spend): add net auto-router savings to the cost-optimization dashboard (#35521)

* feat(spend): add net auto-router savings to the cost-optimization dashboard

The dashboard credited compression and prompt caching but said nothing about the
optimization that picks the model, so the driver with the largest lever on a bill
was the one an operator could not see.

Savings are the counterfactual: without a router a deployment runs one model, and
it has to be one that can carry the hardest request, so the baseline is the
priciest model in the router's hardest configured tier. A cheap tier is a choice
the router made, not a ceiling it was bounded by. `auto_router_savings_baseline_model`
overrides it for operators who would genuinely have run something else. Both are
provider-qualified before pricing, because a bare name can resolve to a different
vendor's rates or to nothing at all, and a deployment is priced by its `base_model`
where it has one, which is how Azure deployments are priced everywhere else.

Both arms price the request's real usage through `generic_cost_per_token` rather
than re-deriving per-token arithmetic, so tiered rates, ephemeral cache-write tiers
and regional uplifts stay consistent with what was actually billed. `prompt_tokens`
already includes the cache buckets, so charging them again at the input rate would
price the same tokens twice.

Cache state is what makes this hard. The baseline serves every turn, so whether it
had the prompt cached is whether the conversation was already underway. On a
continuing conversation it wrote the prompt earlier and would only read it now, so
this request's write is what switching cost and counts against the saving. On a
first turn nothing was cached for any model, the baseline would have written the
same prompt, and both arms carry the write at their own rates. Charging the write
to both cases understates a first turn to a few percent of its value, and because
the write premium is fixed by prompt size while the saving grows with completion
length, it can render a profitable route as a loss.

That shape is read off the conversation rather than remembered: a second human ask
means an earlier turn was served. No cache, no session id, and no dependence on a
caller sending a session header. It cannot see a switch on a turn the router did
not classify, and it reads a few-shot prompt's synthetic turns as prior
conversation; both err toward charging the write, which under-claims.

The baseline and the shape ride on the existing `routing_decision` record, which is
already carried from the router to the spend log, already classified for redaction,
and already written-or-cleared per attempt. A fallback that re-enters the hook
therefore cannot leave either fact behind to be attributed to a deployment that
never routed, and no new metadata key crosses the trust boundary.

The result is signed. Whether a switch pays off is a race between the rate gap and
the cache-write cost, and a narrow gap loses; flooring at zero would hide exactly
the routing behaviour an operator needs to see. The donut plots only drivers that
saved, while the card and range total keep the sign.

Savings accrue into a new `autorouter_savings_spend` column on the six daily rollup
tables, declared `NotRequired` because rows queued by a pod on the previous release
carry no such key. It is summed by the rollup merge the cross-pod Redis drain also
runs, and carried through the aggregation query, the per-row accumulation and the
response model, so the dashboard reads a value the API actually sends. Tests
enumerate the drivers from the response model itself and assert each is summed,
accumulated, carried and totalled, so one added later cannot be half-wired.

* fix(spend): let the baseline pay for a continuing turn's own growth

`_baseline_usage` moved every cache-creation token into the baseline's read bucket
whenever the conversation was underway. That is right for a switch, where the
baseline never left the model it was on and really would only read, but wrong for a
turn that stayed put: the prompt grew, and the tokens written are that growth. They
are new to every model, so the baseline would have paid to write them too. Forgiving
it that write made the counterfactual cheaper than it was and shrank the reported
saving on ordinary steady-state traffic, by about 2% per turn.

The selected arm was never involved; it has always been priced on the real usage.
The error sat entirely on the baseline.

The condition is that the request read more than it wrote, not that it read anything.
A switch onto a model already holding a small prefix of this prompt still writes most
of it, and that write is the switch's own cost; keying off a nonzero read would have
handed such a request the full rate gap, turning +$0.0056 into +$0.1177. Comparing
the two buckets separates a warm continuation, which reads far more than it writes,
from a cold arrival, which does the reverse, and it leaves the existing invariant
intact: a request reading 0 and one reading 1 both still land in the same place.

* fix(spend): price each arm under the key litellm billed it, and see agent turns

Two ways the savings number read the wrong thing, both from identifying a model by
its name when the name is not what it costs.

The counterfactual was ranked and priced on the public rate for the model a
deployment names. A deployment may not be charged that rate: the router registers
its configured prices under the deployment's own id and deliberately keeps them off
the shared model-name key so deployments sharing a backend model do not pollute each
other. So a hardest-tier deployment configured above its public rate lost the
ranking to a cheaper candidate, and once chosen was priced at a rate nobody pays.
Which key prices a deployment is now `_select_model_name_for_cost_calc`'s decision,
the resolver the real request is billed through, rather than a second rule here that
would have to re-learn that per-second and tiered overrides count, that a partial
override still counts, and that a deployment configured at zero is priced at zero
rather than treated as unpriced.

The arm being subtracted had the same fault and a sharper edge. It priced the spend
log's `model`, which on Azure is the deployment name, absent from the cost map, so
the whole driver silently read zero for that traffic. It no longer re-derives
anything: `model_map_information.model_map_key` is what litellm actually billed the
request under, recorded at request time by that same resolver with `base_model` and
custom pricing already applied.

Separately, the conversation-shape discriminator counted human asks, and an agent
loop can run twenty turns on one of them. Its tool traffic rides `tool_result`
blocks on user turns that flatten to empty text, and `tool` roles that are never
read, so a long agentic conversation looked like its own first turn and was handed
the arithmetic that leaves the cache write on both arms. That is the one direction
this must never fail in, because it inflates. An assistant turn is the direct
evidence that something answered earlier, and it is blind to how the tool plumbing
is spelled on either surface.

* fix(spend): give the cost-key resolver both inputs the selected arm needs

The served model was resolved through one input at a time, and each choice broke the
half the other fixed.

`model_map_key` is the served model already resolved through `base_model`, which is
the only way an Azure deployment name reaches the cost map at all; without it the
selected arm priced a name absent from the map, returned nothing, and the whole
driver silently read zero for that traffic. But it is built without
`router_model_id`, so it never carries a deployment's own price overrides, and a
custom-priced deployment was compared at its public rate while the baseline used the
real override. On a deployment configured well above its public rate that inverted
the answer outright: a route that lost $21.88 reported saving $0.10.

`_select_model_name_for_cost_calc` takes both, so it gets both. Which key prices a
deployment stays its decision rather than a rule restated here.

* fix(spend): same model is only the same cost when it is the same deployment

The short-circuit compared resolved model identity, so two deployments of one model
collapsed to "no switch" and reported zero. They are not the same cost: a deployment
can carry a negotiated rate, and routing from the dear one to the list-price one is a
real saving the dashboard reported as $0.00 against a true $21.93.

Both arms now carry the key litellm prices them under, so the comparison is between
deployments rather than between names.

* refactor(spend): price from resolved rates, not from a name we keep re-resolving

Four review rounds landed on one mechanism: which identifier prices a deployment.
base_model, then the deployment id, then cache-only overrides. Each round added a
clause to a resolution rule that should not exist, and a wrong primitive fails once
per input shape, so each shape arrived as its own finding.

`Router.get_deployment_model_info` already owns this. It merges a deployment's
configured prices over the built-in map, folds in `base_model` defaults for
deployments whose name is not a model, and falls back to the model name when nothing
is overridden. Every shape hand-rolled here (cache-only, partial, per-second, Azure)
was that function re-implemented badly.

`generic_cost_per_token` now accepts already-resolved rates instead of demanding a
name it looks up itself, which is what forced the name-bending in the first place.
Both arms resolve through the owner and pass what they got: the counterfactual by the
deployment the router would have used, the served request by the deployment that
served it. The invented cost-key resolver is gone, and `Baseline` carries a
deployment id rather than a key we chose on litellm's behalf.

Net 64 insertions against 79 deletions.

* test(spend): follow _most_expensive onto the router that prices its candidates

Ranking moved through `Router.get_deployment_model_info`, since what a deployment
costs is the router's answer to give; these four cases were still calling the old
free-function signature.

* fix(spend): rank baseline candidates by what a request costs, not by two rates

"Most expensive" was decided by comparing output rate then input rate. That is a
property of a rate, not of a request: a deployment dearer per output token can be
cheaper per cached token, so the comparison ordered cache-heavy traffic backwards and
recorded the wrong counterfactual.

Candidates are now costed on one reference request through the same engine the
savings themselves use, which leaves cache read and write rates, tiered tables and
every other billing dimension to that engine rather than to another rule restated
here. The reference request is cache-heavy because auto-routed traffic is.

* fix(spend): pick the baseline against the request that ran, not a stand-in for one

Ranking happened in the pre-routing hook, where the request has not executed yet, so
candidates were costed against a hard-coded reference workload: 20k prompt, 19k of it
cached, 1k out. Which candidate is dearest depends on that mix, so a pooled hardest
tier holding a deployment with non-proportional configured rates could be ranked for
a request nothing like the one served.

The mix is known on the spend path, so the ranking belongs there. The routing
decision now carries the tier's candidates rather than a winner already chosen, and
the baseline is resolved against the usage that actually happened. The reference
workload is gone; nothing here assumes a traffic shape any more.

The router is passed in rather than imported from `proxy_server` inside the
computation, so the savings stay a pure function of their arguments and the caller
owns where the router comes from. That also makes the spend path testable without a
running proxy, which the previous shape was not.

* refactor(spend): measure savings against one configured model, not a derived one

The counterfactual was derived per request: enumerate the hardest tier's
deployments, resolve each one's effective pricing, price them all, take the dearest.
That machinery produced a review finding per input shape it had not anticipated,
and every answer it gave was one an operator could have stated in a line of config.

So they state it. `litellm_settings.autorouter_savings_baseline_model` names the
model the traffic would have run on without a router, for every auto-router on the
proxy, and unset means the driver is off rather than a model nobody named being
guessed at. `savings_baseline.py` and its tests are deleted outright, along with the
tier enumeration, the candidate list on the routing decision, and the per-deployment
override that shadowed it.

Cache-state handling is untouched: the baseline is still priced on this request's own
read and write split, so a switch still pays for re-warming the cache and a first
turn still charges the write to both arms.

45 insertions against 482 deletions.

* refactor(router): compute the conversation shape once and pass it down

`_classify_and_route` re-derived it from the messages the hook had already resolved,
so an ordinary routed request walked the turn list twice for one boolean. The hook
computes it and hands it over, which is also where the affinity-hit path already got
it from.

Also moves `_get_llm_router` below the imports it sat among.

* fix(router): drop the dead conversation_continuing parameter off the hook

It was added to `async_pre_routing_hook` by mistake and immediately overwritten by
the value the hook computes, so it never did anything. It also widened a signature
every pre-routing strategy shares with the protocol in `types/router.py`, leaving
this one router diverged from `AutoRouter` and the interface for no reason.

Also records why an unreadable request counts as continuing: no messages is no
evidence a turn was served, so it pays the cache write and under-claims rather than
being handed a first turn's larger saving on nothing.

* fix(spend): charge a baseline its input rate for cache buckets it cannot price

A model with no cache_creation_input_token_cost, which is every OpenAI, Azure and Gemini entry, resolved that rate to 0.0 and carried the whole written prompt for free, so a first turn routed onto a cheaper model reported a loss. Same hole on cache reads. Those tokens are plain input on such a model, so they move into the text bucket.

* refactor(spend): build the daily upsert payloads in one shot

`common_data` and `update_data` were constructed and then appended to: `request_id`
conditionally for tag rows, `endpoint` unconditionally a few lines later. A dict that
grows after its literal cannot be reasoned about by reading the literal, which is the
whole point of building it at once.

The conditional key resolves to a spreadable value before either payload, so both are
single expressions and the tag branch appears once instead of twice.

Not wrapped in MappingProxyType, though it was suggested: these go straight to
prisma, whose query builder branches on `isinstance(value, dict)` to tell a nested
node from a scalar. A mappingproxy is a Mapping but not a dict, so it falls through
to the serializer and raises `TypeError: Type <class 'mappingproxy'> not
serializable` inside the batch upsert, where the surrounding except would log it and
leave the rollups silently unwritten.

* fix(spend): keep the one-shot upsert payloads under the type-discipline budget

Building both payloads as single literals traded a mutation for two dict literals,
and LIT002 counts construction rather than mutation, so the change the review asked
for is the one the gate charges for.

The empty branch is the avoidable half: it is the same value every time, so it moves
to a module constant built once instead of a literal per transaction, and it is a
read-only mapping so none of the call sites that spread it can fill it in later.
This commit is contained in:
tin-berri 2026-08-03 20:10:37 -07:00 committed by GitHub
parent f60e99c583
commit 9e3a8df6c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1197 additions and 61 deletions

View file

@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE "LiteLLM_DailyUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyOrganizationSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyEndUserSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyAgentSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTeamSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
-- AlterTable
ALTER TABLE "LiteLLM_DailyTagSpend" ADD COLUMN IF NOT EXISTS "autorouter_savings_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;

View file

@ -748,6 +748,7 @@ model LiteLLM_DailyUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -782,6 +783,7 @@ model LiteLLM_DailyOrganizationSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -816,6 +818,7 @@ model LiteLLM_DailyEndUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -849,6 +852,7 @@ model LiteLLM_DailyAgentSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -882,6 +886,7 @@ model LiteLLM_DailyTeamSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -917,6 +922,7 @@ model LiteLLM_DailyTagSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)

View file

@ -264,6 +264,7 @@ databricks_key: Optional[str] = None
openai_like_key: Optional[str] = None
azure_key: Optional[str] = None
anthropic_key: Optional[str] = None
autorouter_savings_baseline_model: Optional[str] = None
replicate_key: Optional[str] = None
bytez_key: Optional[str] = None
gdc_key: Optional[str] = None

View file

@ -683,6 +683,7 @@ def generic_cost_per_token(
custom_llm_provider: str,
service_tier: str | None = None,
data_residency: str | None = None,
model_info: ModelInfo | None = None,
) -> tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
@ -700,7 +701,12 @@ def generic_cost_per_token(
"""
## GET MODEL INFO
model_info = get_model_info(model=model, custom_llm_provider=custom_llm_provider)
# A caller that already resolved the deployment's effective rates passes them in
# rather than handing back a name for this to re-resolve. A name cannot express a
# per-deployment override: those are registered under the deployment id and kept off
# the shared model-name key, so resolving from the name here reads the public rate.
if model_info is None:
model_info = get_model_info(model=model, custom_llm_provider=custom_llm_provider)
## CALCULATE INPUT COST
### Cost of processing (non-cache hit + cache hit) + Cost of cache-writing (cache writing)

View file

@ -14,7 +14,7 @@ from pydantic import (
field_validator,
model_validator,
)
from typing_extensions import Required, TypedDict
from typing_extensions import NotRequired, Required, TypedDict
from litellm._uuid import uuid
from litellm.constants import MCP_STDIO_ALLOWED_COMMANDS
@ -4566,6 +4566,11 @@ class BaseDailySpendTransaction(TypedDict):
# cost-savings metrics (dollars, priced per request before aggregation)
compression_savings_spend: float
prompt_caching_savings_spend: float
# Not required: rows queued by a pod running the previous release, or replayed from
# the Redis buffer across an upgrade, carry no such key. Every reader coalesces a
# missing value to zero, so requiring it here would describe a shape the aggregation
# is explicitly tested against.
autorouter_savings_spend: NotRequired[float]
# request level metrics
spend: float

View file

@ -12,7 +12,9 @@ import os
import random
import time
import traceback
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
Any,
@ -68,6 +70,26 @@ else:
ProxyLogging = Any
# Only tag rows carry a request_id, so the other entity types spread nothing. Built
# once here rather than as an empty literal per transaction, and read-only so it cannot
# be filled in by accident from one of the call sites that spreads it.
_NO_TAG_REQUEST_ID: Mapping[str, Any] = MappingProxyType({})
def _get_llm_router():
"""The proxy's router, or None outside a running proxy.
Injected rather than imported where it is used, so the savings computation stays
a pure function of its arguments and the caller owns where the router comes from.
"""
try:
from litellm.proxy.proxy_server import llm_router
return llm_router
except Exception: # noqa: BLE001 # no proxy in scope; savings degrade to zero
return None
def _extract_cache_read_tokens(usage_obj: dict) -> int:
"""
Anthropic: top-level cache_read_input_tokens field.
@ -1545,6 +1567,40 @@ class DBSpendUpdateWriter:
# Get the table dynamically
table = getattr(batcher, table_name)
# Additive metrics that older queued rows may omit; one
# enumeration feeds both the create and the increment below
optional_metrics = {
field: value
for field, value in (
("cache_read_input_tokens", transaction.get("cache_read_input_tokens")),
(
"cache_creation_input_tokens",
transaction.get("cache_creation_input_tokens"),
),
("compression_saved_tokens", transaction.get("compression_saved_tokens")),
(
"compression_savings_spend",
transaction.get("compression_savings_spend"),
),
(
"prompt_caching_savings_spend",
transaction.get("prompt_caching_savings_spend"),
),
("autorouter_savings_spend", transaction.get("autorouter_savings_spend")),
)
if value is not None
}
# Only tag rows carry a request_id. Resolved to a spreadable
# value here so both payloads are built in one shot: a dict
# appended to after construction is one nobody can reason about
# by reading its literal.
tag_request_id: Mapping[str, Any] = (
MappingProxyType({"request_id": transaction["request_id"]})
if entity_type == "tag" and "request_id" in transaction
else _NO_TAG_REQUEST_ID
)
# Common data structure for both create and update
common_data = {
entity_id_field: entity_id,
@ -1561,34 +1617,10 @@ class DBSpendUpdateWriter:
"api_requests": transaction["api_requests"],
"successful_requests": transaction["successful_requests"],
"failed_requests": transaction["failed_requests"],
**optional_metrics,
**tag_request_id,
}
# Add cache-related fields if they exist
if "cache_read_input_tokens" in transaction:
common_data["cache_read_input_tokens"] = transaction.get(
"cache_read_input_tokens", 0
)
if "cache_creation_input_tokens" in transaction:
common_data["cache_creation_input_tokens"] = transaction.get(
"cache_creation_input_tokens", 0
)
if "compression_saved_tokens" in transaction:
common_data["compression_saved_tokens"] = transaction.get(
"compression_saved_tokens", 0
)
if "compression_savings_spend" in transaction:
common_data["compression_savings_spend"] = transaction.get(
"compression_savings_spend", 0
)
if "prompt_caching_savings_spend" in transaction:
common_data["prompt_caching_savings_spend"] = transaction.get(
"prompt_caching_savings_spend", 0
)
if entity_type == "tag" and "request_id" in transaction:
common_data["request_id"] = transaction.get("request_id")
# Create update data structure
update_data = {
"prompt_tokens": {"increment": transaction["prompt_tokens"]},
"completion_tokens": {"increment": transaction["completion_tokens"]},
@ -1596,36 +1628,12 @@ class DBSpendUpdateWriter:
"api_requests": {"increment": transaction["api_requests"]},
"successful_requests": {"increment": transaction["successful_requests"]},
"failed_requests": {"increment": transaction["failed_requests"]},
**{field: {"increment": value} for field, value in optional_metrics.items()},
# An existing row predating the endpoint column gets it filled in here
"endpoint": transaction.get("endpoint") or "",
**tag_request_id,
}
# Add cache-related fields to update if they exist
if "cache_read_input_tokens" in transaction:
update_data["cache_read_input_tokens"] = {
"increment": transaction.get("cache_read_input_tokens", 0)
}
if "cache_creation_input_tokens" in transaction:
update_data["cache_creation_input_tokens"] = {
"increment": transaction.get("cache_creation_input_tokens", 0)
}
if "compression_saved_tokens" in transaction:
update_data["compression_saved_tokens"] = {
"increment": transaction.get("compression_saved_tokens", 0)
}
if "compression_savings_spend" in transaction:
update_data["compression_savings_spend"] = {
"increment": transaction.get("compression_savings_spend", 0)
}
if "prompt_caching_savings_spend" in transaction:
update_data["prompt_caching_savings_spend"] = {
"increment": transaction.get("prompt_caching_savings_spend", 0)
}
if entity_type == "tag" and "request_id" in transaction:
update_data["request_id"] = transaction.get("request_id")
# Add endpoint to update_data so existing rows get their endpoint field updated
update_data["endpoint"] = transaction.get("endpoint") or ""
table.upsert(
where=where_clause,
data={
@ -1875,6 +1883,10 @@ class DBSpendUpdateWriter:
custom_llm_provider=payload.get("custom_llm_provider", None),
compression_saved_tokens=compression_saved_tokens,
cache_read_input_tokens=cache_read_input_tokens,
routing_decision=_metadata.get("routing_decision"),
model_id=payload.get("model_id"),
llm_router=_get_llm_router(),
usage_object=usage_obj,
)
daily_transaction = BaseDailySpendTransaction(
@ -1896,6 +1908,7 @@ class DBSpendUpdateWriter:
compression_saved_tokens=compression_saved_tokens,
compression_savings_spend=savings_spend.compression,
prompt_caching_savings_spend=savings_spend.prompt_caching,
autorouter_savings_spend=savings_spend.autorouter,
)
return daily_transaction
except Exception as e:

View file

@ -133,6 +133,10 @@ class DailySpendUpdateQueue(BaseUpdateQueue):
payload.get("prompt_caching_savings_spend", 0) or 0
) + daily_transaction.get("prompt_caching_savings_spend", 0)
daily_transaction["autorouter_savings_spend"] = (
payload.get("autorouter_savings_spend", 0) or 0
) + daily_transaction.get("autorouter_savings_spend", 0)
else:
aggregated_daily_spend_update_transactions[_key] = deepcopy(payload)
return aggregated_daily_spend_update_transactions

View file

@ -95,6 +95,9 @@ class DailySpendRecord(Protocol):
@property
def prompt_caching_savings_spend(self) -> float: ...
@property
def autorouter_savings_spend(self) -> float: ...
@property
def api_requests(self) -> int: ...
@ -135,6 +138,7 @@ class _GroupingSetsRow(SimpleNamespace):
compression_saved_tokens: int | None
compression_savings_spend: float | None
prompt_caching_savings_spend: float | None
autorouter_savings_spend: float | None
api_requests: int | None
successful_requests: int | None
failed_requests: int | None
@ -158,6 +162,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) ->
existing_metrics.compression_saved_tokens += record.compression_saved_tokens or 0
existing_metrics.compression_savings_spend += record.compression_savings_spend or 0
existing_metrics.prompt_caching_savings_spend += record.prompt_caching_savings_spend or 0
existing_metrics.autorouter_savings_spend += record.autorouter_savings_spend or 0
existing_metrics.api_requests += record.api_requests or 0
existing_metrics.successful_requests += record.successful_requests or 0
existing_metrics.failed_requests += record.failed_requests or 0
@ -590,6 +595,7 @@ def _build_aggregated_sql_query(
SUM(compression_saved_tokens)::bigint AS compression_saved_tokens,
SUM(compression_savings_spend)::float AS compression_savings_spend,
SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend,
SUM(autorouter_savings_spend)::float AS autorouter_savings_spend,
SUM(api_requests)::bigint AS api_requests,
SUM(successful_requests)::bigint AS successful_requests,
SUM(failed_requests)::bigint AS failed_requests
@ -732,6 +738,7 @@ def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics:
compression_saved_tokens=record.compression_saved_tokens or 0,
compression_savings_spend=record.compression_savings_spend or 0,
prompt_caching_savings_spend=record.prompt_caching_savings_spend or 0,
autorouter_savings_spend=record.autorouter_savings_spend or 0,
api_requests=record.api_requests or 0,
successful_requests=record.successful_requests or 0,
failed_requests=record.failed_requests or 0,
@ -986,6 +993,7 @@ async def get_daily_activity(
total_compression_saved_tokens=metadata_metrics.compression_saved_tokens,
total_compression_savings_spend=metadata_metrics.compression_savings_spend,
total_prompt_caching_savings_spend=metadata_metrics.prompt_caching_savings_spend,
total_autorouter_savings_spend=metadata_metrics.autorouter_savings_spend,
page=page,
total_pages=-(-total_count // page_size), # Ceiling division
has_more=(page * page_size) < total_count,
@ -1075,6 +1083,7 @@ async def get_daily_activity_aggregated(
total_compression_saved_tokens=aggregated["totals"].compression_saved_tokens,
total_compression_savings_spend=aggregated["totals"].compression_savings_spend,
total_prompt_caching_savings_spend=aggregated["totals"].prompt_caching_savings_spend,
total_autorouter_savings_spend=aggregated["totals"].autorouter_savings_spend,
page=1,
total_pages=1,
has_more=False,

View file

@ -748,6 +748,7 @@ model LiteLLM_DailyUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -782,6 +783,7 @@ model LiteLLM_DailyOrganizationSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -816,6 +818,7 @@ model LiteLLM_DailyEndUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -849,6 +852,7 @@ model LiteLLM_DailyAgentSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -882,6 +886,7 @@ model LiteLLM_DailyTeamSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -917,6 +922,7 @@ model LiteLLM_DailyTagSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)

View file

@ -8,15 +8,22 @@ are known) and summed into the daily tables; tokens cannot be priced after they
have been aggregated across models.
"""
from typing import NamedTuple
from collections.abc import Mapping
from typing import TYPE_CHECKING, NamedTuple
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
if TYPE_CHECKING:
from litellm.router import Router
from litellm.types.utils import ModelInfo, PromptTokensDetailsWrapper, Usage
class SavingsSpend(NamedTuple):
compression: float
prompt_caching: float
autorouter: float = 0.0
def _input_and_cache_read_cost(model: str | None, custom_llm_provider: str | None) -> tuple[float, float]:
@ -44,11 +51,243 @@ def _input_and_cache_read_cost(model: str | None, custom_llm_provider: str | Non
return input_cost, float(cache_read_cost)
class _ModelIdentity(NamedTuple):
model: str
provider: str
def _resolve_model(model: str | None, custom_llm_provider: str | None) -> _ModelIdentity | None:
"""Canonical ``(model, provider)``, or ``None`` when the model cannot be resolved.
The two sides of the comparison arrive spelled differently: the spend log records a
normalized model name alongside its provider, while the baseline arrives as the
operator wrote it in config, with the provider prefixed, implied, or absent. Raw
string equality therefore reads `anthropic/claude-opus-5` as a switch away from
`claude-opus-5`, and pricing a bare name with no provider can resolve it to a
different vendor's rates than the deployment it names.
"""
if not model:
return None
try:
resolved_model, provider, _, _ = litellm.get_llm_provider(model=model, custom_llm_provider=custom_llm_provider)
except Exception as e: # noqa: BLE001 # get_llm_provider raises for unroutable names; degrade to zero savings
verbose_proxy_logger.debug(
"savings: cannot resolve provider for model=%s custom_llm_provider=%s (%s)", model, custom_llm_provider, e
)
return None
return _ModelIdentity(model=resolved_model, provider=provider)
def _effective_model_info(router: "Router | None", deployment_id: str | None, model: str) -> ModelInfo | None:
"""What a deployment is actually charged, or ``None`` to price by name.
`Router.get_deployment_model_info` owns this: it merges a deployment's configured
prices over the built-in map, folds in `base_model` defaults for deployments whose
name is not a model, and falls back to the model name when nothing is overridden.
Resolving a name here instead reads the public rate, which a deployment with a
negotiated price does not pay, and an Azure deployment name prices to nothing at all.
"""
if router is None or deployment_id is None:
return None
try:
return router.get_deployment_model_info(deployment_id, model)
except Exception as e: # noqa: BLE001 # a dashboard metric must not fail the spend write
verbose_proxy_logger.debug("savings: no deployment pricing for %s (%s)", model, e)
return None
def _model_info(model: _ModelIdentity) -> ModelInfo | None:
"""The public rates for ``model``, or ``None`` when it has none."""
try:
return litellm.get_model_info(model=model.model, custom_llm_provider=model.provider)
except Exception as e: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models
verbose_proxy_logger.debug("savings: no pricing for provider=%s model=%s (%s)", model.provider, model.model, e)
return None
def _cost_of_usage(model: _ModelIdentity, usage: Usage, model_info: ModelInfo | None = None) -> float | None:
"""What ``usage`` costs on ``model``, or ``None`` when the model has no pricing."""
try:
prompt_cost, completion_cost = generic_cost_per_token(
model=model.model, usage=usage, custom_llm_provider=model.provider, model_info=model_info
)
except Exception as e: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models; degrade to zero savings
verbose_proxy_logger.debug(
"savings: cannot price usage for provider=%s model=%s (%s)", model.provider, model.model, e
)
return None
return prompt_cost + completion_cost
def _cache_token_split(usage: Usage) -> tuple[int, int]:
"""``(cache_read_tokens, cache_creation_tokens)`` for a request."""
details = usage.prompt_tokens_details
if details is None:
return 0, 0
read = getattr(details, "cached_tokens", 0) or 0
created = (getattr(details, "cache_creation_tokens", 0) or 0) or (getattr(details, "cache_write_tokens", 0) or 0)
return int(read), int(created)
_CACHE_SPLIT_FIELDS = frozenset(
("cached_tokens", "cache_creation_tokens", "cache_write_tokens", "cache_creation_token_details", "text_tokens")
)
def _baseline_cache_rate_keys(baseline_info: ModelInfo | None) -> tuple[bool, bool]:
"""Whether the baseline model has a ``(cache read, cache write)`` rate of its own.
A missing rate is not a free bucket. `_get_token_base_cost` resolves an absent
`cache_read_input_token_cost` or `cache_creation_input_token_cost` to 0.0, so a
baseline whose provider prices caching implicitly, which is every OpenAI, Azure and
Gemini entry for cache writes, would carry the whole prompt for nothing and turn a
profitable route into a reported loss. Such a model pays its plain input rate for
those tokens, so the buckets it cannot price become ordinary input below.
"""
if baseline_info is None:
return True, True
return bool(baseline_info.get("cache_read_input_token_cost")), bool(
baseline_info.get("cache_creation_input_token_cost")
)
def _baseline_usage(usage: Usage, conversation_continuing: bool, baseline_info: ModelInfo | None = None) -> Usage:
"""The same request as a single-model baseline would have met it.
The baseline is one model serving every turn, so whether it had this prompt cached
is simply whether the conversation was already underway. On a continuing
conversation it wrote the prompt on an earlier turn and would only read it now, so
the cache tokens move into the read bucket and whatever this request paid to write
counts against the saving; that write is what switching models costs.
On a conversation's first turn nothing was cached anywhere, for any model. The
baseline would have written the same prompt, so the cache buckets stay where they are
and both arms carry the write at their own rates, unless the baseline has no rate for
a bucket, in which case those tokens are its plain input. Charging the write to this case
too, which is all a single rollup row can support, understates a first turn to a
few percent of its value and can render a profitable route as a loss.
A continuing turn that mostly read from cache is the third case: the selected model
was already warm, so it is the one that has been serving this conversation and the
baseline's cache holds exactly what its does. The tokens written are the turn's own
growth, new to every model, and the baseline would have paid to write them too.
Moving them would forgive the baseline a write it really owes and shrink the
reported saving. "Mostly read" rather than "read anything" on purpose: a switch onto
a model holding a small prefix of this prompt still writes most of it, and must keep
counting that write against the saving.
Only the cache buckets move. Every other field the request was priced on travels
through untouched, audio and image and video counts among them, because the baseline
is this same request served by a model that happened to be warm; naming the fields to
keep instead would price the baseline on a request that never ran, and would go stale
the next time a priced field is added.
"""
cache_read, cache_creation = _cache_token_split(usage)
details = usage.prompt_tokens_details
if details is None or (cache_read <= 0 and cache_creation <= 0):
return usage
# The tokens this request paid to write move into the cached count and the creation
# charge is dropped: on one model that cache was already warm, so the baseline would
# have read them rather than paying to create them. The 5m/1h breakdown goes with
# them; left behind it re-charges the write.
warm = conversation_continuing and cache_creation > 0 and cache_read <= cache_creation
reads = cache_read + cache_creation if warm else cache_read
writes = 0 if warm else cache_creation
prices_reads, prices_writes = _baseline_cache_rate_keys(baseline_info)
reads = reads if prices_reads else 0
writes = writes if prices_writes else 0
if (reads, writes) == (cache_read, cache_creation):
return usage
other_modalities = sum(
(getattr(details, field, 0) or 0) for field in ("audio_tokens", "image_tokens", "video_tokens")
)
return Usage(
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
total_tokens=usage.total_tokens,
completion_tokens_details=usage.completion_tokens_details,
prompt_tokens_details=PromptTokensDetailsWrapper(
**details.model_dump(exclude=_CACHE_SPLIT_FIELDS),
cached_tokens=reads,
cache_creation_tokens=writes,
cache_write_tokens=writes,
cache_creation_token_details=details.cache_creation_token_details if writes else None,
# Whatever no longer sits in a cache bucket is plain input on the baseline.
text_tokens=max(usage.prompt_tokens - reads - writes - other_modalities, 0),
),
)
def compute_autorouter_savings(
baseline_model: str | None,
selected_model: str | None,
selected_provider: str | None,
usage: Usage,
conversation_continuing: bool = True,
selected_info: ModelInfo | None = None,
) -> float:
"""Net dollars the router saved, or cost, by serving this request on ``selected_model``.
Signed on purpose. Switching models leaves the new one with a cold cache, so the
request pays a cache-creation charge that staying on one model would not have
incurred; when that charge outweighs the cheaper rates, routing lost money and the
dashboard has to be able to say so. Zero when both sides resolve to the same
deployment, or when either cannot be resolved or priced.
``conversation_continuing`` says whether the baseline would already have had this
prompt cached. It defaults to True because that is the conservative reading: a
request whose shape the router could not determine is charged the write and
under-claims rather than inflating a savings figure.
"""
# No provider argument for the baseline on purpose: it arrives from the routing
# metadata as a single self-describing string, already qualified by the auto-router,
# so there is no second field that could disagree with it.
baseline = _resolve_model(baseline_model, None)
selected = _resolve_model(selected_model, selected_provider)
if baseline is None or selected is None:
return 0.0
# Same model is only the same cost when it is also the same deployment. Two
# deployments of one model can carry different negotiated rates, and routing from
# the dear one to the cheap one is a real saving that short-circuiting on the model
# name alone reports as zero.
if baseline == selected:
return 0.0
baseline_info = _model_info(baseline)
baseline_cost = _cost_of_usage(
baseline, _baseline_usage(usage, conversation_continuing, baseline_info), baseline_info
)
selected_cost = _cost_of_usage(selected, usage, selected_info)
if baseline_cost is None or selected_cost is None:
return 0.0
return baseline_cost - selected_cost
def _usage_from_spend_log(usage_object: Mapping[str, object] | None) -> Usage | None:
"""Rebuild the request's ``Usage`` from the copy the spend log recorded."""
if not usage_object:
return None
try:
return Usage(**usage_object)
except Exception as e: # noqa: BLE001 # a malformed usage_object must not fail the daily spend write
# Warning, not debug: this silently zeroes the auto-router driver for every
# affected row, and a shape change in Usage would otherwise show up only as a
# dashboard that quietly reads $0.00.
verbose_proxy_logger.warning("savings: unusable usage_object, auto-router savings will read zero (%s)", e)
return None
def compute_savings_spend(
model: str | None,
custom_llm_provider: str | None,
compression_saved_tokens: int,
cache_read_input_tokens: int,
routing_decision: Mapping[str, object] | None = None,
usage_object: Mapping[str, object] | None = None,
model_id: str | None = None,
llm_router: "Router | None" = None,
) -> SavingsSpend:
"""
Dollar savings for one request, split by optimization driver.
@ -56,8 +295,35 @@ def compute_savings_spend(
Compression savings price the tokens compression removed at the model's
input rate. Prompt-caching savings price the cache-read tokens at the
difference between the input rate and the discounted cache-read rate.
Auto-router savings compare the served ``model`` against the counterfactual
baseline the router recorded on its ``routing_decision``, and are zero unless the
two differ. That record also says whether the conversation was already underway,
which is what tells a mid-conversation switch from a first turn.
"""
input_cost, cache_read_cost = _input_and_cache_read_cost(model, custom_llm_provider)
compression = max(compression_saved_tokens, 0) * input_cost
prompt_caching = max(cache_read_input_tokens, 0) * max(input_cost - cache_read_cost, 0.0)
return SavingsSpend(compression=compression, prompt_caching=prompt_caching)
usage = _usage_from_spend_log(usage_object)
if usage is None or not model:
return SavingsSpend(compression=compression, prompt_caching=prompt_caching)
# The counterfactual is one model an operator would have run instead of the router,
# configured once for the proxy rather than derived per request. Unset means the
# driver is off; a routing decision is what says this request was auto-routed at all.
decision = routing_decision if isinstance(routing_decision, Mapping) else {}
autorouter = (
compute_autorouter_savings(
baseline_model=litellm.autorouter_savings_baseline_model,
selected_model=model,
selected_provider=custom_llm_provider,
usage=usage,
# Absent means the router never recorded a shape, which is the conservative
# reading: charge the cache write rather than claim a first turn's saving.
conversation_continuing=decision.get("conversation_continuing") is not False,
selected_info=_effective_model_info(llm_router, model_id, model or ""),
)
if decision
else 0.0
)
return SavingsSpend(compression=compression, prompt_caching=prompt_caching, autorouter=autorouter)

View file

@ -224,6 +224,40 @@ def _iter_human_asks_newest_first(messages: Sequence[Mapping[str, object]]) -> I
)
def _conversation_is_continuing(messages: Sequence[Mapping[str, object]] | None) -> bool:
"""Whether this request continues a conversation that was already underway.
The counterfactual the savings driver prices against is one model serving every
turn, so whether that model had this prompt cached is just whether an earlier turn
exists. An assistant turn in the history is the direct evidence of one: something
answered before, so a single-model deployment wrote the prompt then and would only
read it now, and the write this request paid is what switching models cost. A
conversation's first turn has no assistant turn, nothing was cached for any model,
and the baseline would have paid the same write.
Assistant turns rather than human asks, because an agent loop can run twenty turns
on one human ask: its tool traffic rides `tool_result` blocks on user turns that
flatten to empty text, and on `tool` roles, so counting asks reads a long
conversation as its own first turn and hands it the untouched-write arithmetic. That
is the one direction this must never fail in, since it inflates.
Reading the conversation rather than remembering it keeps this free of a cache, a
session id and their failure modes, and it works for callers that send no session
header at all. A few-shot prompt's synthetic assistant turns read as prior
conversation, which charges the write and under-claims; that is the safe side.
So is an unreadable request. No messages says nothing about whether a turn was
served, and a surface that carries its turns somewhere this cannot see, or a
genuinely single-turn call arriving with none, is treated as continuing: it pays the
cache write and under-claims rather than being handed a first turn's larger saving
on no evidence. That direction is deliberate in both cases and is the only one that
cannot inflate.
"""
if not messages:
return True
return any(message.get("role") == "assistant" for message in messages)
def _newest_turn_ask(messages: Sequence[Mapping[str, object]]) -> str | None:
"""The human ask on the newest user turn, or None when that turn carries only plumbing.
@ -647,6 +681,7 @@ class ComplexityRouter(CustomLogger):
escalation_keyword: str | None = None,
escalated: bool = False,
classifier_model: str | None = None,
conversation_continuing: bool = True,
) -> StandardLoggingRoutingDecision:
"""Assemble the per-request provenance record for this router's decision.
@ -660,6 +695,7 @@ class ComplexityRouter(CustomLogger):
router_type="complexity",
routed_model=routed_model,
cause=cause,
conversation_continuing=conversation_continuing,
)
if tier is not None:
decision["tier"] = tier.value
@ -1392,6 +1428,8 @@ class ComplexityRouter(CustomLogger):
if isinstance(metadata, dict):
metadata[RETURN_RAW_MODEL_NAME_METADATA_KEY] = True
conversation_continuing = _conversation_is_continuing(self._resolve_messages(messages, request_kwargs))
use_session_affinity = self.config.session_affinity and not self.config.plugins
session_id = self._get_session_id_from_request_kwargs(request_kwargs) if use_session_affinity else None
cache_key = self._get_session_affinity_cache_key(session_id, request_kwargs) if session_id is not None else None
@ -1438,6 +1476,7 @@ class ComplexityRouter(CustomLogger):
cause=cause,
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
),
)
@ -1447,6 +1486,7 @@ class ComplexityRouter(CustomLogger):
messages=messages,
input=input,
specific_deployment=specific_deployment,
conversation_continuing=conversation_continuing,
)
if cache_key is not None and response is not None:
await self.litellm_router_instance.cache.async_set_cache(
@ -1463,6 +1503,7 @@ class ComplexityRouter(CustomLogger):
messages: list[dict[str, Any]] | None = None,
input: str | list | None = None,
specific_deployment: bool | None = False,
conversation_continuing: bool = True,
) -> PreRoutingHookResponse | None:
"""
Classifies the request by complexity and returns the appropriate model.
@ -1509,7 +1550,11 @@ class ComplexityRouter(CustomLogger):
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(routed_model=routed_model, cause="default_fallback"),
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause="default_fallback",
conversation_continuing=conversation_continuing,
),
)
newest_ask = _newest_turn_ask(resolved_messages)
@ -1532,6 +1577,7 @@ class ComplexityRouter(CustomLogger):
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
conversation_continuing=conversation_continuing,
cause=keyword_cause,
tier=routed_tier,
matched_keyword=override.matched_keyword,
@ -1579,6 +1625,7 @@ class ComplexityRouter(CustomLogger):
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
conversation_continuing=conversation_continuing,
cause=outcome.cause,
tier=tier,
score=score,

View file

@ -25,6 +25,7 @@ class SpendMetrics(BaseModel):
compression_saved_tokens: int = Field(default=0)
compression_savings_spend: float = Field(default=0.0)
prompt_caching_savings_spend: float = Field(default=0.0)
autorouter_savings_spend: float = Field(default=0.0)
total_tokens: int = Field(default=0)
successful_requests: int = Field(default=0)
failed_requests: int = Field(default=0)
@ -85,6 +86,7 @@ class DailySpendMetadata(BaseModel):
total_compression_saved_tokens: int = Field(default=0)
total_compression_savings_spend: float = Field(default=0.0)
total_prompt_caching_savings_spend: float = Field(default=0.0)
total_autorouter_savings_spend: float = Field(default=0.0)
page: int = Field(default=1)
total_pages: int = Field(default=1)
has_more: bool = Field(default=False)
@ -111,6 +113,7 @@ class LiteLLM_DailyUserSpend(BaseModel):
compression_saved_tokens: int = 0
compression_savings_spend: float = 0.0
prompt_caching_savings_spend: float = 0.0
autorouter_savings_spend: float = 0.0
spend: float = 0.0
api_requests: int = 0
successful_requests: int = 0

View file

@ -2734,6 +2734,7 @@ class StandardLoggingRoutingDecision(TypedDict, total=False):
classifier_model: str
escalated: bool
tier_boundaries: StandardLoggingRoutingDecisionTierBoundaries
conversation_continuing: bool
# Fields whose values quote the caller's prompt. Dropped when an operator turns message
@ -2753,6 +2754,7 @@ DERIVED_ROUTING_DECISION_FIELDS: FrozenSet[str] = frozenset(
"classifier_model",
"escalated",
"tier_boundaries",
"conversation_continuing",
}
)

View file

@ -748,6 +748,7 @@ model LiteLLM_DailyUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -782,6 +783,7 @@ model LiteLLM_DailyOrganizationSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -816,6 +818,7 @@ model LiteLLM_DailyEndUserSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -849,6 +852,7 @@ model LiteLLM_DailyAgentSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -882,6 +886,7 @@ model LiteLLM_DailyTeamSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)
@ -917,6 +922,7 @@ model LiteLLM_DailyTagSpend {
compression_saved_tokens BigInt @default(0)
compression_savings_spend Float @default(0.0)
prompt_caching_savings_spend Float @default(0.0)
autorouter_savings_spend Float @default(0.0)
spend Float @default(0.0)
api_requests BigInt @default(0)
successful_requests BigInt @default(0)

View file

@ -16,6 +16,9 @@ from litellm.proxy._types import (
Litellm_EntityType,
SpendUpdateQueueItem,
)
from typing import get_args
from litellm.proxy._types import BaseDailySpendTransaction
from litellm.proxy.db.db_transaction_queue.daily_spend_update_queue import (
DailySpendUpdateQueue,
)
@ -209,6 +212,7 @@ async def test_get_aggregated_daily_spend_update_transactions_same_key():
"compression_saved_tokens": 0,
"compression_savings_spend": 0,
"prompt_caching_savings_spend": 0,
"autorouter_savings_spend": 0,
}
updates = [{test_key: test_transaction1}, {test_key: test_transaction2}]
@ -259,6 +263,7 @@ async def test_flush_and_get_aggregated_daily_spend_update_transactions(
"compression_saved_tokens": 0,
"compression_savings_spend": 0,
"prompt_caching_savings_spend": 0,
"autorouter_savings_spend": 0,
}
# Add updates to queue
@ -527,3 +532,58 @@ async def test_compression_saved_tokens_aggregation(daily_spend_update_queue):
assert agg["cache_creation_input_tokens"] == 7
assert agg["compression_savings_spend"] == pytest.approx(0.0076)
assert agg["prompt_caching_savings_spend"] == pytest.approx(0.0108)
@pytest.mark.asyncio
async def test_every_optional_daily_metric_aggregates(daily_spend_update_queue):
"""Every additive metric must survive the merge, not just the ones wired by hand.
Two requests landing on one rollup key before a flush is the common case under
load, and this same merge runs again on every cross-pod Redis drain. A metric
persisted by the database write but skipped here is silently dropped on both
paths, so the driver reads as zero on the dashboard however much it saved.
"""
test_key = "user1_2023-01-01_key123_claude-haiku-4-5_anthropic"
def _numeric(annotation):
# additive metrics may be declared NotRequired[float] for rows queued by a pod
# running the previous release, so unwrap before matching
args = get_args(annotation)
return (args[0] if args else annotation) in (int, float)
numeric_fields = [
name for name, annotation in BaseDailySpendTransaction.__annotations__.items() if _numeric(annotation)
]
assert "autorouter_savings_spend" in numeric_fields
increments = {field: index + 1 for index, field in enumerate(numeric_fields)}
await daily_spend_update_queue.add_update({test_key: dict(increments)})
await daily_spend_update_queue.add_update({test_key: dict(increments)})
await daily_spend_update_queue.aggregate_queue_updates()
updates = await daily_spend_update_queue.flush_all_updates_from_in_memory_queue()
agg = updates[0][test_key]
for field, value in increments.items():
assert agg[field] == pytest.approx(value * 2), f"{field} did not accumulate"
@pytest.mark.asyncio
async def test_optional_metric_missing_from_an_older_payload_still_aggregates(
daily_spend_update_queue,
):
"""A queued row written before a metric existed must not zero it out."""
test_key = "user1_2023-01-01_key123_claude-haiku-4-5_anthropic"
base = {
"spend": 1.0,
"prompt_tokens": 10,
"completion_tokens": 5,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
}
await daily_spend_update_queue.add_update({test_key: dict(base)})
await daily_spend_update_queue.add_update({test_key: {**base, "autorouter_savings_spend": 0.25}})
await daily_spend_update_queue.aggregate_queue_updates()
updates = await daily_spend_update_queue.flush_all_updates_from_in_memory_queue()
assert updates[0][test_key]["autorouter_savings_spend"] == pytest.approx(0.25)

View file

@ -19,7 +19,10 @@ from litellm.proxy.management_endpoints.common_daily_activity import (
get_daily_activity_aggregated,
update_metrics,
)
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics
from litellm.types.proxy.management_endpoints.common_daily_activity import (
DailySpendMetadata,
SpendMetrics,
)
@pytest.mark.asyncio
@ -153,6 +156,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
"compression_saved_tokens": 0,
"compression_savings_spend": 0.0,
"prompt_caching_savings_spend": 0.0,
"autorouter_savings_spend": 0.0,
"failed_requests": 0,
}
mock_rows = [
@ -498,6 +502,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_1.compression_saved_tokens = 0
mock_record_1.compression_savings_spend = 0.0
mock_record_1.prompt_caching_savings_spend = 0.0
mock_record_1.autorouter_savings_spend = 0.0
mock_record_1.api_requests = 10
mock_record_1.successful_requests = 9
mock_record_1.failed_requests = 1
@ -520,6 +525,7 @@ async def test_tag_daily_activity_metadata_totals_not_zero():
mock_record_2.compression_saved_tokens = 0
mock_record_2.compression_savings_spend = 0.0
mock_record_2.prompt_caching_savings_spend = 0.0
mock_record_2.autorouter_savings_spend = 0.0
mock_record_2.api_requests = 5
mock_record_2.successful_requests = 5
mock_record_2.failed_requests = 0
@ -582,6 +588,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
"compression_saved_tokens": 0,
"compression_savings_spend": 0.0,
"prompt_caching_savings_spend": 0.0,
"autorouter_savings_spend": 0.0,
"failed_requests": 0,
}
mock_rows = [
@ -669,6 +676,7 @@ def _daily_user_spend_record(*, user_id, api_key, spend, model="gpt-4", model_gr
compression_saved_tokens=0,
compression_savings_spend=0.0,
prompt_caching_savings_spend=0.0,
autorouter_savings_spend=0.0,
api_requests=1,
successful_requests=1,
failed_requests=0,
@ -973,6 +981,7 @@ async def test_get_daily_activity_aggregated_empty_result_set():
"compression_saved_tokens": None,
"compression_savings_spend": None,
"prompt_caching_savings_spend": None,
"autorouter_savings_spend": None,
"api_requests": None,
"successful_requests": None,
"failed_requests": None,
@ -1016,6 +1025,7 @@ def _no_spend_record():
compression_saved_tokens=None,
compression_savings_spend=None,
prompt_caching_savings_spend=None,
autorouter_savings_spend=None,
api_requests=None,
successful_requests=None,
failed_requests=None,
@ -1050,3 +1060,54 @@ def test_update_metrics_handles_none_values():
assert metrics.cache_read_input_tokens == 0
assert metrics.cache_creation_input_tokens == 0
assert metrics.compression_saved_tokens == 0
class TestEverySavingsDriverSurvivesTheReadPath:
"""A savings driver is only real if it survives the whole read path.
The write path can price a driver correctly and persist it to all six rollup
tables, and the dashboard can still render a permanent $0.00 because the
aggregation query never summed the column or the response model never
declared it. That failure is silent: the card renders, the number is just
always zero, which is indistinguishable from having saved nothing. These
tests enumerate the drivers from the response model itself, so a driver added
later cannot be half-wired.
"""
def _drivers(self) -> list[str]:
drivers = [field for field in SpendMetrics.model_fields if field.endswith("_savings_spend")]
assert drivers, "expected the dashboard response to expose at least one savings driver"
return drivers
def test_every_driver_is_summed_by_the_rollup_query(self):
sql, _ = _build_aggregated_sql_query(
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id="user-1",
start_date="2026-07-01",
end_date="2026-07-31",
model=None,
api_key=None,
timezone_offset_minutes=None,
)
for driver in self._drivers():
assert f"SUM({driver})" in sql, f"{driver} is never summed, so it reads as zero"
def test_every_driver_is_accumulated_across_rows(self):
for driver in self._drivers():
record = _no_spend_record()
setattr(record, driver, 1.25)
metrics = update_metrics(SpendMetrics(), record)
assert getattr(metrics, driver) == pytest.approx(1.25), f"{driver} is dropped when accumulating rows"
def test_every_driver_is_carried_by_a_single_row_conversion(self):
for driver in self._drivers():
record = _no_spend_record()
setattr(record, driver, 2.5)
assert getattr(_record_to_spend_metrics(record), driver) == pytest.approx(2.5)
def test_every_driver_has_a_range_total(self):
for driver in self._drivers():
assert f"total_{driver}" in DailySpendMetadata.model_fields, (
f"total_{driver} is missing, so the range summary omits the driver"
)

View file

@ -6,7 +6,14 @@ sys.path.insert(0, os.path.abspath("../../../.."))
import pytest
import litellm
from litellm.proxy.spend_tracking.savings import compute_savings_spend
from litellm.router import Router
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
from litellm.proxy.spend_tracking.savings import (
_baseline_usage,
compute_autorouter_savings,
compute_savings_spend,
)
from litellm.types.utils import Usage
def _anthropic_costs(model: str) -> tuple[float, float]:
@ -16,6 +23,39 @@ def _anthropic_costs(model: str) -> tuple[float, float]:
return input_cost, cache_read_cost
def _cached_usage_object() -> dict:
"""A cache-heavy Anthropic request, shaped as the spend log records it.
`prompt_tokens` is the inclusive total: 3 uncached text tokens plus 500 read
from cache plus 12304 written to cache.
"""
return {
"prompt_tokens": 12807,
"completion_tokens": 500,
"total_tokens": 13307,
"prompt_tokens_details": {"cached_tokens": 500, "cache_creation_tokens": 12304, "text_tokens": 3},
"cache_creation_input_tokens": 12304,
"cache_read_input_tokens": 500,
}
def _cost_on(model: str, usage_object: dict) -> float:
prompt_cost, completion_cost = generic_cost_per_token(
model=model, usage=Usage(**usage_object), custom_llm_provider="anthropic"
)
return prompt_cost + completion_cost
def _flat_rates(model: str) -> tuple[float, float, float]:
info = litellm.get_model_info(model=model, custom_llm_provider="anthropic")
input_cost = info["input_cost_per_token"] or 0.0
return (
input_cost,
info["output_cost_per_token"] or 0.0,
info.get("cache_creation_input_token_cost") or input_cost,
)
def test_compression_savings_priced_at_input_rate():
input_cost, _ = _anthropic_costs("claude-sonnet-5")
result = compute_savings_spend(
@ -76,3 +116,421 @@ def test_negative_token_counts_clamp_to_zero():
)
assert result.compression == 0.0
assert result.prompt_caching == 0.0
def _usage(fresh: int, cached: int, written: int, out: int) -> Usage:
"""Usage as the spend log records it; `prompt_tokens` is the inclusive total."""
return Usage(
prompt_tokens=fresh + cached + written,
completion_tokens=out,
total_tokens=fresh + cached + written + out,
prompt_tokens_details={"cached_tokens": cached, "cache_creation_tokens": written, "text_tokens": fresh},
cache_read_input_tokens=cached,
cache_creation_input_tokens=written,
)
def _savings(baseline: str, selected: str, usage: Usage, continuing: bool = True) -> float:
"""Savings for a request, defaulting to a conversation already underway.
`continuing=True` is the mid-conversation case, where the baseline had the prompt
cached and this request's write is what the switch cost. `continuing=False` is a
conversation's first turn, where nothing was cached for any model.
"""
return compute_autorouter_savings(
baseline_model=baseline,
selected_model=selected,
selected_provider="anthropic",
usage=usage,
conversation_continuing=continuing,
)
def test_switching_models_mid_conversation_charges_the_cold_cache_write():
"""Staying on one model writes the cache once and reads it thereafter. Switching
leaves the new model cold, so it pays to write the whole prompt again; when that
charge outweighs the cheaper rates the route lost money and must report a loss.
Pricing the baseline as if it too re-wrote the cache credits a charge it never
paid, which is how a losing switch used to read as the largest saving on the page.
"""
usage = _usage(fresh=3, cached=500, written=12304, out=500)
result = _savings("claude-sonnet-5", "claude-haiku-4-5", usage)
sonnet = litellm.get_model_info("claude-sonnet-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
warm_baseline = (
3 * sonnet["input_cost_per_token"]
+ 12804 * sonnet["cache_read_input_token_cost"]
+ 500 * sonnet["output_cost_per_token"]
)
actually_paid = (
3 * haiku["input_cost_per_token"]
+ 500 * haiku["cache_read_input_token_cost"]
+ 12304 * haiku["cache_creation_input_token_cost"]
+ 500 * haiku["output_cost_per_token"]
)
assert result == pytest.approx(warm_baseline - actually_paid)
assert result < 0, "a cache-thrashing switch must report a loss, not a saving"
phantom = 12304 * sonnet["cache_creation_input_token_cost"]
assert result != pytest.approx(warm_baseline + phantom - actually_paid)
def test_a_cold_switch_never_beats_turning_caching_off():
"""Switching to a cold model makes it write the whole prompt again. That write is a
real cost of switching, so the same traffic must look worse than if caching were off
entirely.
The baseline is priced as a warm cache even though this request read nothing: a
switch reads nothing precisely because the new model's cache is empty, and staying
on one model would have had the prompt cached already. Gating the warm baseline on
a read charged the baseline a write it would never repeat, which made a cold switch
report a larger saving than no caching at all.
"""
cold_switch = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", _usage(0, 0, 20_000, 1_000))
caching_off = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", _usage(20_000, 0, 0, 1_000))
assert cold_switch < caching_off
opus = litellm.get_model_info("claude-opus-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
warm_baseline = 20_000 * opus["cache_read_input_token_cost"] + 1_000 * opus["output_cost_per_token"]
actually_paid = 20_000 * haiku["cache_creation_input_token_cost"] + 1_000 * haiku["output_cost_per_token"]
assert cold_switch == pytest.approx(warm_baseline - actually_paid)
def test_moving_one_token_between_cache_buckets_does_not_move_the_answer():
"""A continuing conversation writes a few new tokens and reads the rest. Treating the
presence of a write as the signal for a switch made that ordinary increment flip the
result, so a request reading 19,999 and writing 1 landed somewhere entirely different
from one reading 20,000 and writing none.
"""
reads_nothing = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", _usage(0, 0, 20_000, 1_000))
reads_one = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", _usage(0, 1, 19_999, 1_000))
assert reads_one == pytest.approx(reads_nothing, abs=1e-4)
def test_multimodal_prompts_are_priced_on_the_baseline_too():
"""The baseline is this same request met by a warm cache, so every field it was
priced on has to survive. Rebuilding the details from the cache buckets alone
dropped the image and audio counts, which priced the baseline as a text-only
request that never ran and shrank the reported saving on multimodal traffic.
"""
details = {"cached_tokens": 0, "cache_creation_tokens": 16_000, "text_tokens": 0, "image_tokens": 4_000}
with_images = Usage(
prompt_tokens=20_000,
completion_tokens=1_000,
total_tokens=21_000,
prompt_tokens_details=details,
)
baseline = _baseline_usage(with_images, conversation_continuing=True)
assert baseline.prompt_tokens_details.image_tokens == 4_000, "image tokens must survive into the baseline"
opus = litellm.get_model_info("claude-opus-5", "anthropic")
priced, _ = generic_cost_per_token(model="claude-opus-5", usage=baseline, custom_llm_provider="anthropic")
text_only = 20_000 * opus["cache_read_input_token_cost"]
assert priced > text_only, "dropping the image tokens undercharges the baseline and hides the saving"
def test_the_baseline_is_never_charged_a_cache_write():
"""Carrying the details through must not carry the 5m/1h creation breakdown with
them. `generic_cost_per_token` charges a creation cost whenever that breakdown is
present, even against a zeroed creation count, which would put the phantom write
back on the baseline for every long-cache request.
"""
long_cache = Usage(
prompt_tokens=20_000,
completion_tokens=1_000,
total_tokens=21_000,
prompt_tokens_details={
"cached_tokens": 0,
"cache_creation_tokens": 20_000,
"text_tokens": 0,
"cache_creation_token_details": {"ephemeral_1h_input_tokens": 20_000},
},
)
baseline = _baseline_usage(long_cache, conversation_continuing=True)
opus = litellm.get_model_info("claude-opus-5", "anthropic")
priced, _ = generic_cost_per_token(model="claude-opus-5", usage=baseline, custom_llm_provider="anthropic")
assert priced == pytest.approx(20_000 * opus["cache_read_input_token_cost"]), (
"the baseline reads a warm cache; it never pays to create one"
)
def test_uncached_request_is_the_plain_rate_difference():
usage = _usage(fresh=2000, cached=0, written=0, out=500)
sonnet = litellm.get_model_info("claude-sonnet-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
assert _savings("claude-sonnet-5", "claude-haiku-4-5", usage) == pytest.approx(
2000 * (sonnet["input_cost_per_token"] - haiku["input_cost_per_token"])
+ 500 * (sonnet["output_cost_per_token"] - haiku["output_cost_per_token"])
)
def test_escalation_reports_its_real_cost():
"""Routing up to a pricier model is a real cost; hiding it behind a zero floor
would let the dashboard only ever move in one direction."""
usage = _usage(fresh=2000, cached=0, written=0, out=500)
assert _savings("claude-haiku-4-5", "claude-sonnet-5", usage) < 0
def test_autorouter_savings_zero_when_model_unchanged():
assert _savings("claude-opus-5", "claude-opus-5", _usage(3, 500, 12304, 500)) == 0.0
def test_autorouter_savings_unknown_baseline_fails_open_to_zero():
assert _savings("totally-made-up-model-xyz", "claude-haiku-4-5", _usage(3, 500, 12304, 500)) == 0.0
def test_autorouter_savings_zero_without_baseline():
result = compute_savings_spend(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
compression_saved_tokens=0,
cache_read_input_tokens=0,
routing_decision=None,
usage_object=_cached_usage_object(),
)
assert result.autorouter == 0.0
def test_compute_savings_spend_carries_a_losing_switch_through(monkeypatch):
"""The signed value must survive into SavingsSpend; clamping it here would put the
dashboard back to only ever showing gains."""
monkeypatch.setattr(litellm, "autorouter_savings_baseline_model", "claude-sonnet-5")
result = compute_savings_spend(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
compression_saved_tokens=0,
cache_read_input_tokens=0,
routing_decision={"conversation_continuing": True},
usage_object=_cached_usage_object(),
)
assert result.autorouter < 0
def test_the_driver_is_off_until_a_baseline_is_configured():
"""No configured counterfactual means there is nothing to measure against, so the
driver reports zero rather than inventing a model the operator never named."""
result = compute_savings_spend(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
compression_saved_tokens=1000,
cache_read_input_tokens=0,
routing_decision={"conversation_continuing": True},
usage_object=_cached_usage_object(),
)
assert result.autorouter == 0.0
assert result.compression > 0, "the other drivers keep working"
def test_malformed_usage_object_does_not_fail_the_spend_write():
"""The daily spend write must survive an unusable usage_object; losing one row's
savings is recoverable, losing the row is not."""
result = compute_savings_spend(
model="claude-haiku-4-5",
custom_llm_provider="anthropic",
compression_saved_tokens=1000,
cache_read_input_tokens=0,
routing_decision={"conversation_continuing": True},
usage_object={"prompt_tokens": ["not", "a", "number"]},
)
assert result.autorouter == 0.0
assert result.compression > 0
def test_model_without_cache_read_pricing_yields_no_caching_savings():
"""A model with no discounted cache-read rate cannot have saved anything by
reading from cache, so the driver must report zero rather than the full input rate."""
model = "azure/gpt-3.5-turbo"
assert litellm.get_model_info(model=model).get("cache_read_input_token_cost") is None
result = compute_savings_spend(
model=model,
custom_llm_provider="azure",
compression_saved_tokens=0,
cache_read_input_tokens=5000,
)
assert result.prompt_caching == 0.0
def test_the_same_deployment_spelled_two_ways_is_not_a_switch():
"""The spend log records a normalized model name while the baseline arrives as the
operator wrote it in config. Comparing the raw strings makes a request that never
changed model look like a switch, and prices one deployment against itself."""
# Must be a cached request: the baseline arm is priced against a warm cache and the
# selected arm against what was actually paid, so treating one deployment as two
# charges it a cold-cache write it never took, inventing a loss on a request that
# never changed model. An uncached request prices identically either way and would
# make this assertion vacuous.
usage = _usage(fresh=3, cached=500, written=12304, out=500)
assert _savings("anthropic/claude-opus-5", "claude-opus-5", usage) == 0.0
assert _savings("claude-opus-5", "anthropic/claude-opus-5", usage) == 0.0
def test_baseline_is_priced_under_its_own_provider():
"""Two providers can serve the same bare model name at different rates, so dropping
the provider prices the baseline against a vendor the operator never named. Here it
decides whether routing reads as a saving or a loss."""
usage = Usage(prompt_tokens=100_000, completion_tokens=10_000, total_tokens=110_000)
azure = compute_autorouter_savings(
baseline_model="azure_ai/deepseek-r1",
selected_model="claude-haiku-4-5",
selected_provider="anthropic",
usage=usage,
)
deepseek = compute_autorouter_savings(
baseline_model="deepseek/deepseek-r1",
selected_model="claude-haiku-4-5",
selected_provider="anthropic",
usage=usage,
)
assert azure != pytest.approx(deepseek)
assert azure > 0 > deepseek
def test_unresolvable_baseline_fails_open_to_zero():
usage = _usage(fresh=2000, cached=0, written=0, out=500)
assert _savings("no-such-provider-xyz/no-such-model", "claude-haiku-4-5", usage) == 0.0
def test_a_first_turn_is_the_rate_difference_not_a_switch_penalty():
"""Nothing was cached anywhere on a conversation's first turn, so the baseline would
have paid the same cache write. Charging it to the selected arm alone reported a
fraction of the real saving; on this shape roughly 4% of it.
"""
usage = _usage(fresh=0, cached=0, written=20_000, out=1_000)
first_turn = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", usage, continuing=False)
opus = litellm.get_model_info("claude-opus-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
both_write = (20_000 * opus["cache_creation_input_token_cost"] + 1_000 * opus["output_cost_per_token"]) - (
20_000 * haiku["cache_creation_input_token_cost"] + 1_000 * haiku["output_cost_per_token"]
)
assert first_turn == pytest.approx(both_write)
mid_conversation = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", usage)
assert first_turn > mid_conversation * 10, "a first turn must not be priced as a switch"
def test_a_first_turn_that_saves_money_never_reports_a_loss():
"""The write premium is fixed by prompt size while the saving grows with completion
length, so charging the write to a first turn made short answers over a large cached
prompt read as losses on requests that genuinely saved. That is the shape most likely
to be on the dashboard, and the sign has to be right.
"""
short_answer = _usage(fresh=0, cached=0, written=20_000, out=200)
assert _savings("anthropic/claude-opus-5", "claude-haiku-4-5", short_answer, continuing=False) > 0
assert _savings("anthropic/claude-opus-5", "claude-haiku-4-5", short_answer) < 0
def test_an_undetermined_conversation_shape_stays_conservative():
"""The default must charge the write. A caller that cannot be read, or a surface the
router never classified, has said nothing about whether the baseline was warm, and a
savings figure must not inflate on a guess.
"""
usage = _usage(fresh=0, cached=0, written=20_000, out=1_000)
defaulted = compute_autorouter_savings(
baseline_model="anthropic/claude-opus-5",
selected_model="claude-haiku-4-5",
selected_provider="anthropic",
usage=usage,
)
assert defaulted == pytest.approx(_savings("anthropic/claude-opus-5", "claude-haiku-4-5", usage))
assert defaulted < _savings("anthropic/claude-opus-5", "claude-haiku-4-5", usage, continuing=False)
def test_a_continuing_turn_on_the_same_model_writes_its_growth_on_both_arms():
"""A conversation that grew by a few tokens writes those on whatever model serves
it, and they are new to every model, so the baseline would have written them too.
Moving them into the baseline's read bucket forgives it a write it really owes and
shrinks the reported saving on ordinary steady-state traffic.
"""
usage = _usage(fresh=0, cached=19_900, written=100, out=1_000)
opus = litellm.get_model_info("claude-opus-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
def cost(info: dict) -> float:
return (
19_900 * info["cache_read_input_token_cost"]
+ 100 * info["cache_creation_input_token_cost"]
+ 1_000 * info["output_cost_per_token"]
)
both_write_the_growth = cost(opus) - cost(haiku)
assert _savings("anthropic/claude-opus-5", "claude-haiku-4-5", usage) == pytest.approx(both_write_the_growth)
def test_a_switch_onto_a_partly_cached_model_still_pays_for_the_write():
"""A model holding a small prefix of this prompt still has to write the rest, and
that write is the switch's cost. Keying the same-model case off reading *anything*
rather than reading *most of it* would hand this request the full rate gap and
inflate the saving by an order of magnitude.
"""
mostly_written = _usage(fresh=0, cached=500, written=19_500, out=1_000)
reported = _savings("anthropic/claude-opus-5", "claude-haiku-4-5", mostly_written)
opus = litellm.get_model_info("claude-opus-5", "anthropic")
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
if_treated_as_same_model = (
500 * opus["cache_read_input_token_cost"]
+ 19_500 * opus["cache_creation_input_token_cost"]
+ 1_000 * opus["output_cost_per_token"]
) - (
500 * haiku["cache_read_input_token_cost"]
+ 19_500 * haiku["cache_creation_input_token_cost"]
+ 1_000 * haiku["output_cost_per_token"]
)
assert reported < if_treated_as_same_model / 10, "a mostly-cold switch must not be priced as a continuation"
def test_a_baseline_that_prices_caching_implicitly_still_pays_for_its_prompt():
"""OpenAI, Azure and Gemini entries carry no `cache_creation_input_token_cost`,
because those providers cache implicitly and charge nothing to write. Leaving this
request's written tokens in the creation bucket priced them at the 0.0 the cost
resolver falls back to, so the baseline carried a 20k prompt for free and a first
turn that saved money reported a loss. Those tokens are plain input on such a model.
"""
first_turn = _usage(fresh=0, cached=0, written=20_000, out=1_000)
reported = compute_autorouter_savings(
baseline_model="gpt-5",
selected_model="claude-haiku-4-5",
selected_provider="anthropic",
usage=first_turn,
conversation_continuing=False,
)
gpt5 = litellm.get_model_info("gpt-5", "openai")
assert gpt5.get("cache_creation_input_token_cost") is None, "pick a baseline with no cache-write rate"
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
baseline_pays_input = 20_000 * gpt5["input_cost_per_token"] + 1_000 * gpt5["output_cost_per_token"]
actually_paid = (
20_000 * haiku["cache_creation_input_token_cost"] + 1_000 * haiku["output_cost_per_token"]
)
assert reported == pytest.approx(baseline_pays_input - actually_paid)
assert reported > 0, "routing a cold first turn onto a cheaper model is a saving, not a loss"
def test_a_baseline_with_no_cache_read_rate_is_charged_its_input_rate():
"""The same hole on the other bucket. A baseline whose entry has no
`cache_read_input_token_cost` reads for 0.0, so a continuing turn priced the whole
prompt at nothing and every switch away from it reported a loss.
"""
continuing = _usage(fresh=0, cached=0, written=20_000, out=1_000)
reported = compute_autorouter_savings(
baseline_model="xai/grok-4",
selected_model="claude-haiku-4-5",
selected_provider="anthropic",
usage=continuing,
conversation_continuing=True,
)
grok = litellm.get_model_info("grok-4", "xai")
assert grok.get("cache_read_input_token_cost") is None, "pick a baseline with no cache-read rate"
haiku = litellm.get_model_info("claude-haiku-4-5", "anthropic")
baseline_pays_input = 20_000 * grok["input_cost_per_token"] + 1_000 * grok["output_cost_per_token"]
actually_paid = (
20_000 * haiku["cache_creation_input_token_cost"] + 1_000 * haiku["output_cost_per_token"]
)
assert reported == pytest.approx(baseline_pays_input - actually_paid)

View file

@ -4094,6 +4094,23 @@ class TestRecordRoutingDecision:
assert request_kwargs == {}
def test_clearing_the_decision_takes_the_savings_facts_with_it(self):
"""A fallback to a plain model group re-enters the hook with the same
`request_kwargs`. The baseline and the conversation shape ride inside the
decision rather than beside it, so one clear cannot leave either behind and
attribute an auto-router saving to a deployment that never routed."""
decision = {
"router_model_name": "smart-router",
"router_type": "complexity",
"routed_model": "gpt-4o-mini",
"savings_baseline_model": "anthropic/claude-opus-5",
"conversation_continuing": False,
}
request_kwargs: Dict = {"litellm_metadata": {"routing_decision": decision}}
Router._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None)
assert request_kwargs["litellm_metadata"] == {}
class TestEscalationIsRecordedConsistently:
"""An escalation keyword records two separate facts on every path: that the caller
asked, and whether the tier actually moved. Dropping the ask when there is nowhere
@ -5039,3 +5056,142 @@ class TestClassifierTrustBoundary:
assert "Classify only the current message" not in system_prompt
assert "using the earlier turns quoted above it as context" in system_prompt
assert "rate the work it approves rather than the reply itself" in system_prompt
class TestConversationShapeDiscriminator:
"""Whether the counterfactual single model would already have had the prompt cached."""
@staticmethod
def _router(mock_router_instance, basic_config) -> ComplexityRouter:
return ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config={**basic_config, "session_affinity": False},
)
@pytest.mark.asyncio
async def test_a_single_ask_is_a_first_turn(self, mock_router_instance, basic_config):
"""Nothing is cached for any model yet, so the baseline would have paid the same
cache write and the saving is the plain rate difference."""
mock_router_instance.cache = DualCache()
result = await self._router(mock_router_instance, basic_config).async_pre_routing_hook(
model="test-model",
request_kwargs={"metadata": {}},
messages=[{"role": "user", "content": "Hello!"}],
)
assert result.routing_decision["conversation_continuing"] is False
@pytest.mark.asyncio
async def test_a_second_ask_means_the_baseline_was_already_warm(self, mock_router_instance, basic_config):
"""An earlier turn was served, so a single-model deployment wrote the prompt then
and would only read it now; this request's write is what switching cost."""
mock_router_instance.cache = DualCache()
result = await self._router(mock_router_instance, basic_config).async_pre_routing_hook(
model="test-model",
request_kwargs={"metadata": {}},
messages=[
{"role": "user", "content": "First question about the codebase"},
{"role": "assistant", "content": "Here is the answer"},
{"role": "user", "content": "Hello!"},
],
)
assert result.routing_decision["conversation_continuing"] is True
@pytest.mark.asyncio
async def test_it_needs_no_session_id(self, mock_router_instance, basic_config):
"""The whole point of reading the conversation rather than remembering it: a
caller that sends no session header is still classified correctly."""
mock_router_instance.cache = DualCache()
router = self._router(mock_router_instance, basic_config)
first = await router.async_pre_routing_hook(
model="test-model", request_kwargs={}, messages=[{"role": "user", "content": "Hello!"}]
)
later = await router.async_pre_routing_hook(
model="test-model",
request_kwargs={},
messages=[
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "Answer"},
{"role": "user", "content": "Hello!"},
],
)
assert first.routing_decision["conversation_continuing"] is False
assert later.routing_decision["conversation_continuing"] is True
@pytest.mark.asyncio
async def test_it_touches_no_cache(self, mock_router_instance, basic_config):
"""Reading the request instead of remembering it is what removes the routing-path
round-trip, and with it a cache failure that would read as a first turn."""
cache = AsyncMock()
cache.async_get_cache = AsyncMock(return_value=None)
mock_router_instance.cache = cache
result = await self._router(mock_router_instance, basic_config).async_pre_routing_hook(
model="test-model",
request_kwargs={"metadata": {}},
messages=[{"role": "user", "content": "Hello!"}],
)
assert result.routing_decision["conversation_continuing"] is False
assert cache.async_get_cache.await_count == 0
assert cache.async_set_cache.await_count == 0
@pytest.mark.parametrize(
"history",
[
pytest.param(
[
{"role": "user", "content": "do X"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "1", "name": "t", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "1", "content": "r"}]},
{"role": "assistant", "content": [{"type": "tool_use", "id": "2", "name": "t", "input": {}}]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "2", "content": "r"}]},
],
id="messages-api-tool-result-blocks",
),
pytest.param(
[
{"role": "user", "content": "do X"},
{"role": "assistant", "tool_calls": [{"id": "1"}]},
{"role": "tool", "tool_call_id": "1", "content": "r"},
],
id="chat-completions-tool-role",
),
],
)
def test_an_agent_loop_on_one_human_ask_is_not_a_first_turn(self, history):
"""An agent can run twenty turns on a single human ask: its tool traffic rides
`tool_result` blocks that flatten to empty text and `tool` roles. Counting human
asks read that as a first turn and handed it the untouched-write arithmetic,
which is the one direction this must never fail in, because it inflates."""
from litellm.router_strategy.complexity_router.complexity_router import _conversation_is_continuing
assert _conversation_is_continuing(history) is True
def test_a_system_prompt_does_not_make_a_first_turn_look_continued(self):
from litellm.router_strategy.complexity_router.complexity_router import _conversation_is_continuing
assert _conversation_is_continuing([{"role": "system", "content": "s"}, {"role": "user", "content": "hi"}]) is False
def test_unreadable_messages_stay_conservative(self):
"""No messages says nothing about the baseline's cache, so it keeps charging the
write and under-claims rather than inflating."""
from litellm.router_strategy.complexity_router.complexity_router import _conversation_is_continuing
assert _conversation_is_continuing(None) is True
assert _conversation_is_continuing([]) is True
assert _conversation_is_continuing([{"role": "user", "content": ""}]) is False
@pytest.mark.asyncio
async def test_the_shape_travels_on_every_pre_routing_response(self):
"""A response without it defaults to charging the write, silently undoing the fix
for whichever routing path forgot it."""
import inspect
from litellm.router_strategy.complexity_router import complexity_router as module
source = inspect.getsource(module.ComplexityRouter.async_pre_routing_hook) + inspect.getsource(
module.ComplexityRouter._classify_and_route
)
builds = source.split("self._build_routing_decision(")[1:]
assert builds
missing = [i for i, block in enumerate(builds) if "conversation_continuing=conversation_continuing" not in block.split("),")[0]]
assert not missing, f"routing decisions {missing} do not carry the conversation shape"

View file

@ -23521,6 +23521,11 @@ export interface components {
* @default 0
*/
total_api_requests: number;
/**
* Total Autorouter Savings Spend
* @default 0
*/
total_autorouter_savings_spend: number;
/**
* Total Cache Creation Input Tokens
* @default 0
@ -31427,6 +31432,11 @@ export interface components {
* @default 0
*/
api_requests: number;
/**
* Autorouter Savings Spend
* @default 0
*/
autorouter_savings_spend: number;
/**
* Cache Creation Input Tokens
* @default 0