diff --git a/litellm/utils.py b/litellm/utils.py index b164a9c4671..5e3fc52b27d 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -3057,12 +3057,13 @@ def register_model( existing_model.pop(_cost_field, None) ## override / add new keys to the existing model cost dictionary updated_dictionary = _update_dictionary(existing_model, value) - litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary) + if updated_dictionary: + litellm.model_cost.setdefault(model_cost_key, {}).update(updated_dictionary) - # Invalidate case-insensitive lookup map since model_cost was modified - _invalidate_model_cost_lowercase_map() + # Invalidate case-insensitive lookup map since model_cost was modified + _invalidate_model_cost_lowercase_map() - verbose_logger.debug("added/updated model=%s in litellm.model_cost: %s", model_cost_key, model_cost_key) + verbose_logger.debug("added/updated model=%s in litellm.model_cost: %s", model_cost_key, model_cost_key) # add new model names to provider lists if value.get("litellm_provider") == "openai": if key not in litellm.open_ai_chat_completion_models: diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 0c99d128e14..b356f38cff1 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1,3 +1,4 @@ +from typing import Final import json from pathlib import Path @@ -1072,10 +1073,11 @@ def test_tiered_pricing_only_deployment_selects_router_model_id(): entry = litellm.model_cost[router_model_id] assert entry.get("input_cost_per_token") is None assert entry.get("tiered_pricing") is not None - # The stripped shared alias must not carry tiered pricing. - assert ( - litellm.model_cost["dashscope/qwen-tier-only-test"].get("tiered_pricing") is None - ) + # The stripped shared alias must not carry tiered pricing. After + # stripping, this deployment contributes nothing to the shared key, so + # ``register_model`` may not have materialized it at all. + shared_alias_entry: Final = litellm.model_cost.get("dashscope/qwen-tier-only-test") + assert shared_alias_entry is None or shared_alias_entry.get("tiered_pricing") is None selected = _select_model_name_for_cost_calc( model="dashscope/qwen-tier-only-test", diff --git a/tests/test_litellm/test_register_model_custom_pricing.py b/tests/test_litellm/test_register_model_custom_pricing.py index 39f498b4e58..7fdd5d64cc9 100644 --- a/tests/test_litellm/test_register_model_custom_pricing.py +++ b/tests/test_litellm/test_register_model_custom_pricing.py @@ -11,6 +11,7 @@ calculations for DB-sourced models with prompt caching pricing. import copy import os +from typing import Final import pytest @@ -610,25 +611,28 @@ def test_register_model_router_add_deployment_custom_pricing_applies(): ) try: - # ``add_deployment`` runs as part of ``Router.__init__``; the - # registered entry must not block ``_check_provider_match`` for - # the deployment's provider. + # ``add_deployment`` runs as part of ``Router.__init__``; the custom + # pricing is registered under the deployment's ``model_info.id`` (the + # shared backend key deliberately excludes per-deployment pricing). + # That entry must not block ``_check_provider_match`` for the + # deployment's provider. from litellm.utils import _check_provider_match - registered_keys = [ - k for k in (deployment_model, model_key) if k in litellm.model_cost - ] - assert registered_keys, ( - "Router.add_deployment did not register custom pricing for " + registered: Final = litellm.model_cost.get("deployment-28336") + assert registered is not None, ( + "Router.add_deployment did not register custom pricing under " + "the deployment id for " f"{model_key} / {deployment_model}" ) - for k in registered_keys: - assert ( - _check_provider_match(litellm.model_cost[k], "openai") is True - ), f"custom pricing for {k} was dropped by _check_provider_match" + assert registered["input_cost_per_token"] == 0.00042 + assert registered["output_cost_per_token"] == 0.00084 + assert ( + _check_provider_match(registered, "openai") is True + ), "custom pricing for deployment-28336 was dropped by _check_provider_match" finally: litellm.model_cost.pop(model_key, None) litellm.model_cost.pop(deployment_model, None) + litellm.model_cost.pop("deployment-28336", None) del router @@ -793,3 +797,72 @@ def test_embedding_direct_sdk_custom_pricing_still_registers_shared_key(): finally: litellm.model_cost.pop(model_key, None) _invalidate_model_cost_lowercase_map() + + +def test_register_model_empty_payload_does_not_materialize_entry(): + """An empty payload for a model with no built-in entry carries no + information, and ``register_model`` must not create a bare key for it in + ``litellm.model_cost``. Key existence is what cost lookup treats as + "mapped", so an empty entry flips ``completion_cost`` for an unmapped + model from a loud "model isn't mapped" error to a silent $0.0. + """ + from litellm.utils import _runtime_registered_model_cost + + model_key: Final = "deepinfra/fake-unmapped-model-empty-registration" + assert model_key not in litellm.model_cost + with pytest.raises(Exception, match="isn't mapped"): + litellm.completion_cost(model=model_key, prompt="hi", completion="there") + + try: + litellm.register_model({model_key: {}}) # mutable-ok: registration payload under test + + assert model_key not in litellm.model_cost, ( + "register_model materialized an empty model_cost entry for an " + "unmapped model registered with an empty payload" + ) + with pytest.raises(Exception, match="isn't mapped"): + litellm.completion_cost(model=model_key, prompt="hi", completion="there") + finally: + litellm.model_cost.pop(model_key, None) + _runtime_registered_model_cost.pop(model_key, None) + _invalidate_model_cost_lowercase_map() + + +def test_router_init_without_pricing_keeps_unmapped_model_cost_loud(): + """``Router.__init__`` registers every deployment's backend key through + ``register_model`` (``_register_deployment_in_model_cost``, shared-backend + path). For a deployment whose model is not in the cost map and that + carries no pricing or ``model_info``, that registration used to + materialize an empty entry — after which ``litellm.completion_cost`` for + the unmapped model silently returned 0.0 process-wide instead of raising, + even for calls that never touch the router. + """ + from litellm import Router + + backend_model: Final = "deepinfra/fake-unmapped-router-model-empty-reg" + stripped_key: Final = backend_model.split("/", 1)[1] + assert backend_model not in litellm.model_cost + + router: Final = Router( + model_list=[ # mutable-ok: Router constructor payload + { # mutable-ok: deployment entry under test + "model_name": "my-unpriced-model", + "litellm_params": {"model": backend_model, "api_key": "fake-key"}, # mutable-ok: deployment params + } + ] + ) + + try: + assert backend_model not in litellm.model_cost, ( + "Router.__init__ materialized an empty model_cost entry for an " + "unmapped deployment with no pricing" + ) + with pytest.raises(Exception, match="isn't mapped"): + litellm.completion_cost( + model=backend_model, prompt="hi", completion="there" + ) + finally: + del router + litellm.model_cost.pop(backend_model, None) + litellm.model_cost.pop(stripped_key, None) + _invalidate_model_cost_lowercase_map() diff --git a/tests/test_litellm/test_router_model_cost_isolation.py b/tests/test_litellm/test_router_model_cost_isolation.py index b580b03574e..f14a07995c6 100644 --- a/tests/test_litellm/test_router_model_cost_isolation.py +++ b/tests/test_litellm/test_router_model_cost_isolation.py @@ -1131,23 +1131,29 @@ def test_repointing_a_deployment_drops_its_previous_backend_key(monkeypatch): dict(litellm_utils._runtime_registered_model_cost), ) + # ``mode`` is a cost-map schema field, so it flows through to the shared + # backend key. Without at least one such field the shared payload is empty + # and ``register_model`` (correctly) registers nothing for the backend key, + # which would leave this test nothing to observe. router = Router( model_list=[ { "model_name": "moving-target", "litellm_params": {"model": "hosted_vllm/old-backend"}, - "model_info": {"id": "moving-target-id"}, + "model_info": {"id": "moving-target-id", "mode": "chat"}, } ], ) saved_model_cost = litellm.model_cost try: + assert "hosted_vllm/old-backend" in litellm.model_cost + router.upsert_deployment( deployment=Deployment( model_name="moving-target", litellm_params=LiteLLM_Params(model="hosted_vllm/new-backend"), - model_info=ModelInfo(id="moving-target-id"), + model_info=ModelInfo(id="moving-target-id", mode="chat"), ) )