fix(langfuse_otel): keep every reasoning summary in the derived observation output

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-23 07:13:03 +00:00
parent dfd956a788
commit 98964dca87
2 changed files with 58 additions and 30 deletions

View file

@ -155,7 +155,13 @@ class LangfuseOtelLogger(OpenTelemetry):
expanded: Final = tuple(_default_tag(key) for key in default_tags) if isinstance(default_tags, list) else ()
candidates: Final = (
(tuple(tag for tag in caller_tags if isinstance(tag, str)) if isinstance(caller_tags, list) else ())
(
(caller_tags,)
if isinstance(caller_tags, str)
else tuple(tag for tag in caller_tags if isinstance(tag, str))
if isinstance(caller_tags, list)
else ()
)
+ (tuple(tag for tag in request_tags if isinstance(tag, str)) if isinstance(request_tags, list) else ())
+ tuple(tag for tag in expanded if tag is not None)
)
@ -447,35 +453,35 @@ def _extract_output_items(response_obj) -> str | None:
output: Final = response_obj.get("output", [])
if not output:
return None
output_items: Final = tuple(_output_item(item) for item in output)
rendered: Final = tuple(item for item in output_items if item is not None)
rendered: Final = tuple(entry for item in output for entry in _output_items(item))
return safe_dumps(list(rendered)) if rendered else None
def _output_item(item) -> dict | None:
def _output_items(item) -> tuple[dict, ...]:
if not hasattr(item, "type"):
return None
return ()
if item.type == "reasoning" and hasattr(item, "summary"):
return next(
(
{"role": "reasoning_summary", "content": summary.text}
for summary in item.summary
if hasattr(summary, "text")
),
None,
return tuple(
{"role": "reasoning_summary", "content": summary.text}
for summary in item.summary
if hasattr(summary, "text")
)
if item.type == "message":
return {
"role": getattr(item, "role", "assistant"),
"content": getattr(getattr(item, "content", [{}])[0], "text", ""),
}
return (
{
"role": getattr(item, "role", "assistant"),
"content": getattr(getattr(item, "content", [{}])[0], "text", ""),
},
)
if item.type == "function_call":
arguments: Final = getattr(item, "arguments", "{}")
return {
"id": getattr(item, "id", ""),
"name": getattr(item, "name", ""),
"call_id": getattr(item, "call_id", ""),
"type": "function_call",
"arguments": safe_json_loads(arguments, default={}) if isinstance(arguments, str) else arguments,
}
return None
return (
{
"id": getattr(item, "id", ""),
"name": getattr(item, "name", ""),
"call_id": getattr(item, "call_id", ""),
"type": "function_call",
"arguments": safe_json_loads(arguments, default={}) if isinstance(arguments, str) else arguments,
},
)
return ()

View file

@ -774,7 +774,11 @@ class TestLangfuseOtelResponsesAPI:
Summary(
text="Let me analyze this problem step by step...",
type="summary_text",
)
),
Summary(
text="Now checking the forecast data.",
type="summary_text",
),
],
),
ResponseOutputMessage(
@ -816,17 +820,26 @@ class TestLangfuseOtelResponsesAPI:
output_json = output_calls[0].args[2]
output_data = json.loads(output_json)
# Verify output contains reasoning and message
# Verify output contains both reasoning summaries and the message
assert isinstance(output_data, list)
assert len(output_data) == 2
assert len(output_data) == 3
# Verify reasoning summary
assert output_data[0]["role"] == "reasoning_summary"
assert output_data[0]["content"] == "Let me analyze this problem step by step..."
assert output_data[1]["role"] == "reasoning_summary"
assert output_data[1]["content"] == "Now checking the forecast data."
# Verify message
assert output_data[1]["role"] == "assistant"
assert output_data[1]["content"] == "The weather in San Francisco is sunny, 20°C."
assert output_data[2]["role"] == "assistant"
assert output_data[2]["content"] == "The weather in San Francisco is sunny, 20°C."
trace_output_calls = [
call
for call in mock_safe_set_attribute.call_args_list
if call.args[1] == LangfuseSpanAttributes.TRACE_OUTPUT.value
]
assert len(trace_output_calls) > 0, "trace.output should be set"
assert trace_output_calls[0].args[2] == output_json
def test_responses_api_with_function_calls(self):
"""Test Langfuse OTEL logger with Responses API function_call output."""
@ -1067,6 +1080,15 @@ class TestDerivedTraceFields:
"user_api_key_alias:k1",
]
def test_caller_tags_string_is_a_single_tag(self):
attributes = _emitted(
{
"call_type": "acompletion",
"litellm_params": {"metadata": {"tags": "single"}},
}
)
assert json.loads(attributes["langfuse.trace.tags"]) == ["single"]
def test_no_tags_anywhere_leaves_trace_tags_unset(self):
attributes = _emitted(
{