diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index c20991b8d43..c5c892eb720 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -28,6 +28,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, WebhookEvent, ) +from litellm.proxy.auth.model_checks import get_key_models from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.health_check import ( @@ -994,17 +995,20 @@ async def health_endpoint( # response but NOT in the background-cache /health response. This is # surfaced via the "warnings" field below so operators can fix the # missing model_info.id rather than guess at the discrepancy. - # Keys granted SpecialModelNames.all_proxy_models carry the literal - # "all-proxy-models" entry, which matches no real model_name; treat - # them as unrestricted instead of filtering the list down to nothing. - # Keys granted SpecialModelNames.all_team_models inherit the parent - # team's allowlist (same semantics as get_key_models in - # model_checks.py). Without a team_id the sentinel cannot resolve and - # stays in the list, matching nothing; denied rather than - # unrestricted, mirroring _resolve_key_models_for_auth_check. - accessible_models = list(user_api_key_dict.models) - if SpecialModelNames.all_team_models.value in accessible_models and user_api_key_dict.team_id is not None: - accessible_models = list(user_api_key_dict.team_models) + # Resolve the caller's effective allowlist via get_key_models so the + # access sentinels (all-proxy-models / all-team-models) and access + # groups expand to concrete model_names. Filtering on raw .models would + # drop every deployment for keys whose entry is a group or sentinel + # (issue #28206). + accessible_models = get_key_models( + user_api_key_dict=user_api_key_dict, + proxy_model_list=( + llm_router.get_model_names() if llm_router is not None else [] + ), + model_access_groups=( + llm_router.get_model_access_groups() if llm_router is not None else {} + ), + ) restrict_to_allowed_models = ( len(accessible_models) > 0 and SpecialModelNames.all_proxy_models.value not in accessible_models ) diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index a04ad5598df..7577feb49da 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1353,6 +1353,74 @@ async def test_health_endpoint_resolves_all_team_models_to_team_allowlist(): }, f"all-team-models key should health-check the team's models: {returned_names}" +@pytest.mark.asyncio +async def test_health_endpoint_expands_access_group_models(): + """ + A key scoped to an access group carries the group name (not a real + model_name) in user_api_key_dict.models. health_endpoint() must expand the + group to its member model_names before filtering; otherwise the group name + matches nothing and the model list zeroes out (issue #28206). + """ + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.health_endpoints._health_endpoints import health_endpoint + + full_model_list = [ + { + "model_name": "model-a", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "id-a"}, + }, + { + "model_name": "model-b", + "litellm_params": {"model": "openai/gpt-4o"}, + "model_info": {"id": "id-b"}, + }, + ] + + mock_router = MagicMock() + mock_router.get_model_names.return_value = ["model-a", "model-b"] + mock_router.get_model_access_groups.return_value = {"beta-group": ["model-b"]} + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-test-key", + models=["beta-group"], + ) + + captured: dict = {} + + async def fake_perform(**kwargs): + captured["model_list"] = kwargs["model_list"] + return { + "healthy_endpoints": [], + "unhealthy_endpoints": [], + "healthy_count": 0, + "unhealthy_count": 0, + } + + with ( + patch("litellm.proxy.proxy_server.llm_model_list", full_model_list), + patch("litellm.proxy.proxy_server.llm_router", mock_router), + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.use_background_health_checks", False), + patch("litellm.proxy.proxy_server.user_model", None), + patch("litellm.proxy.proxy_server.health_check_results", {}), + patch("litellm.proxy.proxy_server.health_check_details", True), + patch("litellm.proxy.proxy_server.health_check_concurrency", 1), + patch( + "litellm.proxy.health_endpoints._health_endpoints._perform_health_check_and_save", + side_effect=fake_perform, + ), + ): + from fastapi import Response + + await health_endpoint(response=Response(), user_api_key_dict=user_api_key_dict) + + returned_names = {m["model_name"] for m in captured["model_list"]} + assert returned_names == { + "model-b" + }, f"access-group key should expand to its member models: {returned_names}" + + @pytest.mark.asyncio async def test_health_endpoint_filters_background_cache_by_user_access(): """