diff --git a/litellm/router_utils/pattern_match_deployments.py b/litellm/router_utils/pattern_match_deployments.py index c08f8e95cf4..a5a6188003b 100644 --- a/litellm/router_utils/pattern_match_deployments.py +++ b/litellm/router_utils/pattern_match_deployments.py @@ -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]]: """ diff --git a/tests/local_testing/test_router_pattern_matching.py b/tests/local_testing/test_router_pattern_matching.py index d09790d43b1..cf70f1a42ca 100644 --- a/tests/local_testing/test_router_pattern_matching.py +++ b/tests/local_testing/test_router_pattern_matching.py @@ -395,3 +395,58 @@ def test_wildcard_priority_over_deployment_names(): assert ( deployments[0]["litellm_params"]["api_base"] == "http://localhost:8081/openai" ), f"Expected '*' wildcard deployment (8081), got {deployments[0]['litellm_params']['api_base']}" + + +def test_route_does_not_mutate_stored_template(): + """ + Regression: route() substitutes the requested wildcard segment into + litellm_params["model"], but it must never mutate the deployment template + stored in self.patterns. + + A naive shallow copy of only the outer deployment dict shares the nested + litellm_params dict with the template, so writing litellm_params["model"] + would corrupt the stored pattern for every subsequent request. + """ + router = PatternMatchRouter() + deployment = Deployment( + model_name="openai/*", + litellm_params=LiteLLM_Params(model="openai/*"), + model_info=ModelInfo(), + ) + router.add_pattern("openai/*", deployment.to_json(exclude_none=True)) + + first = router.route("openai/gpt-4o") + assert first is not None and first[0]["litellm_params"]["model"] == "openai/gpt-4o" + + stored_template = list(router.patterns.values())[0][0] + assert stored_template["litellm_params"]["model"] == "openai/*" + + second = router.route("openai/gpt-4o-mini") + assert second is not None and second[0]["litellm_params"]["model"] == "openai/gpt-4o-mini" + assert first[0]["litellm_params"]["model"] == "openai/gpt-4o" + + +def test_route_returns_shallow_copy_not_deepcopy(): + """ + Regression: the per-listed-model /v1/models hot path deep-copied every + matched deployment, which blocked the event loop (issue tracked in PR + #33721). route() must isolate the mutated litellm_params.model without a + full deepcopy, so nested objects that are not rewritten stay shared by + reference with the template. Reverting to copy.deepcopy fails this. + """ + router = PatternMatchRouter() + shared_metadata = {"team": "core"} + deployment = Deployment( + model_name="openai/*", + litellm_params=LiteLLM_Params(model="openai/*", metadata=shared_metadata), + model_info=ModelInfo(), + ) + router.add_pattern("openai/*", deployment.to_json(exclude_none=True)) + + stored_template = list(router.patterns.values())[0][0] + + routed = router.route("openai/gpt-4o") + assert routed is not None + + assert routed[0]["litellm_params"] is not stored_template["litellm_params"] + assert routed[0]["litellm_params"]["metadata"] is stored_template["litellm_params"]["metadata"]