Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
- Fix bug in how tokens are counted when using the streaming `generateContent` method. ([#4152](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4152)).
- Add `gen_ai.tool.definitions` attribute to `gen_ai.client.inference.operation.details` log event ([#4142](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4142)).
- Add `gen_ai.tool_definitions` to completion hook ([#4181](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4181))


## Version 0.6b0 (2026-01-27)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,12 +563,12 @@ def _maybe_update_error_type(self, response: GenerateContentResponse):
block_reason = response.prompt_feedback.block_reason.name.upper()
self._error_type = f"BLOCKED_{block_reason}"

def _maybe_get_tool_definitions(self, config):
def _maybe_get_tool_definitions(self, config) -> list[MessagePart]:
if (
self.sem_conv_opt_in_mode
!= _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
return None
return []

tool_definitions = []
if tools := _config_to_tools(config):
Expand All @@ -582,12 +582,14 @@ def _maybe_get_tool_definitions(self, config):
tool_definitions.append(definition)
return tool_definitions

async def _maybe_get_tool_definitions_async(self, config):
async def _maybe_get_tool_definitions_async(
self, config
) -> list[MessagePart]:
if (
self.sem_conv_opt_in_mode
!= _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
return None
return []

tool_definitions = []
if tools := _config_to_tools(config):
Expand All @@ -609,7 +611,7 @@ def _maybe_log_completion_details(
request: Union[ContentListUnion, ContentListUnionDict],
candidates: list[Candidate],
config: Optional[GenerateContentConfigOrDict] = None,
tool_definitions: list[MessagePart] = None,
tool_definitions: Optional[list[MessagePart]] = None,
):
if (
self.sem_conv_opt_in_mode
Expand Down Expand Up @@ -637,14 +639,15 @@ def _maybe_log_completion_details(
inputs=input_messages,
outputs=output_messages,
system_instruction=system_instructions,
tool_definitions=tool_definitions or [],
span=span,
log_record=event,
)
completion_details_attributes = _create_completion_details_attributes(
input_messages,
output_messages,
system_instructions,
tool_definitions,
tool_definitions or [],
)
if self._content_recording_enabled in [
ContentCapturingMode.EVENT_ONLY,
Expand Down
1 change: 1 addition & 0 deletions util/opentelemetry-util-genai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Add `gen_ai.tool_definitions` to completion hook ([#4181](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4181))
- Add support for emitting inference events and enrich message types. ([#3994](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3994))
- Add support for `server.address`, `server.port` on all signals and additional metric-only attributes
([#4069](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4069))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

import hashlib
import json
import logging
import posixpath
import threading
Expand Down Expand Up @@ -47,6 +48,11 @@
gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS + "_ref"
)

GEN_AI_TOOL_DEFINITIONS = getattr(
gen_ai_attributes, "GEN_AI_TOOL_DEFINITIONS", "gen_ai.tool.definitions"
)
GEN_AI_TOOL_DEFINITIONS_REF: Final = GEN_AI_TOOL_DEFINITIONS + "_ref"

_MESSAGE_INDEX_KEY = "index"
_DEFAULT_MAX_QUEUE_SIZE = 20
_DEFAULT_FORMAT = "json"
Expand All @@ -63,13 +69,15 @@ class Completion:
inputs: list[types.InputMessage] | None
outputs: list[types.OutputMessage] | None
system_instruction: list[types.MessagePart] | None
tool_definitions: list[types.MessagePart] | None


@dataclass
class CompletionRefs:
inputs_ref: str
outputs_ref: str
system_instruction_ref: str
tool_definitions_ref: str


JsonEncodeable = list[dict[str, Any]]
Expand All @@ -86,6 +94,34 @@ def is_system_instructions_hashable(
)


def is_tool_definitions_hashable(
tool_definitions: list[types.MessagePart] | None,
) -> bool:
return bool(tool_definitions) and all(
isinstance(x, (dict, str)) for x in tool_definitions
)


def hash_tool_definitions(tool_definitions: list[types.MessagePart]) -> str:
serialized_parts: list[str] = []

for tool in tool_definitions:
if tool is None:
continue

if isinstance(tool, dict):
serialized_parts.append(json.dumps(tool, sort_keys=True))
else:
serialized_parts.append(str(tool))

combined_string = "\n".join(serialized_parts)

return hashlib.sha256(
combined_string.encode("utf-8"),
usedforsecurity=False,
).hexdigest()


class UploadCompletionHook(CompletionHook):
"""An completion hook using ``fsspec`` to upload to external storage

Expand Down Expand Up @@ -166,7 +202,9 @@ def done(future: Future[None]) -> None:
self._semaphore.release()

def _calculate_ref_path(
self, system_instruction: list[types.MessagePart]
self,
system_instruction: list[types.MessagePart],
tool_definitions: list[types.MessagePart] | None = None,
) -> CompletionRefs:
# TODO: experimental with using the trace_id and span_id, or fetching
# gen_ai.response.id from the active span.
Expand All @@ -179,6 +217,11 @@ def _calculate_ref_path(
),
usedforsecurity=False,
).hexdigest()

tool_definitions_hash = None
if tool_definitions and is_tool_definitions_hashable(tool_definitions):
tool_definitions_hash = hash_tool_definitions(tool_definitions)

uuid_str = str(uuid4())
return CompletionRefs(
inputs_ref=posixpath.join(
Expand All @@ -191,6 +234,10 @@ def _calculate_ref_path(
self._base_path,
f"{system_instruction_hash or uuid_str}_system_instruction.{self._format}",
),
tool_definitions_ref=posixpath.join(
self._base_path,
f"{tool_definitions_hash or uuid_str}_tool.definitions.{self._format}",
),
)

def _file_exists(self, path: str) -> bool:
Expand Down Expand Up @@ -247,27 +294,39 @@ def on_completion(
inputs: list[types.InputMessage],
outputs: list[types.OutputMessage],
system_instruction: list[types.MessagePart],
tool_definitions: list[types.MessagePart] | None = None,
span: Span | None = None,
log_record: LogRecord | None = None,
**kwargs: Any,
) -> None:
if not any([inputs, outputs, system_instruction]):
if not any([inputs, outputs, system_instruction, tool_definitions]):
return
# An empty list will not be uploaded.
completion = Completion(
inputs=inputs or None,
outputs=outputs or None,
system_instruction=system_instruction or None,
tool_definitions=tool_definitions or None,
)
# generate the paths to upload to
ref_names = self._calculate_ref_path(system_instruction)
ref_names = self._calculate_ref_path(
system_instruction, tool_definitions
)

def to_dict(
dataclass_list: list[types.InputMessage]
data_list: list[types.InputMessage]
| list[types.OutputMessage]
| list[types.MessagePart],
) -> JsonEncodeable:
return [asdict(dc) for dc in dataclass_list]
response: JsonEncodeable = []
for data in data_list:
if isinstance(data, dict):
Copy link
Contributor

Choose a reason for hiding this comment

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

In what case will it be a dict ? Don't you always serialize the tool definitions to a sting ? And otherwise it's a dataclass ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The tool definitions are serialized into dictionaries, and in a fallback to a string.

response.append(data) # type: ignore
elif isinstance(data, str):
response.append({"content": data})
else:
response.append(asdict(data))
return response

references = [
(ref_name, ref, ref_attr, contents_hashed_to_filename)
Expand All @@ -292,6 +351,12 @@ def to_dict(
completion.system_instruction
),
),
(
ref_names.tool_definitions_ref,
completion.tool_definitions,
GEN_AI_TOOL_DEFINITIONS_REF,
is_tool_definitions_hashable(completion.tool_definitions),
),
]
if ref # Filter out empty input/output/sys instruction
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class CompletionHook(Protocol):
outputs: The outputs of the GenAI interaction.
system_instruction: The system instruction of the GenAI
interaction.
tool_definitions: The list of source system tool definitions
available to the GenAI agent or model.
span: The span associated with the GenAI interaction.
log_record: The event log associated with the GenAI
interaction.
Expand All @@ -72,6 +74,7 @@ def on_completion(
inputs: list[types.InputMessage],
outputs: list[types.OutputMessage],
system_instruction: list[types.MessagePart],
tool_definitions: list[types.MessagePart] | None = None,
span: Span | None = None,
log_record: LogRecord | None = None,
) -> None: ...
Expand Down
Loading