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
@@ -0,0 +1 @@
Record `function_call`, `custom_tool_call` and their output items on Responses API spans, so a turn that follows a tool call keeps its tool-loop history in `gen_ai.input.messages`.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .utils import (
_content_to_parts,
_openai_response_format_to_output_type,
_tool_response_to_data,
get_property_value,
get_served_model,
get_server_address_and_port,
Expand Down Expand Up @@ -63,6 +64,17 @@
ResponseReasoningItem = None
ResponseUsage = None

# `custom_tool_call` arrived in openai 1.99.2, later than the rest of the
# Responses types, so a shared import block would disable all of them on the
# versions in between.
try:
from openai.types.responses.response_custom_tool_call import (
ResponseCustomToolCall,
)
except ImportError:
ResponseCustomToolCall = None


try:
from opentelemetry.util.genai.types import (
Error,
Expand All @@ -75,6 +87,7 @@
ServerToolCallPart,
ServerToolCallResponsePart,
TextPart,
ToolCallResponsePart,
)
from opentelemetry.util.genai.types import (
ToolCallRequestPart as ToolCall,
Expand All @@ -91,6 +104,7 @@
ServerToolCallResponsePart = None
TextPart = None
ToolCall = None
ToolCallResponsePart = None


@dataclass
Expand Down Expand Up @@ -228,6 +242,90 @@ def get_system_instruction(instructions: str | None) -> list[TextPart]:
return [TextPart(content=instructions)]


def _parse_tool_call_arguments(arguments: str | None) -> object:
if arguments is None:
return None

try:
return json.loads(arguments)
except (TypeError, ValueError):
return arguments


def _get_str(value: object) -> str | None:
return value if isinstance(value, str) else None


def _get_call_id(item: object) -> str | None:
"""Return the pairing id; ``id`` covers a provider omitting ``call_id``."""
return _get_str(_get_field(item, "call_id")) or _get_str(
_get_field(item, "id")
)


# Client-side tool calls, by the field holding the call's arguments. A custom
# tool takes free-form text where a function takes a JSON arguments string.
_TOOL_CALL_ARGUMENT_FIELDS = {
"function_call": "arguments",
"custom_tool_call": "input",
}
_TOOL_OUTPUT_TYPES = frozenset(
{"function_call_output", "custom_tool_call_output"}
)


def _get_input_message(item: object) -> InputMessage | None:
"""Convert one input item; a tool-call turn is flat, not a message."""
if InputMessage is None or Role is None:
return None

item_type = _get_field(item, "type")

if item_type in _TOOL_CALL_ARGUMENT_FIELDS and ToolCall is not None:
raw = _get_field(item, _TOOL_CALL_ARGUMENT_FIELDS[item_type])
return InputMessage(
role=Role.ASSISTANT.value,
parts=[
ToolCall(
id=_get_call_id(item),
name=_get_str(_get_field(item, "name")) or "",
arguments=(
_parse_tool_call_arguments(raw)
if item_type == "function_call"
and isinstance(raw, str)
else _tool_response_to_data(raw)
),
)
],
)

if item_type in _TOOL_OUTPUT_TYPES and ToolCallResponsePart is not None:
return InputMessage(
role=Role.TOOL.value,
parts=[
ToolCallResponsePart(
id=_get_call_id(item),
response=_tool_response_to_data(
_get_field(item, "output")
),
)
],
)

role = _get_field(item, "role")
if not isinstance(role, str):
return None
parts = _content_to_parts(_get_field(item, "content"))
if not parts:
return None
name = _get_field(item, "name")
return InputMessage(
role=role,
parts=parts,
name=str(name) if name is not None else None,
)


def get_input_messages(
input_value: str | Sequence[object] | None,
) -> list[InputMessage]:
Expand All @@ -243,17 +341,9 @@ def get_input_messages(

messages: list[InputMessage] = []
for item in _get_sequence(input_value):
role = _get_field(item, "role")
if not isinstance(role, str):
continue

name = _get_field(item, "name")
name_str = str(name) if name is not None else None
parts = _content_to_parts(_get_field(item, "content"))
if parts:
messages.append(
InputMessage(role=role, parts=parts, name=name_str)
)
message = _get_input_message(item)
if message is not None:
messages.append(message)

return messages

Expand All @@ -275,16 +365,6 @@ def _extract_output_parts(content_blocks: Sequence[object]) -> list[TextPart]:
return parts


def _parse_tool_call_arguments(arguments: str | None) -> object:
if arguments is None:
return None

try:
return json.loads(arguments)
except (TypeError, ValueError):
return arguments


def _extract_reasoning_parts(
item: ResponseReasoningItem,
) -> list[ReasoningPart]:
Expand Down Expand Up @@ -470,6 +550,40 @@ def get_tool_definitions_from_response(
return get_tool_definitions(response.tools)


# Empty when the SDK predates these types, which makes every check below False.
_TOOL_CALL_MODELS = tuple(
model
for model in (ResponseFunctionToolCall, ResponseCustomToolCall)
if model is not None
)


def _is_tool_call_item(item: object) -> bool:
"""Whether a response output item is a client-side tool call."""
return bool(_TOOL_CALL_MODELS) and isinstance(item, _TOOL_CALL_MODELS)


def _tool_call_arguments(item: object) -> object:
"""A function's ``arguments`` is a JSON string; a custom tool's ``input`` is text."""
arguments = getattr(item, "arguments", None)
if isinstance(arguments, str):
return _parse_tool_call_arguments(arguments)
return _tool_response_to_data(getattr(item, "input", None))


_TERMINAL_TOOL_CALL_STATUSES = frozenset({"completed", "incomplete"})


def _tool_call_is_terminal(item: object) -> bool:
"""Whether a tool-call output item finished.

``status`` is undeclared on ``custom_tool_call``, so treat its absence as
terminal rather than skipping the item.
"""
status = getattr(item, "status", None)
return status is None or status in _TERMINAL_TOOL_CALL_STATUSES


def _response_types_available() -> bool:
return (
Response is not None
Expand Down Expand Up @@ -506,11 +620,8 @@ def get_output_messages_from_response(
)
continue

if isinstance(item, ResponseFunctionToolCall):
if ToolCall is None or item.status not in {
"completed",
"incomplete",
}:
if _is_tool_call_item(item):
if ToolCall is None or not _tool_call_is_terminal(item):
continue

messages.append(
Expand All @@ -520,9 +631,7 @@ def get_output_messages_from_response(
ToolCall(
id=item.call_id if item.call_id else item.id,
name=item.name,
arguments=_parse_tool_call_arguments(
item.arguments
),
arguments=_tool_call_arguments(item),
)
],
finish_reason="tool_call",
Expand Down Expand Up @@ -580,10 +689,7 @@ def extract_finish_reasons(response: Response | None) -> list[str]:

finish_reasons: list[str] = []
for item in response.output:
if isinstance(item, ResponseFunctionToolCall) and item.status in {
"completed",
"incomplete",
}:
if _is_tool_call_item(item) and _tool_call_is_terminal(item):
finish_reasons.append("tool_calls")
continue

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from __future__ import annotations

import json
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Mapping, Sequence
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -196,6 +196,33 @@ def _is_text_part(content: Any) -> bool:
)


def _tool_response_to_data(value: Any) -> Any:
"""Reduce a tool result payload to data the GenAI attributes can carry.

A caller hands back whatever its tool produced, which may include SDK
models. Those are flattened via ``model_dump()``; anything that is neither
plain data nor a model is dropped rather than guessed at, so recording a
tool result never raises into the instrumented call.
"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return _tool_response_to_data(model_dump())
if isinstance(value, Mapping):
return {
str(key): _tool_response_to_data(item)
for key, item in value.items()
}
# Bytes are a Sequence, but iterating them into a list of ints is not a
# useful reading of a tool result.
if isinstance(value, Sequence) and not isinstance(
value, (bytes, bytearray)
):
return [_tool_response_to_data(item) for item in value]
return None


def _content_to_parts(content: Any) -> list[MessagePart]:
if isinstance(content, str):
return [TextPart(content=content)]
Expand Down Expand Up @@ -258,7 +285,10 @@ def _prepare_input_messages(messages) -> list[InputMessage]:
elif role == Role.TOOL.value:
tool_call_id = get_property_value(message, "tool_call_id")
parts.append(
ToolCallResponsePart(id=tool_call_id, response=content)
ToolCallResponsePart(
id=tool_call_id,
response=_tool_response_to_data(content),
)
)

else:
Expand Down
Loading