diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260731000000_add_autorouter_savings_spend/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260731000000_add_autorouter_savings_spend/migration.sql new file mode 100644 index 000000000000..a7efd444fdb0 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260731000000_add_autorouter_savings_spend/migration.sql @@ -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; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 37ea55f8c132..0d7fa8692c8b 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -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) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e8355f4941aa..dc12d1257573 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -13,7 +13,7 @@ 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 @@ -3327,6 +3327,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): @@ -4565,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 diff --git a/litellm/proxy/db/db_spend_update_writer.py b/litellm/proxy/db/db_spend_update_writer.py index fd8132fef222..244dc99ebbfa 100644 --- a/litellm/proxy/db/db_spend_update_writer.py +++ b/litellm/proxy/db/db_spend_update_writer.py @@ -1556,6 +1556,30 @@ async def _update_daily_spend( # 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 + } + # Common data structure for both create and update common_data = { entity_id_field: entity_id, @@ -1572,30 +1596,9 @@ async def _update_daily_spend( "api_requests": transaction["api_requests"], "successful_requests": transaction["successful_requests"], "failed_requests": transaction["failed_requests"], + **optional_metrics, } - # 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") @@ -1607,30 +1610,9 @@ async def _update_daily_spend( "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()}, } - # 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") @@ -1886,6 +1868,8 @@ async def _common_add_spend_log_transaction_to_daily_transaction( 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"), + usage_object=usage_obj, ) daily_transaction = BaseDailySpendTransaction( @@ -1907,6 +1891,7 @@ async def _common_add_spend_log_transaction_to_daily_transaction( 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: diff --git a/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py b/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py index b6462636393c..444685167e68 100644 --- a/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py +++ b/litellm/proxy/db/db_transaction_queue/daily_spend_update_queue.py @@ -134,6 +134,10 @@ def get_aggregated_daily_spend_update_transactions( 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 diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 1fad1954dc42..9d412eca7990 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 8a5a31710cf6..ea1fb800afa9 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -95,6 +95,9 @@ def compression_savings_spend(self) -> float: ... @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, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 37ea55f8c132..0d7fa8692c8b 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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) diff --git a/litellm/proxy/spend_tracking/savings.py b/litellm/proxy/spend_tracking/savings.py index ad9d02052bbb..075f15b4fdb2 100644 --- a/litellm/proxy/spend_tracking/savings.py +++ b/litellm/proxy/spend_tracking/savings.py @@ -8,15 +8,19 @@ have been aggregated across models. """ +from collections.abc import Mapping from typing import NamedTuple import litellm from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.types.utils import 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 +48,152 @@ 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 _cost_of_usage(model: _ModelIdentity, usage: Usage) -> 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 + ) + 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_usage(usage: Usage) -> Usage: + """The same request as a single-model baseline would have met it. + + Staying on one model, the prompt is written to cache once and read from thereafter, + so whatever this request paid to write would already have been cached on the + baseline. That holds whether or not this request also read anything: a switch to a + cold model reads nothing precisely because its cache is empty, which is the case the + penalty exists for. Gating on a read instead would charge the baseline a write it + would never repeat, and a cold switch would then report a larger saving than the + same traffic with caching turned off. + + 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_creation <= 0: + return usage + 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), + # The tokens this request paid to write are moved 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 it; left behind it re-charges the write. + cached_tokens=cache_read + cache_creation, + cache_creation_tokens=0, + cache_write_tokens=0, + cache_creation_token_details=None, + text_tokens=max(usage.prompt_tokens - cache_read - cache_creation, 0), + ), + ) + + +def compute_autorouter_savings( + baseline_model: str | None, + selected_model: str | None, + selected_provider: str | None, + usage: Usage, +) -> 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. + """ + # 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 or baseline == selected: + return 0.0 + baseline_cost = _cost_of_usage(baseline, _baseline_usage(usage)) + selected_cost = _cost_of_usage(selected, usage) + 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, + baseline_model: str | None = None, + usage_object: Mapping[str, object] | None = None, ) -> SavingsSpend: """ Dollar savings for one request, split by optimization driver. @@ -56,8 +201,21 @@ 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_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) + + usage = _usage_from_spend_log(usage_object) + if usage is None or not model: + return SavingsSpend(compression=compression, prompt_caching=prompt_caching) + + autorouter = compute_autorouter_savings( + baseline_model=baseline_model, + selected_model=model, + selected_provider=custom_llm_provider, + usage=usage, + ) + return SavingsSpend(compression=compression, prompt_caching=prompt_caching, autorouter=autorouter) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index a6a67d575829..20dbd0754388 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -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( diff --git a/litellm/router.py b/litellm/router.py index 1cc9361a76b1..ff7735e355e4 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -7663,6 +7663,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, @@ -7717,6 +7718,7 @@ def init_complexity_router_deployment(self, deployment: Deployment): default_model=default_model, litellm_router_instance=self, complexity_router_config=complexity_router_config, + savings_baseline_model=deployment.litellm_params.auto_router_savings_baseline_model, ) self._register_pre_routing_strategy( registry=self.complexity_routers, @@ -11154,7 +11156,7 @@ async def async_pre_routing_hook( router_strategy = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs) if router_strategy is None: - self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None) + self._record_routing_decision(request_kwargs=request_kwargs, pre_routing_hook_response=None) return None pre_routing_hook_response = await router_strategy.async_pre_routing_hook( @@ -11166,7 +11168,7 @@ async def async_pre_routing_hook( ) self._record_routing_decision( request_kwargs=request_kwargs, - routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None), + pre_routing_hook_response=pre_routing_hook_response, ) # `model` (the alias, e.g. "smart-router") is never the deployment actually @@ -11189,31 +11191,52 @@ async def async_pre_routing_hook( @staticmethod def _record_routing_decision( request_kwargs: dict, - routing_decision: StandardLoggingRoutingDecision | None, + pre_routing_hook_response: PreRoutingHookResponse | None, ) -> None: """Make the request's metadata describe THIS routing attempt, and only this one. Fallbacks re-enter the hook with the same `request_kwargs`, so an attempt that picks a plain model group after an auto-router group failed must clear the earlier decision; leaving it would attribute the first router's tier and cause - to the deployment that actually served the request. Every attempt therefore - writes or clears, never just writes. + to the deployment that actually served the request, and would price savings + against a baseline this attempt never routed against. Every fact the hook + records is written or cleared here together, so no exit can clear one and + leave the other behind. + + `get_or_create_metadata_bucket` picks `litellm_metadata` when present, so + nothing lands in the `metadata` dict that routes like /v1/messages forward + to the provider. """ - if routing_decision is None: - for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")): - if isinstance(bucket, dict): - bucket.pop("routing_decision", None) + routing_decision = pre_routing_hook_response.routing_decision if pre_routing_hook_response else None + baseline_model = pre_routing_hook_response.savings_baseline_model if pre_routing_hook_response else None + + recorded = { + key: value + for key, value in ( + ( + "routing_decision", + Router._redact_prompt_text_if_needed( + request_kwargs=request_kwargs, routing_decision=routing_decision + ) + if routing_decision is not None + else None, + ), + ("auto_router_savings_baseline_model", baseline_model), + ) + if value is not None + } + + cleared = frozenset({"routing_decision", "auto_router_savings_baseline_model"}) - recorded.keys() + for bucket in (request_kwargs.get("metadata"), request_kwargs.get("litellm_metadata")): + if isinstance(bucket, dict): + for key in cleared: + bucket.pop(key, None) + + if not recorded: return - # `get_or_create_metadata_bucket` is the single owner of "which dict holds - # proxy-internal metadata": it picks `litellm_metadata` when present (so the - # decision never lands in the `metadata` dict that routes like /v1/messages - # forward to the provider) and replaces a non-dict value rather than silently - # skipping the write. _, metadata_bucket = get_or_create_metadata_bucket(request_kwargs) - metadata_bucket["routing_decision"] = Router._redact_prompt_text_if_needed( - request_kwargs=request_kwargs, routing_decision=routing_decision - ) + metadata_bucket.update(recorded) @staticmethod def _redact_prompt_text_if_needed( diff --git a/litellm/router_strategy/auto_router/auto_router.py b/litellm/router_strategy/auto_router/auto_router.py index c01e2f10d2c0..2f0ae4e164fa 100644 --- a/litellm/router_strategy/auto_router/auto_router.py +++ b/litellm/router_strategy/auto_router/auto_router.py @@ -29,6 +29,7 @@ def __init__( litellm_router_instance: "Router", auto_router_config_path: Optional[str] = None, auto_router_config: Optional[str] = None, + savings_baseline_model: str | None = None, ): """ Auto-Router class that uses a semantic router to route requests to the appropriate model. @@ -40,6 +41,7 @@ def __init__( default_model: The default model to use if no route is found. embedding_model: The embedding model to use for the auto-router. litellm_router_instance: The instance of the LiteLLM Router. + savings_baseline_model: Overrides the counterfactual model the dashboard measures savings against; derived from this router's own candidates when unset. """ from semantic_router.routers import SemanticRouter @@ -51,6 +53,21 @@ def __init__( self.default_model = default_model self.embedding_model: str = embedding_model self.litellm_router_instance: "Router" = litellm_router_instance + self.configured_savings_baseline_model: str | None = savings_baseline_model + + @property + def savings_baseline_model(self) -> str | None: + """The model this router's savings are measured against. + + Every model group its routes can reach is a candidate, because any of them + could have been the one model a deployment picked without the router. + """ + from litellm.router_strategy.savings_baseline import resolve_baseline + + group_names = frozenset( + name for name in (*(route.name for route in self.loaded_routes), self.default_model) if name + ) + return resolve_baseline(self.configured_savings_baseline_model, self.litellm_router_instance, group_names) def _load_semantic_routing_routes(self) -> List[Route]: from semantic_router.routers import SemanticRouter @@ -156,4 +173,5 @@ async def async_pre_routing_hook( return PreRoutingHookResponse( model=model, messages=messages, + savings_baseline_model=self.savings_baseline_model, ) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index af5b305968d9..8ed900085adb 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -355,6 +355,7 @@ def __init__( litellm_router_instance: Router, complexity_router_config: dict[str, Any] | None = None, default_model: str | None = None, + savings_baseline_model: str | None = None, ): """ Initialize ComplexityRouter. @@ -364,9 +365,11 @@ def __init__( litellm_router_instance: The LiteLLM Router instance. complexity_router_config: Optional configuration dict from proxy config. default_model: Optional default model to use if tier cannot be determined. + savings_baseline_model: Overrides the counterfactual model the dashboard measures savings against; derived from the hardest configured tier when unset. """ self.model_name = model_name self.litellm_router_instance = litellm_router_instance + self.configured_savings_baseline_model: str | None = savings_baseline_model # Parse config - always create a new instance to avoid singleton mutation if complexity_router_config: @@ -414,6 +417,36 @@ def __init__( verbose_router_logger.debug(f"ComplexityRouter initialized for {model_name} with tiers: {self.config.tiers}") + def _hardest_tier_models(self) -> tuple[str, ...]: + """The model or pool serving the hardest tier this router configures. + + REASONING when it is configured, since that is the tier a request has to be + hard enough to reach; otherwise the highest-severity tier present, so a + deployment that only defines SIMPLE and MEDIUM is still measured against + the best it could actually have picked. + """ + for tier in reversed(TIER_SEVERITY_ORDER): + models = self.config.tiers.get(tier.value) + if models: + return tuple(models) if isinstance(models, list) else (models,) + return () + + @property + def savings_baseline_model(self) -> str | None: + """The model this router's savings are measured against. + + A complexity router's tier ladder already names the model an operator + would have had to run to serve the hardest request, so the counterfactual + is the priciest model in that tier rather than the priciest model the + router can reach; a cheap tier is a choice the router made, not a ceiling + it was bounded by. + """ + from litellm.router_strategy.savings_baseline import resolve_baseline + + return resolve_baseline( + self.configured_savings_baseline_model, self.litellm_router_instance, self._hardest_tier_models() + ) + def _estimate_tokens(self, text: str) -> int: """ Estimate token count from text. @@ -1420,6 +1453,7 @@ async def async_pre_routing_hook( return PreRoutingHookResponse( model=routed_model, messages=messages if has_original_messages else None, + savings_baseline_model=self.savings_baseline_model, routing_decision=self._build_routing_decision( routed_model=routed_model, cause=cause, @@ -1496,6 +1530,7 @@ async def _classify_and_route( return PreRoutingHookResponse( model=routed_model, messages=messages if has_original_messages else None, + savings_baseline_model=self.savings_baseline_model, routing_decision=self._build_routing_decision(routed_model=routed_model, cause="default_fallback"), ) @@ -1517,6 +1552,7 @@ async def _classify_and_route( return PreRoutingHookResponse( model=routed_model, messages=messages if has_original_messages else None, + savings_baseline_model=self.savings_baseline_model, routing_decision=self._build_routing_decision( routed_model=routed_model, cause=keyword_cause, @@ -1564,6 +1600,7 @@ async def _classify_and_route( return PreRoutingHookResponse( model=routed_model, messages=messages if has_original_messages else None, + savings_baseline_model=self.savings_baseline_model, routing_decision=self._build_routing_decision( routed_model=routed_model, cause=outcome.cause, diff --git a/litellm/router_strategy/savings_baseline.py b/litellm/router_strategy/savings_baseline.py new file mode 100644 index 000000000000..eddccb704532 --- /dev/null +++ b/litellm/router_strategy/savings_baseline.py @@ -0,0 +1,127 @@ +"""Resolving the counterfactual model a strategy router's savings are measured against. + +Without the router a deployment has to pick one model, and it has to be one that +can carry the hardest request it will see. That model is the baseline: what the +traffic would have cost had nobody routed it. + +Every strategy router answers the same two questions differently, so the shared +part is here and the per-router part is the candidate set it supplies. A semantic +auto-router offers every model group its routes can reach; a complexity router +offers the models in its hardest tier. + +Baselines are always provider-qualified, whether derived or configured, because +they travel to the spend writer as a bare string with no provider beside them; an +operator who writes ``deepseek-r1`` meaning Azure would otherwise be priced +against whoever else owns that name. +""" + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from litellm._logging import verbose_router_logger + +if TYPE_CHECKING: + from litellm.router import Router + + +def canonical_model(model: str, custom_llm_provider: str | None = None) -> str | None: + """``provider/model``, or ``None`` when the pair names no known provider. + + A deployment may name its vendor in the model prefix or in a separate + ``custom_llm_provider``, and the bare name alone is not enough to price: it + can resolve to a different vendor's rates, or to nothing at all. + """ + import litellm + + try: + resolved_model, provider, _, _ = litellm.get_llm_provider(model=model, custom_llm_provider=custom_llm_provider) + except Exception as e: # noqa: BLE001 # an unroutable candidate cannot be the baseline + verbose_router_logger.debug("savings baseline: cannot resolve candidate %s (%s)", model, e) + return None + return f"{provider}/{resolved_model}" + + +def _deployment_model(router: "Router", index: int) -> str | None: + """The model a deployment is priced as, qualified by the provider it declares. + + `litellm_params.model` is not always a model. On Azure it is the deployment name, + which is absent from the cost map, and `model_info.base_model` is what names the + real model; the same holds for wildcard and aliased deployments. Router.py resolves + pricing through the same base_model, base_model, model chain. + """ + deployment = router.model_list[index] + params = deployment.get("litellm_params") + if not isinstance(params, dict): + return None + model_info = deployment.get("model_info") + base_model = model_info.get("base_model") if isinstance(model_info, dict) else None + model = base_model or params.get("base_model") or params.get("model") + return canonical_model(model, params.get("custom_llm_provider")) if model else None + + +def models_for_group(router: "Router", group_name: str) -> tuple[str, ...]: + """The models a model group actually calls. + + Falls back to treating the name as a model itself, which is what a tier + pointing straight at a provider model rather than at a configured group does. + """ + indices = router.model_name_to_deployment_indices.get(group_name) + if not indices: + canonical = canonical_model(group_name) + return (canonical,) if canonical else () + return tuple(model for index in indices if (model := _deployment_model(router, index))) + + +def _priced(model: str) -> tuple[float, float, str] | None: + """``(output_rate, input_rate, model)``, or ``None`` when the model has no pricing.""" + import litellm + + try: + info = litellm.get_model_info(model=model) + except Exception as e: # noqa: BLE001 # unmapped candidates simply cannot be the baseline + verbose_router_logger.debug("savings baseline: no pricing for candidate %s (%s)", model, e) + return None + output_rate = info.get("output_cost_per_token") or 0.0 + input_rate = info.get("input_cost_per_token") or 0.0 + if output_rate <= 0.0 and input_rate <= 0.0: + # A model that costs nothing per token cannot stand in for what the traffic + # would otherwise have cost, and as a baseline it would report the whole + # real spend as a loss. + verbose_router_logger.debug("savings baseline: candidate %s has no per-token price", model) + return None + return (output_rate, input_rate, model) + + +def most_expensive(models: Iterable[str]) -> str | None: + """The priciest model by output rate, input rate breaking the tie.""" + priced = tuple(candidate for model in models if (candidate := _priced(model)) is not None) + if not priced: + verbose_router_logger.debug("savings baseline: no priceable candidates; savings driver disabled") + return None + return max(priced)[2] + + +def resolve_baseline(configured: str | None, router: "Router", group_names: Iterable[str]) -> str | None: + """The baseline for a router offering ``group_names`` as its candidates. + + A configured override wins and is only qualified, never re-derived. Otherwise + the groups are resolved through the parent router's deployments and the + priciest result is taken. + + Derived per call rather than cached: the parent router adds and removes + deployments while it runs, so a baseline pinned on first use would keep naming + a model the router no longer has, and a pricier one added later could never + become the baseline. Resolving costs tens of microseconds against a network + call, which is not worth trading correctness for. + + Never raises. This is read on the routing path to decorate a request that is + about to be served, and a dashboard's counterfactual is not worth failing a + live request over; an unresolvable baseline zeroes the savings driver instead. + """ + try: + if configured: + return canonical_model(configured) + return most_expensive(model for group_name in group_names for model in models_for_group(router, group_name)) + except Exception as e: # noqa: BLE001 # see docstring: routing must not fail for a metric + verbose_router_logger.warning("savings baseline: could not resolve, savings will read zero (%s)", e) + return None diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 00f67d0f39c9..21b7ffca3f29 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -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 diff --git a/litellm/types/router.py b/litellm/types/router.py index 837a93367a2c..07b23b3701eb 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -272,6 +272,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): auto_router_config: Optional[str] = None auto_router_default_model: Optional[str] = None auto_router_embedding_model: Optional[str] = None + auto_router_savings_baseline_model: Optional[str] = None # complexity-router params complexity_router_config: Optional[Dict] = None @@ -840,6 +841,7 @@ class PreRoutingHookResponse(BaseModel): model: str messages: Optional[List[Dict[str, Any]]] routing_decision: StandardLoggingRoutingDecision | None = None + savings_baseline_model: Optional[str] = None _PreRoutingStrategyT_co = TypeVar("_PreRoutingStrategyT_co", covariant=True) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 18991f53e6fa..5229ec69e04b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3401,6 +3401,7 @@ def shared_backend_model_info(model_info: Dict[str, Any]) -> Dict[str, Any]: "auto_router_config", "auto_router_default_model", "auto_router_embedding_model", + "auto_router_savings_baseline_model", "complexity_router_config", "complexity_router_default_model", "adaptive_router_config", diff --git a/schema.prisma b/schema.prisma index 37ea55f8c132..0d7fa8692c8b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -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) diff --git a/tests/test_litellm/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py b/tests/test_litellm/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py index c7e7ef5d4691..a55d4f0dcfdc 100644 --- a/tests/test_litellm/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py +++ b/tests/test_litellm/proxy/db/db_transaction_queue/test_daily_spend_update_queue.py @@ -16,6 +16,9 @@ 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) diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index c2a0d34a9157..f2749be5d6e9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -19,7 +19,10 @@ 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" + ) diff --git a/tests/test_litellm/proxy/spend_tracking/test_savings.py b/tests/test_litellm/proxy/spend_tracking/test_savings.py index 704cf7a63dd9..a5205b65bcaf 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_savings.py +++ b/tests/test_litellm/proxy/spend_tracking/test_savings.py @@ -6,7 +6,13 @@ import pytest import litellm -from litellm.proxy.spend_tracking.savings import compute_savings_spend +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 +22,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 +115,258 @@ 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) -> float: + return compute_autorouter_savings( + baseline_model=baseline, + selected_model=selected, + selected_provider="anthropic", + usage=usage, + ) + + +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) + + 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) + + 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, + baseline_model=None, + usage_object=_cached_usage_object(), + ) + assert result.autorouter == 0.0 + + +def test_compute_savings_spend_carries_a_losing_switch_through(): + """The signed value must survive into SavingsSpend; clamping it here would put the + dashboard back to only ever showing gains.""" + result = compute_savings_spend( + model="claude-haiku-4-5", + custom_llm_provider="anthropic", + compression_saved_tokens=0, + cache_read_input_tokens=0, + baseline_model="claude-sonnet-5", + usage_object=_cached_usage_object(), + ) + assert result.autorouter < 0 + + +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, + baseline_model="claude-opus-5", + 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 diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index f6216d1646e1..68235c7e2153 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -2437,7 +2437,7 @@ async def test_spend_logs_payload_e2e(self): "model": "gpt-4o", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "auto_router_savings_baseline_model": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 20, "prompt_tokens": 10, "total_tokens": 30, "completion_tokens_details": null, "prompt_tokens_details": null}, "model_map_information": {"model_map_key": "gpt-4o", "model_map_value": {"key": "gpt-4o", "max_tokens": 16384, "max_input_tokens": 128000, "max_output_tokens": 16384, "input_cost_per_token": 2.5e-06, "cache_creation_input_token_cost": null, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": 1.25e-06, "output_cost_per_token_batches": 5e-06, "output_cost_per_token": 1e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_reasoning_token": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "openai", "mode": "chat", "supports_system_messages": true, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": false, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": false, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": true, "supports_reasoning": false, "search_context_cost_per_query": {"search_context_size_low": 0.03, "search_context_size_medium": 0.035, "search_context_size_high": 0.05}, "tpm": null, "rpm": null, "supported_openai_params": ["frequency_penalty", "logit_bias", "logprobs", "top_logprobs", "max_tokens", "max_completion_tokens", "modalities", "prediction", "n", "presence_penalty", "seed", "stop", "stream", "stream_options", "temperature", "top_p", "tools", "tool_choice", "function_call", "functions", "max_retries", "extra_headers", "parallel_tool_calls", "audio", "response_format", "user"]}}, "additional_usage_values": {"completion_tokens_details": null, "prompt_tokens_details": null}}', "cache_key": "Cache OFF", "spend": 0.00022500000000000002, "total_tokens": 30, @@ -2533,7 +2533,7 @@ async def test_spend_logs_payload_success_log_with_api_base(self, monkeypatch): "model": "claude-4-sonnet-20250514", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "auto_router_savings_baseline_model": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', "cache_key": "Cache OFF", "spend": 0.01383, "total_tokens": 2598, @@ -2627,7 +2627,7 @@ async def test_spend_logs_payload_success_log_with_router(self, monkeypatch): "model": "claude-4-sonnet-20250514", "user": "", "team_id": "", - "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', + "metadata": '{"applied_guardrails": [], "batch_models": null, "mcp_tool_call_metadata": null, "vector_store_request_metadata": null, "routing_decision": null, "auto_router_savings_baseline_model": null, "internal_call_origin": null, "guardrail_information": null, "compression_savings": null, "usage_object": {"completion_tokens": 503, "prompt_tokens": 2095, "total_tokens": 2598, "completion_tokens_details": null, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}, "model_map_information": {"model_map_key": "claude-4-sonnet-20250514", "model_map_value": {"key": "claude-4-sonnet-20250514", "max_tokens": 128000, "max_input_tokens": 200000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_character": null, "input_cost_per_token_above_128k_tokens": null, "input_cost_per_token_above_200k_tokens": null, "input_cost_per_query": null, "input_cost_per_second": null, "input_cost_per_audio_token": null, "input_cost_per_token_batches": null, "output_cost_per_token_batches": null, "output_cost_per_token": 1.5e-05, "output_cost_per_audio_token": null, "output_cost_per_character": null, "output_cost_per_token_above_128k_tokens": null, "output_cost_per_character_above_128k_tokens": null, "output_cost_per_token_above_200k_tokens": null, "output_cost_per_second": null, "output_cost_per_image": null, "output_vector_size": null, "litellm_provider": "anthropic", "mode": "chat", "supports_system_messages": null, "supports_response_schema": true, "supports_vision": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_assistant_prefill": true, "supports_prompt_caching": true, "supports_audio_input": false, "supports_audio_output": false, "supports_pdf_input": true, "supports_embedding_image_input": false, "supports_native_streaming": null, "supports_web_search": false, "supports_reasoning": true, "search_context_cost_per_query": null, "tpm": null, "rpm": null, "supported_openai_params": ["stream", "stop", "temperature", "top_p", "max_tokens", "max_completion_tokens", "tools", "tool_choice", "extra_headers", "parallel_tool_calls", "response_format", "user", "reasoning_effort", "thinking"]}}, "additional_usage_values": {"completion_tokens_details": {"accepted_prediction_tokens": null, "audio_tokens": null, "reasoning_tokens": null, "rejected_prediction_tokens": null, "text_tokens": 503, "image_tokens": null}, "prompt_tokens_details": {"audio_tokens": null, "cached_tokens": 0, "text_tokens": null, "image_tokens": null}, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0}}', "cache_key": "Cache OFF", "spend": 0.01383, "total_tokens": 2598, diff --git a/tests/test_litellm/router_strategy/test_auto_router.py b/tests/test_litellm/router_strategy/test_auto_router.py index cb46a4ae5538..6e2f54ec499a 100644 --- a/tests/test_litellm/router_strategy/test_auto_router.py +++ b/tests/test_litellm/router_strategy/test_auto_router.py @@ -156,6 +156,22 @@ def test_should_return_empty_string_for_empty_messages_list(self): assert result == "" +def _routes(*names): + routes = [] + for name in names: + route = MagicMock() + route.name = name + routes.append(route) + return routes + + +def _configure_candidates(router, group_to_model): + router.model_list = [ + {"model_name": group, "litellm_params": {"model": model}} for group, model in group_to_model.items() + ] + router.model_name_to_deployment_indices = {group: [i] for i, group in enumerate(group_to_model)} + + @pytest.fixture def mock_router_instance(): """Create a mock LiteLLM Router instance.""" @@ -316,3 +332,243 @@ async def test_async_pre_routing_hook_no_messages(self, mock_router_instance): # Assert assert result is None + + @patch("semantic_router.routers.SemanticRouter") + def test_init_honors_configured_savings_baseline_model(self, mock_semantic_router_class, mock_router_instance): + """An operator-configured baseline overrides the flagship default.""" + mock_semantic_router_class.from_json.return_value = mock_semantic_router_class + + auto_router = AutoRouter( + model_name="test-auto-router", + auto_router_config_path="test/path/router.json", + default_model="gpt-4o-mini", + embedding_model="text-embedding-model", + litellm_router_instance=mock_router_instance, + savings_baseline_model="claude-sonnet-5", + ) + + assert auto_router.savings_baseline_model == "claude-sonnet-5" + + @pytest.mark.asyncio + @patch("semantic_router.routers.SemanticRouter") + @patch("litellm.router_strategy.auto_router.litellm_encoder.LiteLLMRouterEncoder") + async def test_async_pre_routing_hook_carries_savings_baseline_model( + self, + mock_encoder_class, + mock_semantic_router_class, + mock_router_instance, + mock_route_choice, + ): + """The hook response must carry the baseline so the spend writer can price + auto-router savings; without it the dashboard's driver silently stays zero.""" + mock_loaded_router = MagicMock() + mock_loaded_router.routes = ["route1", "route2"] + mock_semantic_router_class.from_json.return_value = mock_loaded_router + + mock_routelayer = MagicMock() + mock_routelayer.return_value = mock_route_choice + mock_semantic_router_class.return_value = mock_routelayer + + auto_router = AutoRouter( + model_name="test-auto-router", + auto_router_config_path="test/path/router.json", + default_model="gpt-4o-mini", + embedding_model="text-embedding-model", + litellm_router_instance=mock_router_instance, + savings_baseline_model="claude-opus-5", + ) + + result = await auto_router.async_pre_routing_hook( + model="test-model", request_kwargs={}, messages=[{"role": "user", "content": "hi"}] + ) + + assert result is not None + assert result.savings_baseline_model == "claude-opus-5" + + +class TestSavingsBaselineModel: + """The counterfactual the cost dashboard measures auto-router savings against. + + Constructed without __init__ on purpose: resolving the baseline touches only the + router's own deployments, never semantic_router, so this runs wherever the rest of + the beta suite is skipped. + """ + + @staticmethod + def _auto_router(group_to_model: dict, route_names: list, default_model: str, configured=None) -> AutoRouter: + parent = MagicMock() + parent.model_list = [ + {"model_name": group, "litellm_params": dict(params) if isinstance(params, dict) else {"model": params}} + for group, params in group_to_model.items() + ] + parent.model_name_to_deployment_indices = {group: [i] for i, group in enumerate(group_to_model)} + + auto_router = AutoRouter.__new__(AutoRouter) + auto_router.loaded_routes = [] + for name in route_names: + route = MagicMock() + route.name = name + auto_router.loaded_routes.append(route) + auto_router.default_model = default_model + auto_router.litellm_router_instance = parent + auto_router.configured_savings_baseline_model = configured + return auto_router + + def test_route_names_resolve_through_the_parent_router_to_pricable_models(self): + """Routes name the router's own model groups, not models, so a group has to be + resolved to the model it actually calls before anything can be priced.""" + auto_router = self._auto_router( + {"cheap-tier": "anthropic/claude-haiku-4-5", "mid-tier": "anthropic/claude-sonnet-5"}, + ["cheap-tier", "mid-tier"], + "cheap-tier", + ) + from litellm.router_strategy.savings_baseline import models_for_group + + parent = auto_router.litellm_router_instance + resolved = sorted( + model for group in ("cheap-tier", "mid-tier") for model in models_for_group(parent, group) + ) + assert resolved == ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-5"] + + def test_baseline_is_the_priciest_model_this_router_could_have_picked(self): + """Without the router a deployment picks one model that can carry the hardest + request, so the counterfactual is this router's priciest candidate. A fixed + flagship credits savings against a model the operator would never have run: a + router choosing only between sonnet and haiku saved nobody the price of opus.""" + sonnet_only = self._auto_router( + {"cheap-tier": "anthropic/claude-haiku-4-5", "mid-tier": "anthropic/claude-sonnet-5"}, + ["cheap-tier", "mid-tier"], + "cheap-tier", + ) + assert sonnet_only.savings_baseline_model == "anthropic/claude-sonnet-5" + + with_flagship = self._auto_router( + { + "cheap-tier": "anthropic/claude-haiku-4-5", + "mid-tier": "anthropic/claude-sonnet-5", + "big-tier": "anthropic/claude-opus-5", + }, + ["cheap-tier", "mid-tier", "big-tier"], + "cheap-tier", + ) + assert with_flagship.savings_baseline_model == "anthropic/claude-opus-5" + + def test_the_default_model_counts_as_a_candidate(self): + auto_router = self._auto_router( + {"cheap-tier": "anthropic/claude-haiku-4-5", "fallback": "anthropic/claude-opus-5"}, + ["cheap-tier"], + "fallback", + ) + assert auto_router.savings_baseline_model == "anthropic/claude-opus-5" + + def test_an_explicit_baseline_overrides_the_derived_one(self): + """And is qualified like a derived one: the baseline reaches the spend writer as + a bare string with no provider beside it, so an operator who writes a name that + another vendor also owns would otherwise be priced against that vendor.""" + auto_router = self._auto_router( + {"cheap-tier": "anthropic/claude-haiku-4-5"}, + ["cheap-tier"], + "cheap-tier", + configured="claude-opus-5", + ) + assert auto_router.savings_baseline_model == "anthropic/claude-opus-5" + + def test_an_unresolvable_explicit_baseline_disables_the_driver(self): + auto_router = self._auto_router( + {"cheap-tier": "anthropic/claude-haiku-4-5"}, + ["cheap-tier"], + "cheap-tier", + configured="no-such-provider-xyz/no-such-model", + ) + assert auto_router.savings_baseline_model is None + + def test_nothing_priceable_disables_the_driver_rather_than_inventing_a_baseline(self): + """A missing number beats a fabricated one.""" + auto_router = self._auto_router({}, ["not-a-real-model"], "also-not-real") + assert auto_router.savings_baseline_model is None + + def test_the_baseline_follows_deployments_added_after_the_first_read(self): + """The parent router adds and removes deployments while it runs. A baseline + pinned on first use would keep naming a model the router no longer has, and a + pricier one added later could never become the baseline.""" + auto_router = self._auto_router( + {"cheap": {"model": "claude-haiku-4-5", "custom_llm_provider": "anthropic"}}, + ["cheap", "big"], + "cheap", + ) + assert auto_router.savings_baseline_model == "anthropic/claude-haiku-4-5" + + parent = auto_router.litellm_router_instance + parent.model_list.append({"model_name": "big", "litellm_params": {"model": "anthropic/claude-opus-5"}}) + parent.model_name_to_deployment_indices["big"] = [len(parent.model_list) - 1] + + assert auto_router.savings_baseline_model == "anthropic/claude-opus-5" + + def test_the_baseline_drops_a_deployment_that_was_removed(self): + auto_router = self._auto_router( + { + "cheap": {"model": "claude-haiku-4-5", "custom_llm_provider": "anthropic"}, + "big": {"model": "claude-opus-5", "custom_llm_provider": "anthropic"}, + }, + ["cheap", "big"], + "cheap", + ) + assert auto_router.savings_baseline_model == "anthropic/claude-opus-5" + + parent = auto_router.litellm_router_instance + parent.model_name_to_deployment_indices.pop("big") + auto_router.loaded_routes = [r for r in auto_router.loaded_routes if r.name != "big"] + + assert auto_router.savings_baseline_model == "anthropic/claude-haiku-4-5" + + def test_a_deployment_naming_its_provider_separately_is_still_priced(self): + """A deployment may name its vendor in `custom_llm_provider` rather than in the + model prefix. Pricing the bare name then resolves to a different vendor's rates + or to nothing at all, so the candidate is mispriced or silently dropped and the + derived baseline is wrong. Vertex prices this model at $0 without its provider + and azure_ai raises outright, so neither could ever win as the priciest.""" + auto_router = self._auto_router( + { + "cheap": {"model": "claude-haiku-4-5", "custom_llm_provider": "anthropic"}, + "vertex-tier": {"model": "claude-sonnet-4@20250514", "custom_llm_provider": "vertex_ai"}, + }, + ["cheap", "vertex-tier"], + "cheap", + ) + assert auto_router.savings_baseline_model == "vertex_ai/claude-sonnet-4@20250514" + + def test_candidates_are_qualified_so_the_spend_writer_resolves_the_same_vendor(self): + """The baseline travels to the spend writer as a bare string, so it has to carry + its provider or the writer prices it under whichever vendor owns the bare name.""" + from litellm.proxy.spend_tracking.savings import _resolve_model + + auto_router = self._auto_router( + {"azure-tier": {"model": "deepseek-r1", "custom_llm_provider": "azure_ai"}}, + ["azure-tier"], + "azure-tier", + ) + baseline = auto_router.savings_baseline_model + assert baseline == "azure_ai/deepseek-r1" + assert _resolve_model(baseline, None) == ("deepseek-r1", "azure_ai") + + def test_a_candidate_with_no_per_token_price_cannot_be_the_baseline(self): + """A model that costs nothing per token cannot stand in for what the traffic + would otherwise have cost. Left in, it would report the whole real spend as a + loss the moment it won the priciest-candidate contest.""" + auto_router = self._auto_router( + {"images": {"model": "dall-e-2", "custom_llm_provider": "openai"}}, + ["images"], + "images", + ) + assert auto_router.savings_baseline_model is None + + def test_a_priced_candidate_still_wins_over_an_unpriced_one(self): + auto_router = self._auto_router( + { + "images": {"model": "dall-e-2", "custom_llm_provider": "openai"}, + "chat": {"model": "claude-haiku-4-5", "custom_llm_provider": "anthropic"}, + }, + ["images", "chat"], + "chat", + ) + assert auto_router.savings_baseline_model == "anthropic/claude-haiku-4-5" diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 6b0047883df4..d189d09dcad3 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -36,6 +36,7 @@ from litellm.types.router import ( Deployment, LiteLLM_Params, + PreRoutingHookResponse, TaggedPreRoutingStrategy, ) @@ -1733,20 +1734,24 @@ def test_router_init_only_params_are_never_sent_to_a_provider(self): ships raw to the real provider as extra_body - verified live via litellm.completion(..., complexity_router_config={...}) landing in extra_body before this list included it.""" + import re + + from litellm.types.router import GenericLiteLLMParams, LiteLLM_Params from litellm.types.utils import all_litellm_params - router_init_only_params = ( - "auto_router_config_path", - "auto_router_config", - "auto_router_default_model", - "auto_router_embedding_model", - "complexity_router_config", - "complexity_router_default_model", - "adaptive_router_config", - "adaptive_router_default_model", - "quality_router_config", - "quality_router_default_model", + # Derived, not hand-listed: a hard-coded tuple can only catch a field being + # REMOVED from the strip list, never a newly added router param that was never + # registered in the first place, which is the way this actually goes wrong. + router_config_param = re.compile(r"^(auto|complexity|adaptive|quality)_router_") + router_init_only_params = tuple( + sorted( + field + for field in set(LiteLLM_Params.model_fields) | set(GenericLiteLLMParams.model_fields) + if router_config_param.match(field) + ) ) + assert len(router_init_only_params) >= 11, "expected the known router-strategy config params" + for param in router_init_only_params: assert param in all_litellm_params, ( f"{param} must stay in litellm.types.utils.all_litellm_params - " @@ -2181,6 +2186,8 @@ class FakeEmbeddingRouter: _CLUSTER_MARKERS = ("k8s", "kube", "container", "cluster", "orchestrat") def __init__(self): + self.model_name_to_deployment_indices: Dict[str, List[int]] = {} + self.model_list: List[Dict] = [] self.async_embedding_calls: List[List[str]] = [] self.async_embedding_kwargs: List[Dict] = [] # Every embedded batch (sync route-index build AND async query), so tests can count @@ -4082,22 +4089,61 @@ class TestRecordRoutingDecision: the request's metadata must describe the current attempt and nothing else.""" DECISION = {"router_model_name": "smart-router", "router_type": "complexity", "routed_model": "gpt-4o-mini"} + STALE_METADATA_KEYS = ("routing_decision", "auto_router_savings_baseline_model") def test_none_clears_a_previous_decision_from_both_buckets(self): request_kwargs: Dict = { "metadata": {"routing_decision": self.DECISION, "keep": 1}, "litellm_metadata": {"routing_decision": self.DECISION}, } - Router._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None) + Router._record_routing_decision(request_kwargs=request_kwargs, pre_routing_hook_response=None) assert "routing_decision" not in request_kwargs["metadata"] assert "routing_decision" not in request_kwargs["litellm_metadata"] assert request_kwargs["metadata"]["keep"] == 1 def test_none_creates_no_bucket_on_a_request_that_had_none(self): request_kwargs: Dict = {} - Router._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None) + Router._record_routing_decision(request_kwargs=request_kwargs, pre_routing_hook_response=None) assert request_kwargs == {} + @pytest.mark.parametrize("stale_key", STALE_METADATA_KEYS) + def test_an_attempt_without_a_strategy_clears_every_stale_fact(self, stale_key): + """A fallback to a plain model group re-enters the hook with the same + `request_kwargs`. Anything the auto-router attempt left behind would be + attributed to the deployment that actually served the request, letting a + caller who forces a router failure inflate the recorded savings.""" + request_kwargs: Dict = { + "metadata": {stale_key: "stale", "keep": 1}, + "litellm_metadata": {stale_key: "stale"}, + } + Router._record_routing_decision(request_kwargs=request_kwargs, pre_routing_hook_response=None) + assert stale_key not in request_kwargs["metadata"] + assert stale_key not in request_kwargs["litellm_metadata"] + assert request_kwargs["metadata"]["keep"] == 1 + + def test_a_response_without_a_baseline_clears_a_previous_one(self): + """Not every pre-routing strategy sets a savings baseline; one that does not + must not inherit the previous attempt's.""" + request_kwargs: Dict = {"litellm_metadata": {"auto_router_savings_baseline_model": "claude-opus-5"}} + Router._record_routing_decision( + request_kwargs=request_kwargs, + pre_routing_hook_response=PreRoutingHookResponse(model="gpt-4o-mini", messages=[]), + ) + assert "auto_router_savings_baseline_model" not in request_kwargs["litellm_metadata"] + + def test_baseline_is_recorded_on_the_internal_bucket(self): + """The baseline must land in `litellm_metadata`, never the `metadata` dict that + surfaces like /v1/messages forward verbatim to the provider.""" + request_kwargs: Dict = {"litellm_metadata": {}, "metadata": {}} + Router._record_routing_decision( + request_kwargs=request_kwargs, + pre_routing_hook_response=PreRoutingHookResponse( + model="claude-haiku-4-5", messages=[], savings_baseline_model="claude-opus-5" + ), + ) + assert request_kwargs["litellm_metadata"]["auto_router_savings_baseline_model"] == "claude-opus-5" + assert "auto_router_savings_baseline_model" not in request_kwargs["metadata"] + class TestEscalationIsRecordedConsistently: """An escalation keyword records two separate facts on every path: that the caller @@ -5054,3 +5100,50 @@ def test_rubric_rates_the_work_a_short_reply_approves(self): assert "Classify only the current message" not in system_prompt assert "in the context of the conversation it continues" in system_prompt assert "Do not rate the quoted sections as if one of them were the request." in system_prompt + +class TestSavingsBaselineModel: + """The counterfactual model a complexity router's savings are measured against.""" + @staticmethod + def _router_with_tiers(tiers: dict, **kwargs) -> ComplexityRouter: + from litellm.router import Router + parent = Router( + model_list=[ + {"model_name": "cheap", "litellm_params": {"model": "anthropic/claude-haiku-4-5"}}, + {"model_name": "mid", "litellm_params": {"model": "anthropic/claude-sonnet-4-5"}}, + {"model_name": "top", "litellm_params": {"model": "anthropic/claude-opus-4-5"}}, + ] + ) + return ComplexityRouter( + model_name="bench", + litellm_router_instance=parent, + complexity_router_config={"tiers": tiers}, + default_model="mid", + **kwargs, + ) + def test_baseline_is_the_reasoning_tier_not_the_priciest_reachable_model(self): + """The ladder names what an operator would have had to run for the hardest + request; a pricier model sitting in a lower tier is a choice, not a ceiling.""" + router = self._router_with_tiers({"SIMPLE": "top", "MEDIUM": "cheap", "REASONING": "mid"}) + assert router.savings_baseline_model == "anthropic/claude-sonnet-4-5" + def test_baseline_is_the_priciest_model_when_the_reasoning_tier_is_a_pool(self): + router = self._router_with_tiers({"SIMPLE": "cheap", "REASONING": ["cheap", "top", "mid"]}) + assert router.savings_baseline_model == "anthropic/claude-opus-4-5" + def test_falls_back_to_the_hardest_configured_tier_when_reasoning_is_absent(self): + router = self._router_with_tiers({"SIMPLE": "cheap", "COMPLEX": "top"}) + assert router.savings_baseline_model == "anthropic/claude-opus-4-5" + def test_a_configured_baseline_wins_and_is_provider_qualified(self): + router = self._router_with_tiers( + {"SIMPLE": "cheap", "REASONING": "mid"}, savings_baseline_model="claude-opus-4-5" + ) + assert router.savings_baseline_model == "anthropic/claude-opus-4-5" + def test_an_unpriceable_tier_disables_the_driver_rather_than_inventing_a_baseline(self): + router = self._router_with_tiers({"REASONING": "not-a-real-model-anywhere"}) + assert router.savings_baseline_model is None + def test_the_baseline_travels_on_every_pre_routing_response(self): + """A response without it silently zeroes the savings driver for that path.""" + import inspect + from litellm.router_strategy.complexity_router import complexity_router as module + source = inspect.getsource(module.ComplexityRouter.async_pre_routing_hook) + returns = source.count("return PreRoutingHookResponse(") + assert returns > 0 + assert source.count("savings_baseline_model=self.savings_baseline_model") == returns diff --git a/tests/test_litellm/router_strategy/test_savings_baseline.py b/tests/test_litellm/router_strategy/test_savings_baseline.py new file mode 100644 index 000000000000..4b98ed737a2b --- /dev/null +++ b/tests/test_litellm/router_strategy/test_savings_baseline.py @@ -0,0 +1,155 @@ +import pytest + +from litellm.router import Router +from litellm.router_strategy.savings_baseline import ( + canonical_model, + models_for_group, + most_expensive, + resolve_baseline, +) + + +@pytest.fixture +def parent() -> Router: + return Router( + model_list=[ + {"model_name": "cheap", "litellm_params": {"model": "anthropic/claude-haiku-4-5"}}, + {"model_name": "top", "litellm_params": {"model": "anthropic/claude-opus-4-5"}}, + {"model_name": "pool", "litellm_params": {"model": "anthropic/claude-haiku-4-5"}}, + {"model_name": "pool", "litellm_params": {"model": "anthropic/claude-opus-4-5"}}, + ] + ) + + +class TestCanonicalModel: + def test_qualifies_a_bare_name_with_the_provider_that_owns_it(self): + assert canonical_model("claude-opus-4-5") == "anthropic/claude-opus-4-5" + + def test_keeps_an_already_qualified_name_qualified(self): + assert canonical_model("anthropic/claude-opus-4-5") == "anthropic/claude-opus-4-5" + + def test_honours_a_separately_declared_provider(self): + assert canonical_model("claude-opus-4-5", "openai") == "openai/claude-opus-4-5" + + def test_returns_none_for_a_name_no_provider_claims(self): + assert canonical_model("") is None + + +class TestModelsForGroup: + def test_resolves_a_group_to_the_models_its_deployments_call(self, parent): + assert models_for_group(parent, "cheap") == ("anthropic/claude-haiku-4-5",) + + def test_returns_every_deployment_in_a_pooled_group(self, parent): + assert sorted(models_for_group(parent, "pool")) == [ + "anthropic/claude-haiku-4-5", + "anthropic/claude-opus-4-5", + ] + + def test_treats_an_unknown_group_as_a_model_name(self, parent): + """A tier can point straight at a provider model rather than a configured group.""" + assert models_for_group(parent, "claude-opus-4-5") == ("anthropic/claude-opus-4-5",) + + +class TestMostExpensive: + def test_picks_by_output_rate(self): + assert ( + most_expensive(["anthropic/claude-haiku-4-5", "anthropic/claude-opus-4-5"]) + == "anthropic/claude-opus-4-5" + ) + + def test_ignores_models_with_no_per_token_price(self): + """A free model as baseline would report the whole real spend as a loss.""" + assert most_expensive(["not-a-real-model-anywhere", "anthropic/claude-haiku-4-5"]) == ( + "anthropic/claude-haiku-4-5" + ) + + def test_returns_none_when_nothing_can_be_priced(self): + assert most_expensive(["not-a-real-model-anywhere"]) is None + + def test_returns_none_for_an_empty_candidate_set(self): + assert most_expensive([]) is None + + +class TestResolveBaseline: + def test_a_configured_baseline_wins_over_the_candidates(self, parent): + assert resolve_baseline("claude-haiku-4-5", parent, ["top"]) == "anthropic/claude-haiku-4-5" + + def test_derives_the_priciest_candidate_when_unconfigured(self, parent): + assert resolve_baseline(None, parent, ["cheap", "top"]) == "anthropic/claude-opus-4-5" + + def test_never_raises_so_a_metric_cannot_fail_a_live_request(self): + """Read on the routing path while decorating a request that is about to be + served; a dashboard counterfactual must not be able to take routing down.""" + + class Exploding: + @property + def model_name_to_deployment_indices(self): + raise RuntimeError("router is mid-reload") + + assert resolve_baseline(None, Exploding(), ["anything"]) is None + + def test_an_empty_candidate_set_zeroes_the_driver_rather_than_inventing_one(self, parent): + assert resolve_baseline(None, parent, []) is None + + +class TestDeploymentsPricedByBaseModel: + """`litellm_params.model` is not always a model. + + On Azure it is the deployment name, which is absent from the cost map, so pricing it + directly drops the candidate. If that candidate was the priciest, the baseline quietly + becomes the second priciest and every saving is understated; if the whole pool is + Azure, nothing prices and the driver reports zero with nothing at default log level + saying why. `model_info.base_model` is what names the real model, which is the chain + router.py already resolves pricing through. + """ + + @staticmethod + def _router(*deployments: dict) -> Router: + return Router(model_list=list(deployments)) + + def test_model_info_base_model_is_preferred_over_the_deployment_name(self): + router = self._router( + { + "model_name": "big", + "litellm_params": {"model": "azure/my-gpt5-deployment"}, + "model_info": {"base_model": "azure/gpt-4.1"}, + }, + ) + assert models_for_group(router, "big") == ("azure/gpt-4.1",) + + def test_litellm_params_base_model_is_the_other_accepted_spelling(self): + router = self._router( + { + "model_name": "big", + "litellm_params": {"model": "azure/my-gpt5-deployment", "base_model": "azure/gpt-4.1"}, + }, + ) + assert models_for_group(router, "big") == ("azure/gpt-4.1",) + + def test_a_deployment_without_a_base_model_still_prices_by_its_model(self): + router = self._router({"model_name": "big", "litellm_params": {"model": "anthropic/claude-opus-4-5"}}) + assert models_for_group(router, "big") == ("anthropic/claude-opus-4-5",) + + def test_an_azure_deployment_can_win_the_priciest_candidate(self): + """Without the base_model hop the Azure candidate never prices, so the cheaper + model wins by default and the reported saving shrinks.""" + router = self._router( + {"model_name": "cheap", "litellm_params": {"model": "anthropic/claude-haiku-4-5"}}, + { + "model_name": "big", + "litellm_params": {"model": "azure/my-gpt5-deployment"}, + "model_info": {"base_model": "azure/gpt-4.1"}, + }, + ) + assert resolve_baseline(None, router, ["cheap", "big"]) == "azure/gpt-4.1" + + def test_an_all_azure_pool_still_has_a_baseline(self): + """Otherwise nothing prices, the driver is disabled and the card reads $0.00.""" + router = self._router( + { + "model_name": "big", + "litellm_params": {"model": "azure/my-gpt5-deployment"}, + "model_info": {"base_model": "azure/gpt-4.1"}, + }, + ) + assert resolve_baseline(None, router, ["big"]) == "azure/gpt-4.1" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index 5c26ac304777..26b249500062 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -29,12 +29,14 @@ vi.mock("@/components/shared/charts", () => ({ colors, showLegend, maxBarSize, + stack, }: { data: unknown; categories: string[]; colors?: readonly string[]; showLegend?: boolean; maxBarSize?: number; + stack?: boolean; }) => (
({ data-colors={(colors ?? []).join(",")} data-show-legend={String(showLegend ?? true)} data-max-bar-size={maxBarSize === undefined ? "" : String(maxBarSize)} + data-stack={String(stack ?? false)} data-series={JSON.stringify(data)} /> ), @@ -228,6 +231,109 @@ describe("UsageTab", () => { expect(slices).toEqual([{ driver: "Compression", usd: expect.closeTo(0.04, 5) }]); }); + it("does not stack the per-day drivers, because one of them can be negative", async () => { + // Stacking sums the series into one bar. Auto-router savings go negative when a + // model switch pays for a cold cache, and that segment would be drawn below the + // axis while the rest of the bar still read as the day's total. + const { getByRole, getByTestId } = renderWith([ + day("2026-07-12", { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.02, + autorouter_savings_spend: -0.05, + }), + ]); + + await userEvent.click(getByRole("tab", { name: "Per day" })); + const bars = getByTestId("bar-chart"); + expect(bars.getAttribute("data-stack")).toBe("false"); + expect(readSeries(bars)[0]).toMatchObject({ "Auto-router": -0.05 }); + }); + + it("lays the savings header out with the card's own slots so nothing shifts between tabs", async () => { + // The subtitle differs in length between the tabs ("Running total saved" vs "Saved + // per day"). Hand-rolled rows made it compete with the legend and the toggle for + // width, so the header grew a line on one tab and the chart moved with it. CardHeader + // sizes the action column to its content and gives the rest to the title column. + const { getByRole, getByTestId, container } = renderWith(twoDays()); + + const header = () => { + const legend = getByTestId("chart-legend"); + const action = legend.closest('[data-slot="card-action"]') as HTMLElement; + const cardHeader = action.parentElement as HTMLElement; + const description = cardHeader.querySelector('[data-slot="card-description"]') as HTMLElement; + return { action, cardHeader, description }; + }; + + const before = header(); + expect(before.action).toBeTruthy(); + expect(before.description).toBeTruthy(); + // the toggle rides in the same action slot as the legend, so neither moves alone + expect(before.action.contains(getByRole("tablist"))).toBe(true); + // the subtitle lives outside that slot, so its length cannot reposition the controls + expect(before.action.contains(before.description)).toBe(false); + expect(before.description.textContent).toContain("Running total saved"); + + await userEvent.click(getByRole("tab", { name: "Per day" })); + + const after = header(); + expect(after.action).toBe(before.action); + expect(after.cardHeader).toBe(before.cardHeader); + expect(after.action.contains(after.description)).toBe(false); + expect(after.description.textContent).toContain("Saved per day"); + expect(container.textContent).toContain("Savings"); + }); + + it("subtracts a losing auto-router route from the total and keeps it out of the donut", () => { + // Switching models leaves the new one with a cold cache, so a route can cost more + // than the baseline would have. A negative slice is meaningless in a donut, but the + // total has to keep the loss or the page can only ever report good news. + const { getByText, getByTestId } = renderWith([ + day("2026-07-12", { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.02, + autorouter_savings_spend: -0.05, + }), + ]); + + expect(getByText("$0.0700")).toBeInTheDocument(); + expect(getByText("-$0.0500")).toBeInTheDocument(); + + const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]"); + expect(slices.map((d: { driver: string }) => d.driver)).toEqual(["Compression", "Prompt caching"]); + expect(getByTestId("donut-chart").getAttribute("data-label")).toBe("$0.1200"); + }); + + it("carries auto-router savings into the summary card, donut slice, and cumulative series", () => { + const { getByText, getByTestId } = renderWith([ + day("2026-07-12", { + compression_savings_spend: 0.04, + prompt_caching_savings_spend: 0.006, + autorouter_savings_spend: 0.02, + }), + day("2026-07-13", { + compression_savings_spend: 0.1, + prompt_caching_savings_spend: 0.01, + autorouter_savings_spend: 0.05, + }), + ]); + + // Total saved now sums three drivers, and the auto-router card carries its own total. + expect(getByText("$0.2260")).toBeInTheDocument(); + expect(getByText("$0.0700")).toBeInTheDocument(); + + // The driver donut gains a third slice priced from the range totals. + const slices = JSON.parse(getByTestId("donut-chart").getAttribute("data-slices") ?? "[]"); + expect(slices).toEqual([ + { driver: "Compression", usd: expect.closeTo(0.14, 5) }, + { driver: "Prompt caching", usd: expect.closeTo(0.016, 5) }, + { driver: "Auto-router", usd: expect.closeTo(0.07, 5) }, + ]); + + // And the cumulative line accumulates the auto-router series alongside the others. + const series = readSeries(getByTestId("area-chart")); + expect(series[2]["Auto-router"]).toBeCloseTo(0.07, 5); + }); + it("renders spend-by-tool bars from the tool spend endpoint", async () => { const toolSpend = { by_tool: [ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index ec37418e0b55..8f507ca323a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -5,7 +5,7 @@ import { Info } from "lucide-react"; import { AreaChart, BarChart, CustomLegend, DonutChart, SEQUENTIAL_COLOR_RAMP } from "@/components/shared/charts"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; @@ -38,7 +38,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = { end_date: null, }; -const SAVINGS_COLORS = ["emerald", "blue"] as const; +const SAVINGS_COLORS = ["emerald", "blue", "amber"] as const; const shortDate = (iso: string): string => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); @@ -47,6 +47,7 @@ const isoDay = (d: Date): string => d.toISOString().slice(0, 10); const compressionOf = (m: SpendMetrics): number => m.compression_savings_spend ?? 0; const cachingOf = (m: SpendMetrics): number => m.prompt_caching_savings_spend ?? 0; +const autorouterOf = (m: SpendMetrics): number => m.autorouter_savings_spend ?? 0; const savedTokensOf = (m: SpendMetrics): number => m.compression_saved_tokens ?? 0; const SummaryCard = ({ label, value, hint, info }: { label: string; value: string; hint?: string; info?: string }) => ( @@ -105,8 +106,9 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const compressionTotal = useMemo(() => results.reduce((sum, d) => sum + compressionOf(d.metrics), 0), [results]); const cachingTotal = useMemo(() => results.reduce((sum, d) => sum + cachingOf(d.metrics), 0), [results]); + const autorouterTotal = useMemo(() => results.reduce((sum, d) => sum + autorouterOf(d.metrics), 0), [results]); const savedTokensTotal = useMemo(() => results.reduce((sum, d) => sum + savedTokensOf(d.metrics), 0), [results]); - const totalSaved = compressionTotal + cachingTotal; + const totalSaved = compressionTotal + cachingTotal + autorouterTotal; const [accumulation, setAccumulation] = useState("cumulative"); @@ -122,6 +124,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { date: shortDate(d.date), Compression: compressionOf(d.metrics), "Prompt caching": cachingOf(d.metrics), + "Auto-router": autorouterOf(d.metrics), })), [results], ); @@ -143,14 +146,19 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { .filter(Boolean) .join(" \u00b7 "); + // A driver can come out negative (auto-router pays a cold-cache write on every + // model switch), and a negative slice has no meaning in a donut, so only drivers + // that actually saved are plotted; the range total keeps the signed truth. const byDriver = useMemo( () => [ { driver: "Compression", usd: compressionTotal }, { driver: "Prompt caching", usd: cachingTotal }, + { driver: "Auto-router", usd: autorouterTotal }, ].filter((d) => d.usd > 0), - [compressionTotal, cachingTotal], + [compressionTotal, cachingTotal, autorouterTotal], ); + const plottedDriverTotal = useMemo(() => byDriver.reduce((sum, d) => sum + d.usd, 0), [byDriver]); const topTools = useMemo(() => topToolsBySpend(toolSpend?.by_tool ?? []), [toolSpend]); const topToolNames = useMemo(() => topTools.map((t) => t.tool_name), [topTools]); @@ -174,11 +182,11 @@ const UsageTab: React.FC = ({ accessToken, activity }) => {
-
+
= ({ accessToken, activity }) => { hint="Cache read discount" info="Tokens the provider served from cache, priced at the discount between the input and cache-read rates." /> +
+ {/* CardHeader's own slots rather than hand-rolled rows: the action column is + sized to its content and the title column takes the rest, so the subtitle + never competes with the controls for width and neither moves when it grows. + The controls wrap within their column instead of pushing past the card */} -
-
- Savings -

{savingsSubtitle}

-
-
- - setAccumulation(value as SavingsAccumulation)}> - - Cumulative - {intervalLabel} - - -
-
+ Savings + {savingsSubtitle} + + + setAccumulation(value as SavingsAccumulation)}> + + Cumulative + {intervalLabel} + + +
{accumulation === "cumulative" ? ( @@ -225,12 +239,14 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { showDots={overTime.length <= MAX_POINTS_WITH_DOTS} /> ) : ( + // Not stacked: a driver can be negative once a model switch is charged + // for its cold cache, and stacking would draw that segment below the axis + // while the remaining bar still read as the day's total @@ -247,10 +263,10 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { data={byDriver} index="driver" category="usd" - colors={["emerald", "blue"]} + colors={SAVINGS_COLORS} valueFormatter={usd} showLabel - label={usd(totalSaved)} + label={usd(plottedDriverTotal)} />
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts index dc08799a3c4d..5c73f0fc5dca 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.test.ts @@ -10,6 +10,7 @@ import { localIsoDay, toCumulative, topToolsBySpend, + usd, withStartAnchor, } from "./costOptimizationUtils"; @@ -239,22 +240,25 @@ describe("localIsoDay", () => { }); describe("toCumulative", () => { - const point = (date: string, compression: number, caching: number) => ({ + const point = (date: string, compression: number, caching: number, autorouter: number = 0) => ({ date, Compression: compression, "Prompt caching": caching, + "Auto-router": autorouter, }); it("turns each reading into everything saved up to that point", () => { const running = toCumulative([point("Jul 1", 1, 10), point("Jul 2", 2, 20), point("Jul 3", 3, 30)]); expect(running.map((p) => p.Compression)).toEqual([1, 3, 6]); expect(running.map((p) => p["Prompt caching"])).toEqual([10, 30, 60]); + expect(running.map((p) => p["Auto-router"])).toEqual([0, 0, 0]); }); it("accumulates each driver on its own, so one flat series cannot lift the other", () => { const running = toCumulative([point("Jul 1", 0, 5), point("Jul 2", 0, 5)]); expect(running.map((p) => p.Compression)).toEqual([0, 0]); expect(running.map((p) => p["Prompt caching"])).toEqual([5, 10]); + expect(running.map((p) => p["Auto-router"])).toEqual([0, 0]); }); it("never falls, even across a quiet interval", () => { @@ -267,13 +271,19 @@ describe("toCumulative", () => { expect(running.map((p) => p.date)).toEqual(["9am", "10am"]); expect(toCumulative([])).toEqual([]); }); + + it("accumulates auto-router savings like other drivers", () => { + const running = toCumulative([point("Jul 1", 1, 1, 5), point("Jul 2", 1, 1, 10)]); + expect(running.map((p) => p["Auto-router"])).toEqual([5, 15]); + }); }); describe("withStartAnchor", () => { - const point = (date: string, compression: number, caching: number) => ({ + const point = (date: string, compression: number, caching: number, autorouter: number = 0) => ({ date, Compression: compression, "Prompt caching": caching, + "Auto-router": autorouter, }); it("lifts a single-day cumulative off a floating dot by prepending a $0 origin", () => { @@ -285,6 +295,7 @@ describe("withStartAnchor", () => { const anchored = withStartAnchor([point("Jul 16", 5, 1), point("Jul 17", 9, 4)], "Jul 16"); expect(anchored.map((p) => p.Compression)).toEqual([0, 5, 9]); expect(anchored.map((p) => p["Prompt caching"])).toEqual([0, 1, 4]); + expect(anchored.map((p) => p["Auto-router"])).toEqual([0, 0, 0]); }); it("leaves an empty series alone so the chart's own no-data state can show", () => { @@ -306,3 +317,19 @@ describe("formatRangeLabel", () => { expect(formatRangeLabel(new Date(2026, 6, 23), undefined)).toBe(""); }); }); + +describe("usd", () => { + it("keeps four decimals for sub-dollar amounts so small savings stay visible", () => { + expect(usd(0.05)).toBe("$0.0500"); + expect(usd(1.5)).toBe("$1.50"); + expect(usd(0)).toBe("$0.00"); + }); + + it("signs a loss ahead of the symbol and keeps its precision", () => { + // A driver can be negative once a model switch is charged for its cold cache. + // Sizing decimals off the raw value would render this as "$-0.00". + expect(usd(-0.05)).toBe("-$0.0500"); + expect(usd(-0.0004)).toBe("-$0.0004"); + expect(usd(-12.4)).toBe("-$12.40"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index 32eb6ae198db..43ebdea82d4c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -3,8 +3,11 @@ import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking"; import { formatNumberWithCommas } from "@/utils/dataUtils"; export const usd = (value: number): string => { - const decimals = value > 0 && value < 1 ? 4 : 2; - return `$${formatNumberWithCommas(value, decimals)}`; + // Sized and signed off the magnitude: a driver can come out negative, and a small + // loss rendered at two decimals would read as "$-0.00" + const magnitude = Math.abs(value); + const decimals = magnitude > 0 && magnitude < 1 ? 4 : 2; + return `${value < 0 ? "-" : ""}$${formatNumberWithCommas(magnitude, decimals)}`; }; export const pct = (ratio: number): string => `${formatNumberWithCommas(ratio * 100, 1)}%`; @@ -161,9 +164,10 @@ export type SavingsPoint = { date: string; Compression: number; "Prompt caching": number; + "Auto-router": number; }; -export const SAVINGS_SERIES = ["Compression", "Prompt caching"] as const; +export const SAVINGS_SERIES = ["Compression", "Prompt caching", "Auto-router"] as const; /** * Running total of each series across the selected window. The total restarts @@ -179,6 +183,7 @@ export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] => date: point.date, Compression: (previous?.Compression ?? 0) + point.Compression, "Prompt caching": (previous?.["Prompt caching"] ?? 0) + point["Prompt caching"], + "Auto-router": (previous?.["Auto-router"] ?? 0) + point["Auto-router"], }, ]; }, []); @@ -193,7 +198,7 @@ export const toCumulative = (points: readonly SavingsPoint[]): SavingsPoint[] => export const withStartAnchor = (cumulative: readonly SavingsPoint[], startLabel: string): SavingsPoint[] => cumulative.length === 0 ? [...cumulative] - : [{ date: startLabel, Compression: 0, "Prompt caching": 0 }, ...cumulative]; + : [{ date: startLabel, Compression: 0, "Prompt caching": 0, "Auto-router": 0 }, ...cumulative]; /** "Jul 16 – Jul 23", collapsing to a single date when the range is one day. */ export const formatRangeLabel = (from: Date | undefined, to: Date | undefined): string => { diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index bf33fa371117..b10fc79be159 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -11,6 +11,7 @@ export interface SpendMetrics { compression_saved_tokens?: number; compression_savings_spend?: number; prompt_caching_savings_spend?: number; + autorouter_savings_spend?: number; } export type DailyData = { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 109d638fb9c0..fc722a3fdd8c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -23487,6 +23487,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 @@ -25936,6 +25941,8 @@ export interface components { auto_router_default_model?: string | null; /** Auto Router Embedding Model */ auto_router_embedding_model?: string | null; + /** Auto Router Savings Baseline Model */ + auto_router_savings_baseline_model?: string | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; /** Aws Bedrock Project Id */ @@ -31388,6 +31395,11 @@ export interface components { * @default 0 */ api_requests: number; + /** + * Autorouter Savings Spend + * @default 0 + */ + autorouter_savings_spend: number; /** * Cache Creation Input Tokens * @default 0 @@ -34068,6 +34080,8 @@ export interface components { auto_router_default_model?: string | null; /** Auto Router Embedding Model */ auto_router_embedding_model?: string | null; + /** Auto Router Savings Baseline Model */ + auto_router_savings_baseline_model?: string | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; /** Aws Bedrock Project Id */