diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 3a97752e3fe..6d2806c1522 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -26,6 +26,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypeV import fastapi import yaml from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status +from pydantic import ValidationError as PydanticValidationError from typing_extensions import ReadOnly, TypedDict import litellm @@ -7019,6 +7020,7 @@ async def key_health( ``` """ from litellm.proxy.litellm_pre_call_utils import ( + KeyAndTeamLoggingSettings, _get_dynamic_logging_metadata, # pyright: ignore[reportPrivateUsage] # the request-time resolver; the health check must report the same callbacks a request would use ) from litellm.proxy.proxy_server import proxy_config @@ -7028,6 +7030,22 @@ async def key_health( if key_metadata and "logging" in key_metadata: _raise_if_key_logging_missing_callback_name(decrypt_callback_vars(key_metadata)["logging"]) + configured_entries: Final = ( + KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict) + or KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict) + or () + ) + invalid_entries: Final = _describe_invalid_callback_entries(configured_entries) + if invalid_entries is not None: + return KeyHealthResponse( + key="unhealthy", + logging_callbacks=LoggingCallbackStatus( + callbacks=_configured_callback_names(configured_entries), + status="unhealthy", + details=invalid_entries, + ), + ) + callback_settings: Final = _get_dynamic_logging_metadata( user_api_key_dict=user_api_key_dict, proxy_config=proxy_config ) @@ -7091,6 +7109,32 @@ def _raise_if_key_logging_missing_callback_name(key_logging: Sequence[Mapping[st raise ValueError("callback_name is required in key_logging") +def _configured_callback_names(entries: Sequence[Mapping[str, object]]) -> tuple[str, ...]: + return tuple(dict.fromkeys(name for entry in entries if isinstance(name := entry.get("callback_name"), str))) + + +def _describe_invalid_callback_entries(entries: Sequence[Mapping[str, object]]) -> str | None: + errors: Final = tuple( + f"{entry.get('callback_name')}: {error}" + for entry in entries + if (error := _callback_entry_error(entry)) is not None + ) + if not errors: + return None + return f"Invalid callback metadata, requests ignore these entries: {'; '.join(errors)}" + + +def _callback_entry_error(entry: Mapping[str, object]) -> str | None: + try: + AddTeamCallback.model_validate(entry) + except PydanticValidationError as e: + return ", ".join( + f"{'.'.join(str(part) for part in err['loc'])} {err['msg']}" + for err in e.errors(include_url=False, include_input=False) + ) + return None + + async def flush_gcs_and_describe_failures(gcs_logger: CustomLogger | None) -> str | None: from litellm.integrations.gcs_bucket.gcs_bucket import GCSBucketLogger diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 883b1f1a56e..d295c072bb1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -18240,6 +18240,41 @@ async def test_key_health_without_any_effective_callbacks_reports_healthy_and_se test_logging.assert_not_awaited() +@pytest.mark.parametrize( + "caller", + [ + UserAPIKeyAuth(api_key="sk-1", metadata={"logging": [{"callback_name": "langfuse"}]}), + UserAPIKeyAuth( + api_key="sk-1", + metadata={"logging": []}, + team_metadata={"logging": [{"callback_name": "langfuse", "callback_vars": {"not_a_var": "x"}}]}, + ), + ], + ids=["key entry without callback_vars", "team entry with an unknown callback var"], +) +@pytest.mark.asyncio +async def test_key_health_reports_callback_entries_requests_ignore_instead_of_calling_them_healthy( + caller: UserAPIKeyAuth, +): + from litellm.proxy.management_endpoints.key_management_endpoints import key_health + + with ( + patch("litellm.proxy.proxy_server.proxy_config", _default_team_gcs_proxy_config("team-gcs")), # test-quality-ok: key_health reads the module-level proxy config + patch( # test-quality-ok: the mock completion behind test_key_logging needs a running proxy + "litellm.proxy.management_endpoints.key_management_endpoints.test_key_logging", AsyncMock() + ) as test_logging, + ): + response = await key_health(request=MagicMock(), user_api_key_dict=caller) + + assert response["key"] == "unhealthy" + logging_callbacks: Final = response["logging_callbacks"] + assert logging_callbacks is not None + assert logging_callbacks["callbacks"] == ("langfuse",) + assert logging_callbacks["status"] == "unhealthy" + assert "langfuse:" in (logging_callbacks["details"] or "") + test_logging.assert_not_awaited() + + @pytest.mark.asyncio async def test_key_health_rejects_key_logging_entries_without_a_callback_name(): from litellm.proxy.management_endpoints.key_management_endpoints import key_health