diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 507e8e4d4d..e0d018d434 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -24,6 +24,7 @@ from litellm.proxy._types import ( LitellmUserRoles, ProxyErrorTypes, ProxyException, + SpecialModelNames, UserAPIKeyAuth, WebhookEvent, ) @@ -1074,8 +1075,26 @@ 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. - if len(user_api_key_dict.models) > 0: - allowed_models = set(user_api_key_dict.models) + # 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) + restrict_to_allowed_models = ( + len(accessible_models) > 0 + and SpecialModelNames.all_proxy_models.value not in accessible_models + ) + if restrict_to_allowed_models: + allowed_models = set(accessible_models) _llm_model_list = [ m for m in _llm_model_list if m.get("model_name") in allowed_models ] @@ -1087,7 +1106,7 @@ async def health_endpoint( # other healthy model would still report healthy_count > 0 and # the targeted-503 path would never fire. targeted_ids = _resolve_targeted_model_ids(_llm_model_list, model, model_id) - if len(user_api_key_dict.models) > 0: + if restrict_to_allowed_models: allowed_model_ids = { (m.get("model_info") or {}).get("id") for m in _llm_model_list 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 d31cfdc39b..a04ad5598d 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1221,6 +1221,138 @@ async def test_health_endpoint_filters_model_list_by_user_access(): }, f"health_endpoint did not scope model_list to caller access: {returned_names}" +@pytest.mark.asyncio +async def test_health_endpoint_keeps_full_model_list_for_all_proxy_models(): + """ + A key granted all model permissions carries the literal + "all-proxy-models" entry in user_api_key_dict.models. It matches no real + model_name, so the access filter must be skipped entirely; otherwise the + model list filters down to nothing and /health reports 0/0 counts. + """ + from litellm.proxy._types import SpecialModelNames, 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"}, + }, + ] + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-test-key", + models=[SpecialModelNames.all_proxy_models.value], + ) + + 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", None), + 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-a", + "model-b", + }, f"all-proxy-models key should health-check every model: {returned_names}" + + +@pytest.mark.asyncio +async def test_health_endpoint_resolves_all_team_models_to_team_allowlist(): + """ + A key granted "all-team-models" carries the literal sentinel in + user_api_key_dict.models, which matches no real model_name. With a + team_id the sentinel must resolve to the team's allowlist (same + semantics as get_key_models); otherwise the filter would zero out the + model list just like the all-proxy-models case. + """ + from litellm.proxy._types import SpecialModelNames, 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"}, + }, + ] + + user_api_key_dict = UserAPIKeyAuth( + api_key="hashed-test-key", + models=[SpecialModelNames.all_team_models.value], + team_id="team-1", + team_models=["model-b"], + ) + + 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", None), + 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"all-team-models key should health-check the team's models: {returned_names}" + + @pytest.mark.asyncio async def test_health_endpoint_filters_background_cache_by_user_access(): """