diff --git a/litellm/router.py b/litellm/router.py index af2181cedb5..e59d3027115 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -15345,7 +15345,11 @@ class Router(DeprecatedRouterMembers): object_models: Final = set(getattr(user_api_key_auth, "models", []) or []) object_team_models: Final = set(getattr(user_api_key_auth, "team_models", []) or []) - allowed_models: Final = object_models | object_team_models + allowed_models: Final = ( + object_models + if object_models and "all-team-models" not in object_models + else object_models | object_team_models + ) if not allowed_models: return healthy_deployments diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 74959155380..d413ed87933 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -11549,6 +11549,124 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): assert ["AG2"] in deployment_groups +def test_key_access_group_narrows_below_team_for_same_public_model(): + """ + When the team grants two access groups serving the same public model but the + key is restricted to one of them, deployment selection must be constrained to + the key's group. The team's broader access must not widen the key's choice of + same-named deployments. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["zz-grp-A"]}, + }, + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["zz-grp-B"]}, + }, + ] + ) + + scoped_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team1", + models=["zz-grp-A"], + team_models=["zz-grp-A", "zz-grp-B"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="zz-test-same", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team1", + "user_api_key_auth": scoped_key, + } + }, + ) + + assert len(deployments) == 1 + assert deployments[0].get("model_info", {}).get("access_groups") == ["zz-grp-A"] + + seen = set() + for _ in range(20): + response = router.completion( + model="zz-test-same", + messages=[{"role": "user", "content": "hello"}], + metadata={"user_api_key_team_id": "team1", "user_api_key_auth": scoped_key}, + ) + seen.add(response.choices[0].message.content) + + assert seen == {"response-via-AG1"} + + +def test_key_all_team_models_inherits_team_access_groups(): + """ + A key whose models field is the ``all-team-models`` sentinel inherits the + team's access groups, so both same-named deployments stay selectable. + """ + from litellm.proxy._types import UserAPIKeyAuth + + router = litellm.Router( + model_list=[ + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-5.1", + "api_key": "key1", + "mock_response": "response-via-AG1", + }, + "model_info": {"access_groups": ["zz-grp-A"]}, + }, + { + "model_name": "zz-test-same", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "key2", + "mock_response": "response-via-AG2", + }, + "model_info": {"access_groups": ["zz-grp-B"]}, + }, + ] + ) + + inherit_key = UserAPIKeyAuth( + api_key="hashed-key", + team_id="team1", + models=["all-team-models"], + team_models=["zz-grp-A", "zz-grp-B"], + ) + + _model, deployments = router._common_checks_available_deployment( + model="zz-test-same", + request_kwargs={ + "metadata": { + "user_api_key_team_id": "team1", + "user_api_key_auth": inherit_key, + } + }, + ) + + deployment_groups = [ + d.get("model_info", {}).get("access_groups") for d in deployments + ] + assert ["zz-grp-A"] in deployment_groups + assert ["zz-grp-B"] in deployment_groups + + def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( monkeypatch: pytest.MonkeyPatch, ):