From 416d15d67afc957df0a4b861a2308d042783a2f0 Mon Sep 17 00:00:00 2001 From: Muhammad Hassaan <125120351+hassaanch23@users.noreply.github.com> Date: Sun, 30 Aug 2026 02:54:57 +0500 Subject: [PATCH] fix(cost): treat a zero rate as a price when selecting a cost metric `select_cost_metric_for_model` picked its metric by truthiness: if model_info.get("input_cost_per_character"): return "cost_per_character" elif model_info.get("input_cost_per_token"): return "cost_per_token" else: raise ValueError(f"Model {...} does not have ...") A rate of 0 is a valid price -- it declares the model free -- but it is falsy, so a free model fell through to the raise and reported that fields it had explicitly set were missing: {"key": "my-free-tts", "input_cost_per_character": 0} -> ValueError: Model my-free-tts does not have 'input_cost_per_character' or 'input_cost_per_token' The single caller is the speech/aspeech branch of `completion_cost` (cost_calculator.py), so a TTS model priced at 0 could not be costed at all, and the error was indistinguishable from a genuinely unpriced model. Selection now tests presence with `is not None`. This matches `tier_rate` in this same package, which already documents the convention: "A rate that is explicitly present wins over the fallback, an explicit zero included, so a tier can declare a token type free." Zero-cost models are a supported concept elsewhere in the codebase (the router's `_zero_cost_cache`, the auth layer's zero-cost budget bypass), so a free TTS model is a configuration users can reasonably expect to work. Behaviour is unchanged for priced models, for a model with both fields set (character pricing still wins), and for a model with neither -- which still raises, as does one that sets both to an explicit None. Adds tests/test_litellm/test_zero_cost_metric_selection.py: 9 tests, 4 of which fail without this change. --- .../litellm_core_utils/llm_cost_calc/utils.py | 10 ++- .../test_zero_cost_metric_selection.py | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 tests/test_litellm/test_zero_cost_metric_selection.py diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 19e3f624268..ad64ab1488b 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -137,10 +137,16 @@ def select_cost_metric_for_model( """ Select 'cost_per_character' if model_info has 'input_cost_per_character' Select 'cost_per_token' if model_info has 'input_cost_per_token' + + Presence is what selects the metric, not truthiness: a rate of ``0`` is a + valid price, declaring the model free. Testing truthiness treated such a + model as unpriced and raised below, reporting that fields it had explicitly + set were missing. This matches ``tier_rate`` in this package, where an + explicit zero already wins over the fallback. """ - if model_info.get("input_cost_per_character"): + if model_info.get("input_cost_per_character") is not None: return "cost_per_character" - elif model_info.get("input_cost_per_token"): + elif model_info.get("input_cost_per_token") is not None: return "cost_per_token" else: raise ValueError( diff --git a/tests/test_litellm/test_zero_cost_metric_selection.py b/tests/test_litellm/test_zero_cost_metric_selection.py new file mode 100644 index 00000000000..0d666d8daa4 --- /dev/null +++ b/tests/test_litellm/test_zero_cost_metric_selection.py @@ -0,0 +1,63 @@ +"""Tests for cost-metric selection on models priced at zero. + +`select_cost_metric_for_model` chose a metric by truthiness, so a rate of 0 -- +a valid price declaring the model free -- was indistinguishable from an absent +one, and the speech cost path raised ValueError claiming fields the model had +explicitly set were missing. +""" + +import pytest + +from litellm.litellm_core_utils.llm_cost_calc.utils import ( + select_cost_metric_for_model, +) + + +@pytest.mark.parametrize( + "model_info, expected", + [ + ({"key": "tts-1", "input_cost_per_token": 1.5e-05}, "cost_per_token"), + ({"key": "tts-1", "input_cost_per_character": 1.5e-05}, "cost_per_character"), + # character pricing wins when both are present + ( + { + "key": "tts-1", + "input_cost_per_character": 1.5e-05, + "input_cost_per_token": 3.0e-05, + }, + "cost_per_character", + ), + ], +) +def test_priced_models_select_their_metric(model_info, expected): + assert select_cost_metric_for_model(model_info) == expected + + +@pytest.mark.parametrize( + "model_info, expected", + [ + ({"key": "free-tts", "input_cost_per_character": 0}, "cost_per_character"), + ({"key": "free-tts", "input_cost_per_token": 0}, "cost_per_token"), + ({"key": "free-tts", "input_cost_per_character": 0.0}, "cost_per_character"), + ( + {"key": "free-tts", "input_cost_per_character": 0, "input_cost_per_token": 0}, + "cost_per_character", + ), + ], +) +def test_zero_is_a_price_not_an_absent_field(model_info, expected): + # A zero rate declares the model free. Selecting on truthiness dropped + # through to the ValueError below, so a free TTS model could not be costed. + assert select_cost_metric_for_model(model_info) == expected + + +def test_missing_cost_fields_still_raise(): + with pytest.raises(ValueError, match="does not have"): + select_cost_metric_for_model({"key": "unpriced-tts"}) + + +def test_explicit_none_is_treated_as_absent(): + with pytest.raises(ValueError, match="does not have"): + select_cost_metric_for_model( + {"key": "unpriced-tts", "input_cost_per_character": None, "input_cost_per_token": None} + )