fix(proxy): stop GET /v1/models from pegging CPU on large deployments

/v1/models built a ModelInfo per listed model and each entry recomputed
group metadata via the uncached Router.get_model_group_info, expanded
wildcard routes with a full copy.deepcopy per matched deployment, and
re-ran pattern_router.route(model_group) inside the per-deployment loop.
On large deployment lists this repeated deep copying blocked the event
loop for minutes and failed health checks (#33636).

create_model_info_response now reads limits through the lru_cached
_cached_get_model_group_info; wildcard expansion shallow-copies the
deployment and its litellm_params (only litellm_params["model"] is
rewritten); and _set_model_group_info drops the redundant per-deployment
route re-check, since get_model_list already normalizes a matched
wildcard deployment's model_name to the requested group.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Krrish Dholakia 2026-07-17 04:14:43 +00:00
parent 9cae6fa437
commit cd7933ecef
6 changed files with 88 additions and 22 deletions

View file

@ -6124,7 +6124,7 @@ def create_model_info_response(
# to the base response rather than 500-ing the whole listing.
if llm_router is not None:
try:
model_group_info = llm_router.get_model_group_info(model_id)
model_group_info = llm_router._cached_get_model_group_info(model_id)
except Exception as e:
verbose_proxy_logger.debug(
"create_model_info_response: get_model_group_info failed for %s: %s",

View file

@ -8746,13 +8746,7 @@ class Router:
if model_list is None:
return None
for model in model_list:
is_match = False
if "model_name" in model and model["model_name"] == model_group: # exact match
is_match = True
elif "model_name" in model and self.pattern_router.route(model_group) is not None: # wildcard model
is_match = True
if not is_match:
if "model_name" not in model or model["model_name"] != model_group:
continue
# model in model group found #
litellm_params = LiteLLM_Params(**model["litellm_params"]) # type: ignore

View file

@ -2,7 +2,6 @@
Class to handle llm wildcard routing and regex pattern matching
"""
import copy
import re
from re import Match
from typing import Dict, List, Optional, Tuple
@ -98,16 +97,19 @@ class PatternMatchRouter:
return re.escape(pattern).replace(r"\*", "(.*)")
def _return_pattern_matched_deployments(self, matched_pattern: Match, deployments: List[Dict]) -> List[Dict]:
new_deployments = []
for deployment in deployments:
new_deployment = copy.deepcopy(deployment)
new_deployment["litellm_params"]["model"] = PatternMatchRouter.set_deployment_model_name(
matched_pattern=matched_pattern,
litellm_deployment_litellm_model=deployment["litellm_params"]["model"],
)
new_deployments.append(new_deployment)
return new_deployments
return [
{
**deployment,
"litellm_params": {
**deployment["litellm_params"],
"model": PatternMatchRouter.set_deployment_model_name(
matched_pattern=matched_pattern,
litellm_deployment_litellm_model=deployment["litellm_params"]["model"],
),
},
}
for deployment in deployments
]
def route(self, request: Optional[str], filtered_model_names: Optional[List[str]] = None) -> Optional[List[Dict]]:
"""

View file

@ -482,7 +482,7 @@ from litellm.types.router import ModelGroupInfo
def _router_returning(model_group_info):
router = MagicMock()
router.get_model_group_info = MagicMock(return_value=model_group_info)
router._cached_get_model_group_info = MagicMock(return_value=model_group_info)
return router
@ -500,7 +500,11 @@ def test_create_model_info_response_includes_max_tokens_when_available():
model_id="qwen-vllm", provider="openai", llm_router=router
)
router.get_model_group_info.assert_called_once_with("qwen-vllm")
# /v1/models must read limits through the lru_cached accessor, not the
# uncached public method that recomputes group info per listed model and
# pegged the event loop for minutes (issue #33636).
router._cached_get_model_group_info.assert_called_once_with("qwen-vllm")
router.get_model_group_info.assert_not_called()
assert response["id"] == "qwen-vllm"
assert response["object"] == "model"
assert response["max_input_tokens"] == 32768
@ -582,7 +586,7 @@ def test_create_model_info_response_degrades_when_group_info_raises():
# A malformed deployment must not turn the listing into a 500; the entry
# falls back to the base fields without limits.
router = MagicMock()
router.get_model_group_info = MagicMock(side_effect=ValueError("bad deployment"))
router._cached_get_model_group_info = MagicMock(side_effect=ValueError("bad deployment"))
response = create_model_info_response(
model_id="broken", provider="openai", llm_router=router

View file

@ -0,0 +1,36 @@
import re
from litellm.router_utils.pattern_match_deployments import PatternMatchRouter
def test_return_pattern_matched_deployments_does_not_deepcopy_source():
# Regression for issue #33636: expanding a wildcard route used to
# copy.deepcopy the whole deployment per matched deployment, which pegged
# the proxy CPU for large deployments listed via GET /v1/models. Only
# litellm_params["model"] is rewritten, so the expansion must shallow-copy:
# the source deployment stays untouched and every nested object is shared
# by identity (a reintroduced deepcopy would break the identity assert).
shared_metadata = {"tier": "gold"}
deployment = {
"model_name": "openai/*",
"litellm_params": {"model": "openai/*", "api_key": "sk-test", "metadata": shared_metadata},
"model_info": {"id": "abc"},
}
router = PatternMatchRouter()
router.add_pattern("openai/*", deployment)
matched = router.route("openai/gpt-4")
assert matched is not None
(expanded,) = matched
assert expanded["litellm_params"]["model"] == "openai/gpt-4"
# source is left untouched
assert deployment["litellm_params"]["model"] == "openai/*"
# shallow copy: new top-level and litellm_params dicts, shared nested objects
assert expanded is not deployment
assert expanded["litellm_params"] is not deployment["litellm_params"]
assert expanded["litellm_params"]["metadata"] is shared_metadata
assert expanded["model_info"] is deployment["model_info"]

View file

@ -1581,6 +1581,36 @@ def test_model_group_info_db_fallback_with_stringified_cost_values():
assert isinstance(result.output_cost_per_token, float)
def test_set_model_group_info_resolves_wildcard_concrete_model():
"""
Regression for issue #33636: _set_model_group_info dropped the per-deployment
pattern_router.route(model_group) re-check, one of the repeated wildcard
expansions that pegged the proxy CPU on GET /v1/models. The loop now matches
only on model_name equality, which is safe because get_model_list already
normalizes a matched wildcard deployment's model_name to the requested group.
A concrete model served by a wildcard route must still resolve to a valid
ModelGroupInfo; if that normalization ever regresses, this fails instead of
silently dropping the group's limits.
"""
router = litellm.Router(
model_list=[
{
"model_name": "openai/*",
"litellm_params": {"model": "openai/*", "api_key": "fake"},
},
]
)
result = router._set_model_group_info(
model_group="openai/gpt-4o",
user_facing_model_group_name="openai/gpt-4o",
)
assert result is not None
assert result.model_group == "openai/gpt-4o"
assert "openai" in result.providers
def test_get_model_access_groups_caching():
"""
Test that get_model_access_groups caches the no-args result