From 69b6efe471d78d7bd769ef3f6c7da8e5536a5f43 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sat, 12 Sep 2026 13:00:33 -0700 Subject: [PATCH 1/4] fix(google-genai): capture server tool message parts Assisted-by: Codex --- .../google_genai/interactions.py | 114 +++++++++++-- .../instrumentation/google_genai/message.py | 48 ++++++ ...ntent_server_tool_calling_conformance.yaml | 58 +++++++ ...tions_server_tool_calling_conformance.yaml | 55 ++++++ .../tests/conformance/server_tool_calling.py | 160 ++++++++++++++++++ .../tests/generate_content/test_message.py | 108 +++++++++++- .../tests/interactions/test_parser.py | 84 +++++++++ .../tests/test_conformance.py | 6 + 8 files changed, 617 insertions(+), 16 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/generate_content_server_tool_calling_conformance.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/interactions_server_tool_calling_conformance.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/conformance/server_tool_calling.py diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py index b5459eaf5..2af22da4c 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py @@ -98,8 +98,11 @@ class Stream: GenericPart, GenericToolDefinition, InputMessage, + MessagePart, OutputMessage, Role, + ServerToolCallPart, + ServerToolCallResponsePart, TextPart, ToolCallRequestPart, ToolCallResponsePart, @@ -201,6 +204,81 @@ def _get_field(obj: Any, name: str) -> Any: return getattr(obj, name, None) +_SERVER_TOOL_CALL_NAMES = { + "code_execution_call": "code_execution", + "file_search_call": "file_search", + "google_maps_call": "google_maps", + "google_search_call": "google_search", + "mcp_server_tool_call": "mcp", + "processing_call": "processing", + "retrieval_call": "retrieval", + "url_context_call": "url_context", +} + +_SERVER_TOOL_RESPONSE_NAMES = { + "code_execution_result": "code_execution", + "file_search_result": "file_search", + "google_maps_result": "google_maps", + "google_search_result": "google_search", + "mcp_server_tool_result": "mcp", + "processing_result": "processing", + "retrieval_result": "retrieval", + "url_context_result": "url_context", +} + + +def _to_server_tool_part(item: Any) -> MessagePart | None: + item_type = _get_field(item, "type") + tool_name = _SERVER_TOOL_CALL_NAMES.get(item_type) + response_name = _SERVER_TOOL_RESPONSE_NAMES.get(item_type) + if tool_name is None and response_name is None: + return None + + if isinstance(item, dict): + payload = dict(item) + else: + model_dump = getattr(item, "model_dump", None) + if not callable(model_dump): + return None + payload = model_dump(exclude_none=True, mode="json") + + item_id = payload.pop("id", None) + call_id = payload.pop("call_id", None) + payload.pop("type", None) + name = payload.pop("name", None) + canonical_name = tool_name or response_name + if canonical_name is None: + return None + payload["type"] = canonical_name + + if response_name is not None: + return ServerToolCallResponsePart( + id=call_id if isinstance(call_id, str) else None, + server_tool_call_response=payload, + ) + return ServerToolCallPart( + id=item_id if isinstance(item_id, str) else None, + name=name if isinstance(name, str) else canonical_name, + server_tool_call=payload, + ) + + +def _interaction_item_to_part(item: Any) -> MessagePart | None: + item_type = _get_field(item, "type") + if item_type == "function_call": + return ToolCallRequestPart( + id=_get_field(item, "id"), + name=_get_field(item, "name") or "", + arguments=_get_field(item, "arguments"), + ) + if item_type == "function_result": + return ToolCallResponsePart( + id=_get_field(item, "call_id"), + response=_get_field(item, "result"), + ) + return _to_server_tool_part(item) + + # Logic for parsing Input is tricky: # https://github.com/open-telemetry/donation-openinference/blob/6cdd644d79fccf50aedcb614187f924ddfcafb7b/python/instrumentation/openinference-instrumentation-google-genai/src/openinference/instrumentation/google_genai/interactions_attributes.py#L103 # It doesn't make sense for this to be a List[InputMessage] (per semconv), @@ -225,19 +303,9 @@ def _interactions_input_to_messages( parts = [] for item in input_data: item_type = _get_field(item, "type") - if item_type == "function_call": - call_id = _get_field(item, "id") - name = _get_field(item, "name") - arguments = _get_field(item, "arguments") - part = ToolCallRequestPart( - id=call_id, name=name or "", arguments=arguments - ) - parts.append(part) - elif item_type == "function_result": - call_id = _get_field(item, "call_id") - result = _get_field(item, "result") - part = ToolCallResponsePart(id=call_id, response=result) - parts.append(part) + message_part = _interaction_item_to_part(item) + if message_part is not None: + parts.append(message_part) elif isinstance(item, str): parts.append(TextPart(content=item)) elif item_type == "text": @@ -283,11 +351,27 @@ def _get_interaction_output_text(interaction: Interaction) -> str: def _interactions_response_to_messages( interaction: Interaction, ) -> list[OutputMessage]: - output_text = _get_interaction_output_text(interaction) + parts: list[MessagePart] = [] + for step in interaction.steps or []: + if part := _interaction_item_to_part(step): + parts.append(part) + continue + if _get_field(step, "type") != "model_output": + continue + for item in _get_field(step, "content") or []: + if _get_field(item, "type") == "text" and isinstance( + text := _get_field(item, "text"), str + ): + parts.append(TextPart(content=text)) + + if not any(isinstance(part, TextPart) for part in parts): + parts.append( + TextPart(content=_get_interaction_output_text(interaction)) + ) return [ OutputMessage( role=Role.ASSISTANT.value, - parts=[TextPart(content=output_text)], + parts=parts, finish_reason="stop", ) ] diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py index aabdf2336..0e4e52fcb 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py @@ -15,6 +15,8 @@ MessagePart, OutputMessage, Role, + ServerToolCallPart, + ServerToolCallResponsePart, SystemInstructionPart, TextPart, ToolCallRequestPart, @@ -122,6 +124,52 @@ def tool_call_id(name: str | None) -> str: response=response.response, ) + if call := getattr(part, "tool_call", None): + tool_type = getattr(call.tool_type, "value", call.tool_type) + name = str(tool_type).lower() if tool_type else "unknown" + return ServerToolCallPart( + id=call.id, + name=name, + server_tool_call={ + "type": name, + "arguments": call.args, + }, + ) + + if response := getattr(part, "tool_response", None): + tool_type = getattr(response.tool_type, "value", response.tool_type) + name = str(tool_type).lower() if tool_type else "unknown" + return ServerToolCallResponsePart( + id=response.id, + server_tool_call_response={ + "type": name, + "response": response.response, + }, + ) + + if code := getattr(part, "executable_code", None): + language = getattr(code.language, "value", code.language) + return ServerToolCallPart( + id=getattr(code, "id", None), + name="code_execution", + server_tool_call={ + "type": "code_execution", + "code": code.code, + "language": language, + }, + ) + + if result := getattr(part, "code_execution_result", None): + outcome = getattr(result.outcome, "value", result.outcome) + return ServerToolCallResponsePart( + id=getattr(result, "id", None), + server_tool_call_response={ + "type": "code_execution", + "outcome": outcome, + "output": result.output, + }, + ) + _logger.info("Unknown part dropped from telemetry %s", part) return None diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/generate_content_server_tool_calling_conformance.yaml b/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/generate_content_server_tool_calling_conformance.yaml new file mode 100644 index 000000000..9e02497d1 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/generate_content_server_tool_calling_conformance.yaml @@ -0,0 +1,58 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"contents":[{"parts":[{"text":"Run Python to print one."}],"role":"user"}],"tools":[{"codeExecution":{}}]}' + headers: + content-type: + - application/json + host: + - generativelanguage.googleapis.com + x-goog-api-key: + - test_google_genai_api_key + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent + response: + body: + string: |- + { + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "id": "code-1", + "code": "print(1)", + "language": "PYTHON" + } + }, + { + "codeExecutionResult": { + "id": "code-1", + "outcome": "OUTCOME_OK", + "output": "1" + } + }, + {"text": "The result is 1."} + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": { + "promptTokenCount": 8, + "candidatesTokenCount": 10, + "totalTokenCount": 18 + }, + "modelVersion": "gemini-2.5-flash", + "responseId": "response-code-1" + } + headers: + content-type: + - application/json; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/interactions_server_tool_calling_conformance.yaml b/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/interactions_server_tool_calling_conformance.yaml new file mode 100644 index 000000000..e30711ed2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/cassettes/interactions_server_tool_calling_conformance.yaml @@ -0,0 +1,55 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: |- + {"model":"gemini-2.5-flash","input":"Search for OpenTelemetry.","tools":[{"type":"google_search"}]} + headers: + content-type: + - application/json + host: + - generativelanguage.googleapis.com + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/interactions + response: + body: + string: |- + { + "id": "interaction-search-1", + "status": "completed", + "usage": { + "total_tokens": 30, + "total_input_tokens": 10, + "total_output_tokens": 20, + "total_cached_tokens": 0, + "total_thought_tokens": 0 + }, + "steps": [ + { + "type": "google_search_call", + "id": "search-1", + "arguments": {"queries": ["OpenTelemetry"]}, + "search_type": "web_search" + }, + { + "type": "google_search_result", + "call_id": "search-1", + "result": [], + "is_error": false + }, + { + "type": "model_output", + "content": [ + {"type": "text", "text": "OpenTelemetry is an observability framework."} + ] + } + ], + "object": "interaction", + "model": "gemini-2.5-flash" + } + headers: + content-type: + - application/json + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/conformance/server_tool_calling.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/conformance/server_tool_calling.py new file mode 100644 index 000000000..45513bc28 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/conformance/server_tool_calling.py @@ -0,0 +1,160 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenarios: Google GenAI server-side tool calls.""" + +from __future__ import annotations + +import json +from typing import Any + +from google.genai import Client + +from opentelemetry.instrumentation.google_genai import ( + GoogleGenAiSdkInstrumentor, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test.weaver_live_check import LiveCheckReport +from opentelemetry.test_util_genai.conformance import ( + ExpectedViolation, + Scenario, +) +from opentelemetry.test_util_genai.instrumentor import instrument + + +def _output_parts(report: LiveCheckReport) -> list[dict[str, Any]]: + output_messages = [ + json.loads(attribute["value"]) + for entry in report["samples"] + if "span" in entry + for attribute in entry["span"]["attributes"] + if attribute["name"] == "gen_ai.output.messages" + ] + assert len(output_messages) == 1 + return output_messages[0][0]["parts"] + + +class GenerateContentServerToolCallingScenario(Scenario): + expected_spans = {"generate_content": 1} + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + + def validate(self, report: LiveCheckReport) -> None: + super().validate(report) + assert _output_parts(report)[:2] == [ + { + "name": "code_execution", + "server_tool_call": { + "code": "print(1)", + "language": "PYTHON", + "type": "code_execution", + }, + "id": "code-1", + "type": "server_tool_call", + }, + { + "server_tool_call_response": { + "outcome": "OUTCOME_OK", + "output": "1", + "type": "code_execution", + }, + "id": "code-1", + "type": "server_tool_call_response", + }, + ] + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + GoogleGenAiSdkInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette( + "generate_content_server_tool_calling_conformance.yaml" + ): + client = Client( + api_key="test_google_genai_api_key", vertexai=False + ) + client.models.generate_content( + model="gemini-2.5-flash", + contents="Run Python to print one.", + config={"tools": [{"code_execution": {}}]}, + ) + + +class InteractionsServerToolCallingScenario(Scenario): + expected_spans = {"interactions.create": 1} + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + expected_violations = ( + ExpectedViolation( + advice_id="genai_operation_name_unknown", + message_substring="interactions.create", + ), + ) + + def validate(self, report: LiveCheckReport) -> None: + super().validate(report) + assert _output_parts(report)[:2] == [ + { + "name": "google_search", + "server_tool_call": { + "arguments": {"queries": ["OpenTelemetry"]}, + "search_type": "web_search", + "type": "google_search", + }, + "id": "search-1", + "type": "server_tool_call", + }, + { + "server_tool_call_response": { + "is_error": False, + "result": [], + "type": "google_search", + }, + "id": "search-1", + "type": "server_tool_call_response", + }, + ] + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + GoogleGenAiSdkInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette( + "interactions_server_tool_calling_conformance.yaml" + ): + client = Client( + api_key="test_google_genai_api_key", vertexai=False + ) + client.interactions.create( + model="gemini-2.5-flash", + input="Search for OpenTelemetry.", + tools=[{"type": "google_search"}], + ) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py index 577335959..8aed71709 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py @@ -6,9 +6,15 @@ from google.genai import types as genai_types from opentelemetry.instrumentation.google_genai.message import ( + _to_part, to_system_instructions, ) -from opentelemetry.util.genai.types import GenericPart, TextPart +from opentelemetry.util.genai.types import ( + GenericPart, + ServerToolCallPart, + ServerToolCallResponsePart, + TextPart, +) class TestGoogleGenAiMessage(unittest.TestCase): @@ -46,3 +52,103 @@ def test_to_system_instructions_empty_or_unknown_part(self): content = genai_types.Content(parts=[genai_types.Part()]) instructions = to_system_instructions(content=content) self.assertEqual(instructions, []) + + def test_to_part_maps_code_execution(self): + call_id = ( + {"id": "code-1"} + if "id" in genai_types.ExecutableCode.model_fields + else {} + ) + response_id = ( + {"id": "code-1"} + if "id" in genai_types.CodeExecutionResult.model_fields + else {} + ) + call = _to_part( + genai_types.Part( + executable_code=genai_types.ExecutableCode( + code="print(1)", + language=genai_types.Language.PYTHON, + **call_id, + ) + ), + 0, + ) + response = _to_part( + genai_types.Part( + code_execution_result=genai_types.CodeExecutionResult( + outcome=genai_types.Outcome.OUTCOME_OK, + output="1", + **response_id, + ) + ), + 1, + ) + + self.assertIsInstance(call, ServerToolCallPart) + self.assertEqual(call.id, call_id.get("id")) + self.assertEqual(call.name, "code_execution") + self.assertEqual( + call.server_tool_call, + { + "type": "code_execution", + "code": "print(1)", + "language": "PYTHON", + }, + ) + self.assertIsInstance(response, ServerToolCallResponsePart) + self.assertEqual(response.id, response_id.get("id")) + self.assertEqual( + response.server_tool_call_response, + { + "type": "code_execution", + "outcome": "OUTCOME_OK", + "output": "1", + }, + ) + + @unittest.skipUnless( + "tool_call" in genai_types.Part.model_fields, + "server tool parts are unavailable in this SDK", + ) + def test_to_part_maps_server_tool_call_and_response(self): + call = _to_part( + genai_types.Part( + tool_call=genai_types.ToolCall( + id="tool-1", + tool_type=genai_types.ToolType.GOOGLE_SEARCH_WEB, + args={"query": "OpenTelemetry"}, + ) + ), + 0, + ) + response = _to_part( + genai_types.Part( + tool_response=genai_types.ToolResponse( + id="tool-1", + tool_type=genai_types.ToolType.GOOGLE_SEARCH_WEB, + response={"results": []}, + ) + ), + 1, + ) + + self.assertIsInstance(call, ServerToolCallPart) + self.assertEqual(call.id, "tool-1") + self.assertEqual(call.name, "google_search_web") + self.assertEqual( + call.server_tool_call, + { + "type": "google_search_web", + "arguments": {"query": "OpenTelemetry"}, + }, + ) + self.assertIsInstance(response, ServerToolCallResponsePart) + self.assertEqual(response.id, "tool-1") + self.assertEqual( + response.server_tool_call_response, + { + "type": "google_search_web", + "response": {"results": []}, + }, + ) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py index cc4a2c539..d1171b17e 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -13,6 +13,8 @@ ) from opentelemetry.util.genai.types import ( GenericPart, + ServerToolCallPart, + ServerToolCallResponsePart, TextPart, ToolCallRequestPart, ToolCallResponsePart, @@ -97,6 +99,39 @@ def test_input_to_messages_tool_result_step(self) -> None: self.assertEqual(messages[0].parts[0].id, "call-123") self.assertEqual(messages[0].parts[0].response, {"val": 10}) + def test_input_to_messages_server_tool_steps(self) -> None: + steps = [ + { + "type": "google_search_call", + "id": "search-1", + "arguments": {"query": "OpenTelemetry"}, + }, + { + "type": "google_search_result", + "call_id": "search-1", + "result": [], + }, + ] + + parts = _interactions_input_to_messages(steps)[0].parts + + self.assertIsInstance(parts[0], ServerToolCallPart) + self.assertEqual(parts[0].id, "search-1") + self.assertEqual(parts[0].name, "google_search") + self.assertEqual( + parts[0].server_tool_call, + { + "arguments": {"query": "OpenTelemetry"}, + "type": "google_search", + }, + ) + self.assertIsInstance(parts[1], ServerToolCallResponsePart) + self.assertEqual(parts[1].id, "search-1") + self.assertEqual( + parts[1].server_tool_call_response, + {"result": [], "type": "google_search"}, + ) + def test_input_to_messages_generic_fallback(self) -> None: steps = [{"type": "some_unsupported_type"}] messages = _interactions_input_to_messages(steps) @@ -127,3 +162,52 @@ def test_response_to_messages(self) -> None: self.assertEqual(len(messages[0].parts), 1) self.assertIsInstance(messages[0].parts[0], TextPart) self.assertEqual(messages[0].parts[0].content, "Model response text") + + def test_response_to_messages_includes_tool_steps(self) -> None: + mock_interaction = unittest.mock.MagicMock() + mock_interaction.output_text = "Search complete" + mock_interaction.steps = [ + { + "type": "mcp_server_tool_call", + "id": "mcp-1", + "name": "search", + "server_name": "docs", + "arguments": {"query": "OpenTelemetry"}, + }, + { + "type": "mcp_server_tool_result", + "call_id": "mcp-1", + "name": "search", + "server_name": "docs", + "result": {"items": []}, + }, + { + "type": "model_output", + "content": [{"type": "text", "text": "Search complete"}], + }, + ] + + parts = _interactions_response_to_messages(mock_interaction)[0].parts + + self.assertIsInstance(parts[0], ServerToolCallPart) + self.assertEqual(parts[0].id, "mcp-1") + self.assertEqual(parts[0].name, "search") + self.assertEqual( + parts[0].server_tool_call, + { + "server_name": "docs", + "arguments": {"query": "OpenTelemetry"}, + "type": "mcp", + }, + ) + self.assertIsInstance(parts[1], ServerToolCallResponsePart) + self.assertEqual(parts[1].id, "mcp-1") + self.assertEqual( + parts[1].server_tool_call_response, + { + "server_name": "docs", + "result": {"items": []}, + "type": "mcp", + }, + ) + self.assertEqual(parts[2], TextPart(content="Search complete")) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py index f337627bf..909a66a10 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py @@ -26,6 +26,10 @@ from .conformance.embedding import EmbeddingScenario from .conformance.generate_content import GenerateContentScenario from .conformance.inference import InferenceScenario +from .conformance.server_tool_calling import ( + GenerateContentServerToolCallingScenario, + InteractionsServerToolCallingScenario, +) from .conformance.tool_calling import ToolCallingScenario @@ -36,6 +40,8 @@ EmbeddingScenario(), GenerateContentScenario(), ToolCallingScenario(), + GenerateContentServerToolCallingScenario(), + InteractionsServerToolCallingScenario(), ], ids=lambda s: type(s).__name__, ) From de24c1aebbca602f0df433d5680c1223f1f2a3ea Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sat, 12 Sep 2026 13:01:00 -0700 Subject: [PATCH 2/4] chore(google-genai): add changelog fragment Assisted-by: Codex --- .../.changelog/701.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/.changelog/701.fixed diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/701.fixed b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/701.fixed new file mode 100644 index 000000000..88d7a735a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/701.fixed @@ -0,0 +1 @@ +Capture provider-executed tool calls and results as server tool message parts. From 535f120e654bd2995b227f4cc723e4c54a2520c0 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sat, 12 Sep 2026 13:16:28 -0700 Subject: [PATCH 3/4] refactor(google-genai): tighten server tool types Assisted-by: Codex --- .../google_genai/interactions.py | 77 +++++++++++++------ .../instrumentation/google_genai/message.py | 20 ++--- .../tests/interactions/test_parser.py | 51 ++++++------ 3 files changed, 94 insertions(+), 54 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py index 2af22da4c..8d84520e0 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py @@ -20,6 +20,7 @@ from google.genai._interactions.types.interaction_sse_event import ( InteractionSSEEvent, ) + from google.genai._interactions.types.step import Step _HAS_INTERACTIONS = True except ImportError: @@ -37,6 +38,7 @@ from google.genai._gaos.types.interactions import ( Interaction, InteractionSSEEvent, + Step, Usage, ) from google.genai._gaos.types.interactions import ( @@ -69,6 +71,9 @@ class Input: class InteractionSSEEvent: pass + class Step: + pass + class Stream: pass @@ -226,9 +231,18 @@ def _get_field(obj: Any, name: str) -> Any: "url_context_result": "url_context", } +_TOOL_STEP_TYPES = { + "function_call", + "function_result", + *_SERVER_TOOL_CALL_NAMES, + *_SERVER_TOOL_RESPONSE_NAMES, +} + -def _to_server_tool_part(item: Any) -> MessagePart | None: - item_type = _get_field(item, "type") +def _to_server_tool_part( + item: Step | dict[str, object], +) -> MessagePart | None: + item_type = item.get("type") if isinstance(item, dict) else item.type tool_name = _SERVER_TOOL_CALL_NAMES.get(item_type) response_name = _SERVER_TOOL_RESPONSE_NAMES.get(item_type) if tool_name is None and response_name is None: @@ -237,10 +251,7 @@ def _to_server_tool_part(item: Any) -> MessagePart | None: if isinstance(item, dict): payload = dict(item) else: - model_dump = getattr(item, "model_dump", None) - if not callable(model_dump): - return None - payload = model_dump(exclude_none=True, mode="json") + payload = item.model_dump(exclude_none=True, mode="json") item_id = payload.pop("id", None) call_id = payload.pop("call_id", None) @@ -263,18 +274,34 @@ def _to_server_tool_part(item: Any) -> MessagePart | None: ) -def _interaction_item_to_part(item: Any) -> MessagePart | None: - item_type = _get_field(item, "type") +def _interaction_item_to_part( + item: Step | dict[str, object], +) -> MessagePart | None: + if isinstance(item, dict): + item_type = item.get("type") + item_id = item.get("id") + name = item.get("name") + arguments = item.get("arguments") + call_id = item.get("call_id") + result = item.get("result") + else: + item_type = item.type + item_id = item.id if item_type == "function_call" else None + name = item.name if item_type == "function_call" else None + arguments = item.arguments if item_type == "function_call" else None + call_id = item.call_id if item_type == "function_result" else None + result = item.result if item_type == "function_result" else None + if item_type == "function_call": return ToolCallRequestPart( - id=_get_field(item, "id"), - name=_get_field(item, "name") or "", - arguments=_get_field(item, "arguments"), + id=item_id if isinstance(item_id, str) else None, + name=name if isinstance(name, str) else "", + arguments=arguments, ) if item_type == "function_result": return ToolCallResponsePart( - id=_get_field(item, "call_id"), - response=_get_field(item, "result"), + id=call_id if isinstance(call_id, str) else None, + response=result, ) return _to_server_tool_part(item) @@ -300,14 +327,21 @@ def _interactions_input_to_messages( if not isinstance(input_data, Sequence): input_data = [input_data] - parts = [] + parts: list[MessagePart] = [] for item in input_data: + if isinstance(item, str): + parts.append(TextPart(content=item)) + continue + item_type = _get_field(item, "type") - message_part = _interaction_item_to_part(item) + message_part = None + if isinstance(item, dict): + message_part = _interaction_item_to_part(item) + elif item_type in _TOOL_STEP_TYPES: + message_part = _interaction_item_to_part(cast(Step, item)) + if message_part is not None: parts.append(message_part) - elif isinstance(item, str): - parts.append(TextPart(content=item)) elif item_type == "text": part = TextPart(content=_get_field(item, "text") or "") parts.append(part) @@ -356,12 +390,11 @@ def _interactions_response_to_messages( if part := _interaction_item_to_part(step): parts.append(part) continue - if _get_field(step, "type") != "model_output": + if step.type != "model_output": continue - for item in _get_field(step, "content") or []: - if _get_field(item, "type") == "text" and isinstance( - text := _get_field(item, "text"), str - ): + for item in step.content or []: + if item.type == "text" and isinstance(item.text, str): + text = item.text parts.append(TextPart(content=text)) if not any(isinstance(part, TextPart) for part in parts): diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py index 0e4e52fcb..febfef29b 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py @@ -125,8 +125,7 @@ def tool_call_id(name: str | None) -> str: ) if call := getattr(part, "tool_call", None): - tool_type = getattr(call.tool_type, "value", call.tool_type) - name = str(tool_type).lower() if tool_type else "unknown" + name = call.tool_type.value.lower() if call.tool_type else "unknown" return ServerToolCallPart( id=call.id, name=name, @@ -137,8 +136,11 @@ def tool_call_id(name: str | None) -> str: ) if response := getattr(part, "tool_response", None): - tool_type = getattr(response.tool_type, "value", response.tool_type) - name = str(tool_type).lower() if tool_type else "unknown" + name = ( + response.tool_type.value.lower() + if response.tool_type + else "unknown" + ) return ServerToolCallResponsePart( id=response.id, server_tool_call_response={ @@ -147,25 +149,23 @@ def tool_call_id(name: str | None) -> str: }, ) - if code := getattr(part, "executable_code", None): - language = getattr(code.language, "value", code.language) + if code := part.executable_code: return ServerToolCallPart( id=getattr(code, "id", None), name="code_execution", server_tool_call={ "type": "code_execution", "code": code.code, - "language": language, + "language": code.language.value if code.language else None, }, ) - if result := getattr(part, "code_execution_result", None): - outcome = getattr(result.outcome, "value", result.outcome) + if result := part.code_execution_result: return ServerToolCallResponsePart( id=getattr(result, "id", None), server_tool_call_response={ "type": "code_execution", - "outcome": outcome, + "outcome": result.outcome.value if result.outcome else None, "output": result.output, }, ) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py index d1171b17e..36ba959df 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -8,6 +8,7 @@ from opentelemetry.instrumentation.google_genai.interactions import ( _HAS_INTERACTIONS, + Interaction, _interactions_input_to_messages, _interactions_response_to_messages, ) @@ -164,30 +165,36 @@ def test_response_to_messages(self) -> None: self.assertEqual(messages[0].parts[0].content, "Model response text") def test_response_to_messages_includes_tool_steps(self) -> None: - mock_interaction = unittest.mock.MagicMock() - mock_interaction.output_text = "Search complete" - mock_interaction.steps = [ + interaction = Interaction.model_validate( { - "type": "mcp_server_tool_call", - "id": "mcp-1", - "name": "search", - "server_name": "docs", - "arguments": {"query": "OpenTelemetry"}, - }, - { - "type": "mcp_server_tool_result", - "call_id": "mcp-1", - "name": "search", - "server_name": "docs", - "result": {"items": []}, - }, - { - "type": "model_output", - "content": [{"type": "text", "text": "Search complete"}], - }, - ] + "status": "completed", + "output_text": "Search complete", + "steps": [ + { + "type": "mcp_server_tool_call", + "id": "mcp-1", + "name": "search", + "server_name": "docs", + "arguments": {"query": "OpenTelemetry"}, + }, + { + "type": "mcp_server_tool_result", + "call_id": "mcp-1", + "name": "search", + "server_name": "docs", + "result": {"items": []}, + }, + { + "type": "model_output", + "content": [ + {"type": "text", "text": "Search complete"} + ], + }, + ], + } + ) - parts = _interactions_response_to_messages(mock_interaction)[0].parts + parts = _interactions_response_to_messages(interaction)[0].parts self.assertIsInstance(parts[0], ServerToolCallPart) self.assertEqual(parts[0].id, "mcp-1") From 9dc7243354d2b25e37b25c33b5945023e6de964d Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Sat, 12 Sep 2026 13:49:31 -0700 Subject: [PATCH 4/4] fix google genai v2 interaction test --- .../tests/interactions/test_parser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py index 36ba959df..7ef2b6dfe 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -167,6 +167,9 @@ def test_response_to_messages(self) -> None: def test_response_to_messages_includes_tool_steps(self) -> None: interaction = Interaction.model_validate( { + "id": "interaction-1", + "created": "2026-06-24T18:51:26Z", + "updated": "2026-06-24T18:51:26Z", "status": "completed", "output_text": "Search complete", "steps": [