Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e30b7f2
feat(spend): add net auto-router savings to the cost-optimization das…
tin-berri Jul 31, 2026
4897f6e
fix(spend): price auto-router savings through litellm's cost engine a…
tin-berri Jul 31, 2026
0b9c503
test(spend): cover the savings fail-open paths
tin-berri Jul 31, 2026
eca16fa
refactor(spend): trim savings docstrings to what the code cannot say
tin-berri Jul 31, 2026
db15ef3
fix(spend): charge the cold-cache write to the model switch that caus…
tin-berri Aug 1, 2026
c46e96a
fix(spend): compare and price auto-router models as resolved identities
tin-berri Aug 1, 2026
59d66e4
feat(spend): derive the savings baseline from the router's own candid…
tin-berri Aug 1, 2026
e2ffae8
fix(ui): say what the auto-router savings are measured against
tin-berri Aug 1, 2026
6843ad0
fix(router): price baseline candidates under the provider their deplo…
tin-berri Aug 1, 2026
52ca688
refactor(spend): let the types carry the facts the tests already assert
tin-berri Aug 1, 2026
c507283
fix(router): keep the savings baseline out of the outbound provider r…
tin-berri Aug 1, 2026
e12415a
fix(router): derive the savings baseline per call instead of caching it
tin-berri Aug 1, 2026
e9b4f95
fix(ui): stop the savings legend and tab control moving between tabs
tin-berri Aug 1, 2026
669fd84
fix(ui): keep the savings header one shape across both tabs
tin-berri Aug 1, 2026
f66687b
fix(ui): lay the savings header out with the card's own slots
tin-berri Aug 1, 2026
d539fa6
fix(spend): charge the cold-cache write on the request that actually …
tin-berri Aug 1, 2026
68c56fe
feat(spend): give the complexity router a savings baseline from its h…
tin-berri Aug 1, 2026
11c6688
fix(router): price baseline candidates by base_model, not the deploym…
tin-berri Aug 1, 2026
6cf689d
chore: drop the built dashboard bundle from this branch
tin-berri Aug 1, 2026
5457640
chore: restore the built dashboard bundle to the branch point
tin-berri Aug 1, 2026
d0b59af
fix(spend): price the baseline on the whole request, not just its cac…
tin-berri Aug 1, 2026
bfe3413
docs(ui): say on the card that a cold first turn is undercounted
tin-berri Aug 1, 2026
10a2a29
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
tin-berri Aug 1, 2026
5410306
style: bring the savings changes under the strict and type-discipline…
tin-berri Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3321,6 +3321,7 @@ class SpendLogsMetadata(TypedDict):
max_retries: Optional[int] # Max retries configured for this request
cost_breakdown: Optional[CostBreakdown] # Detailed cost breakdown (input_cost, output_cost, margin, discount, etc.)
compression_savings: CompressionSavingsMetadata | None
auto_router_savings_baseline_model: str | None # counterfactual model for the auto-router savings driver


