diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index a96fac32c2a..6b240dba52e 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -196,19 +196,25 @@ class LangfuseOtelLogger(OpenTelemetry): "content": getattr(getattr(item, "content", [{}])[0], "text", ""), } ) - elif item_type == "function_call": - arguments_str = getattr(item, "arguments", "{}") - arguments_obj = ( - safe_json_loads(arguments_str, default={}) - if isinstance(arguments_str, str) - else arguments_str - ) + elif item_type in ("function_call", "custom_tool_call"): + # A custom (freeform) tool carries its payload as a raw string under `input`, + # where a function call carries a JSON string under `arguments`. + if item_type == "custom_tool_call": + payload_key, payload_value = "input", getattr(item, "input", "") + else: + arguments_str = getattr(item, "arguments", "{}") + payload_key = "arguments" + payload_value = ( + safe_json_loads(arguments_str, default={}) + if isinstance(arguments_str, str) + else arguments_str + ) langfuse_tool_call = { "id": getattr(item, "id", ""), "name": getattr(item, "name", ""), "call_id": getattr(item, "call_id", ""), - "type": "function_call", - "arguments": arguments_obj, + "type": item_type, + payload_key: payload_value, } output_items_data.append(langfuse_tool_call) if output_items_data: diff --git a/tests/unit/integrations/test_langfuse_otel.py b/tests/unit/integrations/test_langfuse_otel.py index 0a9ce55fe16..346cec3a806 100644 --- a/tests/unit/integrations/test_langfuse_otel.py +++ b/tests/unit/integrations/test_langfuse_otel.py @@ -1005,6 +1005,59 @@ class TestLangfuseOtelResponsesAPI: assert output_data[0]["name"] == "get_weather" assert output_data[0]["arguments"] == {} + def test_responses_api_with_custom_tool_calls(self): + """Test Langfuse OTEL logger with Responses API custom_tool_call output.""" + from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes + from litellm.types.responses.main import CustomToolCallOutputItem + + # Create Responses API response with a custom (freeform) tool call + response_obj = ResponsesAPIResponse( + id="response-790", + created_at=1625247700, + output=[ + CustomToolCallOutputItem( + id="ctc-123", + type="custom_tool_call", + name="exec", + call_id="call-def", + input="console.log('hello')", + status="completed", + ) + ], + ) + + kwargs = { + "call_type": "responses", + "messages": [{"role": "user", "content": "Print hello"}], + "model": "gpt-4o", + "optional_params": {}, + } + + mock_span = MagicMock() + + with patch( + "litellm.integrations.arize._utils.safe_set_attribute" + ) as mock_safe_set_attribute: + LangfuseOtelLogger._set_langfuse_specific_attributes( + mock_span, kwargs, response_obj + ) + + output_calls = [ + call + for call in mock_safe_set_attribute.call_args_list + if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value + ] + + assert len(output_calls) > 0, "observation.output should be set" + output_data = json.loads(output_calls[0].args[2]) + assert isinstance(output_data, list) + assert len(output_data) == 1 + assert output_data[0]["type"] == "custom_tool_call" + assert output_data[0]["id"] == "ctc-123" + assert output_data[0]["name"] == "exec" + assert output_data[0]["call_id"] == "call-def" + assert output_data[0]["input"] == "console.log('hello')" + if __name__ == "__main__": pytest.main([__file__])