fix(proxy): keep /health inside the caller's team for unrestricted non-admin keys

This commit is contained in:
mateo-berri 2026-09-04 09:53:53 -07:00
parent 69a45e81cc
commit 8b3faa6ed8
2 changed files with 64 additions and 5 deletions

View file

@ -878,11 +878,17 @@ def _health_accessible_model_names(
def _caller_may_probe_deployment(
deployment: Mapping[str, object], allowed_models: frozenset[str], llm_router: Router | None, team_id: str | None
deployment: Mapping[str, object],
allowed_models: frozenset[str] | None,
llm_router: Router | None,
team_id: str | None,
caller_is_admin: bool,
) -> bool:
"""Same deployment visibility rule as routing: another team's deployment is never in scope, team-less callers included."""
if not Router._deployment_usable_by_team(deployment, team_id):
if not caller_is_admin and not Router._deployment_usable_by_team(deployment, team_id):
return False
if allowed_models is None:
return True
if llm_router is None:
return deployment.get("model_name") in allowed_models
model: Final = dict(deployment)
@ -1120,12 +1126,12 @@ async def health_endpoint(
detail={"error": "Model list not initialized"},
)
allowed_models: Final = _health_accessible_model_names(user_api_key_dict, llm_router)
restrict_to_allowed_models: Final = allowed_models is not None
restrict_to_allowed_models: Final = not is_admin or allowed_models is not None
_llm_model_list: Final = [
m
for m in copy.deepcopy(llm_model_list)
if allowed_models is None
or _caller_may_probe_deployment(m, allowed_models, llm_router, user_api_key_dict.team_id)
if not restrict_to_allowed_models
or _caller_may_probe_deployment(m, allowed_models, llm_router, user_api_key_dict.team_id, is_admin)
]
targeted_ids: Final = _resolve_targeted_model_ids(_llm_model_list, model, model_id)
if restrict_to_allowed_models and targeted_ids is not None and not targeted_ids:

View file

@ -1836,6 +1836,8 @@ async def test_health_endpoint_treats_no_team_all_team_models_as_unrestricted():
user_api_key_dict=UserAPIKeyAuth(
api_key="hashed-test-key", models=[SpecialModelNames.all_team_models.value], team_id=None
),
model=None,
model_id=None,
)
assert {m["model_name"] for m in captured["model_list"]} == {"bedrock-nova", "gpt-5.4-mini"}
@ -2844,6 +2846,57 @@ async def test_health_endpoint_hides_team_deployments_from_a_key_with_no_team():
assert probed == {"id-bedrock"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("team_id", "expected_ids"),
[(None, {"id-bedrock"}), ("team-a", {"id-bedrock"}), ("team-b", {"id-bedrock", "id-team-b"})],
)
async def test_health_endpoint_keeps_an_unrestricted_non_admin_key_to_its_own_team(team_id, expected_ids):
"""
A key with no model restriction is still bound by routing's team rule:
it may probe global deployments and its own team's, never another team's.
"""
probed = await _live_probed_model_ids(
_TEAM_MODEL_LIST,
UserAPIKeyAuth(api_key="hashed-test-key", models=[], team_id=team_id),
)
assert probed == expected_ids
@pytest.mark.asyncio
async def test_health_endpoint_lets_a_proxy_admin_probe_every_teams_deployment():
probed = await _live_probed_model_ids(
_TEAM_MODEL_LIST,
UserAPIKeyAuth(api_key="hashed-test-key", models=[], user_role=LitellmUserRoles.PROXY_ADMIN),
)
assert probed == {"id-bedrock", "id-team-b"}
@pytest.mark.asyncio
async def test_health_endpoint_keeps_an_unrestricted_non_admin_key_to_its_own_team_on_background_cache_path():
from fastapi import Response
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
with _proxy_health_globals(
_TEAM_MODEL_LIST,
_router_for(_TEAM_MODEL_LIST),
use_background_health_checks=True,
health_check_results=_TEAM_CACHED_RESULTS,
):
result = await health_endpoint(
response=Response(),
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-test-key", models=[], team_id="team-a"),
model=None,
model_id=None,
)
assert [ep["model_id"] for ep in result["healthy_endpoints"]] == ["id-bedrock"]
assert result["healthy_count"] == 1
@pytest.mark.asyncio
async def test_health_endpoint_shows_a_teams_own_deployment_by_its_public_name():
"""