diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 60570834939..2c75a608d0d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2056,6 +2056,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase): for key, value in callback_vars.items(): if key not in valid_keys: raise ValueError(f"Invalid callback variable: {key}. Must be one of {valid_keys}") + if key in (CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR) and not isinstance(value, str): + raise ValueError(f"{key} must be a string, got {type(value).__name__}") callback_vars[key] = str(value) validate_no_callback_env_reference(key, callback_vars[key], source="key/team callback metadata") if key == "langfuse_environment": diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index 097b2d95cf4..3317d5af4b4 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -10,8 +10,11 @@ from collections.abc import Callable, Mapping, Sequence from types import MappingProxyType from typing import Final +from pydantic import TypeAdapter + from litellm.types.utils import CAPTURE_MESSAGE_CONTENT_VAR, EXCLUDED_SERVICES_VAR +_STRING_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[str, object]) _NEWRELIC_CALLBACK: Final = "newrelic" _NEWRELIC_VAR_PREFIX: Final = "newrelic_" _LANGFUSE_OTEL_CALLBACK: Final = "langfuse_otel" @@ -269,13 +272,18 @@ def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str entries: Final = _object_sequence(metadata.get("logging")) if entries is None: return None + entry_error: Final = next( + (error for entry in entries if (error := _logging_entry_error(entry)) is not None), + None, + ) + if entry_error is not None: + return entry_error entry_vars: Final = tuple(_entry_callback_vars(entry) for entry in entries) named_vars: Final = tuple(zip((_entry_callback_name(entry) for entry in entries), entry_vars)) return next( ( error for error in ( - *(_logging_entry_error(entry) for entry in entries), *(conflicting_span_scope_error(entry_vars[i], entry_vars[:i]) for i in range(len(entry_vars))), *(_conflicting_entry_setting_error(named_vars[i], named_vars[:i]) for i in range(len(named_vars))), ) @@ -330,12 +338,25 @@ def _entry_callback_vars(entry: object) -> Mapping[str, str]: def _logging_entry_error(entry: object) -> str | None: if not isinstance(entry, Mapping): return None - callback_name: Final = entry.get("callback_name") - if not isinstance(callback_name, str) or not isinstance(entry.get("callback_vars"), Mapping): + typed_entry: Final[Mapping[str, object]] = _STRING_OBJECT_MAPPING_ADAPTER.validate_python(entry) + callback_name: Final = typed_entry.get("callback_name") + callback_vars_raw: Final = typed_entry.get("callback_vars") + if not isinstance(callback_name, str) or not isinstance(callback_vars_raw, Mapping): return None - callback_type_raw: Final = entry.get("callback_type") + callback_vars: Final[Mapping[str, object]] = _STRING_OBJECT_MAPPING_ADAPTER.validate_python(callback_vars_raw) + if CAPTURE_MESSAGE_CONTENT_VAR in callback_vars: + capture_message_content: Final = callback_vars[CAPTURE_MESSAGE_CONTENT_VAR] + if not isinstance(capture_message_content, str): + return f"{CAPTURE_MESSAGE_CONTENT_VAR} must be a string, got {type(capture_message_content).__name__}" + if EXCLUDED_SERVICES_VAR in callback_vars: + excluded_services: Final = callback_vars[EXCLUDED_SERVICES_VAR] + if not isinstance(excluded_services, str): + return f"{EXCLUDED_SERVICES_VAR} must be a string, got {type(excluded_services).__name__}" + callback_type_raw: Final = typed_entry.get("callback_type") return callback_config_error( - callback_name, _entry_callback_vars(entry), callback_type_raw if isinstance(callback_type_raw, str) else None + callback_name, + _entry_callback_vars(typed_entry), + callback_type_raw if isinstance(callback_type_raw, str) else None, ) diff --git a/tests/unit/proxy/common_utils/test_callback_config_validation.py b/tests/unit/proxy/common_utils/test_callback_config_validation.py index e194798f128..31fb0b52225 100644 --- a/tests/unit/proxy/common_utils/test_callback_config_validation.py +++ b/tests/unit/proxy/common_utils/test_callback_config_validation.py @@ -343,6 +343,23 @@ def test_an_unsupported_capture_message_content_is_rejected_on_key_logging_metad assert error is not None and "Invalid capture_message_content" in error +@pytest.mark.usefixtures("otel_v2_on") +@pytest.mark.parametrize("callback_var", ["capture_message_content", "excluded_services"]) +def test_key_logging_metadata_rejects_non_string_destination_callback_vars(callback_var: str) -> None: + error: Final = logging_metadata_config_error( + { + "logging": [ + { + "callback_name": "newrelic", + "callback_type": "success_and_failure", + "callback_vars": {callback_var: None}, + } + ] + } + ) + assert error == f"{callback_var} must be a string, got NoneType" + + @pytest.mark.usefixtures("otel_v2_on") def test_each_entry_keeps_its_own_capture_message_content() -> None: error: Final = logging_metadata_config_error( diff --git a/tests/unit/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_callback_endpoints.py index 2919b520b49..3dc51d2b8c3 100644 --- a/tests/unit/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_callback_endpoints.py @@ -8,11 +8,12 @@ redacted audit rows for callback mutations. import json from collections.abc import Iterator -from typing import Final +from typing import Final, cast from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from fastapi import HTTPException, Request +from pydantic import ValidationError import litellm from litellm.proxy._types import ( @@ -1821,3 +1822,12 @@ def test_add_team_callback_returns_http_422_for_unknown_excluded_services() -> N assert response.status_code == 422 assert "excluded_services" in response.text + + +@pytest.mark.parametrize("callback_var", ["capture_message_content", "excluded_services"]) +@pytest.mark.parametrize("value", [None, 1]) +def test_add_team_callback_rejects_non_string_destination_callback_vars(callback_var: str, value: object) -> None: + callback_vars: Final = cast("dict[str, str]", {callback_var: value}) + with pytest.raises(ValidationError) as exc: + AddTeamCallback(callback_name="newrelic", callback_vars=callback_vars) + assert f"{callback_var} must be a string, got {type(value).__name__}" in str(exc.value)