From 437a179612c939ee79a09b569be2ac94dce05233 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 15:18:23 +0530 Subject: [PATCH 01/10] fix(router): constrain same-name deployment routing by access groups Filter router candidate deployments by caller-authorized model access groups when access is granted via group membership, preventing cross-group load balancing for shared public model names. Made-with: Cursor --- litellm/router.py | 77 ++++++++++++++++++++ tests/test_litellm/test_router.py | 115 ++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index f6976109bc..2e436973e7 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9241,6 +9241,12 @@ class Router: healthy_deployments = self._get_all_deployments( model_name=model, team_id=request_team_id ) + healthy_deployments = self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=healthy_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ) if len(healthy_deployments) == 0: # check if the user sent in a deployment name instead @@ -9264,6 +9270,14 @@ class Router: healthy_deployments = self._get_all_deployments( model_name=model, team_id=request_team_id ) + healthy_deployments = ( + self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=healthy_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ) + ) # If still no deployments after checking for fallbacks, raise an error if len(healthy_deployments) == 0: @@ -9289,6 +9303,68 @@ class Router: return model, healthy_deployments + def _filter_deployments_by_model_access_groups( + self, + model: str, + healthy_deployments: List, + request_kwargs: Optional[Dict], + request_team_id: Optional[str], + ) -> List: + """ + Restrict candidate deployments to caller-authorized model access groups. + + This is only applied when: + - request metadata includes `user_api_key_auth`, and + - caller permissions for this model are access-group-only + (no explicit model, wildcard, or all-proxy grants). + """ + if not healthy_deployments or request_kwargs is None: + return healthy_deployments + + metadata = request_kwargs.get("metadata") or {} + litellm_metadata = request_kwargs.get("litellm_metadata") or {} + user_api_key_auth = metadata.get("user_api_key_auth") or litellm_metadata.get( + "user_api_key_auth" + ) + if user_api_key_auth is None: + return healthy_deployments + + object_models = set(getattr(user_api_key_auth, "models", []) or []) + object_team_models = set(getattr(user_api_key_auth, "team_models", []) or []) + allowed_models = object_models | object_team_models + if not allowed_models: + return healthy_deployments + + # If caller has direct model/wildcard/all-proxy access, do not constrain + # deployment choice by access group. + if ( + model in allowed_models + or "*" in allowed_models + or "all-proxy-models" in allowed_models + ): + return healthy_deployments + + access_groups_for_model = self.get_model_access_groups( + model_name=model, team_id=request_team_id + ) + if len(access_groups_for_model) == 0: + return healthy_deployments + + allowed_access_groups = set(access_groups_for_model.keys()) & allowed_models + if not allowed_access_groups: + return healthy_deployments + + filtered_deployments = [] + for deployment in healthy_deployments: + deployment_model_info = deployment.get("model_info") or {} + deployment_access_groups = set( + deployment_model_info.get("access_groups", []) or [] + ) + if deployment_access_groups & allowed_access_groups: + filtered_deployments.append(deployment) + + return filtered_deployments + async def async_get_healthy_deployments( self, model: str, @@ -9796,6 +9872,7 @@ class Router: messages=messages, input=input, specific_deployment=specific_deployment, + request_kwargs=request_kwargs, ) if isinstance(healthy_deployments, dict): diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 2ae54f5510..af8633361a 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3204,3 +3204,118 @@ async def test_multiregion_team_failover_between_regions(): "response from us-east-1", "response from us-west-2", ] + + +def test_access_group_scoped_key_filters_deployments_with_same_public_model(): + """ + If a key can access a model only via access group membership, + router candidate deployments for that public model should be constrained + to deployments in the allowed access group. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["AG1"]}, + }, + { + "model_name": "gpt-5", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + ] + ) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team2", + models=["AG2"], + team_models=["AG2"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="gpt-5", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team2", + "user_api_key_auth": scoped_key, + } + }, + ) + + assert len(deployments) == 1 + assert deployments[0].get("model_info", {}).get("access_groups") == ["AG2"] + + seen = set() + for _ in range(20): + response = router.completion( + model="gpt-5", + messages=[{"role": "user", "content": "hello"}], + metadata={"user_api_key_team_id": "team2", "user_api_key_auth": scoped_key}, + ) + seen.add(response.choices[0].message.content) + + assert seen == {"response-via-AG2"} + + +def test_explicit_model_access_does_not_force_access_group_filtering(): + """ + If a key has explicit model access in addition to access group entries, + do not force access-group-only filtering for deployment selection. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["AG1"]}, + }, + { + "model_name": "gpt-5", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + ] + ) + + explicit_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team2", + models=["AG2", "gpt-5"], + team_models=["AG2", "gpt-5"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="gpt-5", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team2", + "user_api_key_auth": explicit_key, + } + }, + ) + + deployment_groups = [d.get("model_info", {}).get("access_groups") for d in deployments] + assert ["AG1"] in deployment_groups + assert ["AG2"] in deployment_groups From a3da4721cac04518a1acef5dddf34e83b18d4092 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 15:21:35 +0530 Subject: [PATCH 02/10] test(router): add coverage for access-group deployment filter Add a router utils unit test that directly exercises _filter_deployments_by_model_access_groups for access-group-only key permissions. Made-with: Cursor --- .../test_router_utils_common_utils.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_litellm/router_utils/test_router_utils_common_utils.py b/tests/test_litellm/router_utils/test_router_utils_common_utils.py index 02241d4bc9..c6fff7945d 100644 --- a/tests/test_litellm/router_utils/test_router_utils_common_utils.py +++ b/tests/test_litellm/router_utils/test_router_utils_common_utils.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest from litellm import Router +from litellm.proxy._types import UserAPIKeyAuth from litellm.router_utils.common_utils import ( _deployment_supports_web_search, filter_team_based_models, @@ -362,3 +363,46 @@ def test_invalidate_model_group_info_cache(): # Invalidate and verify cache is cleared router._invalidate_model_group_info_cache() assert router._cached_get_model_group_info.cache_info().currsize == 0 + + +def test_filter_deployments_by_model_access_groups_access_group_only_key(): + """ + Access-group-only keys should only route to deployments in allowed groups, + even when multiple deployments share the same public model name. + """ + router = Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": {"model": "openai/gpt-5.1", "api_key": "key-1"}, + "model_info": {"access_groups": ["AG1"]}, + }, + { + "model_name": "gpt-5", + "litellm_params": {"model": "openai/gpt-4o", "api_key": "key-2"}, + "model_info": {"access_groups": ["AG2"]}, + }, + ] + ) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team-2", + models=["AG2"], + team_models=["AG2"], + ) + + filtered = router._filter_deployments_by_model_access_groups( + model="gpt-5", + healthy_deployments=router._get_all_deployments(model_name="gpt-5"), + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team-2", + "user_api_key_auth": scoped_key, + } + }, + request_team_id="team-2", + ) + + assert len(filtered) == 1 + assert filtered[0].get("model_info", {}).get("access_groups") == ["AG2"] From d6be59eac51a304739061559a9e5f865dc2a5e81 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Tue, 21 Apr 2026 15:28:58 +0530 Subject: [PATCH 03/10] chore(router): clarify empty access-group overlap behavior Document why empty allowed_access_groups intentionally preserves unfiltered deployments to avoid breaking non-access-group authorization paths. Made-with: Cursor --- litellm/router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/router.py b/litellm/router.py index 2e436973e7..9edf28df2b 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9352,6 +9352,8 @@ class Router: allowed_access_groups = set(access_groups_for_model.keys()) & allowed_models if not allowed_access_groups: + # No overlap means this request was not authorized via model access + # group membership for this model, so do not force group filtering. return healthy_deployments filtered_deployments = [] From 5feb6008d89043cc555da9cb844e84497be83597 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 1 May 2026 17:56:37 +0530 Subject: [PATCH 04/10] Fix greptile review --- litellm/router.py | 6 ++- tests/test_litellm/test_router.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 7a2e4fc996..0020f2fbe1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9400,6 +9400,7 @@ class Router: healthy_deployments = self._get_all_deployments( model_name=model, team_id=request_team_id ) + _pre_model_access_group_filter_len = len(healthy_deployments) healthy_deployments = self._filter_deployments_by_model_access_groups( model=model, healthy_deployments=healthy_deployments, @@ -9409,7 +9410,10 @@ class Router: if len(healthy_deployments) == 0: # check if the user sent in a deployment name instead - healthy_deployments = self._get_deployment_by_litellm_model(model=model) + # Do not fall back when access-group filtering removed every candidate; + # _get_deployment_by_litellm_model does not re-apply that filter. + if _pre_model_access_group_filter_len == 0: + healthy_deployments = self._get_deployment_by_litellm_model(model=model) if verbose_router_logger.isEnabledFor(logging.DEBUG): verbose_router_logger.debug( diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index af8633361a..e62a650d99 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3319,3 +3319,75 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): deployment_groups = [d.get("model_info", {}).get("access_groups") for d in deployments] assert ["AG1"] in deployment_groups assert ["AG2"] in deployment_groups + + +def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( + monkeypatch: pytest.MonkeyPatch, +): + """ + When access-group filtering removes all candidates, _get_deployment_by_litellm_model + must not run: it does not re-apply access groups and could return blocked deployments + that share the same litellm_params.model as the request model string. + + ``get_model_access_groups`` is patched to expose AG1 for the public model (so the + access-group filter runs with a non-empty allowed set) while every deployment + returned for that name is AG2-only — filtered to empty. Without the guard, the + litellm-model fallback would return both rows because ``litellm_params.model`` matches. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": { + "model": "gpt-5", + "api_key": "key1", + "mock_response": "blocked-dep-1", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + { + "model_name": "gpt-5", + "litellm_params": { + "model": "gpt-5", + "api_key": "key2", + "mock_response": "blocked-dep-2", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + ] + ) + + orig_groups = router.get_model_access_groups + + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): + if model_name == "gpt-5" and model_access_group is None: + return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} + return orig_groups( + model_name=model_name, + model_access_group=model_access_group, + team_id=team_id, + ) + + monkeypatch.setattr(router, "get_model_access_groups", fake_get_model_access_groups) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team2", + models=["AG1"], + team_models=["AG1"], + ) + + with pytest.raises(litellm.BadRequestError): + router._common_checks_available_deployment( + model="gpt-5", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team2", + "user_api_key_auth": scoped_key, + } + }, + ) From 0a9c076e8b992cb39315453d239776bf2a0d55be Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 1 May 2026 18:28:24 +0530 Subject: [PATCH 05/10] Fix greptile review --- litellm/router.py | 105 ++++++++++++++++++------------ tests/test_litellm/test_router.py | 77 ++++++++++++++++++++++ 2 files changed, 141 insertions(+), 41 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 0020f2fbe1..08a56e6abb 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9312,6 +9312,55 @@ class Router: """ return [m for m in self.model_list if m["litellm_params"]["model"] == model] + def _try_early_resolve_deployments_for_model_not_in_names( + self, model: str, request_team_id: Optional[str] + ) -> Optional[Tuple[str, Union[List, Dict]]]: + """ + When ``model`` is not in ``self.model_names``, try team routes, pattern routes, + team pattern routers, then default deployment. Returns None if none apply. + """ + if model in self.model_names: + return None + # Check for team-specific deployments by team_public_model_name. + # This intentionally takes priority over team pattern routers below, + # so that named team deployments shadow wildcard/pattern routes. + if request_team_id is not None: + team_deployments = self._get_all_deployments( + model_name=model, team_id=request_team_id + ) + if team_deployments: + return model, team_deployments + + pattern_deployments = self.pattern_router.get_deployments_by_pattern( + model=model, + ) + + if pattern_deployments: + return model, pattern_deployments + + if ( + request_team_id is not None + and request_team_id in self.team_pattern_routers + ): + pattern_deployments = self.team_pattern_routers[ + request_team_id + ].get_deployments_by_pattern( + model=model, + ) + if pattern_deployments: + return model, pattern_deployments + + if self.default_deployment is not None: + # Shallow copy with nested litellm_params copy (100x+ faster than deepcopy) + updated_deployment = self.default_deployment.copy() + updated_deployment["litellm_params"] = self.default_deployment[ + "litellm_params" + ].copy() + updated_deployment["litellm_params"]["model"] = model + return model, updated_deployment + + return None + def _common_checks_available_deployment( self, model: str, @@ -9354,46 +9403,11 @@ class Router: if _model_from_alias is not None: model = _model_from_alias - if model not in self.model_names: - # Check for team-specific deployments by team_public_model_name. - # This intentionally takes priority over team pattern routers below, - # so that named team deployments shadow wildcard/pattern routes. - if request_team_id is not None: - team_deployments = self._get_all_deployments( - model_name=model, team_id=request_team_id - ) - if team_deployments: - return model, team_deployments - - # check if provider/ specific wildcard routing use pattern matching - pattern_deployments = self.pattern_router.get_deployments_by_pattern( - model=model, - ) - - if pattern_deployments: - return model, pattern_deployments - - if ( - request_team_id is not None - and request_team_id in self.team_pattern_routers - ): - pattern_deployments = self.team_pattern_routers[ - request_team_id - ].get_deployments_by_pattern( - model=model, - ) - if pattern_deployments: - return model, pattern_deployments - - # check if default deployment is set - if self.default_deployment is not None: - # Shallow copy with nested litellm_params copy (100x+ faster than deepcopy) - updated_deployment = self.default_deployment.copy() - updated_deployment["litellm_params"] = self.default_deployment[ - "litellm_params" - ].copy() - updated_deployment["litellm_params"]["model"] = model - return model, updated_deployment + early = self._try_early_resolve_deployments_for_model_not_in_names( + model=model, request_team_id=request_team_id + ) + if early is not None: + return early ## get healthy deployments ### get all deployments @@ -9407,6 +9421,9 @@ class Router: request_kwargs=request_kwargs, request_team_id=request_team_id, ) + _access_group_filter_emptied_candidates = ( + _pre_model_access_group_filter_len > 0 and len(healthy_deployments) == 0 + ) if len(healthy_deployments) == 0: # check if the user sent in a deployment name instead @@ -9422,7 +9439,13 @@ class Router: if len(healthy_deployments) == 0: # Check for default fallbacks if no deployments are found for the requested model - if self._has_default_fallbacks(): + # Do not fall back to another model when access-group filtering removed every + # candidate for the requested name: re-filtering the fallback model can be a + # no-op when it has no access_groups, incorrectly serving a different model. + if ( + self._has_default_fallbacks() + and not _access_group_filter_emptied_candidates + ): fallback_model = self._get_first_default_fallback() if fallback_model: verbose_router_logger.info( diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index e62a650d99..5ab16f9862 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3391,3 +3391,80 @@ def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( } }, ) + + +def test_access_group_block_does_not_silently_use_default_fallback_model( + monkeypatch: pytest.MonkeyPatch, +): + """ + When access-group filtering empties candidates for model X, the router must not use + ``fallbacks`` default ``*`` routing to model Y: Y may have no ``access_groups``, so + ``_filter_deployments_by_model_access_groups`` would not constrain Y and the caller + would be served despite being blocked from X. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": { + "model": "gpt-5", + "api_key": "key1", + "mock_response": "blocked-dep-1", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + { + "model_name": "gpt-5", + "litellm_params": { + "model": "gpt-5", + "api_key": "key2", + "mock_response": "blocked-dep-2", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + { + "model_name": "gpt-4-fallback", + "litellm_params": { + "model": "gpt-4", + "api_key": "fallback-key", + "mock_response": "should-not-reach", + }, + }, + ], + fallbacks=[{"*": ["gpt-4-fallback"]}], + ) + + orig_groups = router.get_model_access_groups + + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): + if model_name == "gpt-5" and model_access_group is None: + return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} + return orig_groups( + model_name=model_name, + model_access_group=model_access_group, + team_id=team_id, + ) + + monkeypatch.setattr(router, "get_model_access_groups", fake_get_model_access_groups) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team2", + models=["AG1"], + team_models=["AG1"], + ) + + with pytest.raises(litellm.BadRequestError): + router._common_checks_available_deployment( + model="gpt-5", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team2", + "user_api_key_auth": scoped_key, + } + }, + ) From e942ef6eea35ed8928469228f862b2cd6efc1f40 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 1 May 2026 18:30:33 +0530 Subject: [PATCH 06/10] Fix black --- litellm/router.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 08a56e6abb..ab92e1e24f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9338,10 +9338,7 @@ class Router: if pattern_deployments: return model, pattern_deployments - if ( - request_team_id is not None - and request_team_id in self.team_pattern_routers - ): + if request_team_id is not None and request_team_id in self.team_pattern_routers: pattern_deployments = self.team_pattern_routers[ request_team_id ].get_deployments_by_pattern( From 87fa3323ff238171f0da20c368bed025a0ee86b0 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 1 May 2026 15:51:40 -0700 Subject: [PATCH 07/10] Update litellm/router.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/router.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index ab92e1e24f..d01397fd0f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9422,15 +9422,21 @@ class Router: _pre_model_access_group_filter_len > 0 and len(healthy_deployments) == 0 ) + if len(healthy_deployments) == 0: + # check if the user sent in a deployment name instead + # Do not fall back when access-group filtering removed every candidate; if len(healthy_deployments) == 0: # check if the user sent in a deployment name instead # Do not fall back when access-group filtering removed every candidate; # _get_deployment_by_litellm_model does not re-apply that filter. if _pre_model_access_group_filter_len == 0: - healthy_deployments = self._get_deployment_by_litellm_model(model=model) - - if verbose_router_logger.isEnabledFor(logging.DEBUG): - verbose_router_logger.debug( + _litellm_model_deployments = self._get_deployment_by_litellm_model(model=model) + healthy_deployments = self._filter_deployments_by_model_access_groups( + model=model, + healthy_deployments=_litellm_model_deployments, + request_kwargs=request_kwargs, + request_team_id=request_team_id, + ) f"initial list of deployments: {healthy_deployments}" ) From 36f03f1087ed2f657f18824da8de1e64bdcdaf90 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 1 May 2026 23:01:43 +0000 Subject: [PATCH 08/10] Fix syntax errors from botched merge in router.py --- litellm/router.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index d01397fd0f..32e852db84 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9422,21 +9422,23 @@ class Router: _pre_model_access_group_filter_len > 0 and len(healthy_deployments) == 0 ) - if len(healthy_deployments) == 0: - # check if the user sent in a deployment name instead - # Do not fall back when access-group filtering removed every candidate; if len(healthy_deployments) == 0: # check if the user sent in a deployment name instead # Do not fall back when access-group filtering removed every candidate; # _get_deployment_by_litellm_model does not re-apply that filter. if _pre_model_access_group_filter_len == 0: - _litellm_model_deployments = self._get_deployment_by_litellm_model(model=model) + _litellm_model_deployments = self._get_deployment_by_litellm_model( + model=model + ) healthy_deployments = self._filter_deployments_by_model_access_groups( model=model, healthy_deployments=_litellm_model_deployments, request_kwargs=request_kwargs, request_team_id=request_team_id, ) + + if verbose_router_logger.isEnabledFor(logging.DEBUG): + verbose_router_logger.debug( f"initial list of deployments: {healthy_deployments}" ) From 9d945fe23b23621918cb79143516381b09788ec8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 1 May 2026 23:44:02 +0000 Subject: [PATCH 09/10] Fix access-group bypass via litellm-model fallback path When _get_all_deployments returns 0 candidates and the litellm-model fallback branch (_get_deployment_by_litellm_model) finds deployments that the access-group filter then empties, _access_group_filter_emptied_candidates remained False (it was captured before that branch ran). The router would then proceed to default fallbacks; the fallback model could have no access_groups and short-circuit the filter, silently serving a caller blocked by access-group restrictions. Update the flag inside the litellm-model branch when filtering empties a non-empty candidate set so the default-fallback guard still triggers. --- litellm/router.py | 10 +++++ tests/test_litellm/test_router.py | 73 ++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 878d65fa8f..8e29f8cfc1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -9475,6 +9475,16 @@ class Router: request_kwargs=request_kwargs, request_team_id=request_team_id, ) + # If the litellm-model lookup produced candidates that access-group + # filtering then removed, treat this the same as the by-name path + # being emptied: prevent default-model fallback from bypassing the + # restriction (the fallback model may have no access_groups and + # would short-circuit the filter). + if ( + len(_litellm_model_deployments) > 0 + and len(healthy_deployments) == 0 + ): + _access_group_filter_emptied_candidates = True if verbose_router_logger.isEnabledFor(logging.DEBUG): verbose_router_logger.debug( diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index e759619b5f..b6ea6b374e 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3379,7 +3379,9 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): }, ) - deployment_groups = [d.get("model_info", {}).get("access_groups") for d in deployments] + deployment_groups = [ + d.get("model_info", {}).get("access_groups") for d in deployments + ] assert ["AG1"] in deployment_groups assert ["AG2"] in deployment_groups @@ -3531,3 +3533,72 @@ def test_access_group_block_does_not_silently_use_default_fallback_model( } }, ) + + +def test_access_group_block_via_litellm_model_branch_does_not_use_default_fallback( + monkeypatch: pytest.MonkeyPatch, +): + """ + When the by-name lookup returns no deployments and the litellm-model fallback + branch finds candidates that access-group filtering then empties, the router + must not fall through to default ``fallbacks`` routing — the default fallback + model may have no ``access_groups`` and would short-circuit the filter, + silently serving a caller blocked by access-group restrictions. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5-alias", + "litellm_params": { + "model": "gpt-5", + "api_key": "key1", + "mock_response": "blocked-dep-1", + }, + "model_info": {"access_groups": ["AG2"]}, + }, + { + "model_name": "gpt-4-fallback", + "litellm_params": { + "model": "gpt-4", + "api_key": "fallback-key", + "mock_response": "should-not-reach", + }, + }, + ], + fallbacks=[{"*": ["gpt-4-fallback"]}], + ) + + orig_groups = router.get_model_access_groups + + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): + if model_name == "gpt-5" and model_access_group is None: + return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} + return orig_groups( + model_name=model_name, + model_access_group=model_access_group, + team_id=team_id, + ) + + monkeypatch.setattr(router, "get_model_access_groups", fake_get_model_access_groups) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team2", + models=["AG1"], + team_models=["AG1"], + ) + + with pytest.raises(litellm.BadRequestError): + router._common_checks_available_deployment( + model="gpt-5", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team2", + "user_api_key_auth": scoped_key, + } + }, + ) From 0edbe6b3dc830152b49b8344f7dbe57ef83204f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 2 May 2026 00:18:45 +0000 Subject: [PATCH 10/10] test(router): cover _try_early_resolve_deployments_for_model_not_in_names The router_code_coverage CI check requires every function in router.py to be referenced by at least one test under tests/{local_testing, router_unit_tests,test_litellm} in a file with "router" in its name. The recently-extracted helper had no direct test, so the check failed with "0.45% of functions in router.py are not tested". Add a focused test that exercises the four return paths: model already in self.model_names, no fallback applies, pattern-router match, and default_deployment substitution (also asserting the stored default isn't mutated). https://claude.ai/code/session_019AVp1XL7RT9RxRe4qRLkay --- tests/test_litellm/test_router.py | 95 +++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index b6ea6b374e..48facace52 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -3602,3 +3602,98 @@ def test_access_group_block_via_litellm_model_branch_does_not_use_default_fallba } }, ) + + +def test_try_early_resolve_deployments_for_model_not_in_names(): + """ + Direct coverage for ``_try_early_resolve_deployments_for_model_not_in_names``: + + - Returns ``None`` when the requested model is already in ``self.model_names`` + (the by-name lookup path will handle it). + - Returns ``None`` when there are no team deployments, no pattern matches, and + no default deployment to fall back to. + - Returns the pattern-router match when the model matches a wildcard route. + - Returns the default deployment with the request model substituted in when one + is configured, without mutating the stored default. + """ + router_in_names = litellm.Router( + model_list=[ + { + "model_name": "gpt-5", + "litellm_params": { + "model": "openai/gpt-5", + "api_key": "key1", + }, + }, + ] + ) + + assert ( + router_in_names._try_early_resolve_deployments_for_model_not_in_names( + model="gpt-5", request_team_id=None + ) + is None + ) + assert ( + router_in_names._try_early_resolve_deployments_for_model_not_in_names( + model="some-unknown-model", request_team_id=None + ) + is None + ) + + pattern_router = litellm.Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": "key-pattern", + }, + }, + ] + ) + + pattern_result = ( + pattern_router._try_early_resolve_deployments_for_model_not_in_names( + model="openai/gpt-4o-mini", request_team_id=None + ) + ) + assert pattern_result is not None + resolved_model, pattern_deployments = pattern_result + assert resolved_model == "openai/gpt-4o-mini" + assert isinstance(pattern_deployments, list) and len(pattern_deployments) == 1 + + default_router = litellm.Router( + model_list=[ + { + "model_name": "named-model", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key-named", + }, + }, + ] + ) + default_router.default_deployment = { + "model_name": "default", + "litellm_params": { + "model": "openai/will-be-overridden", + "api_key": "key-default", + }, + } + + default_result = ( + default_router._try_early_resolve_deployments_for_model_not_in_names( + model="brand-new-model", request_team_id=None + ) + ) + assert default_result is not None + resolved_model, default_deployment = default_result + assert resolved_model == "brand-new-model" + assert isinstance(default_deployment, dict) + assert default_deployment["litellm_params"]["model"] == "brand-new-model" + # The original default_deployment must not be mutated. + assert ( + default_router.default_deployment["litellm_params"]["model"] + == "openai/will-be-overridden" + )