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 43dadc5138)
This commit is contained in:
Mateo Wang 2026-06-17 15:36:23 -07:00 committed by Yuneng Jiang
parent 9e30985290
commit 0dc6951af6
No known key found for this signature in database
2 changed files with 60 additions and 4 deletions

View file

@ -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

View file

@ -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.