Skip to content

Commit dfd6b8e

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 1a94f81 commit dfd6b8e

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
@@ -811,6 +811,58 @@ async def test_async_messages_create_captures_tool_use_content(
811811
_assert_weather_tool_definitions(span)
812812

813813

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

176176

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

11511200

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

0 commit comments

Comments
 (0)