mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-10 22:24:51 +00:00
refactor: scope /health response to caller's models and tidy display fields (#26935)
* refactor: scope /health response to caller's models and tidy display fields Two small consistency changes to the /health response: 1. health_endpoint() now narrows _llm_model_list to deployments whose model_name is in user_api_key_dict.models, matching how other model listing endpoints already scope their output. The same narrowing applies to the cached health_check_results dict when background_health_checks is enabled, via a new _filter_health_check_results_by_model_ids helper. 2. ILLEGAL_DISPLAY_PARAMS in health_check.py picks up api_base and api_version, which are provider routing fields and not part of the health response shape. Tests in tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py pin both behaviors so future changes do not widen the response shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address greptile review feedback (greploop iteration 1) - tests: extend background-cache test with model_id on cached entries plus positive assertions that model-a's deployment is the one returned, so the test is no longer satisfied by an empty result. - _health_endpoints.py: add a verbose_proxy_logger.debug line when a scoped key has accessible model_names but the matching deployments have no model_info.id, so the empty cache-result case is observable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * make background-cache test's non-vacuity explicit Restructure test_health_endpoint_filters_background_cache_by_user_access so the assertions positively pin the post-scoping result (one entry, model_id == "id-a", api_base == https://example-a.test) and add fixture sanity checks that confirm the source cache had two entries and every cached entry carries a model_id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * gate api_base in /health response on proxy-admin role Replace the blanket strip of api_base / api_version with a role-aware post-processor: - api_base is now left in the cleaned per-deployment dict that _clean_endpoint_data produces (api_version stays in the denylist). - health_endpoint() removes api_base from each endpoint entry before returning when the caller's user_role is not PROXY_ADMIN / PROXY_ADMIN_VIEW_ONLY. The strip uses a copy so the shared health_check_results cache still carries api_base for subsequent admin reads. Net effect: a proxy admin can still see which Vertex region or Azure resource is healthy in the /health output, while non-admin keys (and read-only keys) only see model / model_id / status fields. Tests: - test_health_endpoint_admin_sees_api_base_non_admin_does_not pins both branches and verifies the cache is not mutated. - test_clean_endpoint_data_strips_credentials_but_keeps_api_base replaces the previous mask/drop tests now that the cleaning helper no longer touches api_base. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * address review feedback: api_version symmetry, missing-id warnings, deprecation header Three blockers raised in review: 1. api_version asymmetry — api_base was role-gated for proxy admins, but api_version was unconditionally stripped via ILLEGAL_DISPLAY_PARAMS. Move api_version out of the credential denylist and into a new ADMIN_ONLY_HEALTH_DISPLAY_PARAMS tuple alongside api_base, so admins keep both routing fields and non-admins lose both. Useful for telling apart Vertex regions or Azure api-versions from the /health response. 2. Silent empty results when scoped key's deployments lack model_info.id — raise the existing log from .debug to .warning, and add a structured "warnings" field to the response so the caller can distinguish "no deployments configured" from "deployments excluded due to missing model_info.id". 3. Migration signal for the api_base / api_version removal — when a non-admin caller hits /health, set a "Litellm-Health-Field-Notice" response header so existing dashboards or scripts that parsed those fields can detect the change programmatically rather than silently seeing absent keys. Tests adjusted: existing background-cache test injects a Response stub, admin-vs-non-admin test now asserts both api_base and api_version are gated and asserts the notice header. New test covers the warnings field when a scoped key's deployments are missing model_info.id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * defensive copies + clarifying comments in /health filter - _filter_health_check_results_by_model_ids now shallow-copies each retained endpoint dict before returning. The shared module-level health_check_results cache should never be mutated by downstream transforms, even though _strip_admin_only_fields_from_health_result already builds new dicts today. - Document the live (model_name) vs cache (model_id) scoping asymmetry so future readers do not have to derive it from the warnings field. - Document why _PROXY_ADMIN_ROLES includes PROXY_ADMIN_VIEW_ONLY (read- only operators need routing fields to diagnose health). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
yuneng-jiang
Claude Opus 4.7
parent
02582466c4
commit
b14e1d7d6a
@@ -29,6 +29,10 @@ ILLEGAL_DISPLAY_PARAMS = [
|
||||
"exception", # internal; not JSON-serializable, never for display
|
||||
"litellm_metadata", # internal tracking metadata with auth objects; not for display
|
||||
]
|
||||
# Provider routing fields. Allowed for proxy admins so they can see which
|
||||
# region/version a deployment is checking; gated at the endpoint layer for
|
||||
# non-admin callers (see _strip_admin_only_fields_from_health_result).
|
||||
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS = ("api_base", "api_version")
|
||||
|
||||
MINIMAL_DISPLAY_PARAMS = ["model", "mode_error"]
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from litellm.proxy._types import (
|
||||
CallInfo,
|
||||
EnterpriseLicenseData,
|
||||
Litellm_EntityType,
|
||||
LitellmUserRoles,
|
||||
ProxyErrorTypes,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
@@ -28,6 +29,7 @@ from litellm.proxy._types import (
|
||||
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 (
|
||||
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
|
||||
_clean_endpoint_data,
|
||||
_update_litellm_params_for_health_check,
|
||||
perform_health_check,
|
||||
@@ -723,6 +725,90 @@ async def _save_background_health_checks_to_db(
|
||||
# Continue execution - don't let database save failure break health checks
|
||||
|
||||
|
||||
_PROXY_ADMIN_ROLES = frozenset(
|
||||
{
|
||||
LitellmUserRoles.PROXY_ADMIN.value,
|
||||
# View-only admins are operators (oncall, support); they need the
|
||||
# routing fields (api_base, api_version) to diagnose health and tell
|
||||
# which provider region a check is hitting. They cannot mutate config
|
||||
# so granting them the read-only view is safe.
|
||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
|
||||
"""
|
||||
Return True if the caller has a proxy-admin role (full or view-only).
|
||||
|
||||
user_role on UserAPIKeyAuth can be either a LitellmUserRoles enum or its
|
||||
string value depending on how the auth path constructed the object, so we
|
||||
compare against the raw value rather than the enum identity.
|
||||
"""
|
||||
role = user_api_key_dict.user_role
|
||||
if role is None:
|
||||
return False
|
||||
role_value = role.value if hasattr(role, "value") else role
|
||||
return role_value in _PROXY_ADMIN_ROLES
|
||||
|
||||
|
||||
def _strip_admin_only_fields_from_health_result(result: dict) -> dict:
|
||||
"""
|
||||
Return a copy of the /health response with provider routing fields
|
||||
(``api_base``, ``api_version``) removed from each healthy/unhealthy
|
||||
endpoint entry. Used to hide those fields from non-admin callers while
|
||||
still showing them which deployments they own and whether each one is
|
||||
healthy. Proxy admins receive the unmodified result.
|
||||
"""
|
||||
out = dict(result)
|
||||
drop = set(ADMIN_ONLY_HEALTH_DISPLAY_PARAMS)
|
||||
for key in ("healthy_endpoints", "unhealthy_endpoints"):
|
||||
eps = out.get(key)
|
||||
if isinstance(eps, list):
|
||||
out[key] = [
|
||||
(
|
||||
{k: v for k, v in ep.items() if k not in drop}
|
||||
if isinstance(ep, dict)
|
||||
else ep
|
||||
)
|
||||
for ep in eps
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _filter_health_check_results_by_model_ids(
|
||||
results: dict, allowed_model_ids: set
|
||||
) -> dict:
|
||||
"""
|
||||
Restrict a cached background health-check result dict to endpoints whose
|
||||
model_id is in ``allowed_model_ids``.
|
||||
|
||||
Endpoints without a model_id (e.g. CLI-model entries that predate the
|
||||
model_id wiring) are dropped conservatively — we cannot prove they belong
|
||||
to the caller, so they are excluded rather than leaked.
|
||||
|
||||
Each retained endpoint is shallow-copied before being returned, so any
|
||||
downstream transform (e.g. _strip_admin_only_fields_from_health_result)
|
||||
cannot accidentally mutate the shared ``health_check_results`` cache.
|
||||
"""
|
||||
healthy = [
|
||||
dict(ep)
|
||||
for ep in (results.get("healthy_endpoints") or [])
|
||||
if ep.get("model_id") in allowed_model_ids
|
||||
]
|
||||
unhealthy = [
|
||||
dict(ep)
|
||||
for ep in (results.get("unhealthy_endpoints") or [])
|
||||
if ep.get("model_id") in allowed_model_ids
|
||||
]
|
||||
return {
|
||||
"healthy_endpoints": healthy,
|
||||
"unhealthy_endpoints": unhealthy,
|
||||
"healthy_count": len(healthy),
|
||||
"unhealthy_count": len(unhealthy),
|
||||
}
|
||||
|
||||
|
||||
async def _perform_health_check_and_save(
|
||||
model_list,
|
||||
target_model,
|
||||
@@ -771,6 +857,7 @@ async def _perform_health_check_and_save(
|
||||
|
||||
@router.get("/health", tags=["health"], dependencies=[Depends(user_api_key_auth)])
|
||||
async def health_endpoint(
|
||||
response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
model: Optional[str] = fastapi.Query(
|
||||
None, description="Specify the model name (optional)"
|
||||
@@ -838,11 +925,26 @@ async def health_endpoint(
|
||||
detail={"error": f"Model with ID {model_id} not found"},
|
||||
)
|
||||
|
||||
is_admin = _is_proxy_admin(user_api_key_dict)
|
||||
|
||||
def _post_process(result: dict) -> dict:
|
||||
# api_base / api_version reveal which provider/region/internal host the
|
||||
# deployment talks to; only proxy admins receive them. Non-admin keys
|
||||
# still see model/model_id and the healthy/unhealthy status. We also
|
||||
# set a header so non-admin clients that previously parsed those
|
||||
# fields can detect the change programmatically.
|
||||
if is_admin:
|
||||
return result
|
||||
response.headers["Litellm-Health-Field-Notice"] = (
|
||||
"api_base and api_version are admin-only on this endpoint"
|
||||
)
|
||||
return _strip_admin_only_fields_from_health_result(result)
|
||||
|
||||
try:
|
||||
if llm_model_list is None:
|
||||
# if no router set, check if user set a model using litellm --model ollama/llama2
|
||||
if user_model is not None:
|
||||
return await _perform_health_check_and_save(
|
||||
cli_result = await _perform_health_check_and_save(
|
||||
model_list=[],
|
||||
target_model=None,
|
||||
cli_model=user_model,
|
||||
@@ -853,20 +955,59 @@ async def health_endpoint(
|
||||
model_id=None, # CLI model doesn't have model_id
|
||||
max_concurrency=health_check_concurrency,
|
||||
)
|
||||
return _post_process(cli_result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"error": "Model list not initialized"},
|
||||
)
|
||||
_llm_model_list = copy.deepcopy(llm_model_list)
|
||||
### FILTER MODELS FOR ONLY THOSE USER HAS ACCESS TO ###
|
||||
# Live path: scope by model_name (every deployment has one).
|
||||
# Cache path: scope by model_id (the cache is keyed on model_id).
|
||||
# Consequence: a deployment whose model_name the caller can access
|
||||
# but which lacks model_info.id will appear in the live /health
|
||||
# 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:
|
||||
pass
|
||||
else:
|
||||
pass #
|
||||
allowed_models = set(user_api_key_dict.models)
|
||||
_llm_model_list = [
|
||||
m for m in _llm_model_list if m.get("model_name") in allowed_models
|
||||
]
|
||||
if use_background_health_checks:
|
||||
return health_check_results
|
||||
if len(user_api_key_dict.models) > 0:
|
||||
allowed_model_ids = {
|
||||
(m.get("model_info") or {}).get("id")
|
||||
for m in _llm_model_list
|
||||
if (m.get("model_info") or {}).get("id")
|
||||
}
|
||||
filtered = _filter_health_check_results_by_model_ids(
|
||||
health_check_results, allowed_model_ids
|
||||
)
|
||||
if not allowed_model_ids:
|
||||
# Caller has accessible model_names but none of the
|
||||
# matching deployments expose a model_info.id, so the
|
||||
# cache filter (which keys on model_id) drops every
|
||||
# entry. Surface this both as a warning log and a
|
||||
# structured "warnings" field on the response so the
|
||||
# caller can distinguish "no deployments found" from
|
||||
# "deployments excluded due to missing model_info.id".
|
||||
verbose_proxy_logger.warning(
|
||||
"health_endpoint: scoped key %s has accessible models %s "
|
||||
"but none of the matching deployments carry a model_info.id; "
|
||||
"background health-check cache will return an empty result.",
|
||||
user_api_key_dict.user_id,
|
||||
list(user_api_key_dict.models),
|
||||
)
|
||||
filtered["warnings"] = [
|
||||
"Some accessible deployments are missing model_info.id "
|
||||
"and were excluded from this response. Ask a proxy admin "
|
||||
"to populate model_info.id for these models."
|
||||
]
|
||||
return _post_process(filtered)
|
||||
return _post_process(health_check_results)
|
||||
else:
|
||||
return await _perform_health_check_and_save(
|
||||
router_result = await _perform_health_check_and_save(
|
||||
model_list=_llm_model_list,
|
||||
target_model=target_model,
|
||||
cli_model=None,
|
||||
@@ -877,6 +1018,7 @@ async def health_endpoint(
|
||||
model_id=model_id,
|
||||
max_concurrency=health_check_concurrency,
|
||||
)
|
||||
return _post_process(router_result)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error(
|
||||
"litellm.proxy.proxy_server.py::health_endpoint(): Exception occured - {}".format(
|
||||
|
||||
@@ -778,3 +778,373 @@ def test_get_callback_identifier_custom_logger_registry_and_fallback():
|
||||
result = get_callback_identifier(my_callback_function)
|
||||
# Should fall back to callback_name() which returns __name__
|
||||
assert result == "my_callback_function"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health response shape: model-access scoping and display-field allowlist
|
||||
# ---------------------------------------------------------------------------
|
||||
# These tests pin the contract that the /health response (a) only includes
|
||||
# deployments the calling key is allowed to see, and (b) does not return
|
||||
# provider routing fields like api_base / api_version. They guard against
|
||||
# regressions that would widen the response shape.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_filters_model_list_by_user_access():
|
||||
"""
|
||||
health_endpoint() should restrict _llm_model_list to deployments whose
|
||||
model_name appears in user_api_key_dict.models before running the health
|
||||
check. A key scoped to ["model-a"] should only see model-a in the result,
|
||||
not other deployments configured on the proxy.
|
||||
"""
|
||||
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",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
"model_info": {"id": "id-a"},
|
||||
},
|
||||
{
|
||||
"model_name": "model-b",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o",
|
||||
"api_base": "https://example-b.test",
|
||||
"api_version": "2024-10-21",
|
||||
},
|
||||
"model_info": {"id": "id-b"},
|
||||
},
|
||||
]
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-test-key",
|
||||
models=["model-a"],
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
assert (
|
||||
"model_list" in captured
|
||||
), "health_endpoint did not call _perform_health_check_and_save"
|
||||
returned_names = {m["model_name"] for m in captured["model_list"]}
|
||||
assert returned_names == {
|
||||
"model-a"
|
||||
}, f"health_endpoint did not scope model_list to caller access: {returned_names}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_filters_background_cache_by_user_access():
|
||||
"""
|
||||
When background_health_checks is enabled, health_endpoint() should also
|
||||
scope the cached result to the caller's allowed models rather than
|
||||
returning the cache verbatim.
|
||||
"""
|
||||
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",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
"model_info": {"id": "id-a"},
|
||||
},
|
||||
{
|
||||
"model_name": "model-b",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o",
|
||||
"api_base": "https://example-b.test",
|
||||
},
|
||||
"model_info": {"id": "id-b"},
|
||||
},
|
||||
]
|
||||
|
||||
cached_results = {
|
||||
"healthy_endpoints": [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"model_id": "id-a",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"model_id": "id-b",
|
||||
"api_base": "https://example-b.test",
|
||||
},
|
||||
],
|
||||
"unhealthy_endpoints": [],
|
||||
"healthy_count": 2,
|
||||
"unhealthy_count": 0,
|
||||
}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-test-key",
|
||||
models=["model-a"],
|
||||
)
|
||||
|
||||
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", True),
|
||||
patch("litellm.proxy.proxy_server.user_model", None),
|
||||
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
|
||||
patch("litellm.proxy.proxy_server.health_check_details", True),
|
||||
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
|
||||
):
|
||||
from fastapi import Response
|
||||
|
||||
result = await health_endpoint(
|
||||
response=Response(), user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
|
||||
# Sanity: the source cache had two entries before scoping; the scoping
|
||||
# step is what reduces it to one. (This guards against the test passing
|
||||
# vacuously when the cache filter drops everything because cached
|
||||
# entries lack the model_id key — both entries carry model_id above.)
|
||||
assert len(cached_results["healthy_endpoints"]) == 2
|
||||
assert all(
|
||||
ep.get("model_id") for ep in cached_results["healthy_endpoints"]
|
||||
), "test fixture invariant: every cached entry must carry a model_id"
|
||||
|
||||
# The non-admin caller must not see api_base on the returned cache entries.
|
||||
returned = result.get("healthy_endpoints", [])
|
||||
assert (
|
||||
len(returned) == 1
|
||||
), f"expected exactly one cached entry after scoping, got {len(returned)}"
|
||||
assert returned[0]["model_id"] == "id-a"
|
||||
assert "api_base" not in returned[0]
|
||||
assert result["healthy_count"] == 1
|
||||
assert result["unhealthy_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_admin_sees_routing_fields_non_admin_does_not():
|
||||
"""
|
||||
A proxy admin should still see ``api_base`` and ``api_version`` in the
|
||||
/health response so they can tell which Vertex region / Azure resource
|
||||
+ API version is healthy. A non-admin caller must not — both fields
|
||||
should be stripped, and the response should carry a notice header so
|
||||
non-admin clients can detect the change programmatically.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
|
||||
|
||||
full_model_list = [
|
||||
{
|
||||
"model_name": "model-a",
|
||||
"litellm_params": {
|
||||
"model": "openai/gpt-4o",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
"model_info": {"id": "id-a"},
|
||||
},
|
||||
]
|
||||
cached_results = {
|
||||
"healthy_endpoints": [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"model_id": "id-a",
|
||||
"api_base": "https://us-central1-aiplatform.googleapis.com/v1/projects/p",
|
||||
"api_version": "2024-10-21",
|
||||
},
|
||||
],
|
||||
"unhealthy_endpoints": [],
|
||||
"healthy_count": 1,
|
||||
"unhealthy_count": 0,
|
||||
}
|
||||
|
||||
admin_key = UserAPIKeyAuth(
|
||||
api_key="hashed-admin-key",
|
||||
models=["model-a"],
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
non_admin_key = UserAPIKeyAuth(
|
||||
api_key="hashed-user-key",
|
||||
models=["model-a"],
|
||||
)
|
||||
|
||||
common_patches = [
|
||||
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", True),
|
||||
patch("litellm.proxy.proxy_server.user_model", None),
|
||||
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
|
||||
patch("litellm.proxy.proxy_server.health_check_details", True),
|
||||
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
|
||||
]
|
||||
|
||||
for p in common_patches:
|
||||
p.start()
|
||||
try:
|
||||
admin_response = Response()
|
||||
non_admin_response = Response()
|
||||
admin_result = await health_endpoint(
|
||||
response=admin_response, user_api_key_dict=admin_key
|
||||
)
|
||||
non_admin_result = await health_endpoint(
|
||||
response=non_admin_response, user_api_key_dict=non_admin_key
|
||||
)
|
||||
finally:
|
||||
for p in common_patches:
|
||||
p.stop()
|
||||
|
||||
admin_eps = admin_result.get("healthy_endpoints", [])
|
||||
non_admin_eps = non_admin_result.get("healthy_endpoints", [])
|
||||
|
||||
assert len(admin_eps) == 1
|
||||
assert (
|
||||
admin_eps[0]["api_base"]
|
||||
== "https://us-central1-aiplatform.googleapis.com/v1/projects/p"
|
||||
), "admin must see the full api_base so they can identify the region"
|
||||
assert (
|
||||
admin_eps[0]["api_version"] == "2024-10-21"
|
||||
), "admin must see api_version so they can distinguish provider deployments"
|
||||
|
||||
assert len(non_admin_eps) == 1
|
||||
assert "api_base" not in non_admin_eps[0]
|
||||
assert "api_version" not in non_admin_eps[0]
|
||||
|
||||
# Non-admin response must advertise that api_base/api_version were
|
||||
# withheld so clients that previously parsed them can detect the change.
|
||||
assert (
|
||||
non_admin_response.headers.get("Litellm-Health-Field-Notice")
|
||||
== "api_base and api_version are admin-only on this endpoint"
|
||||
)
|
||||
assert "Litellm-Health-Field-Notice" not in admin_response.headers
|
||||
|
||||
# Stripping must produce a copy — the shared cache must still carry the
|
||||
# routing fields so the next admin caller can read them.
|
||||
cached_first = cached_results["healthy_endpoints"][0]
|
||||
assert (
|
||||
cached_first["api_base"]
|
||||
== "https://us-central1-aiplatform.googleapis.com/v1/projects/p"
|
||||
)
|
||||
assert cached_first["api_version"] == "2024-10-21"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_endpoint_warns_when_scoped_models_lack_model_id():
|
||||
"""
|
||||
When a scoped key's accessible models exist on the proxy but none of the
|
||||
matching deployments expose a ``model_info.id``, the cache filter drops
|
||||
everything. The response should include a structured ``warnings`` field
|
||||
so the caller can distinguish "no deployments configured" from
|
||||
"deployments excluded due to missing model_info.id".
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
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",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
# Intentionally no model_info.id — this is the misconfiguration
|
||||
# the warnings field is meant to flag.
|
||||
"model_info": {},
|
||||
},
|
||||
]
|
||||
cached_results = {
|
||||
"healthy_endpoints": [
|
||||
{
|
||||
"model": "openai/gpt-4o",
|
||||
"model_id": "id-a",
|
||||
"api_base": "https://example-a.test",
|
||||
},
|
||||
],
|
||||
"unhealthy_endpoints": [],
|
||||
"healthy_count": 1,
|
||||
"unhealthy_count": 0,
|
||||
}
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-user-key",
|
||||
models=["model-a"],
|
||||
)
|
||||
|
||||
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", True),
|
||||
patch("litellm.proxy.proxy_server.user_model", None),
|
||||
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
|
||||
patch("litellm.proxy.proxy_server.health_check_details", True),
|
||||
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
|
||||
):
|
||||
result = await health_endpoint(
|
||||
response=Response(), user_api_key_dict=user_api_key_dict
|
||||
)
|
||||
|
||||
assert result["healthy_count"] == 0
|
||||
assert result["unhealthy_count"] == 0
|
||||
assert "warnings" in result, (
|
||||
"empty cache result must surface a warnings field so the caller "
|
||||
"can distinguish 'no deployments' from 'deployments excluded'"
|
||||
)
|
||||
assert any("model_info.id" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_clean_endpoint_data_strips_credentials_keeps_routing_fields():
|
||||
"""
|
||||
_clean_endpoint_data() drops credentials but leaves api_base /
|
||||
api_version intact — the per-caller hide/show happens in the endpoint
|
||||
layer based on user role, not in the cleaning helper. This guarantees
|
||||
proxy admins continue to see those fields in the /health response.
|
||||
"""
|
||||
from litellm.proxy.health_check import _clean_endpoint_data
|
||||
|
||||
raw = {
|
||||
"model": "openai/gpt-4o",
|
||||
"api_key": "sk-test",
|
||||
"api_base": "https://example.test/v1",
|
||||
"api_version": "2024-10-21",
|
||||
"aws_access_key_id": "AKIAEXAMPLE",
|
||||
}
|
||||
|
||||
cleaned = _clean_endpoint_data(raw, details=True)
|
||||
|
||||
assert "api_key" not in cleaned
|
||||
assert "aws_access_key_id" not in cleaned
|
||||
assert cleaned.get("api_base") == "https://example.test/v1"
|
||||
assert cleaned.get("api_version") == "2024-10-21"
|
||||
|
||||
Reference in New Issue
Block a user