Skip to content

Commit c04e6b4

Browse files
[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
1 parent 5408a52 commit c04e6b4

3 files changed

Lines changed: 200 additions & 1 deletion

File tree

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from __future__ import annotations
77

88
import logging
9-
from collections.abc import Awaitable, Callable
9+
from collections.abc import Awaitable, Callable, Iterator
1010
from typing import TYPE_CHECKING, Any, cast
1111

1212
from anthropic.types import Message as AnthropicMessage
@@ -96,6 +96,19 @@ def _fail_context_manager_response(
9696
)
9797

9898

99+
def _materialize_tools(kwargs: dict[str, Any]) -> None:
100+
"""Replace a one-shot ``tools`` iterator with a list.
101+
102+
``tools`` is typed as an iterable, so it may be a generator, and both the
103+
SDK and the tool definitions read it. Call this before either of them does:
104+
``create`` reads the request after this wrapper, but ``stream`` serializes
105+
it while building the manager, before the invocation is created.
106+
"""
107+
tools = kwargs.get("tools")
108+
if isinstance(tools, Iterator):
109+
kwargs["tools"] = list(cast("Iterator[Any]", tools))
110+
111+
99112
def _is_raw_response(result: object) -> bool:
100113
"""Whether ``result`` is a raw-response object to route through the proxy.
101114
@@ -131,6 +144,7 @@ def traced_method(
131144
| AnthropicStream[RawMessageStreamEvent]
132145
| MessagesStreamWrapper[None]
133146
):
147+
_materialize_tools(kwargs)
134148
invocation = _create_invocation(
135149
handler, instance, args, kwargs, capture_content
136150
)
@@ -189,6 +203,7 @@ async def traced_method(
189203
| AnthropicAsyncStream[RawMessageStreamEvent]
190204
| AsyncMessagesStreamWrapper[None]
191205
):
206+
_materialize_tools(kwargs)
192207
invocation = _create_invocation(
193208
handler, instance, args, kwargs, capture_content
194209
)
@@ -269,6 +284,7 @@ def traced_method(
269284
args: tuple[Any, ...],
270285
kwargs: dict[str, Any],
271286
) -> MessagesStreamManagerWrapper[Any]:
287+
_materialize_tools(kwargs)
272288
return MessagesStreamManagerWrapper(
273289
wrapped(*args, **kwargs),
274290
lambda: _create_invocation(
@@ -294,6 +310,7 @@ def traced_method(
294310
args: tuple[Any, ...],
295311
kwargs: dict[str, Any],
296312
) -> AsyncMessagesStreamManagerWrapper[Any]:
313+
_materialize_tools(kwargs)
297314
return AsyncMessagesStreamManagerWrapper(
298315
wrapped(*args, **kwargs),
299316
lambda: _create_invocation(

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,58 @@ async def test_async_messages_create_captures_tool_use_content(
815815
_assert_weather_tool_definitions(span)
816816

817817

818+
@pytest.mark.asyncio
819+
async def test_async_messages_create_tools_generator_reaches_the_sdk(
820+
span_exporter, instrument_with_content
821+
):
822+
"""Async counterpart: a one-shot ``tools`` iterator must reach the SDK.
823+
824+
Served by a mock transport rather than a cassette, because the assertion is
825+
about the request body the SDK sends.
826+
"""
827+
seen = {}
828+
829+
def respond(request):
830+
seen["body"] = json.loads(request.content)
831+
return _http_lib.Response(
832+
200,
833+
json={
834+
"id": "msg_generator",
835+
"type": "message",
836+
"role": "assistant",
837+
"model": "claude-sonnet-4-20250514",
838+
"content": [{"type": "text", "text": "ok"}],
839+
"stop_reason": "end_turn",
840+
"stop_sequence": None,
841+
"usage": {"input_tokens": 1, "output_tokens": 1},
842+
},
843+
)
844+
845+
client = AsyncAnthropic(
846+
api_key="test_anthropic_api_key",
847+
base_url="http://anthropic.test",
848+
http_client=_http_lib.AsyncClient(
849+
transport=_http_lib.MockTransport(respond)
850+
),
851+
)
852+
try:
853+
await client.messages.create(
854+
model="claude-sonnet-4-20250514",
855+
max_tokens=256,
856+
messages=[
857+
{"role": "user", "content": "What is the weather in SF?"}
858+
],
859+
tools=(tool for tool in [_WEATHER_TOOL]),
860+
)
861+
finally:
862+
await client.close()
863+
864+
assert seen["body"]["tools"] == [_WEATHER_TOOL]
865+
spans = span_exporter.get_finished_spans()
866+
assert len(spans) == 1
867+
_assert_weather_tool_definitions(spans[0])
868+
869+
818870
@pytest.mark.asyncio
819871
@pytest.mark.vcr()
820872
@pytest.mark.cassette("test_async_messages_create_captures_tool_use_content")

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,55 @@ def _load_span_messages(span, attribute):
176176
}
177177

178178

179+
_STREAM_SSE_BODY = b"".join(
180+
f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode()
181+
for name, payload in (
182+
(
183+
"message_start",
184+
{
185+
"type": "message_start",
186+
"message": {
187+
"id": "msg_generator",
188+
"type": "message",
189+
"role": "assistant",
190+
"model": "claude-sonnet-4-20250514",
191+
"content": [],
192+
"stop_reason": None,
193+
"stop_sequence": None,
194+
"usage": {"input_tokens": 1, "output_tokens": 1},
195+
},
196+
},
197+
),
198+
(
199+
"content_block_start",
200+
{
201+
"type": "content_block_start",
202+
"index": 0,
203+
"content_block": {"type": "text", "text": ""},
204+
},
205+
),
206+
(
207+
"content_block_delta",
208+
{
209+
"type": "content_block_delta",
210+
"index": 0,
211+
"delta": {"type": "text_delta", "text": "ok"},
212+
},
213+
),
214+
("content_block_stop", {"type": "content_block_stop", "index": 0}),
215+
(
216+
"message_delta",
217+
{
218+
"type": "message_delta",
219+
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
220+
"usage": {"output_tokens": 2},
221+
},
222+
),
223+
("message_stop", {"type": "message_stop"}),
224+
)
225+
)
226+
227+
179228
def _assert_weather_tool_definitions(span):
180229
assert _load_span_messages(
181230
span, GenAIAttributes.GEN_AI_TOOL_DEFINITIONS
@@ -1151,6 +1200,87 @@ def test_sync_messages_create_captures_tool_use_content(
11511200
_assert_weather_tool_definitions(span)
11521201

11531202

1203+
def test_sync_messages_create_tools_generator_reaches_the_sdk(
1204+
span_exporter, instrument_with_content
1205+
):
1206+
"""A one-shot ``tools`` iterator must still reach the SDK.
1207+
1208+
Served by a mock transport rather than a cassette, because the assertion is
1209+
about the request body the SDK sends.
1210+
"""
1211+
seen = {}
1212+
1213+
def respond(request):
1214+
seen["body"] = json.loads(request.content)
1215+
return httpx.Response(
1216+
200,
1217+
json={
1218+
"id": "msg_generator",
1219+
"type": "message",
1220+
"role": "assistant",
1221+
"model": "claude-sonnet-4-20250514",
1222+
"content": [{"type": "text", "text": "ok"}],
1223+
"stop_reason": "end_turn",
1224+
"stop_sequence": None,
1225+
"usage": {"input_tokens": 1, "output_tokens": 1},
1226+
},
1227+
)
1228+
1229+
client = Anthropic(
1230+
api_key="test_anthropic_api_key",
1231+
base_url="http://anthropic.test",
1232+
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
1233+
)
1234+
client.messages.create(
1235+
model="claude-sonnet-4-20250514",
1236+
max_tokens=256,
1237+
messages=[{"role": "user", "content": "What is the weather in SF?"}],
1238+
tools=(tool for tool in [_WEATHER_TOOL]),
1239+
)
1240+
1241+
assert seen["body"]["tools"] == [_WEATHER_TOOL]
1242+
spans = span_exporter.get_finished_spans()
1243+
assert len(spans) == 1
1244+
_assert_weather_tool_definitions(spans[0])
1245+
1246+
1247+
def test_sync_messages_stream_tools_generator_is_recorded(
1248+
span_exporter, instrument_with_content
1249+
):
1250+
"""``stream`` serializes the request before the invocation exists.
1251+
1252+
The generator has to be frozen in the wrapper, not while the invocation is
1253+
built, or the SDK drains it first and the span records no tools.
1254+
"""
1255+
seen = {}
1256+
1257+
def respond(request):
1258+
seen["body"] = json.loads(request.content)
1259+
return httpx.Response(
1260+
200,
1261+
headers={"content-type": "text/event-stream"},
1262+
content=_STREAM_SSE_BODY,
1263+
)
1264+
1265+
client = Anthropic(
1266+
api_key="test_anthropic_api_key",
1267+
base_url="http://anthropic.test",
1268+
http_client=httpx.Client(transport=httpx.MockTransport(respond)),
1269+
)
1270+
with client.messages.stream(
1271+
model="claude-sonnet-4-20250514",
1272+
max_tokens=256,
1273+
messages=[{"role": "user", "content": "What is the weather in SF?"}],
1274+
tools=(tool for tool in [_WEATHER_TOOL]),
1275+
) as stream:
1276+
stream.until_done()
1277+
1278+
assert seen["body"]["tools"] == [_WEATHER_TOOL]
1279+
spans = span_exporter.get_finished_spans()
1280+
assert len(spans) == 1
1281+
_assert_weather_tool_definitions(spans[0])
1282+
1283+
11541284
@pytest.mark.vcr()
11551285
@pytest.mark.cassette("test_sync_messages_create_captures_tool_use_content")
11561286
@pytest.mark.skipif(

0 commit comments

Comments
 (0)