fix(router): cast model_info cost values to float in _set_model_group_info (#33556)

Cost values read from deployment model_info can be strings when the
config YAML contains scientific notation with an integer mantissa
(e.g. 1e-05), which YAML 1.2 parsers such as PyYAML 6.x treat as a
string. Comparing that string against the running float aggregate in
_set_model_group_info raised TypeError and broke /model_group/info,
the prometheus remaining-usage callback, and the
x-litellm-response-cost header. Coerce input/output cost values to
float before comparing and storing them.
This commit is contained in:
Yassin Kortam 2026-07-16 12:05:36 -07:00 committed by GitHub
parent 899ddef219
commit c012373e1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 145 additions and 8 deletions

View file

@ -251,6 +251,15 @@ else:
PreRoutingHookResponse = Any
def _cost_value_as_float(value: Union[str, int, float, None]) -> Optional[float]:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class RoutingArgs(enum.Enum):
ttl = 60 # 1min (RPM/TPM expire key)
@ -8750,8 +8759,8 @@ class Router:
# Get mode from database model_info if available, otherwise default to "chat"
db_model_info = model.get("model_info", {})
mode = db_model_info.get("mode", "chat")
input_cost_per_token = db_model_info.get("input_cost_per_token")
output_cost_per_token = db_model_info.get("output_cost_per_token")
input_cost_per_token = _cost_value_as_float(db_model_info.get("input_cost_per_token"))
output_cost_per_token = _cost_value_as_float(db_model_info.get("output_cost_per_token"))
model_info = ModelMapInfo(
key=model_group,
@ -8802,16 +8811,18 @@ class Router:
)
):
model_group_info.max_output_tokens = model_info["max_output_tokens"]
if model_info.get("input_cost_per_token", None) is not None and (
_input_cost_per_token = _cost_value_as_float(model_info.get("input_cost_per_token"))
if _input_cost_per_token is not None and (
model_group_info.input_cost_per_token is None
or (model_info["input_cost_per_token"] or 0.0) > (model_group_info.input_cost_per_token or 0.0)
or _input_cost_per_token > (model_group_info.input_cost_per_token or 0.0)
):
model_group_info.input_cost_per_token = model_info["input_cost_per_token"]
if model_info.get("output_cost_per_token", None) is not None and (
model_group_info.input_cost_per_token = _input_cost_per_token
_output_cost_per_token = _cost_value_as_float(model_info.get("output_cost_per_token"))
if _output_cost_per_token is not None and (
model_group_info.output_cost_per_token is None
or (model_info["output_cost_per_token"] or 0.0) > (model_group_info.output_cost_per_token or 0.0)
or _output_cost_per_token > (model_group_info.output_cost_per_token or 0.0)
):
model_group_info.output_cost_per_token = model_info["output_cost_per_token"]
model_group_info.output_cost_per_token = _output_cost_per_token
if (
model_info.get("supports_parallel_function_calling", None) is not None
and model_info["supports_parallel_function_calling"] is True # type: ignore

View file

@ -1455,6 +1455,132 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost():
assert result.output_cost_per_token is None
@pytest.mark.parametrize(
"value,expected",
[
("1e-05", 1e-05),
("0.00001", 1e-05),
(1e-05, 1e-05),
(5, 5.0),
(None, None),
("not-a-number", None),
],
)
def test_cost_value_as_float(value, expected):
from litellm.router import _cost_value_as_float
assert _cost_value_as_float(value) == expected
def test_model_group_info_with_stringified_cost_values():
"""
YAML 1.2 parsers emit '1e-05' (integer mantissa) as a string, so cost
values in deployment model_info can arrive as str. Aggregating the model
group must not raise TypeError('>' between str and float) and must return
float costs.
"""
router = litellm.Router(
model_list=[
{
"model_name": "my-custom-model",
"litellm_params": {
"model": "openai/my-custom-backend-1",
"api_key": "fake",
},
"model_info": {
"input_cost_per_token": "1e-05",
"output_cost_per_token": "1e-05",
},
},
{
"model_name": "my-custom-model",
"litellm_params": {
"model": "openai/my-custom-backend-2",
"api_key": "fake",
},
"model_info": {
"input_cost_per_token": "2e-05",
"output_cost_per_token": "2e-05",
},
},
]
)
def _model_info_with_str_costs(model_id: str, model_name: str):
for model in router.model_list:
if model["model_info"]["id"] == model_id:
return {
"key": model_name,
"input_cost_per_token": model["model_info"]["input_cost_per_token"],
"output_cost_per_token": model["model_info"]["output_cost_per_token"],
"litellm_provider": "openai",
"mode": "chat",
}
return None
with patch.object(
router, "get_deployment_model_info", side_effect=_model_info_with_str_costs
):
result = router._set_model_group_info(
model_group="my-custom-model",
user_facing_model_group_name="my-custom-model",
)
assert result is not None
assert result.input_cost_per_token == 2e-05
assert result.output_cost_per_token == 2e-05
assert isinstance(result.input_cost_per_token, float)
assert isinstance(result.output_cost_per_token, float)
def test_model_group_info_db_fallback_with_stringified_cost_values():
"""
Fallback path: when get_deployment_model_info returns nothing, costs are
read straight from the deployment's model_info dict, which can hold
stringified floats parsed from YAML. They must be coerced to float.
"""
router = litellm.Router(
model_list=[
{
"model_name": "my-custom-model",
"litellm_params": {
"model": "openai/my-custom-backend-1",
"api_key": "fake",
},
"model_info": {
"input_cost_per_token": "1e-05",
"output_cost_per_token": "3e-05",
},
},
{
"model_name": "my-custom-model",
"litellm_params": {
"model": "openai/my-custom-backend-2",
"api_key": "fake",
},
"model_info": {
"input_cost_per_token": "2e-05",
"output_cost_per_token": "2e-05",
},
},
]
)
with patch.object(
router, "get_deployment_model_info", side_effect=Exception("not found")
):
result = router._set_model_group_info(
model_group="my-custom-model",
user_facing_model_group_name="my-custom-model",
)
assert result is not None
assert result.input_cost_per_token == 2e-05
assert result.output_cost_per_token == 3e-05
assert isinstance(result.input_cost_per_token, float)
assert isinstance(result.output_cost_per_token, float)
def test_get_model_access_groups_caching():
"""
Test that get_model_access_groups caches the no-args result