fix(otel): reject non-string destination callback vars

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mrinal 2026-10-11 03:36:35 +00:00
parent 5dc107a2f5
commit 37addcfb6f
4 changed files with 56 additions and 6 deletions

View file

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

View file

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

View file

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

View file

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