fix(langfuse_otel): serialize pydantic Message objects in set_messages

`LangfuseLLMObsOTELAttributes.set_messages` does
`json.dumps({"messages": kwargs.get("messages"), ...})` directly. When
the caller passes `list[litellm.Message]` — a documented public API
exposed via the top-level `litellm.Message` export — `json.dumps`
raises `TypeError: Object of type Message is not JSON serializable`,
the entire OpenInference attribute setter bails out, and every LLM
call logs:

    [Arize/Phoenix] Failed to set OpenInference span attributes:
    Object of type Message is not JSON serializable

The span still gets exported but with the input payload missing and
its kind degraded from `GENERATION` to a generic span (the kind
attribute is set later in the same try/except, so it gets skipped on
failure too).

Fix: convert pydantic models to dicts via `.model_dump()` before
`json.dumps`. Plain-dict messages keep working unchanged.

Same pattern (`json.dumps(messages)`) exists in `weave/weave_otel.py`
and a related-but-distinct issue (using `.get()` against pydantic
objects) exists in `arize/_utils.py`; those are out of scope here.

Refs #13672.
This commit is contained in:
taxfree-python 2026-05-01 20:14:22 +09:00
parent 934ecdca78
commit 51d940b497
2 changed files with 72 additions and 1 deletions

View file

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

View file

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