diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 8b57bdca2fe..54f8d13102a 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -182,6 +182,7 @@ services = ( "arize", "galileo", "newrelic", + "pointfive", "sqs", ] | str @@ -269,6 +270,7 @@ async def health_services_endpoint( "arize", "galileo", "newrelic", + "pointfive", "sqs", ]: raise HTTPException( @@ -293,7 +295,7 @@ async def health_services_endpoint( service == "openmeter" or service == "braintrust" or service == "generic_api" - or (service_in_success_callbacks and service != "langfuse") + or (service_in_success_callbacks and service not in ("langfuse", "pointfive")) ): _ = await litellm.acompletion( model="openai/litellm-mock-response-model", @@ -385,6 +387,27 @@ async def health_services_endpoint( ), } + elif service == "pointfive": + if not _is_proxy_admin(user_api_key_dict): + non_admin_detail: Final[_ServiceTestErrorDetail] = { + "error": "Only proxy admins can trigger the PointFive liveness ping." + } + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=non_admin_detail) + from litellm.integrations.pointfive import PointFiveLogger + + try: + pointfive_logger: Final = PointFiveLogger(start_periodic_flush=False) + except ValueError as missing_key: + # No key configured is the answer the operator asked for, not a server error. + no_key: Final[_ServiceTestSuccessResponse] = {"status": "unhealthy", "message": str(missing_key)} + return no_key + response = await pointfive_logger.async_health_check() + pointfive_health: Final[_ServiceTestSuccessResponse] = { + "status": response["status"], + "message": (response["error_message"] if response["status"] == "unhealthy" else "PointFive is healthy") + or "PointFive is healthy", + } + return pointfive_health if service == "webhook": user_info: Final = CallInfo( token=user_api_key_dict.token or "", 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 e3f71692c78..47799852b35 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1056,6 +1056,35 @@ async def test_health_services_endpoint_galileo(status, error_message): mock_instance.async_health_check.assert_awaited_once() +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,error_message", + [ + ("healthy", ""), + ("unhealthy", "PointFive authentication failed"), + ], +) +async def test_health_services_endpoint_pointfive(monkeypatch, status, error_message): + import litellm.integrations.pointfive as pointfive_package + + mock_instance = MagicMock() + mock_instance.async_health_check = AsyncMock(return_value={"status": status, "error_message": error_message}) + logger_class = MagicMock(return_value=mock_instance) + monkeypatch.setattr(pointfive_package, "PointFiveLogger", logger_class) + + result = await health_services_endpoint(user_api_key_dict=_pointfive_admin(), service="pointfive") + + if status == "healthy": + assert result["status"] == "healthy" + assert result["message"] == "PointFive is healthy" + else: + assert result["status"] == "unhealthy" + assert result["message"] == error_message + mock_instance.async_health_check.assert_awaited_once() + # A check that left the periodic flush running would leak a flusher per press of the ui test button. + logger_class.assert_called_once_with(start_periodic_flush=False) + + @pytest.mark.asyncio async def test_health_services_endpoint_datadog_llm_observability(): """ @@ -2928,3 +2957,60 @@ def test_test_model_connection_accepts_image_edit_mode(monkeypatch): assert response.status_code == 200, response.text assert response.json()["status"] == "success" + + +def _pointfive_admin() -> UserAPIKeyAuth: + return UserAPIKeyAuth(token="admin-token", user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN) + + +@pytest.mark.asyncio +async def test_health_services_endpoint_pointfive_without_a_key_is_unhealthy_not_a_server_error(monkeypatch): + """ + The logger refuses to start without an api key. + + That refusal is the answer the operator asked for, so it has to come back as an + unhealthy result rather than a 500 from the endpoint. + """ + import litellm.integrations.pointfive as pointfive_package + + def refuse(**_): + raise ValueError("pointfive logging requires an api key. Set POINTFIVE_API_KEY") + + monkeypatch.setattr(pointfive_package, "PointFiveLogger", refuse) + + result = await health_services_endpoint(user_api_key_dict=_pointfive_admin(), service="pointfive") + + assert result["status"] == "unhealthy" + assert "requires an api key" in result["message"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, + ], +) +async def test_health_services_endpoint_pointfive_blocks_non_admin(monkeypatch, role): + """ + The ping travels on the proxy-wide PointFive credential and stamps liveness at PointFive. + + A tenant key must not be able to keep an integration looking alive, or read back + account-level authentication failures through it. + """ + import litellm.integrations.pointfive as pointfive_package + from litellm.proxy._types import ProxyException + + logger_class = MagicMock() + monkeypatch.setattr(pointfive_package, "PointFiveLogger", logger_class) + + with pytest.raises(ProxyException) as raised: + await health_services_endpoint( + user_api_key_dict=UserAPIKeyAuth(token="t", user_id="u", user_role=role), service="pointfive" + ) + + assert str(raised.value.code) == "403" + logger_class.assert_not_called() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 137c67e837c..fcaf75d1348 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -47944,7 +47944,7 @@ export interface operations { parameters: { query: { /** @description Specify the service being hit. */ - service: ("slack_budget_alerts" | "langfuse" | "langfuse_otel" | "slack" | "ms_teams" | "openmeter" | "webhook" | "email" | "braintrust" | "datadog" | "datadog_llm_observability" | "generic_api" | "arize" | "galileo" | "newrelic" | "sqs") | string; + service: ("slack_budget_alerts" | "langfuse" | "langfuse_otel" | "slack" | "ms_teams" | "openmeter" | "webhook" | "email" | "braintrust" | "datadog" | "datadog_llm_observability" | "generic_api" | "arize" | "galileo" | "newrelic" | "pointfive" | "sqs") | string; }; header?: never; path?: never;