diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 2c238f5aa62..38c086d9282 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -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 () diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index 6f05552a278..9c38ecd4e13 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -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( {