fix(health): expand sentinels + access-groups in health_endpoint model filter (#28206)

health_endpoint filtered _llm_model_list by raw user_api_key_dict.models, so keys carrying all-proxy-models/all-team-models sentinels or an access-group name matched no real model_name and the list collapsed to empty, surfacing as healthy_count/unhealthy_count 0 in the dashboard. Resolve the effective allowlist via get_key_models instead, the same helper used by /v1/models and /model/info, so sentinels and access groups expand to concrete model_names before filtering.
This commit is contained in:
vanshtaneja23 2026-06-27 02:50:35 -06:00
parent b443037783
commit 482bae10f4
2 changed files with 83 additions and 11 deletions

View file

@ -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
)

View file

@ -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():
"""