From 1a94f81618b67cf8999efb13380a0631f42e8135 Mon Sep 17 00:00:00 2001 From: Zening Chen Date: Tue, 8 Sep 2026 07:23:52 +0000 Subject: [PATCH 1/2] [`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 --- .../.changelog/652.fixed | 1 + .../genai/anthropic/messages_extractors.py | 57 +++++++++ .../instrumentation/genai/anthropic/patch.py | 4 + .../tests/test_async_messages.py | 93 ++++++++++++-- .../tests/test_messages_extractors.py | 116 ++++++++++++++++++ .../tests/test_sync_messages.py | 91 ++++++++++++-- 6 files changed, 340 insertions(+), 22 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/652.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/652.fixed b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/652.fixed new file mode 100644 index 000000000..80938e44e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/.changelog/652.fixed @@ -0,0 +1 @@ +Record ``gen_ai.tool.definitions`` from the ``tools`` request parameter. diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py index 9780251ae..25c2bbbb4 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py @@ -24,10 +24,13 @@ ) from opentelemetry.util.genai.invocation import InferenceInvocation from opentelemetry.util.genai.types import ( + FunctionToolDefinition, + GenericToolDefinition, InputMessage, OutputMessage, SystemInstructionPart, TextPart, + ToolDefinition, ) from opentelemetry.util.types import AttributeValue @@ -63,6 +66,7 @@ class MessageRequestParams: stream: bool | None = None messages: Iterable[MessageParam] | None = None system: str | Iterable[TextBlockParam] | None = None + tools: Iterable[ToolUnionParam] | None = None @dataclass @@ -132,6 +136,58 @@ def get_system_instruction( ] +def _tool_field(tool: object, key: str) -> object: + if isinstance(tool, Mapping): + return cast("Mapping[str, object]", tool).get(key) + return getattr(tool, key, None) + + +def get_tool_definitions( + tools: Iterable[ToolUnionParam] | None, +) -> list[ToolDefinition] | None: + """Convert the request's ``tools`` into semconv tool definitions. + + A custom tool carries its JSON schema in ``input_schema`` and maps onto a + function definition. Server tools are identified by a versioned ``type`` + (``web_search_20250305``, ``bash_20250124``, ...) and toolsets + (``computer_toolset_20260801``, ...) carry no ``name`` at all, so their type + stands in for the name. + """ + if tools is None: + return None + + definitions: list[ToolDefinition] = [] + for tool in tools: + name = _tool_field(tool, "name") + tool_type = _tool_field(tool, "type") + input_schema = _tool_field(tool, "input_schema") + if input_schema is not None or tool_type in (None, "custom"): + description = _tool_field(tool, "description") + definitions.append( + FunctionToolDefinition( + name=name if isinstance(name, str) else "", + description=( + description if isinstance(description, str) else None + ), + # The schema requires an object; drop anything else rather + # than emit a tool definition that fails validation. + parameters=( + input_schema + if isinstance(input_schema, Mapping) + else None + ), + ) + ) + elif isinstance(tool_type, str): + definitions.append( + GenericToolDefinition( + name=name if isinstance(name, str) else tool_type, + type=tool_type, + ) + ) + return definitions or None + + def get_output_messages_from_message( message: Message | None, ) -> list[OutputMessage]: @@ -232,6 +288,7 @@ def extract_params( # pylint: disable=too-many-locals stream=stream, messages=messages, system=system, + tools=tools, ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py index cd1cd9de5..c6d6c9399 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py @@ -24,6 +24,7 @@ get_llm_request_attributes, get_server_address_and_port, get_system_instruction, + get_tool_definitions, ) from .utils import is_anthropic_async_stream, is_anthropic_stream from .wrappers import ( @@ -249,6 +250,9 @@ def _create_invocation( invocation.system_instruction = ( get_system_instruction(params.system) if capture_content else [] ) + invocation.tool_definitions = ( + get_tool_definitions(params.tools) if capture_content else None + ) invocation.attributes = attributes return invocation diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py index b33377053..8a06bccd8 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py @@ -83,6 +83,30 @@ def _load_span_messages(span, attribute): return parsed +_WEATHER_TOOL = { + "name": "get_weather", + "description": "Get weather by city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, +} + + +def _assert_weather_tool_definitions(span): + assert _load_span_messages( + span, GenAIAttributes.GEN_AI_TOOL_DEFINITIONS + ) == [ + { + "type": "function", + "name": "get_weather", + "description": "Get weather by city", + "parameters": _WEATHER_TOOL["input_schema"], + } + ] + + class _AsyncErrorInjectingStreamDelegate: def __init__(self, inner): self._inner = inner @@ -480,6 +504,35 @@ async def test_async_messages_create_streaming_delegates_response_attribute( await stream.close() +@pytest.mark.asyncio +@pytest.mark.vcr() +@pytest.mark.cassette("test_async_messages_stream") +@pytest.mark.skipif( + not _has_tools_param, + reason="anthropic SDK too old to support 'tools' parameter", +) +async def test_async_messages_stream_records_tool_definitions( + span_exporter, async_anthropic_client, instrument_with_content +): + """``stream`` builds its invocation lazily -- it must still see ``tools``. + + Replays the plain ``test_async_messages_stream`` cassette: tool definitions + come from the request, so the recorded response does not matter. + """ + async with async_anthropic_client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=100, + messages=[{"role": "user", "content": "Say hello in one word."}], + tools=[_WEATHER_TOOL], + ) as stream: + async for _ in stream: + pass + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + @pytest.mark.asyncio @pytest.mark.vcr() async def test_async_messages_stream( # pylint: disable=too-many-locals @@ -739,17 +792,7 @@ async def test_async_messages_create_captures_tool_use_content( model=model, max_tokens=256, messages=messages, - tools=[ - { - "name": "get_weather", - "description": "Get weather by city", - "input_schema": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, - } - ], + tools=[_WEATHER_TOOL], tool_choice={"type": "tool", "name": "get_weather"}, ) @@ -765,6 +808,34 @@ async def test_async_messages_create_captures_tool_use_content( for message in output_messages for part in message.get("parts", []) ) + _assert_weather_tool_definitions(span) + + +@pytest.mark.asyncio +@pytest.mark.vcr() +@pytest.mark.cassette("test_async_messages_create_captures_tool_use_content") +@pytest.mark.skipif( + not _has_tools_param, + reason="anthropic SDK too old to support 'tools' parameter", +) +async def test_async_messages_create_omits_tool_definitions_without_content( + span_exporter, async_anthropic_client, instrument_no_content +): + """Tool definitions are content: they stay off when capture is off.""" + await async_anthropic_client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "What is the weather in SF?"}], + tools=[_WEATHER_TOOL], + tool_choice={"type": "tool", "name": "get_weather"}, + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes @pytest.mark.asyncio diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py index 1b986609d..f1f780465 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_messages_extractors.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import pytest from anthropic.types import ( ServerToolUseBlock, WebSearchToolResultBlock, @@ -14,12 +15,15 @@ from opentelemetry.instrumentation.genai.anthropic.messages_extractors import ( extract_params, + get_tool_definitions, set_invocation_response_attributes, ) from opentelemetry.instrumentation.genai.anthropic.utils import ( _convert_content_block_to_part, ) from opentelemetry.util.genai.types import ( + FunctionToolDefinition, + GenericToolDefinition, ServerToolCallPart, ServerToolCallResponsePart, ) @@ -163,3 +167,115 @@ def test_convert_server_tool_dicts(): }, "type": "web_fetch", } + + +def test_extract_params_keeps_tools(): + tools = [{"name": "get_weather", "input_schema": {"type": "object"}}] + + params = extract_params(tools=tools) + + assert params.tools is tools + + +def test_get_tool_definitions_maps_custom_tool_to_function(): + schema = { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + } + + definitions = get_tool_definitions( + [ + { + "name": "get_weather", + "description": "Get weather by city", + "input_schema": schema, + } + ] + ) + + assert definitions == [ + FunctionToolDefinition( + name="get_weather", + description="Get weather by city", + parameters=schema, + ) + ] + assert definitions[0].type == "function" + + +def test_get_tool_definitions_maps_explicit_custom_type_to_function(): + definitions = get_tool_definitions([{"type": "custom", "name": "noop"}]) + + assert definitions == [ + FunctionToolDefinition(name="noop", description=None, parameters=None) + ] + + +def test_get_tool_definitions_maps_server_tool_to_generic(): + definitions = get_tool_definitions( + [ + { + "name": "web_search", + "type": "web_search_20250305", + "max_uses": 3, + } + ] + ) + + assert definitions == [ + GenericToolDefinition(name="web_search", type="web_search_20250305") + ] + + +def test_get_tool_definitions_falls_back_to_type_for_unnamed_toolset(): + definitions = get_tool_definitions( + [{"type": "computer_toolset_20260801", "configs": []}] + ) + + assert definitions == [ + GenericToolDefinition( + name="computer_toolset_20260801", + type="computer_toolset_20260801", + ) + ] + + +def test_get_tool_definitions_reads_tool_objects(): + class _Tool: + name = "get_weather" + description = "Get weather by city" + input_schema = {"type": "object"} + + definitions = get_tool_definitions([_Tool()]) + + assert definitions == [ + FunctionToolDefinition( + name="get_weather", + description="Get weather by city", + parameters={"type": "object"}, + ) + ] + + +def test_get_tool_definitions_mixes_custom_and_server_tools(): + definitions = get_tool_definitions( + [ + {"name": "get_weather", "input_schema": {"type": "object"}}, + {"name": "bash", "type": "bash_20250124"}, + ] + ) + + assert definitions == [ + FunctionToolDefinition( + name="get_weather", + description=None, + parameters={"type": "object"}, + ), + GenericToolDefinition(name="bash", type="bash_20250124"), + ] + + +@pytest.mark.parametrize("tools", [None, []]) +def test_get_tool_definitions_without_tools(tools): + assert get_tool_definitions(tools) is None diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py index 4ba22eafd..60995853a 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -163,6 +163,30 @@ def _load_span_messages(span, attribute): return parsed +_WEATHER_TOOL = { + "name": "get_weather", + "description": "Get weather by city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, +} + + +def _assert_weather_tool_definitions(span): + assert _load_span_messages( + span, GenAIAttributes.GEN_AI_TOOL_DEFINITIONS + ) == [ + { + "type": "function", + "name": "get_weather", + "description": "Get weather by city", + "parameters": _WEATHER_TOOL["input_schema"], + } + ] + + def _skip_if_cassette_missing_and_no_real_key(request): cassette_path = ( Path(__file__).parent / "cassettes" / f"{request.node.name}.yaml" @@ -745,6 +769,34 @@ def test_sync_messages_create_streaming_captures_content( ] +@pytest.mark.vcr() +@pytest.mark.cassette("test_sync_messages_stream") +@pytest.mark.skipif( + not _has_tools_param, + reason="anthropic SDK too old to support 'tools' parameter", +) +def test_sync_messages_stream_records_tool_definitions( + span_exporter, anthropic_client, instrument_with_content +): + """``stream`` builds its invocation lazily -- it must still see ``tools``. + + Replays the plain ``test_sync_messages_stream`` cassette: tool definitions + come from the request, so the recorded response does not matter. + """ + with anthropic_client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=100, + messages=[{"role": "user", "content": "Say hello in one word."}], + tools=[_WEATHER_TOOL], + ) as stream: + for _ in stream: + pass + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + @pytest.mark.vcr() def test_sync_messages_stream( # pylint: disable=too-many-locals request, span_exporter, anthropic_client, instrument_no_content @@ -1078,17 +1130,7 @@ def test_sync_messages_create_captures_tool_use_content( model=model, max_tokens=256, messages=messages, - tools=[ - { - "name": "get_weather", - "description": "Get weather by city", - "input_schema": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"], - }, - } - ], + tools=[_WEATHER_TOOL], tool_choice={"type": "tool", "name": "get_weather"}, ) @@ -1104,6 +1146,33 @@ def test_sync_messages_create_captures_tool_use_content( for message in output_messages for part in message.get("parts", []) ) + _assert_weather_tool_definitions(span) + + +@pytest.mark.vcr() +@pytest.mark.cassette("test_sync_messages_create_captures_tool_use_content") +@pytest.mark.skipif( + not _has_tools_param, + reason="anthropic SDK too old to support 'tools' parameter", +) +def test_sync_messages_create_omits_tool_definitions_without_content( + span_exporter, anthropic_client, instrument_no_content +): + """Tool definitions are content: they stay off when capture is off.""" + anthropic_client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "What is the weather in SF?"}], + tools=[_WEATHER_TOOL], + tool_choice={"type": "tool", "name": "get_weather"}, + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in span.attributes + assert GenAIAttributes.GEN_AI_TOOL_DEFINITIONS not in span.attributes @pytest.mark.vcr() From dfd6b8ea93aa933b269dca4b2ea029884fc13581 Mon Sep 17 00:00:00 2001 From: Zening Chen Date: Wed, 9 Sep 2026 23:43:25 +0000 Subject: [PATCH 2/2] [`opentelemetry-instrumentation-genai-anthropic`] Freeze a one-shot tools iterator tools is typed as an iterable, so a caller may pass a generator. Reading tool definitions drained it before the SDK serialized the request, and the request went out with an empty tool list. Materialize it in the wrappers, ahead of both readers. The SDK materializes the value itself when it builds the request body, so the request is unchanged. Placement matters and differs between the two entry points: create reads the request after the wrapper runs, but stream serializes it while building the manager, before the invocation is created. Doing this while building the invocation would leave stream recording no tools. Assisted-by: Claude Opus 5 --- .../instrumentation/genai/anthropic/patch.py | 19 ++- .../tests/test_async_messages.py | 52 +++++++ .../tests/test_sync_messages.py | 130 ++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py index c6d6c9399..6169b7639 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/patch.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterator from typing import TYPE_CHECKING, Any, cast from anthropic.types import Message as AnthropicMessage @@ -96,6 +96,19 @@ def _fail_context_manager_response( ) +def _materialize_tools(kwargs: dict[str, Any]) -> None: + """Replace a one-shot ``tools`` iterator with a list. + + ``tools`` is typed as an iterable, so it may be a generator, and both the + SDK and the tool definitions read it. Call this before either of them does: + ``create`` reads the request after this wrapper, but ``stream`` serializes + it while building the manager, before the invocation is created. + """ + tools = kwargs.get("tools") + if isinstance(tools, Iterator): + kwargs["tools"] = list(cast("Iterator[Any]", tools)) + + def _is_raw_response(result: object) -> bool: """Whether ``result`` is a raw-response object to route through the proxy. @@ -131,6 +144,7 @@ def traced_method( | AnthropicStream[RawMessageStreamEvent] | MessagesStreamWrapper[None] ): + _materialize_tools(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -189,6 +203,7 @@ async def traced_method( | AnthropicAsyncStream[RawMessageStreamEvent] | AsyncMessagesStreamWrapper[None] ): + _materialize_tools(kwargs) invocation = _create_invocation( handler, instance, args, kwargs, capture_content ) @@ -269,6 +284,7 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> MessagesStreamManagerWrapper[Any]: + _materialize_tools(kwargs) return MessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( @@ -294,6 +310,7 @@ def traced_method( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> AsyncMessagesStreamManagerWrapper[Any]: + _materialize_tools(kwargs) return AsyncMessagesStreamManagerWrapper( wrapped(*args, **kwargs), lambda: _create_invocation( diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py index 8a06bccd8..3b7592a87 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_async_messages.py @@ -811,6 +811,58 @@ async def test_async_messages_create_captures_tool_use_content( _assert_weather_tool_definitions(span) +@pytest.mark.asyncio +async def test_async_messages_create_tools_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """Async counterpart: a one-shot ``tools`` iterator must reach the SDK. + + Served by a mock transport rather than a cassette, because the assertion is + about the request body the SDK sends. + """ + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return _http_lib.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncAnthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=_http_lib.AsyncClient( + transport=_http_lib.MockTransport(respond) + ), + ) + try: + await client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[ + {"role": "user", "content": "What is the weather in SF?"} + ], + tools=(tool for tool in [_WEATHER_TOOL]), + ) + finally: + await client.close() + + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + @pytest.mark.asyncio @pytest.mark.vcr() @pytest.mark.cassette("test_async_messages_create_captures_tool_use_content") diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py index 60995853a..99b28b5c1 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_sync_messages.py @@ -174,6 +174,55 @@ def _load_span_messages(span, attribute): } +_STREAM_SSE_BODY = b"".join( + f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() + for name, payload in ( + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [], + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + ( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + ( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "ok"}, + }, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 2}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ) +) + + def _assert_weather_tool_definitions(span): assert _load_span_messages( span, GenAIAttributes.GEN_AI_TOOL_DEFINITIONS @@ -1149,6 +1198,87 @@ def test_sync_messages_create_captures_tool_use_content( _assert_weather_tool_definitions(span) +def test_sync_messages_create_tools_generator_reaches_the_sdk( + span_exporter, instrument_with_content +): + """A one-shot ``tools`` iterator must still reach the SDK. + + Served by a mock transport rather than a cassette, because the assertion is + about the request body the SDK sends. + """ + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_generator", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "What is the weather in SF?"}], + tools=(tool for tool in [_WEATHER_TOOL]), + ) + + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + +def test_sync_messages_stream_tools_generator_is_recorded( + span_exporter, instrument_with_content +): + """``stream`` serializes the request before the invocation exists. + + The generator has to be frozen in the wrapper, not while the invocation is + built, or the SDK drains it first and the span records no tools. + """ + seen = {} + + def respond(request): + seen["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=_STREAM_SSE_BODY, + ) + + client = Anthropic( + api_key="test_anthropic_api_key", + base_url="http://anthropic.test", + http_client=httpx.Client(transport=httpx.MockTransport(respond)), + ) + with client.messages.stream( + model="claude-sonnet-4-20250514", + max_tokens=256, + messages=[{"role": "user", "content": "What is the weather in SF?"}], + tools=(tool for tool in [_WEATHER_TOOL]), + ) as stream: + stream.until_done() + + assert seen["body"]["tools"] == [_WEATHER_TOOL] + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + _assert_weather_tool_definitions(spans[0]) + + @pytest.mark.vcr() @pytest.mark.cassette("test_sync_messages_create_captures_tool_use_content") @pytest.mark.skipif(