class SpendLogsPayload(TypedDict):
Expand Down
19 changes: 18 additions & 1 deletion litellm/proxy/db/db_spend_update_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,10 @@ async def _update_daily_spend(
common_data["prompt_caching_savings_spend"] = transaction.get(
"prompt_caching_savings_spend", 0
)
if "autorouter_savings_spend" in transaction:
common_data["autorouter_savings_spend"] = transaction.get(
"autorouter_savings_spend", 0
)

if entity_type == "tag" and "request_id" in transaction:
common_data["request_id"] = transaction.get("request_id")
Expand Down Expand Up @@ -1630,6 +1634,12 @@ async def _update_daily_spend(
update_data["prompt_caching_savings_spend"] = {
"increment": transaction.get("prompt_caching_savings_spend", 0)
}
if "autorouter_savings_spend" in transaction:
update_data[
"autorouter_savings_spend"
] = { # mutable-ok: extends preset dict following compression_savings_spend pattern
"increment": transaction.get("autorouter_savings_spend", 0)
}

if entity_type == "tag" and "request_id" in transaction:
update_data["request_id"] = transaction.get("request_id")
Expand Down Expand Up @@ -1881,11 +1891,17 @@ async def _common_add_spend_log_transaction_to_daily_transaction(

cache_read_input_tokens = _extract_cache_read_tokens(usage_obj)
compression_saved_tokens = extract_compression_saved_tokens(_metadata)
cache_creation_input_tokens = _extract_cache_creation_tokens(usage_obj)

savings_spend = compute_savings_spend(
model=payload.get("model", None),
custom_llm_provider=payload.get("custom_llm_provider", None),
compression_saved_tokens=compression_saved_tokens,
cache_read_input_tokens=cache_read_input_tokens,
baseline_model=_metadata.get("auto_router_savings_baseline_model"),
prompt_tokens=payload.get("prompt_tokens", 0),
completion_tokens=payload.get("completion_tokens", 0),
cache_creation_input_tokens=cache_creation_input_tokens,
)

daily_transaction = BaseDailySpendTransaction(
Expand All @@ -1903,10 +1919,11 @@ async def _common_add_spend_log_transaction_to_daily_transaction(
successful_requests=1 if request_status == "success" else 0,
failed_requests=1 if request_status != "success" else 0,
cache_read_input_tokens=cache_read_input_tokens,
cache_creation_input_tokens=_extract_cache_creation_tokens(usage_obj),
cache_creation_input_tokens=cache_creation_input_tokens,
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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
)
return daily_transaction
except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/litellm_pre_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def parse_cache_control(cache_control):
"applied_policies",
"policy_sources",
"routing_decision",
"auto_router_savings_baseline_model",
INTERNAL_CALL_ORIGIN_METADATA_KEY,
"standard_logging_object",
"proxy_server_request",
Expand Down
6 changes: 6 additions & 0 deletions litellm/proxy/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 82 additions & 16 deletions litellm/proxy/spend_tracking/savings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,113 @@
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]:
class _ModelRates(NamedTuple):
input: float
output: float
cache_read: float
cache_write: float


def _model_rates(model: str | None, custom_llm_provider: str | None) -> _ModelRates:
"""
Return ``(input_cost_per_token, cache_read_cost_per_token)`` for a model.
Per-token prices for a model, in its four billed dimensions.

Falls open to ``(0.0, 0.0)`` when the model is unknown so savings degrade to
zero rather than raising inside the spend writer. When a model has no
separate cache-read price the cache-read cost mirrors the input cost, which
yields zero caching savings.
Falls open to all-zero rates when the model is unknown so savings degrade to
zero rather than raising inside the spend writer. When a model has no separate
cache-read price the cache-read rate mirrors the input rate, which yields zero
caching savings; a missing cache-write price falls back to the input rate,
which is what providers without a distinct cache-creation charge bill.
"""
if not model:
return 0.0, 0.0
return _ModelRates(0.0, 0.0, 0.0, 0.0)
try:
info = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
except Exception as e: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models; degrade to zero savings
verbose_proxy_logger.debug(
"savings: no model info for provider=%s model=%s (%s)", custom_llm_provider, model, e
)
return 0.0, 0.0
return _ModelRates(0.0, 0.0, 0.0, 0.0)
input_cost = float(info.get("input_cost_per_token") or 0.0)
cache_read_cost = info.get("cache_read_input_token_cost")
if cache_read_cost is None:
return input_cost, input_cost
return input_cost, float(cache_read_cost)
output_cost = float(info.get("output_cost_per_token") or 0.0)
cache_read = info.get("cache_read_input_token_cost")
cache_write = info.get("cache_creation_input_token_cost")
return _ModelRates(
input=input_cost,
output=output_cost,
cache_read=input_cost if cache_read is None else float(cache_read),
cache_write=input_cost if cache_write is None else float(cache_write),
)


def compute_autorouter_savings(
baseline_model: str | None,
selected_model: str | None,
baseline_provider: str | None,
selected_provider: str | None,
prompt_tokens: int,
completion_tokens: int,
cache_creation_input_tokens: int,
) -> float:
"""
Net dollars saved by routing this request to ``selected_model`` instead of the
counterfactual ``baseline_model``.

The model-switch delta prices prompt tokens at each model's input rate and
completion tokens at each model's output rate (output is typically several
times the input rate, so pricing completions at the input rate materially
understates the gap). The cache-write penalty is the cost of switching: a
cold cache on the selected deployment forces a cache-creation charge that the
baseline would not have incurred, priced at the selected model's cache-write
rate. Cache-read discounts are deliberately excluded here; they are attributed
to the prompt-caching driver, so folding them in would double-count.

Returns zero when routing did not change the model or when pricing is unknown.
Floored at zero so an escalation to a pricier model never reads as negative
savings on the dashboard.
"""
if not baseline_model or not selected_model or baseline_model == selected_model:
return 0.0
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
baseline = _model_rates(baseline_model, baseline_provider)
selected = _model_rates(selected_model, selected_provider)
baseline_cost = (prompt_tokens * baseline.input) + (completion_tokens * baseline.output)
selected_cost = (prompt_tokens * selected.input) + (completion_tokens * selected.output)
cache_write_penalty = max(cache_creation_input_tokens, 0) * selected.cache_write
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
return max((baseline_cost - selected_cost) - cache_write_penalty, 0.0)


def compute_savings_spend(
model: str | None,
custom_llm_provider: str | None,
compression_saved_tokens: int,
cache_read_input_tokens: int,
baseline_model: str | None = None,
baseline_provider: str | None = None,
prompt_tokens: int = 0,
completion_tokens: int = 0,
cache_creation_input_tokens: int = 0,
) -> SavingsSpend:
"""
Dollar savings for one request, split by optimization driver.

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_model`` and are zero unless the two differ.
"""
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)
rates = _model_rates(model, custom_llm_provider)
compression = max(compression_saved_tokens, 0) * rates.input
prompt_caching = max(cache_read_input_tokens, 0) * max(rates.input - rates.cache_read, 0.0)
autorouter = compute_autorouter_savings(
baseline_model=baseline_model,
selected_model=model,
baseline_provider=baseline_provider,
selected_provider=custom_llm_provider,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
)
return SavingsSpend(compression=compression, prompt_caching=prompt_caching, autorouter=autorouter)
1 change: 1 addition & 0 deletions litellm/proxy/spend_tracking/spend_tracking_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _get_spend_logs_metadata(
max_retries=None,
cost_breakdown=None,
compression_savings=None,
auto_router_savings_baseline_model=None,
litellm_call_id=litellm_call_id,
)
verbose_proxy_logger.debug(
Expand Down
25 changes: 25 additions & 0 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -7670,6 +7670,7 @@ def init_auto_router_deployment(self, deployment: Deployment):
default_model=default_model,
embedding_model=embedding_model,
litellm_router_instance=self,
savings_baseline_model=deployment.litellm_params.auto_router_savings_baseline_model,
)
self._register_pre_routing_strategy(
registry=self.auto_routers,
Expand Down Expand Up @@ -11175,6 +11176,10 @@ async def async_pre_routing_hook(
request_kwargs=request_kwargs,
routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None),
)
self._record_savings_baseline_model(
Comment thread
veria-ai[bot] marked this conversation as resolved.
Resolved
request_kwargs=request_kwargs,
baseline_model=(pre_routing_hook_response.savings_baseline_model if pre_routing_hook_response else None),
)

# `model` (the alias, e.g. "smart-router") is never the deployment actually
# called - apply the alias's own litellm_params (besides `model` itself,
Expand Down Expand Up @@ -11222,6 +11227,26 @@ def _record_routing_decision(
request_kwargs=request_kwargs, routing_decision=routing_decision
)

@staticmethod
def _record_savings_baseline_model(
request_kwargs: dict,
baseline_model: str | None,
) -> None:
"""Stash the auto-router's savings baseline for this attempt, same write-or-clear
discipline as `_record_routing_decision`: a fallback that re-enters this hook
without an auto-router strategy must clear a prior attempt's baseline, or the
spend writer would price savings against a model this attempt never routed
against.
"""
if baseline_model is None:
for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")):
if isinstance(bucket, dict):
bucket.pop("auto_router_savings_baseline_model", None)
return

_, metadata_bucket = get_or_create_metadata_bucket(request_kwargs)
metadata_bucket["auto_router_savings_baseline_model"] = baseline_model

@staticmethod
def _redact_prompt_text_if_needed(
request_kwargs: Mapping[str, Any],
Expand Down
Loading
Loading