diff --git a/litellm/integrations/langfuse/langfuse_otel_attributes.py b/litellm/integrations/langfuse/langfuse_otel_attributes.py index fb4a0a6a36c..89171b5cb40 100644 --- a/litellm/integrations/langfuse/langfuse_otel_attributes.py +++ b/litellm/integrations/langfuse/langfuse_otel_attributes.py @@ -14,6 +14,7 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor BaseLLMObsOTELAttributes, safe_set_attribute, ) +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse from litellm.types.utils import ( EmbeddingResponse, @@ -96,7 +97,7 @@ class LangfuseLLMObsOTELAttributes(BaseLLMObsOTELAttributes): prompt["tools"] = tools input = prompt - safe_set_attribute(span, "langfuse.observation.input", json.dumps(input)) + safe_set_attribute(span, "langfuse.observation.input", safe_dumps(input)) @staticmethod @override diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_otel_attributes.py b/tests/test_litellm/integrations/langfuse/test_langfuse_otel_attributes.py new file mode 100644 index 00000000000..79bdea366cf --- /dev/null +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_otel_attributes.py @@ -0,0 +1,98 @@ +""" +Tests for LangfuseLLMObsOTELAttributes, specifically verifying that pydantic +Message objects in the messages list are correctly serialized without raising +TypeError. Regression test for: + https://github.com/BerriAI/litellm/issues/26977 +""" + +import json +import os +import sys +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.integrations.langfuse.langfuse_otel_attributes import ( + LangfuseLLMObsOTELAttributes, +) +from litellm.types.utils import Message + + +def test_set_messages_with_pydantic_message_objects(): + """ + Regression test for issue #26977: + set_messages should not raise 'Object of type Message is not JSON serializable' + when messages is a list of litellm.Message (pydantic) objects. + """ + span = MagicMock() + kwargs = { + "messages": [Message(role="user", content="hello")], + "optional_params": {}, + } + + # Should not raise TypeError + LangfuseLLMObsOTELAttributes.set_messages(span, kwargs) + + # Verify that set_attribute was called with a valid JSON string + span.set_attribute.assert_called_once() + key, value = span.set_attribute.call_args[0] + assert key == "langfuse.observation.input" + + # The value must be parseable JSON + parsed = json.loads(value) + assert "messages" in parsed + assert parsed["messages"][0]["role"] == "user" + assert parsed["messages"][0]["content"] == "hello" + + +def test_set_messages_with_dict_messages(): + """ + Ensure that plain dict messages still work correctly after the fix. + """ + span = MagicMock() + kwargs = { + "messages": [{"role": "user", "content": "hello"}], + "optional_params": {}, + } + + LangfuseLLMObsOTELAttributes.set_messages(span, kwargs) + + span.set_attribute.assert_called_once() + key, value = span.set_attribute.call_args[0] + assert key == "langfuse.observation.input" + + parsed = json.loads(value) + assert parsed["messages"][0]["role"] == "user" + + +def test_set_messages_with_tools(): + """ + Ensure tools are included in the serialized input and pydantic messages + are handled correctly together. + """ + span = MagicMock() + kwargs = { + "messages": [Message(role="user", content="what's the weather?")], + "optional_params": { + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object"}, + }, + } + ] + }, + } + + LangfuseLLMObsOTELAttributes.set_messages(span, kwargs) + + span.set_attribute.assert_called_once() + key, value = span.set_attribute.call_args[0] + parsed = json.loads(value) + assert "tools" in parsed + assert parsed["tools"][0]["function"]["name"] == "get_weather" + assert parsed["messages"][0]["role"] == "user"