mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-07 04:24:12 +00:00
fix(health): treat all-proxy-models keys as unrestricted in /health (#30087)
* fix(health): treat all-proxy-models keys as unrestricted in /health A key granted all model permissions stores the literal "all-proxy-models" marker in its models list. The /health access filter compared that marker against real model_names, so the model list filtered down to nothing and the WebUI health check returned healthy_count=0, unhealthy_count=0 with HTTP 503. Skip the filter (both the live path and the background-cache model_id scoping) when the marker is present, matching how auth_checks treats SpecialModelNames.all_proxy_models. Fixes #29744. * fix(health): resolve all-team-models sentinel to the team allowlist Same failure shape as the all-proxy-models case: a key carrying the literal "all-team-models" entry matches no real model_name, so the /health access filter would zero out the model list. Resolve the sentinel to the key's team models when team_id is set, matching get_key_models in model_checks.py. Without a team_id the sentinel stays unresolved and matches nothing, denying rather than widening access, mirroring _resolve_key_models_for_auth_check.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user