Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def _interactions_input_to_messages(
)
parts.append(part)
elif item_type is not None:
part = GenericPart(value=type(item).__name__)
part = GenericPart(type=item_type, value=type(item).__name__)
parts.append(part)

return [InputMessage(role="user", parts=parts)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,11 @@ def setUp(self): # pylint: disable=invalid-name
{
"name": "_mock_callable_tool",
"description": "Description of some tool.",
"parameters": None,
"type": "function",
},
{
"name": "mock_tool",
"description": "Description of mock tool.",
"parameters": None,
"type": "function",
},
{
Expand All @@ -128,21 +126,18 @@ def setUp(self): # pylint: disable=invalid-name
{
"name": "mcp_tool",
"description": "A standalone mcp tool",
"parameters": None,
"type": "function",
},
),
(
{
"name": "mcp_tool",
"description": "Tool from session",
"parameters": None,
"type": "function",
},
{
"name": "mcp_tool",
"description": "A standalone mcp tool",
"parameters": None,
"type": "function",
},
),
Expand Down Expand Up @@ -534,14 +529,14 @@ def test_span_attributes_no_content_capture(self):
self.assertIn(
span.attributes[GEN_AI_TOOL_DEFINITIONS],
[
'[{"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"},{"name":"mcp_tool","description":"Tool from session","parameters":null,"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":null,"type":"function"}]',
'[{"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"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":null,"type":"function"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","type":"function"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","type":"function"}]',
],
)
else:
self.assertEqual(
span.attributes[GEN_AI_TOOL_DEFINITIONS],
'[{"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"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"}]',
)

@patch.dict(
Expand Down Expand Up @@ -593,14 +588,14 @@ def test_span_attributes_with_content_capture(self):
self.assertIn(
span.attributes[GEN_AI_TOOL_DEFINITIONS],
[
'[{"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"},{"name":"mcp_tool","description":"Tool from session","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]',
'[{"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"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]',
],
)
else:
self.assertEqual(
span.attributes[GEN_AI_TOOL_DEFINITIONS],
'[{"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"}]',
'[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"}]',
)

def test_log_has_extra_genai_attributes(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ def test_interaction_with_dict_tools_records_tool_definitions(
span = self.otel.get_span_named("interactions.create gemini-2.5-flash")
self.assertEqual(
span.attributes["gen_ai.tool.definitions"],
'[{"name":"dict_tool","description":"Dict tool desc","parameters":null,"type":"function"}]',
'[{"name":"dict_tool","description":"Dict tool desc","type":"function"}]',
)

def test_interaction_with_builtin_tools_records_definitions(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ def test_input_to_messages_generic_fallback(self) -> None:
messages = _interactions_input_to_messages(steps)
self.assertEqual(len(messages[0].parts), 1)
self.assertIsInstance(messages[0].parts[0], GenericPart)
self.assertEqual(messages[0].parts[0].type, "some_unsupported_type")
self.assertEqual(messages[0].parts[0].value, "dict")

def test_input_to_messages_single_non_sequence_step(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,11 @@
# OpenTelemetry SDK and test utilities come transitively from opentelemetry-test-util-genai, which
# every oldest env installs. Pin here only test-only deps that nothing else already provides.

# Exception: this change depends on the None-omitting message/tool serialization added in
# opentelemetry-util-genai 1.1b0, which is not on PyPI yet. Install it from the workspace so the
# oldest env exercises the required behavior. Remove this once 1.1b0 is released and bump the
# pyproject floor to >= 1.1b0.
-e ./util/opentelemetry-util-genai

google-auth==2.50.0
fsspec==2025.9.0
1 change: 1 addition & 0 deletions util/opentelemetry-util-genai/.changelog/284.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Align message dataclasses with the GenAI semconv schemas: add `name` to `InputMessage`/`OutputMessage`, add `Compaction`, `SystemInstructionPart`, `MemoryRecord`, and `RetrievalDocument`, extend `Modality` with `document`, and update `FinishReason` to the schema enum (`tool_calls` becomes `tool_call`, adds `compaction`). Message and tool-definition serialization now omits `None`-valued fields to match the semconv JSON schemas.
Comment thread
lmolkova marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from abc import abstractmethod
from contextlib import AbstractContextManager
from contextvars import Token
from dataclasses import asdict
from types import TracebackType
from typing import TYPE_CHECKING, Any, Sequence, TypeAlias

Expand All @@ -30,6 +29,7 @@
)
from opentelemetry.util.genai.utils import (
ContentCapturingMode,
asdict_without_none,
gen_ai_json_dumps,
get_content_capturing_mode,
)
Expand Down Expand Up @@ -214,7 +214,7 @@ def get_content_attributes(
)

def serialize(items: Sequence[Any]) -> Any:
dicts = [asdict(item) for item in items]
dicts = [asdict_without_none(item) for item in items]
return gen_ai_json_dumps(dicts) if for_span else dicts

# Tool definitions are always captured, the sem conv recommends adding params / description only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from __future__ import annotations

import dataclasses
import hashlib
import json
import logging
Expand All @@ -16,7 +15,7 @@
ThreadPoolExecutor,
)
from contextlib import ExitStack
from dataclasses import asdict, dataclass
from dataclasses import dataclass
from functools import partial
from time import time
from typing import Any, Callable, Final, Literal
Expand All @@ -29,7 +28,10 @@
from opentelemetry.trace import Span
from opentelemetry.util.genai import types
from opentelemetry.util.genai.completion_hook import CompletionHook
from opentelemetry.util.genai.utils import gen_ai_json_dump
from opentelemetry.util.genai.utils import (
asdict_without_none,
gen_ai_json_dump,
)

GEN_AI_INPUT_MESSAGES_REF: Final = (
gen_ai_attributes.GEN_AI_INPUT_MESSAGES + "_ref"
Expand Down Expand Up @@ -92,10 +94,7 @@ def hash_tool_definitions(
if not tool_definitions:
return None
try:
tool_dicts = [
{k: v for k, v in dataclasses.asdict(t).items() if v is not None}
for t in tool_definitions
]
tool_dicts = [asdict_without_none(t) for t in tool_definitions]

encoded_tools = json.dumps(
tool_dicts,
Expand Down Expand Up @@ -325,7 +324,7 @@ def to_dict(
| list[types.MessagePart]
| list[types.ToolDefinition],
) -> JsonEncodeable:
return [asdict(dc) for dc in dataclass_list]
return [asdict_without_none(dc) for dc in dataclass_list]

references = [
(ref_name, ref, ref_attr, contents_hashed_to_filename)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,16 @@ class ContentCapturingMode(Enum):
@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."""
the standard MessagePart types defined in semantic conventions. Set ``type``
to the provider-specific type discriminator and carry the payload in
``value`` to explicitly opt-in to non-standard types.
This will be removed in a future version when all instrumentations use core types.

Per the semconv message schema, ``type`` is a free-form string (the
provider's own type name), not a fixed literal."""

type: str
value: Any
type: Literal["generic"] = "generic"


@dataclass()
Expand Down Expand Up @@ -127,7 +131,20 @@ class Reasoning:
type: Literal["reasoning"] = "reasoning"


Modality = Literal["image", "video", "audio"]
@dataclass()
class Compaction:
"""Represents a compaction/summary of prior conversation history.

This model is specified as part of semconv in `GenAI messages Python models - CompactionPart
<https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/non-normative/models.ipynb>`__.
"""

id: str | None = None
content: str | None = None
type: Literal["compaction"] = "compaction"


Modality = Literal["image", "video", "audio", "document"]


@dataclass()
Expand Down Expand Up @@ -202,26 +219,60 @@ class GenericToolDefinition:
File,
Uri,
Reasoning,
Compaction,
GenericPart, # For provider-specific types; prefer standard types above
]


# System instructions are a narrower set of parts than message content.
# Mirrors the `gen_ai.system_instructions` semconv schema (TextPart | GenericPart).
SystemInstructionPart = Union[Text, GenericPart]


FinishReason = Literal[
"content_filter", "error", "length", "stop", "tool_calls"
"content_filter", "error", "length", "stop", "tool_call", "compaction"
]


@dataclass()
class InputMessage:
role: str
parts: list[MessagePart]
name: str | None = None


@dataclass()
class OutputMessage:
role: str
parts: list[MessagePart]
finish_reason: str | FinishReason
name: str | None = None


@dataclass()
class MemoryRecord:
"""Represents a single memory record exposed to or produced by the model.

This model is specified as part of semconv in `GenAI messages Python models - MemoryRecord
<https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/non-normative/models.ipynb>`__.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This link (and below) doesn't resolve, they point to the old repo

"""

content: Any
id: str | None = None
metadata: dict[str, Any] | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel we should start just using AttributeValue instead of Any in these types, since they ultimately go into a span/log attribute right ?

For Input/OutputMessage we just end up completely serializing those (using json.dumps) types into a string.. so I think those Anys are fine to be left as is..

score: float | None = None


@dataclass()
class RetrievalDocument:
"""Represents a document returned by a retrieval operation.

This model is specified as part of semconv in `GenAI messages Python models - RetrievalDocument
<https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/non-normative/models.ipynb>`__.
"""

id: str
score: float


@dataclass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
from base64 import b64encode
from dataclasses import asdict
from functools import partial
from typing import Any

Expand Down Expand Up @@ -85,6 +86,19 @@ def should_capture_content_on_spans() -> bool:
)


def asdict_without_none(obj: Any) -> dict[str, Any]:
"""``dataclasses.asdict`` that omits keys whose value is ``None``.

Matches the GenAI semconv message schemas, which exclude unset optional
fields rather than serializing them as ``null``. The ``dict_factory`` is
applied recursively by ``asdict``, so nested message parts are pruned too.
"""
return asdict(
obj,
dict_factory=lambda items: {k: v for k, v in items if v is not None},
)


class _GenAiJsonEncoder(json.JSONEncoder):
def default(self, o: Any) -> Any:
if isinstance(o, bytes):
Expand Down
2 changes: 1 addition & 1 deletion util/opentelemetry-util-genai/tests/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ def test_upload_completions(self):
)
self.assert_fsspec_equal(
span.attributes["gen_ai.tool.definitions_ref"],
'[{"name":"test_tool","description":"does something","parameters":null,"type":"function"}]\n',
'[{"name":"test_tool","description":"does something","type":"function"}]\n',
)

def test_stamps_empty_log(self):
Expand Down