From 34918d34f9367251d7e631e37909080a94bfa4ab Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:37:18 -0700 Subject: [PATCH] fix(cost): inherit the backend output rate when a deployment's tiers omit one --- litellm/router.py | 46 +++++++++++++++++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 40 ++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index fb2af41dcf2..cc64d4992a8 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -7635,6 +7635,37 @@ class Router: if backend_value is not None: model_info[field] = backend_value + @staticmethod + def _inherit_builtin_tiered_output_rate( + model_info: dict, backend_model: str, custom_llm_provider: str | None + ) -> None: + """Fill a missing entry-level output rate on a deployment entry whose tier + table omits one, from the backend model's built-in cost map entry. + + A deployment's custom pricing is registered as its own standalone + ``litellm.model_cost`` entry holding only the supplied fields, and the + tiered-cost output fallback reads that same entry, so a tier table that + spells out only input-side rates would bill every completion at 0. + + A user-specified ``output_cost_per_token`` always wins. No-op without a + tier table, when every tier declares its own output rate, or when the + backend model has no canonical entry. + """ + tiers: Final = model_info.get("tiered_pricing") + if not isinstance(tiers, list) or not tiers: + return + if model_info.get("output_cost_per_token") is not None: + return + if all(isinstance(tier, dict) and "output_cost_per_token" in tier for tier in tiers): + return + try: + backend_info: Final = litellm.get_model_info(model=backend_model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises plain Exception for an unmapped backend model + return + backend_rate: Final = backend_info.get("output_cost_per_token") + if backend_rate is not None: + model_info["output_cost_per_token"] = backend_rate + def _create_deployment( self, deployment_info: dict, @@ -7670,6 +7701,11 @@ class Router: backend_model=deployment.litellm_params.model, custom_llm_provider=deployment.litellm_params.custom_llm_provider, ) + Router._inherit_builtin_tiered_output_rate( + model_info=_model_info, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) ## REGISTER MODEL INFO IN LITELLM MODEL COST MAP Router._register_deployment_in_model_cost( @@ -8368,6 +8404,11 @@ class Router: backend_model=deployment.litellm_params.model, custom_llm_provider=deployment.litellm_params.custom_llm_provider, ) + Router._inherit_builtin_tiered_output_rate( + model_info=_model_info_dict, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) # Register custom pricing in litellm.model_cost. # Mirrors _create_deployment() logic to ensure dynamically-added deployments @@ -8598,6 +8639,11 @@ class Router: backend_model=deployment.litellm_params.model, custom_llm_provider=deployment.litellm_params.custom_llm_provider, ) + Router._inherit_builtin_tiered_output_rate( + model_info=model_info, + backend_model=deployment.litellm_params.model, + custom_llm_provider=deployment.litellm_params.custom_llm_provider, + ) return model_info @staticmethod diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 821e9d23e89..4d157e74482 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -734,6 +734,46 @@ def test_generic_cost_per_token_tier_without_an_output_rate_bills_the_model_rate litellm.model_cost.pop(model, None) +def test_router_deployment_with_input_only_tiers_bills_completions_at_the_backend_rate(): + """Regression: the router registers a deployment's custom pricing as a standalone + model_cost entry holding only the supplied fields, so an input-only tier table left + the output-rate fallback nothing to read and billed every completion at 0.""" + from litellm import Router + + model_id = "litellm-test-router-tiered-input-only" + backend_model = "anthropic/claude-haiku-4-5" + backend_output_rate = litellm.get_model_info(backend_model)["output_cost_per_token"] + Router( + model_list=[ + { + "model_name": "tiered-input-only", + "litellm_params": { + "model": backend_model, + "api_key": "sk-test", + "tiered_pricing": [ + {"range": [0, 3000], "input_cost_per_token": 3.25e-07}, + {"range": [3000, 128000], "input_cost_per_token": 8.125e-07}, + ], + }, + "model_info": {"id": model_id}, + } + ] + ) + + try: + usage = Usage(prompt_tokens=21, completion_tokens=4, total_tokens=25) + prompt_cost, completion_cost = generic_cost_per_token( + model=model_id, + usage=usage, + custom_llm_provider="anthropic", + ) + assert round(prompt_cost, 12) == round(21 * 3.25e-07, 12) + assert round(completion_cost, 12) == round(4 * backend_output_rate, 12) + assert backend_output_rate > 0 + finally: + litellm.model_cost.pop(model_id, None) + + def test_generic_cost_per_token_tiered_pricing_bills_reasoning_at_tier_rate(): """Regression: a tier's output_cost_per_reasoning_token must price reasoning tokens on the generic path and in the logged breakdown, not the tier's plain output rate."""