diff --git a/litellm/integrations/langfuse/langfuse_otel_attributes.py b/litellm/integrations/langfuse/langfuse_otel_attributes.py index fb4a0a6a36c..3bb6acd0948 100644 --- a/litellm/integrations/langfuse/langfuse_otel_attributes.py +++ b/litellm/integrations/langfuse/langfuse_otel_attributes.py @@ -86,7 +86,18 @@ class LangfuseLLMObsOTELAttributes(BaseLLMObsOTELAttributes): @staticmethod @override def set_messages(span: "Span", kwargs: Dict[str, Any]): - prompt = {"messages": kwargs.get("messages")} + # Normalize litellm.Message (pydantic) -> dict so json.dumps works. + # Callers can pass list[litellm.Message] via the documented public + # export; without this, json.dumps raises TypeError and the whole + # OpenInference attribute setter bails — surfacing as the spammy + # "Failed to set OpenInference span attributes: Object of type + # Message is not JSON serializable" error and dropping the input + # payload from the span. + raw_messages = kwargs.get("messages") or [] + messages = [ + m.model_dump() if isinstance(m, BaseModel) else m for m in raw_messages + ] + prompt: Dict[str, Any] = {"messages": messages} optional_params = kwargs.get("optional_params", {}) functions = optional_params.get("functions") tools = optional_params.get("tools") diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index 44853d9dce5..9d256e6ccf8 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -115,6 +115,66 @@ class TestLangfuseOtelIntegration: mock_span, mock_kwargs, mock_response, LangfuseLLMObsOTELAttributes ) + def test_set_messages_handles_pydantic_message_objects(self): + """Pydantic ``litellm.Message`` objects in ``messages`` must not raise. + + Regression test for the spammy "Object of type Message is not JSON + serializable" error that previously fired on every LLM call when the + caller passed ``list[litellm.Message]`` (a documented public API) + rather than ``list[dict]``. + """ + from litellm.integrations.langfuse.langfuse_otel_attributes import ( + LangfuseLLMObsOTELAttributes, + ) + from litellm.types.utils import Message + + captured: dict = {} + + def _capture(span, key, value): + captured[key] = value + + kwargs = { + "messages": [ + Message(role="user", content="hello"), + Message(role="assistant", content="hi back"), + ] + } + + with patch( + "litellm.integrations.langfuse.langfuse_otel_attributes.safe_set_attribute", + side_effect=_capture, + ): + # Must not raise — previously raised TypeError inside json.dumps. + LangfuseLLMObsOTELAttributes.set_messages(MagicMock(), kwargs) + + payload = json.loads(captured["langfuse.observation.input"]) + assert payload["messages"][0]["role"] == "user" + assert payload["messages"][0]["content"] == "hello" + assert payload["messages"][1]["role"] == "assistant" + assert payload["messages"][1]["content"] == "hi back" + + def test_set_messages_passes_through_plain_dicts(self): + """Plain-dict messages (the existing path) must keep working unchanged.""" + from litellm.integrations.langfuse.langfuse_otel_attributes import ( + LangfuseLLMObsOTELAttributes, + ) + + captured: dict = {} + + def _capture(span, key, value): + captured[key] = value + + kwargs = {"messages": [{"role": "user", "content": "hello"}]} + + with patch( + "litellm.integrations.langfuse.langfuse_otel_attributes.safe_set_attribute", + side_effect=_capture, + ): + LangfuseLLMObsOTELAttributes.set_messages(MagicMock(), kwargs) + + payload = json.loads(captured["langfuse.observation.input"]) + assert payload["messages"] == [{"role": "user", "content": "hello"}] + def test_set_langfuse_environment_attribute(self): """Test that Langfuse environment is set correctly when environment variable is present.""" mock_span = MagicMock()