diff --git a/instrumentation-genai/opentelemetry-instrumentation-anthropic/src/opentelemetry/instrumentation/anthropic/utils.py b/instrumentation-genai/opentelemetry-instrumentation-anthropic/src/opentelemetry/instrumentation/anthropic/utils.py index 9914a55a22..7399b8831b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-anthropic/src/opentelemetry/instrumentation/anthropic/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-anthropic/src/opentelemetry/instrumentation/anthropic/utils.py @@ -38,7 +38,7 @@ MessagePart, Reasoning, Text, - ToolCall, + ToolCallRequest, ToolCallResponse, ) @@ -114,7 +114,7 @@ def _convert_dict_block_to_part( if block_type == "tool_use": inp = block.get("input") - return ToolCall( + return ToolCallRequest( arguments=inp if isinstance(inp, dict) else None, name=str(block.get("name", "")), id=str(block.get("id", "")), @@ -144,7 +144,9 @@ def _convert_content_block_to_part( return Text(content=block.text) if isinstance(block, (ToolUseBlock, ServerToolUseBlock)): - return ToolCall(arguments=block.input, name=block.name, id=block.id) + return ToolCallRequest( + arguments=block.input, name=block.name, id=block.id + ) if isinstance(block, (ThinkingBlock, RedactedThinkingBlock)): content = ( @@ -229,7 +231,7 @@ def stream_block_state_to_part(state: StreamBlockState) -> MessagePart | None: arguments = json.loads(state.input_json) except ValueError: arguments = state.input_json - return ToolCall( + return ToolCallRequest( arguments=arguments, name=state.tool_name, id=state.tool_id, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-google-genai/pyproject.toml index a63b28d8b4..87b30343e3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/pyproject.toml @@ -39,10 +39,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~=1.37", - "opentelemetry-instrumentation >=0.58b0, <2", - "opentelemetry-semantic-conventions >=0.58b0, <2", - "opentelemetry-util-genai >= 0.3b0, <0.4b0", + "opentelemetry-api ~=1.39", + "opentelemetry-instrumentation >=0.60b0, <2", + "opentelemetry-semantic-conventions >=0.60b0, <2", + "opentelemetry-util-genai >= 0.4b0.dev, <0.5b0", ] [project.optional-dependencies] diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py index 29ef112a6f..a7349f0c83 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py @@ -15,20 +15,20 @@ from __future__ import annotations import logging -from dataclasses import dataclass from enum import Enum -from typing import Literal from google.genai import types as genai_types from opentelemetry.util.genai.types import ( + Blob, FinishReason, InputMessage, MessagePart, OutputMessage, Text, - ToolCall, + ToolCallRequest, ToolCallResponse, + Uri, ) @@ -39,23 +39,6 @@ class Role(str, Enum): TOOL = "tool" -@dataclass -class BlobPart: - data: bytes - mime_type: str - type: Literal["blob"] = "blob" - - -@dataclass -class FileDataPart: - mime_type: str - uri: str - type: Literal["file_data"] = "file_data" - - class Config: - extra = "allow" - - _logger = logging.getLogger(__name__) @@ -121,16 +104,26 @@ def tool_call_id(name: str | None) -> str: if (text := part.text) is not None: return Text(content=text) - if data := part.inline_data: - return BlobPart(mime_type=data.mime_type or "", data=data.data or b"") + if inline_data := part.inline_data: + mime_type = inline_data.mime_type or "" + modality = mime_type.split("/")[0] if mime_type else "" + return Blob( + mime_type=mime_type, + modality=modality, + content=inline_data.data or b"", + ) - if data := part.file_data: - return FileDataPart( - mime_type=data.mime_type or "", uri=data.file_uri or "" + if file_data := part.file_data: + mime_type = file_data.mime_type or "" + modality = mime_type.split("/")[0] if mime_type else "" + return Uri( + mime_type=mime_type, + modality=modality, + uri=file_data.file_uri or "", ) if call := part.function_call: - return ToolCall( + return ToolCallRequest( id=call.id or tool_call_id(call.name), name=call.name or "", arguments=call.args, diff --git a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 357bbeccb3..092a723ec4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -21,11 +21,11 @@ pytest-vcr==1.0.2 google-auth==2.15.0 google-genai==1.32.0 -opentelemetry-api==1.37.0 -opentelemetry-sdk==1.37.0 -opentelemetry-semantic-conventions==0.58b0 -opentelemetry-instrumentation==0.58b0 -opentelemetry-util-genai[upload]==0.3b0 +opentelemetry-api==1.39.0 +opentelemetry-sdk==1.39.0 +opentelemetry-semantic-conventions==0.60b0 +opentelemetry-instrumentation==0.60b0 +-e util/opentelemetry-util-genai[upload] fsspec==2025.9.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py b/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py index 8f642567ca..d694857da4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py @@ -14,7 +14,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any, Optional, cast from uuid import UUID from langchain_core.callbacks import BaseCallbackHandler @@ -29,6 +29,7 @@ Error, InputMessage, LLMInvocation, + MessagePart, OutputMessage, Text, ) @@ -133,7 +134,11 @@ def on_chat_model_start( Text(content=text_value, type="text") ) - input_messages.append(InputMessage(parts=parts, role=role)) + input_messages.append( + InputMessage( + parts=cast(list[MessagePart], parts), role=role + ) + ) llm_invocation = LLMInvocation( request_model=request_model, @@ -206,7 +211,7 @@ def on_llm_end( role = chat_generation.message.type output_message = OutputMessage( role=role, - parts=parts, + parts=cast(list[MessagePart], parts), finish_reason=finish_reason, ) output_messages.append(output_message) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py index 2c8c1b4c71..996c91c04d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/patch.py @@ -39,7 +39,7 @@ LLMInvocation, OutputMessage, Text, - ToolCall, + ToolCallRequest, ) from .instruments import Instruments @@ -914,7 +914,7 @@ def _set_output_messages(self): arguments = json.loads(arguments_str) except json.JSONDecodeError: arguments = arguments_str - tool_call_part = ToolCall( + tool_call_part = ToolCallRequest( name=tool_call.function_name, id=tool_call.tool_call_id, arguments=arguments, diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py index 7e5d2307ca..6afca130b6 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-v2/src/opentelemetry/instrumentation/openai_v2/utils.py @@ -41,7 +41,7 @@ LLMInvocation, OutputMessage, Text, - ToolCall, + ToolCallRequest, ToolCallResponse, ) @@ -452,7 +452,7 @@ def _prepare_input_messages(messages) -> List[InputMessage]: return chat_messages -def extract_tool_calls_new(tool_calls) -> list[ToolCall]: +def extract_tool_calls_new(tool_calls) -> list[ToolCallRequest]: parts = [] for tool_call in tool_calls: call_id = get_property_value(tool_call, "id") @@ -470,7 +470,9 @@ def extract_tool_calls_new(tool_calls) -> list[ToolCall]: arguments = arguments_str # TODO: support custom - parts.append(ToolCall(id=call_id, name=func_name, arguments=arguments)) + parts.append( + ToolCallRequest(id=call_id, name=func_name, arguments=arguments) + ) return parts diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-vertexai/pyproject.toml index 3d715a2a87..e6090642f6 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/pyproject.toml @@ -26,10 +26,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-api ~= 1.37", - "opentelemetry-instrumentation ~= 0.58b0", - "opentelemetry-semantic-conventions ~= 0.58b0", - "opentelemetry-util-genai >= 0.2b0, <0.4b0", + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-util-genai >= 0.4b0.dev, <0.5b0", ] [project.optional-dependencies] 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..9686d0dd7a 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 @@ -52,12 +52,14 @@ ) from opentelemetry.semconv.attributes import server_attributes from opentelemetry.util.genai.types import ( + Blob, ContentCapturingMode, FinishReason, MessagePart, Text, - ToolCall, + ToolCallRequest, ToolCallResponse, + Uri, ) from opentelemetry.util.genai.utils import get_content_capturing_mode from opentelemetry.util.types import AnyValue, AttributeValue @@ -308,21 +310,15 @@ def request_to_events( yield user_event(role=content.role, content=request_content) -@dataclass -class BlobPart: - data: bytes - mime_type: str - type: Literal["blob"] = "blob" - - -@dataclass -class FileDataPart: - mime_type: str - uri: str - type: Literal["file_data"] = "file_data" - - class Config: - extra = "allow" +def _modality_from_mime_type(mime_type: str) -> str: + """Infer modality from MIME type prefix.""" + if mime_type.startswith("image/"): + return "image" + if mime_type.startswith("video/"): + return "video" + if mime_type.startswith("audio/"): + return "audio" + return mime_type def convert_content_to_message_parts( @@ -341,7 +337,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( @@ -353,14 +349,22 @@ def convert_content_to_message_parts( parts.append(Text(content=part.text)) elif "inline_data" in part: part = part.inline_data + mime_type = part.mime_type or "" parts.append( - BlobPart(mime_type=part.mime_type or "", data=part.data or b"") + Blob( + mime_type=mime_type, + modality=_modality_from_mime_type(mime_type), + content=part.data or b"", + ) ) elif "file_data" in part: part = part.file_data + mime_type = part.mime_type or "" parts.append( - FileDataPart( - mime_type=part.mime_type or "", uri=part.file_uri or "" + Uri( + mime_type=mime_type, + modality=_modality_from_mime_type(mime_type), + uri=part.file_uri or "", ) ) else: diff --git a/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt index 5b5c7e8f67..d02e3dc8e4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/requirements.oldest.txt @@ -65,11 +65,12 @@ grpcio>=1.75.1 ; python_version >= "3.14" shapely==2.0.6 ; python_version < "3.10" shapely==2.1.2 ; python_version >= "3.10" # when updating, also update in pyproject.toml -opentelemetry-api==1.37 -opentelemetry-sdk==1.37 -opentelemetry-semantic-conventions==0.58b0 -opentelemetry-instrumentation==0.58b0 -opentelemetry-util-genai[upload]==0.2b0 +opentelemetry-api==1.39 +opentelemetry-sdk==1.39 +opentelemetry-semantic-conventions==0.60b0 +opentelemetry-instrumentation==0.60b0 +# 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/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_chat_completions_experimental.py b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_chat_completions_experimental.py index 5613f551a6..617d9a81c9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_chat_completions_experimental.py +++ b/instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_chat_completions_experimental.py @@ -74,7 +74,7 @@ def test_generate_content_with_files( "gen_ai.usage.output_tokens": 5, "server.address": "us-central1-aiplatform.googleapis.com", "server.port": 443, - "gen_ai.input.messages": '[{"role":"user","parts":[{"content":"Say this is a test","type":"text"},{"mime_type":"image/jpeg","uri":"https://images.pdimagearchive.org/collections/microscopic-delights/1lede-0021.jpg","type":"file_data"},{"data":"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==","mime_type":"image/jpeg","type":"blob"}]}]', + "gen_ai.input.messages": '[{"role":"user","parts":[{"content":"Say this is a test","type":"text"},{"mime_type":"image/jpeg","modality":"image","uri":"https://images.pdimagearchive.org/collections/microscopic-delights/1lede-0021.jpg","type":"uri"},{"mime_type":"image/jpeg","modality":"image","content":"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==","type":"blob"}]}]', "gen_ai.output.messages": '[{"role":"model","parts":[{"content":"This is a test.","type":"text"}],"finish_reason":"stop"}]', } @@ -97,12 +97,14 @@ def test_generate_content_with_files( {"content": "Say this is a test", "type": "text"}, { "mime_type": "image/jpeg", + "modality": "image", "uri": "https://images.pdimagearchive.org/collections/microscopic-delights/1lede-0021.jpg", - "type": "file_data", + "type": "uri", }, { - "data": b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x05\x00\x00\x00\x05\x08\x06\x00\x00\x00\x8do&\xe5\x00\x00\x00\x1cIDAT\x08\xd7c\xf8\xff\xff?\xc3\x7f\x06 \x05\xc3 \x12\x84\xd01\xf1\x82X\xcd\x04\x00\x0e\xf55\xcb\xd1\x8e\x0e\x1f\x00\x00\x00\x00IEND\xaeB`\x82", "mime_type": "image/jpeg", + "modality": "image", + "content": b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x05\x00\x00\x00\x05\x08\x06\x00\x00\x00\x8do&\xe5\x00\x00\x00\x1cIDAT\x08\xd7c\xf8\xff\xff?\xc3\x7f\x06 \x05\xc3 \x12\x84\xd01\xf1\x82X\xcd\x04\x00\x0e\xf55\xcb\xd1\x8e\x0e\x1f\x00\x00\x00\x00IEND\xaeB`\x82", "type": "blob", }, ), diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index d779ac8efe..565a18d587 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, breaking change: usage of ToolCall class renamed to ToolCallRequest + ([#4218](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4218)) - Add EmbeddingInvocation span lifecycle support ([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4219](#4219)) - Populate schema_url on metrics 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 a714e808b6..538d61490b 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -30,6 +30,17 @@ ContextToken: TypeAlias = Token[Context] +@dataclass() +class GenericPart: + """Used for provider-specific message part types that don't match + the standard MessagePart types defined in semantic conventions. Wrap custom + types with GenericPart(value=...) to explicitly opt-in to non-standard types. + This will be removed in a future version when all instrumentations use core types.""" + + value: Any + type: Literal["generic"] = "generic" + + class ContentCapturingMode(Enum): # Do not capture content (default). NO_CONTENT = 0 @@ -42,8 +53,11 @@ class ContentCapturingMode(Enum): @dataclass() -class ToolCall: - """Represents a tool call requested by the model +class ToolCallRequest: + """Represents a tool call requested by the model (message part only). + + Use this for tool calls in message history. For execution tracking with spans + and metrics, use ToolCall instead. This model is specified as part of semconv in `GenAI messages Python models - ToolCallRequestPart `__. @@ -68,6 +82,41 @@ class ToolCallResponse: type: Literal["tool_call_response"] = "tool_call_response" +@dataclass() +class ServerToolCall: + """Represents a server-side tool call invocation. + + Server tool calls are executed by the model provider on the server side rather + than by the client application. Provider-specific tools (e.g., code_interpreter, + web_search) can have well-defined schemas defined by the respective providers. + + This model is specified as part of semconv in `GenAI messages Python models - ServerToolCallPart + `__. + """ + + name: str + server_tool_call: Any + id: str | None = None + type: Literal["server_tool_call"] = "server_tool_call" + + +@dataclass() +class ServerToolCallResponse: + """Represents a server-side tool call response. + + Contains the outcome and details of a server tool execution. Provider-specific + tools (e.g., code_interpreter, web_search) can have well-defined response schemas + defined by the respective providers. + + This model is specified as part of semconv in `GenAI messages Python models - ServerToolCallResponsePart + `__. + """ + + server_tool_call_response: Any + id: str | None = None + type: Literal["server_tool_call_response"] = "server_tool_call_response" + + @dataclass() class Text: """Represents text content sent to or received from the model @@ -158,7 +207,16 @@ class GenericToolDefinition: ToolDefinition = Union[FunctionToolDefinition, GenericToolDefinition] MessagePart = Union[ - Text, ToolCall, ToolCallResponse, Blob, File, Uri, Reasoning, Any + Text, + ToolCallRequest, + ToolCallResponse, + ServerToolCall, + ServerToolCallResponse, + Blob, + File, + Uri, + Reasoning, + GenericPart, # For provider-specific types; prefer standard types above ] @@ -201,6 +259,7 @@ class GenAIInvocation: context_token: ContextToken | None = None span: Span | None = None attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) + error_type: str | None = None monotonic_start_s: float | None = None """ @@ -311,6 +370,51 @@ class EmbeddingInvocation(GenAIInvocation): """ +@dataclass() +class ToolCall(GenAIInvocation): + """Represents a tool call for execution tracking with spans and metrics. + + This type extends GenAIInvocation (like LLMInvocation) for consistent lifecycle + management across all invocation types. It is NOT used as a MessagePart directly - + use ToolCallRequest for that purpose. + + Inherits from GenAIInvocation: + - context_token: Context tracking for span lifecycle + - span: Active span reference + - attributes: Custom attributes dict for extensibility + + Reference: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md#execute-tool-span + + 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) + """ + + # Message identification fields (same as ToolCallRequest) + # Note: These are required fields but must have defaults due to dataclass inheritance + name: str = "" + arguments: Any = None + id: str | None = None + type: Literal["tool_call"] = "tool_call" + + # Execution tracking 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 + + # Timing field (not inherited from GenAIInvocation, matches LLMInvocation pattern) + monotonic_start_s: float | None = None + + @dataclass class Error: message: str 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..d076f5e72b --- /dev/null +++ b/util/opentelemetry-util-genai/tests/test_toolcall.py @@ -0,0 +1,118 @@ +# 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 inheritance structure""" + +import pytest + +from opentelemetry.util.genai.types import ( + GenAIInvocation, + InputMessage, + ServerToolCall, + ServerToolCallResponse, + ToolCall, + ToolCallRequest, +) + + +def test_toolcallrequest_is_message_part(): + """ToolCallRequest is for message parts only""" + tcr = ToolCallRequest( + arguments={"location": "Paris"}, name="get_weather", id="call_123" + ) + msg = InputMessage(role="user", parts=[tcr]) + assert len(msg.parts) == 1 + + +def test_toolcall_inherits_from_genaiinvocation(): + """ToolCall inherits from GenAIInvocation for lifecycle management""" + tc = ToolCall(name="get_weather", arguments={"city": "Paris"}) + assert isinstance(tc, GenAIInvocation) + assert not isinstance(tc, ToolCallRequest) + + +def test_toolcall_has_attributes_dict(): + """ToolCall inherits attributes dict from GenAIInvocation""" + tc = ToolCall(name="test") + tc.attributes["custom.key"] = "value" + assert tc.attributes["custom.key"] == "value" + + +def test_toolcall_in_message_part_union(): + """ToolCall can be used in messages despite not inheriting from ToolCallRequest""" + tc = ToolCall(name="get_weather", arguments={"city": "Paris"}) + msg = InputMessage(role="assistant", parts=[tc]) + assert len(msg.parts) == 1 + assert isinstance(msg.parts[0], GenAIInvocation) + + +def test_server_tool_call_basic(): + """ServerToolCall can be created with required fields""" + stc = ServerToolCall( + name="code_interpreter", + server_tool_call={"type": "code_interpreter", "code": "print(1)"}, + ) + assert stc.name == "code_interpreter" + assert stc.server_tool_call == { + "type": "code_interpreter", + "code": "print(1)", + } + assert stc.id is None + assert stc.type == "server_tool_call" + + +def test_server_tool_call_with_id(): + """ServerToolCall can have an optional id""" + stc = ServerToolCall( + name="web_search", + server_tool_call={"type": "web_search", "query": "weather"}, + id="stc_001", + ) + assert stc.id == "stc_001" + + +def test_server_tool_call_response_basic(): + """ServerToolCallResponse can be created with required fields""" + stcr = ServerToolCallResponse( + server_tool_call_response={ + "type": "code_interpreter", + "output": "1\n", + }, + ) + assert stcr.server_tool_call_response == { + "type": "code_interpreter", + "output": "1\n", + } + assert stcr.id is None + assert stcr.type == "server_tool_call_response" + + +def test_server_tool_call_in_message(): + """ServerToolCall and ServerToolCallResponse work as MessageParts""" + stc = ServerToolCall( + name="code_interpreter", + server_tool_call={"type": "code_interpreter", "code": "x = 1"}, + ) + stcr = ServerToolCallResponse( + server_tool_call_response={"type": "code_interpreter", "output": ""}, + id="stc_001", + ) + msg = InputMessage(role="assistant", parts=[stc, stcr]) + assert len(msg.parts) == 2 + assert isinstance(msg.parts[0], ServerToolCall) + assert isinstance(msg.parts[1], ServerToolCallResponse) + + +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"},