Skip to content

Commit def0cfd

Browse files
[opentelemetry-instrumentation-genai-anthropic] Record gen_ai.tool.definitions
A tool-calling request produced a span with no trace of the tools it offered the model. extract_params reads the request arguments one by one and had no slot for tools, so the list never reached the code that records telemetry. Add the slot and map Anthropic's tool shapes onto the semconv models. A custom tool carries input_schema and becomes a FunctionToolDefinition. Server tools and toolsets become a GenericToolDefinition keyed by their versioned type; a toolset carries no name, so its type stands in for the name the schema requires. Recording follows OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, matching genai-openai. The attribute is opt_in in spans.yaml and sits in the attributes.gen_ai.content group. Assisted-by: Claude Opus 5
1 parent 3e6a57d commit def0cfd

6 files changed

Lines changed: 343 additions & 22 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Record ``gen_ai.tool.definitions`` from the ``tools`` request parameter.

‎instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@
2424
)
2525
from opentelemetry.util.genai.invocation import InferenceInvocation
2626
from opentelemetry.util.genai.types import (
27+
FunctionToolDefinition,
28+
GenericToolDefinition,
2729
InputMessage,
2830
OutputMessage,
2931
SystemInstructionPart,
3032
TextPart,
33+
ToolDefinition,
3134
)
3235
from opentelemetry.util.types import AttributeValue
3336

@@ -63,6 +66,7 @@ class MessageRequestParams:
6366
stream: bool | None = None
6467
messages: Iterable[MessageParam] | None = None
6568
system: str | Iterable[TextBlockParam] | None = None
69+
tools: Iterable[ToolUnionParam] | None = None
6670

6771

