diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py b/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py index 3221cdf9bc..6b280d2bc4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py @@ -56,7 +56,7 @@ FinishReason, MessagePart, Text, - ToolCall, + ToolCallRequest, ToolCallResponse, ) from opentelemetry.util.genai.utils import get_content_capturing_mode @@ -341,7 +341,7 @@ def convert_content_to_message_parts( elif "function_call" in part: part = part.function_call parts.append( - ToolCall( + ToolCallRequest( id=f"{part.name}_{idx}", name=part.name, arguments=json_format.MessageToDict( diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt index 7bfd62ff5f..74b2738541 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt @@ -69,7 +69,8 @@ opentelemetry-api==1.37 opentelemetry-sdk==1.37 opentelemetry-semantic-conventions==0.58b0 opentelemetry-instrumentation==0.58b0 -opentelemetry-util-genai[upload]==0.2b0 +# opentelemetry-util-genai[upload]==0.2b0 +-e util/opentelemetry-util-genai[upload] fsspec==2025.9.0 -e instrumentation-genai/opentelemetry-instrumentation-vertexai[instruments] diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index f64092a697..fa0ad2d7a9 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Enrich ToolCall type ([#4218](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4218)) + ## Version 0.3b0 (2026-02-20) - Add `gen_ai.tool_definitions` to completion hook ([#4181](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4181)) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 045a65b372..e37d0bdf6a 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -42,7 +42,7 @@ class ContentCapturingMode(Enum): @dataclass() -class ToolCall: +class ToolCallRequest: """Represents a tool call requested by the model This model is specified as part of semconv in `GenAI messages Python models - ToolCallRequestPart @@ -55,6 +55,40 @@ class ToolCall: type: Literal["tool_call"] = "tool_call" +@dataclass() +class ToolCall(ToolCallRequest): + """Represents a tool call for execution tracking with spans and metrics. + + This type extends ToolCallRequest with additional fields for tracking tool execution + per the execute_tool span semantic conventions. + + Reference: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md#execute-tool-span + + For simple message parts (tool calls requested by the model), consider using + ToolCallRequest instead to avoid unnecessary execution-tracking fields. + + Semantic convention attributes for execute_tool spans: + - gen_ai.operation.name: "execute_tool" (Required) + - gen_ai.tool.name: Name of the tool (Recommended) + - gen_ai.tool.call.id: Tool call identifier (Recommended if available) + - gen_ai.tool.type: Type classification - "function", "extension", or "datastore" (Recommended if available) + - gen_ai.tool.description: Tool description (Recommended if available) + - gen_ai.tool.call.arguments: Parameters passed to tool (Opt-In, may contain sensitive data) + - gen_ai.tool.call.result: Result returned by tool (Opt-In, may contain sensitive data) + - error.type: Error type if operation failed (Conditionally Required) + """ + + # Execution-only fields (used for execute_tool spans): + # gen_ai.tool.type - Tool type: "function", "extension", or "datastore" + tool_type: str | None = None + # gen_ai.tool.description - Description of what the tool does + tool_description: str | None = None + # gen_ai.tool.call.result - Result returned by the tool (Opt-In, may contain sensitive data) + tool_result: Any = None + # error.type - Error type if the tool call failed + error_type: str | None = None + + @dataclass() class ToolCallResponse: """Represents a tool call result sent to the model or a built-in tool call outcome and details @@ -158,7 +192,15 @@ class GenericToolDefinition: ToolDefinition = Union[FunctionToolDefinition, GenericToolDefinition] MessagePart = Union[ - Text, ToolCall, ToolCallResponse, Blob, File, Uri, Reasoning, Any + Text, + ToolCallRequest, + ToolCall, + ToolCallResponse, + Blob, + File, + Uri, + Reasoning, + Any, ] diff --git a/util/opentelemetry-util-genai/tests/test_toolcall.py b/util/opentelemetry-util-genai/tests/test_toolcall.py new file mode 100644 index 0000000000..a06f39efdd --- /dev/null +++ b/util/opentelemetry-util-genai/tests/test_toolcall.py @@ -0,0 +1,165 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for ToolCallRequest and ToolCall types""" + +import pytest + +from opentelemetry.util.genai.types import ( + InputMessage, + OutputMessage, + ToolCall, + ToolCallRequest, +) + + +def test_toolcallrequest_basic(): + """Test basic ToolCallRequest instantiation""" + tcr = ToolCallRequest(arguments=None, name="get_weather", id=None) + assert tcr.name == "get_weather" + assert tcr.type == "tool_call" + assert tcr.arguments is None + assert tcr.id is None + + +def test_toolcallrequest_with_all_fields(): + """Test ToolCallRequest with all fields""" + tcr = ToolCallRequest( + name="get_weather", + arguments={"location": "Paris"}, + id="call_123", + ) + assert tcr.name == "get_weather" + assert tcr.arguments == {"location": "Paris"} + assert tcr.id == "call_123" + assert tcr.type == "tool_call" + + +def test_toolcallrequest_in_message(): + """Test ToolCallRequest works as message part""" + tcr = ToolCallRequest( + arguments={"location": "Paris"}, name="get_weather", id=None + ) + msg = InputMessage(role="user", parts=[tcr]) + assert len(msg.parts) == 1 + assert msg.parts[0] == tcr + + +def test_toolcall_inherits_from_toolcallrequest(): + """Test that ToolCall inherits from ToolCallRequest""" + tc = ToolCall(arguments=None, name="get_weather", id=None) + assert isinstance(tc, ToolCallRequest) + assert isinstance(tc, ToolCall) + + +def test_toolcall_has_execution_fields(): + """Test ToolCall has execution-only fields""" + tc = ToolCall(arguments=None, name="get_weather", id=None) + assert hasattr(tc, "tool_type") + assert hasattr(tc, "tool_description") + assert hasattr(tc, "tool_result") + assert hasattr(tc, "error_type") + + +def test_toolcall_execution_fields_default_none(): + """Test ToolCall execution fields default to None""" + tc = ToolCall(arguments=None, name="get_weather", id=None) + assert tc.tool_type is None + assert tc.tool_description is None + assert tc.tool_result is None + assert tc.error_type is None + + +def test_toolcall_with_execution_fields(): + """Test ToolCall with execution fields set""" + tc = ToolCall( + name="get_weather", + arguments={"location": "Paris"}, + id="call_123", + tool_type="function", + tool_description="Get current weather", + tool_result={"temp": 20, "condition": "sunny"}, + ) + assert tc.name == "get_weather" + assert tc.tool_type == "function" + assert tc.tool_description == "Get current weather" + assert tc.tool_result == {"temp": 20, "condition": "sunny"} + + +def test_toolcall_with_error(): + """Test ToolCall with error_type set""" + tc = ToolCall( + arguments={"location": "Invalid"}, + name="get_weather", + id=None, + error_type="InvalidLocationError", + ) + assert tc.error_type == "InvalidLocationError" + assert tc.tool_result is None + + +def test_toolcall_backward_compatibility(): + """Test ToolCall still works as message part (backward compatibility)""" + tc = ToolCall( + name="get_weather", + arguments={"location": "Paris"}, + id="call_123", + ) + # Should work in messages + msg = InputMessage(role="user", parts=[tc]) + assert len(msg.parts) == 1 + + # Should work in output messages + out_msg = OutputMessage( + role="assistant", parts=[tc], finish_reason="tool_calls" + ) + assert len(out_msg.parts) == 1 + + +def test_toolcallrequest_no_execution_fields(): + """Test that ToolCallRequest doesn't have execution fields""" + tcr = ToolCallRequest(arguments=None, name="get_weather", id=None) + # ToolCallRequest should only have message part fields + assert not hasattr(tcr, "tool_type") + assert not hasattr(tcr, "tool_description") + assert not hasattr(tcr, "tool_result") + assert not hasattr(tcr, "error_type") + + +def test_mixed_types_in_message(): + """Test using both ToolCallRequest and ToolCall in messages""" + tcr = ToolCallRequest(arguments=None, name="simple_tool", id=None) + tc = ToolCall( + arguments=None, name="complex_tool", id=None, tool_type="function" + ) + + msg = InputMessage(role="user", parts=[tcr, tc]) + assert len(msg.parts) == 2 + assert isinstance(msg.parts[0], ToolCallRequest) + assert isinstance(msg.parts[1], ToolCall) + # ToolCall is also a ToolCallRequest + assert isinstance(msg.parts[1], ToolCallRequest) + + +def test_toolcall_tool_type_values(): + """Test valid tool_type values""" + for tool_type in ["function", "extension", "datastore"]: + tc = ToolCall( + arguments=None, name="test", id=None, tool_type=tool_type + ) + assert tc.tool_type == tool_type + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/util/opentelemetry-util-genai/tests/test_upload.py b/util/opentelemetry-util-genai/tests/test_upload.py index dd87b971e0..aa5fcb5b4a 100644 --- a/util/opentelemetry-util-genai/tests/test_upload.py +++ b/util/opentelemetry-util-genai/tests/test_upload.py @@ -44,7 +44,7 @@ types.InputMessage( role="assistant", parts=[ - types.ToolCall( + types.ToolCallRequest( id="get_capital_0", name="get_capital", arguments={"city": "Paris"},