fix(proxy): report callback entries requests ignore as unhealthy in /key/health

Entries that fail AddTeamCallback validation (for example a key entry without
callback_vars) are dropped by the request-time resolver. /key/health used to
fall through to the next level and call the key healthy with no callbacks.
It now returns unhealthy with the offending entry and the validation error

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-13 00:07:14 +00:00
parent 8ca3812e99
commit edea717f3c
2 changed files with 79 additions and 0 deletions

View file

@ -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

View file

@ -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