diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed new file mode 100644 index 000000000..df6e9e925 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed @@ -0,0 +1 @@ +Report text parts with `thought=True` as `ReasoningPart` instead of `TextPart` in `generate_content` and `generate_content_stream` messages. 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 febfef29b..0a4476632 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 @@ -14,6 +14,7 @@ InputMessage, MessagePart, OutputMessage, + ReasoningPart, Role, ServerToolCallPart, ServerToolCallResponsePart, @@ -91,6 +92,8 @@ def tool_call_id(name: str | None) -> str: return f"{idx}" if (text := part.text) is not None: + if part.thought: + return ReasoningPart(content=text) return TextPart(content=text) if inline_data := part.inline_data: diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 0f5dfd69b..0e775404d 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -13,6 +13,7 @@ GoogleMaps, MediaModality, ModalityTokenCount, + Part, ToolDict, ) from pydantic import BaseModel, Field @@ -751,6 +752,24 @@ def test_span_attributes_with_content_capture(self): '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"}]', ) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_reports_thought_parts_as_reasoning(self): + self.configure_valid_response( + parts=[ + Part(text="Let me think.", thought=True), + Part(text="It is sunny."), + ] + ) + self.generate_content(model="gemini-2.0-flash", contents="Hi") + span = self.otel.get_span_named("generate_content gemini-2.0-flash") + self.assertEqual( + span.attributes[gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES], + '[{"role":"assistant","parts":[{"content":"Let me think.","type":"reasoning"},{"content":"It is sunny.","type":"text"}],"finish_reason":"","name":null}]', + ) + def test_log_has_extra_genai_attributes(self): self.configure_valid_response(text="Yep, it works!") tok = context_api.attach( diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py index e83aefed1..48f55f0ea 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py @@ -9,6 +9,7 @@ GenerateContentResponseUsageMetadata, MediaModality, ModalityTokenCount, + Part, ) from opentelemetry import context as context_api @@ -163,6 +164,21 @@ def test_merges_streamed_chunks_into_one_output_message(self): '[{"role":"assistant","parts":[{"content":"Hello, world","type":"text"}],"finish_reason":"","name":null}]', ) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_reports_streamed_thought_parts_as_reasoning(self): + self.configure_valid_response(part=Part(text="Let me ", thought=True)) + self.configure_valid_response(part=Part(text="think.", thought=True)) + self.configure_valid_response(text="It is sunny.") + self.generate_content(model="gemini-2.0-flash", contents="Hi") + span = self.otel.get_span_named("generate_content gemini-2.0-flash") + self.assertEqual( + span.attributes[gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES], + '[{"role":"assistant","parts":[{"content":"Let me think.","type":"reasoning"},{"content":"It is sunny.","type":"text"}],"finish_reason":"","name":null}]', + ) + def test_includes_token_counts_in_span_not_aggregated_from_responses(self): # Tokens should not be aggregated in streaming. Cumulative counts are returned on each response. self.configure_valid_response( 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 8aed71709..3afd007d7 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 @@ -11,6 +11,7 @@ ) from opentelemetry.util.genai.types import ( GenericPart, + ReasoningPart, ServerToolCallPart, ServerToolCallResponsePart, TextPart, @@ -48,11 +49,28 @@ def test_to_system_instructions_non_text_parts(self): ], ) + def test_to_system_instructions_thought_part(self): + content = genai_types.Content( + parts=[genai_types.Part(text="Let me think.", thought=True)] + ) + instructions = to_system_instructions(content=content) + self.assertEqual(instructions, [GenericPart(type="reasoning")]) + 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_thought_text_to_reasoning(self): + self.assertEqual( + _to_part(genai_types.Part(text="Let me think.", thought=True), 0), + ReasoningPart(content="Let me think."), + ) + self.assertEqual( + _to_part(genai_types.Part(text="Sunny.", thought=False), 0), + TextPart(content="Sunny."), + ) + def test_to_part_maps_code_execution(self): call_id = ( {"id": "code-1"}