From 2240a6be545544f70bf2e1d42a50e1cf8242320c Mon Sep 17 00:00:00 2001 From: Quinn Xu Date: Sun, 27 Sep 2026 17:13:57 +0000 Subject: [PATCH] fix(router): track routers with empty model_list in _live_routers --- litellm/router.py | 7 +-- .../unit/test_router_model_cost_isolation.py | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 1ef68e60440..f8c50b855b1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1034,9 +1034,6 @@ class Router: if model_list is not None: # set_model_list will build indices automatically self.set_model_list(model_list) - # Track this router so a price data reload can rebuild its deployments' - # cost-map entries from the list it is serving at that moment. - _live_routers.add(self) self.healthy_deployments: list = self.model_list for m in model_list: if "model" in m["litellm_params"]: @@ -1044,6 +1041,10 @@ class Router: else: self.model_list: list = [] # initialize an empty list - to allow _add_deployment and delete_deployment to work + # Track every live router so price-data reload can rebuild cost-map entries + # even when the proxy started with an empty / omitted model_list. + _live_routers.add(self) + if allowed_fails is not None: self.allowed_fails = allowed_fails else: diff --git a/tests/unit/test_router_model_cost_isolation.py b/tests/unit/test_router_model_cost_isolation.py index d73f5efa96b..491740213d8 100644 --- a/tests/unit/test_router_model_cost_isolation.py +++ b/tests/unit/test_router_model_cost_isolation.py @@ -1865,6 +1865,55 @@ def test_reload_keeps_custom_pricing_configured_on_litellm_params_for_a_db_model _invalidate_model_cost_lowercase_map() +@pytest.mark.timeout(15) +def test_reload_keeps_cost_map_when_router_starts_with_no_model_list(): + """ + An empty proxy config builds Router() with model_list omitted. Deployments + added afterwards (DB-stored models) must stay in the cost map across a + price data reload. The model is a local provider so add_deployment does + not start a remote login. + """ + from litellm.router import _live_routers + + saved_catalog = litellm.model_cost + fetched_catalog = copy.deepcopy(litellm.model_cost) + model_key = "hosted_vllm/empty-init-priced" + deployment_id = "empty-init-priced-id" + input_cost = 2e-6 + output_cost = 1e-5 + try: + router = Router() + assert router in _live_routers + router.add_deployment( + deployment=Deployment( + model_name=model_key, + litellm_params=LiteLLM_Params( + model=model_key, + api_key="sk-fake", + input_cost_per_token=input_cost, + output_cost_per_token=output_cost, + ), + model_info=ModelInfo(id=deployment_id), + ) + ) + + assert model_key in litellm.model_cost + assert deployment_id in litellm.model_cost + assert litellm.model_cost[deployment_id]["input_cost_per_token"] == input_cost + assert litellm.model_cost[deployment_id]["output_cost_per_token"] == output_cost + + _simulate_price_data_reload(copy.deepcopy(fetched_catalog)) + + assert model_key in litellm.model_cost + assert deployment_id in litellm.model_cost + assert litellm.model_cost[deployment_id]["input_cost_per_token"] == input_cost + assert litellm.model_cost[deployment_id]["output_cost_per_token"] == output_cost + assert router.model_list + finally: + litellm.model_cost = saved_catalog + _invalidate_model_cost_lowercase_map() + + def test_replay_live_router_model_cost_rebuilds_every_live_router(): """ A process can hold more than one Router, so the rebuild has to fan out across