From 0dc6951af6a1e1817e236f47827efe709f930a2b Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:36:23 -0700 Subject: [PATCH] fix(cost): stop non-string service_tier from silently dropping cost tracking (#30690) completion_cost read service_tier straight from the request optional_params and called service_tier.lower() on it, so a non-string value (dict/int/list, reachable via allowed_openai_params/drop_params) raised AttributeError. _response_cost_calculator swallowed that and returned response_cost=None, so the request's cost was silently lost. The isinstance guard alone is not enough: a surviving dict would crash again downstream in _get_service_tier_cost_key, which also calls .lower(). A request-level service_tier is only meaningful for pricing when it is a concrete billable tier string, so coerce any non-string value to None and defer to the tier the provider reports on the response usage, the same way "auto" already does. Adds a regression test driving a dict service_tier through completion_cost; it raises AttributeError before the fix and prices at the served tier after. (cherry picked from commit 43dadc5138d4d267176e11fe5a47d0f0593d790d) --- litellm/cost_calculator.py | 12 +++-- tests/test_litellm/test_cost_calculator.py | 52 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 6c11c8d8a06..71a2577092b 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -1227,10 +1227,14 @@ def completion_cost( # noqa: PLR0915 if service_tier is None and optional_params is not None: service_tier = optional_params.get("service_tier") - # "auto" is a routing preference, not a billable tier: the provider picks - # the tier and reports the one actually served on the response/usage, so - # defer to that instead of pricing the request-level "auto" as standard - if service_tier is not None and service_tier.lower() == ServiceTier.AUTO.value: + # A request-level service_tier only prices the request when it is a + # concrete billable tier string. "auto" is a routing preference and any + # non-string value is not a billable tier, so defer to the tier the + # provider reports on the response/usage instead of crashing or mispricing + if ( + not isinstance(service_tier, str) + or service_tier.lower() == ServiceTier.AUTO.value + ): service_tier = None # Extract service_tier from completion_response if not provided diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index bcc685f0366..ff4e36f593c 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -2054,6 +2054,58 @@ def test_completion_cost_anthropic_auto_tier_uses_served_priority_rate(): assert cost == pytest.approx(expected_priority) +def test_completion_cost_non_string_service_tier_defers_to_served_tier(): + """ + Regression: a non-string request-level ``service_tier`` (reachable via + ``allowed_openai_params``/``drop_params``) must not crash cost tracking. + + Before the fix, ``completion_cost`` called ``service_tier.lower()`` on the + request-level value, so a dict raised ``AttributeError``. ``_response_cost_calculator`` + swallowed it and reported ``response_cost=None``, silently dropping the cost. + The non-string preference must be ignored so pricing defers to the tier the + provider actually served on the response usage. + """ + from litellm import completion_cost + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + + model = "claude-test-non-string-tier-cost-model" + litellm.register_model( + model_cost={ + model: { + "input_cost_per_token": 3e-6, + "output_cost_per_token": 15e-6, + "input_cost_per_token_priority": 6e-6, + "output_cost_per_token_priority": 30e-6, + "litellm_provider": "anthropic", + "max_tokens": 8192, + } + } + ) + + usage = AnthropicConfig().calculate_usage( + usage_object={ + "input_tokens": 1000, + "output_tokens": 500, + "service_tier": "priority", + }, + reasoning_content=None, + ) + response = ModelResponse(usage=usage, model=model) + + cost = completion_cost( + completion_response=response, + model=model, + custom_llm_provider="anthropic", + optional_params={"service_tier": {"name": "auto"}}, + ) + + expected_priority = 1000 * 6e-6 + 500 * 30e-6 + assert cost == pytest.approx(expected_priority) + + def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(): """ Regression for the cache/tier interaction in the Anthropic geo/speed path.