6872
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = (
@@ -138,6 +142,58 @@ def get_system_instruction(
138142
]
139143

140144

145+
def _tool_field(tool: object, key: str) -> object:
146+
if isinstance(tool, Mapping):
147+
return cast("Mapping[str, object]", tool).get(key)
148+
return getattr(tool, key, None)
149+
150+
151+
def get_tool_definitions(
152+
tools: Iterable[ToolUnionParam] | None,
153+
) -> list[ToolDefinition] | None:
154+
"""Convert the request's ``tools`` into semconv tool definitions.
155+
156+
A custom tool carries its JSON schema in ``input_schema`` and maps onto a
157+
function definition. Server tools are identified by a versioned ``type``
158+
(``web_search_20250305``, ``bash_20250124``, ...) and toolsets
159+
(``computer_toolset_20260801``, ...) carry no ``name`` at all, so their type
160+
stands in for the name.
161+
"""
162+
if tools is None:
163+
return None
164+
165+
definitions: list[ToolDefinition] = []
166+
for tool in tools:
167+
name = _tool_field(tool, "name")
168+
tool_type = _tool_field(tool, "type")
169+
input_schema = _tool_field(tool, "input_schema")
170+
if input_schema is not None or tool_type in (None, "custom"):
171+
description = _tool_field(tool, "description")
172+
definitions.append(
173+
FunctionToolDefinition(
174+
name=name if isinstance(name, str) else "",
175+
description=(
176+
description if isinstance(description, str) else None
177+
),
178+
# The schema requires an object; drop anything else rather
179+
# than emit a tool definition that fails validation.
180+
parameters=(
181+
input_schema
182+
if isinstance(input_schema, Mapping)
183+
else None
184+
),
185+
)
186+
)
187+
elif isinstance(tool_type, str):
188+
definitions.append(
189+
GenericToolDefinition(
190+
name=name if isinstance(name, str) else tool_type,
191+
type=tool_type,
192+
)
193+
)
194+
return definitions or None
195+
196+
141197
def get_output_messages_from_message(
142198
message: Message | None,
143199
) -> list[OutputMessage]:
@@ -244,6 +300,7 @@ def extract_params( # pylint: disable=too-many-locals
244300
stream=stream,
245301
messages=messages,
246302
system=system,
303+
tools=tools,
247304
)
248305

249306

‎instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
get_llm_request_attributes,
2525
get_server_address_and_port,
2626
get_system_instruction,
27+
get_tool_definitions,
2728
)
2829
from .utils import is_anthropic_async_stream, is_anthropic_stream
2930
from .wrappers import (
@@ -249,6 +250,9 @@ def _create_invocation(
249250
invocation.system_instruction = (
250251
get_system_instruction(params.system) if capture_content else []
251252
)
253+
invocation.tool_definitions = (
254+
get_tool_definitions(params.tools) if capture_content else None
255+
)
252256
invocation.attributes = attributes
253257
return invocation
254258

‎instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py‎

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ def _load_span_messages(span, attribute):
8787
return parsed
8888

8989

90+
_WEATHER_TOOL = {
91+
"name": "get_weather",
92+
"description": "Get weather by city",
93+
"input_schema": {
94+
"type": "object",
95+
"properties": {"city": {"type": "string"}},
96+
"required": ["city"],
97+
},
98+
}
99+
100+
101+
def _assert_weather_tool_definitions(span):
102+
assert _load_span_messages(
103+
span, GenAIAttributes.GEN_AI_TOOL_DEFINITIONS
104+
) == [
105+
{
106+
"type": "function",
107+
"name": "get_weather",
108+
"description": "Get weather by city",
109+
"parameters": _WEATHER_TOOL["input_schema"],
110+
}
111+
]
112+
113+
90114
class _AsyncErrorInjectingStreamDelegate:
91115
def __init__(self, inner):
92116
self._inner = inner
@@ -484,6 +508,35 @@ async def test_async_messages_create_streaming_delegates_response_attribute(
484508
await stream.close()
485509

486510

511+
@pytest.mark.asyncio
512+
@pytest.mark.vcr()
513+
@pytest.mark.cassette("test_async_messages_stream")
514+
@pytest.mark.skipif(
515+
not _has_tools_param,
516+
reason="anthropic SDK too old to support 'tools' parameter",
517+
)
518+
async def test_async_messages_stream_records_tool_definitions(
519+
span_exporter, async_anthropic_client, instrument_with_content
520+
):
521+
"""``stream`` builds its invocation lazily -- it must still see ``tools``.
522+
523+
Replays the plain ``test_async_messages_stream`` cassette: tool definitions
524+
come from the request, so the recorded response does not matter.
525+
"""
526+
async with async_anthropic_client.messages.stream(
527+
model="claude-sonnet-4-20250514",
528+
max_tokens=100,
529+
messages=[{"role": "user", "content": "Say hello in one word."}],
530+
tools=[_WEATHER_TOOL],
531+
) as stream:
532+
async for _ in stream:
533+
pass
534+
535+
spans = span_exporter.get_finished_spans()
536+
assert len(spans) == 1
537+
_assert_weather_tool_definitions(spans[0])
538+
539+
487540
@pytest.mark.asyncio
488541
@pytest.mark.vcr()
489542
async def test_async_messages_stream( # pylint: disable=too-many-locals
@@ -743,17 +796,7 @@ async def test_async_messages_create_captures_tool_use_content(
743796
model=model,
744797
max_tokens=256,
745798
messages=messages,
746-
tools=[
747-
{
748-
"name": "get_weather",
749-
"description": "Get weather by city",
750-
"input_schema": {
751-
"type": "object",
752-
"properties": {"city": {"type": "string"}},
753-
"required": ["city"],
754-
},
755-
}
756-
],
799+
tools=[_WEATHER_TOOL],
757800
tool_choice={"type": "tool", "name": "get_weather"},
758801
)
759802

@@ -769,6 +812,34 @@ async def test_async_messages_create_captures_tool_use_content(
769812
for message in output_messages
770813
for part in message.get("parts", [])
771814
)
815+
_assert_weather_tool_definitions(span)
816+
817+
818+
@pytest.mark.asyncio
819+
@pytest.mark.vcr()
820+
@pytest.mark.cassette("test_async_messages_create_captures_tool_use_content")
821+
@pytest.mark.skipif(
822+
not _has_tools_param,
823+
reason="anthropic SDK too old to support 'tools' parameter",
824+
)
825+
async def test_async_messages_create_omits_tool_definitions_without_content(
826+
span_exporter, async_anthropic_client, instrument_no_content
827+
):
828+
"""Tool definitions are content: they stay off when capture is off."""
829+
await async_anthropic_client.messages.create(
830+
model="claude-sonnet-4-20250514",
831+
max_tokens=256,
832+
messages=[{"role": "user", "content": "What is the weather in SF?"}],
833+
tools=[_WEATHER_TOOL],
834+
tool_choice={"type": "tool", "name": "get_weather"},
835+
)
836+
837+
spans = span_exporter.get_finished_spans()
838+
assert len(spans) == 1
839+
span = spans[0]
840+
assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes
841+
assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes
842+
assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes
772843

773844

774845
@pytest.mark.asyncio

‎instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@
33

44
"""Tests for Anthropic message parameter extraction."""
55

6+
import pytest
7+
68
from opentelemetry.instrumentation.genai.anthropic.messages_extractors import (
79
extract_params,
10+
get_tool_definitions,
11+
)
12+
from opentelemetry.util.genai.types import (
13+
FunctionToolDefinition,
14+
GenericToolDefinition,
815
)
916

1017

@@ -47,3 +54,115 @@ def test_extract_params_ignores_non_mapping_extra_body():
4754
assert params.temperature is None
4855
assert params.top_p is None
4956
assert params.top_k is None
57+
58+
59+
def test_extract_params_keeps_tools():
60+
tools = [{"name": "get_weather", "input_schema": {"type": "object"}}]
61+
62+
params = extract_params(tools=tools)
63+
64+
assert params.tools is tools
65+
66+
67+
def test_get_tool_definitions_maps_custom_tool_to_function():
68+
schema = {
69+
"type": "object",
70+
"properties": {"city": {"type": "string"}},
71+
"required": ["city"],
72+
}
73+
74+
definitions = get_tool_definitions(
75+
[
76+
{
77+
"name": "get_weather",
78+
"description": "Get weather by city",
79+
"input_schema": schema,
80+
}
81+
]
82+
)
83+
84+
assert definitions == [
85+
FunctionToolDefinition(
86+
name="get_weather",
87+
description="Get weather by city",
88+
parameters=schema,
89+
)
90+
]
91+
assert definitions[0].type == "function"
92+
93+
94+
def test_get_tool_definitions_maps_explicit_custom_type_to_function():
95+
definitions = get_tool_definitions([{"type": "custom", "name": "noop"}])
96+
97+
assert definitions == [
98+
FunctionToolDefinition(name="noop", description=None, parameters=None)
99+
]
100+
101+
102+
def test_get_tool_definitions_maps_server_tool_to_generic():
103+
definitions = get_tool_definitions(
104+
[
105+
{
106+
"name": "web_search",
107+
"type": "web_search_20250305",
108+
"max_uses": 3,
109+
}
110+
]
111+
)
112+
113+
assert definitions == [
114+
GenericToolDefinition(name="web_search", type="web_search_20250305")
115+
]
116+
117+
118+
def test_get_tool_definitions_falls_back_to_type_for_unnamed_toolset():
119+
definitions = get_tool_definitions(
120+
[{"type": "computer_toolset_20260801", "configs": []}]
121+
)
122+
123+
assert definitions == [
124+
GenericToolDefinition(
125+
name="computer_toolset_20260801",
126+
type="computer_toolset_20260801",
127+
)
128+
]
129+
130+
131+
def test_get_tool_definitions_reads_tool_objects():
132+
class _Tool:
133+
name = "get_weather"
134+
description = "Get weather by city"
135+
input_schema = {"type": "object"}
136+
137+
definitions = get_tool_definitions([_Tool()])
138+
139+
assert definitions == [
140+
FunctionToolDefinition(
141+
name="get_weather",
142+
description="Get weather by city",
143+
parameters={"type": "object"},
144+
)
145+
]
146+
147+
148+
def test_get_tool_definitions_mixes_custom_and_server_tools():
149+
definitions = get_tool_definitions(
150+
[
151+
{"name": "get_weather", "input_schema": {"type": "object"}},
152+
{"name": "bash", "type": "bash_20250124"},
153+
]
154+
)
155+
156+
assert definitions == [
157+
FunctionToolDefinition(
158+
name="get_weather",
159+
description=None,
160+
parameters={"type": "object"},
161+
),
162+
GenericToolDefinition(name="bash", type="bash_20250124"),
163+
]
164+
165+
166+
@pytest.mark.parametrize("tools", [None, []])
167+
def test_get_tool_definitions_without_tools(tools):
168+
assert get_tool_definitions(tools) is None

0 commit comments

Comments
 (0)