From aa7919087b43952ea43dba7f9ed234b1ecd35dc1 Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:10:00 +0100 Subject: [PATCH] Avoid consuming one-shot helper iterables --- src/anthropic/lib/_stainless_helpers.py | 25 ++++-- tests/lib/test_stainless_helper_iterables.py | 84 ++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 tests/lib/test_stainless_helper_iterables.py diff --git a/src/anthropic/lib/_stainless_helpers.py b/src/anthropic/lib/_stainless_helpers.py index 9b14fb1cc..2bd6b1529 100644 --- a/src/anthropic/lib/_stainless_helpers.py +++ b/src/anthropic/lib/_stainless_helpers.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Any, cast +from collections.abc import Sequence from typing_extensions import Literal __all__ = [ @@ -94,23 +95,37 @@ def get_helper_tag(obj: object) -> str | None: return getattr(obj, _HELPER_ATTR, None) # type: ignore[return-value] +def _replayable_sequence(value: Any) -> Sequence[Any] | None: + """Return a safely re-iterable helper collection, if available. + + Request parameters accept arbitrary ``Iterable`` values, including generators. + Helper telemetry must not consume a one-shot iterable before request + serialization gets a chance to read it. + """ + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return value + return None + + def collect_helpers( tools: Any = None, messages: Any = None, ) -> list[str]: - """Collect deduplicated helper names from tools and messages.""" + """Collect deduplicated helper names from replayable tools and messages.""" helpers: list[str] = [] def _add(tag: str | None) -> None: if tag is not None and tag not in helpers: helpers.append(tag) - if tools: - for tool in tools: + tool_items = _replayable_sequence(tools) + if tool_items: + for tool in tool_items: _add(get_helper_tag(tool)) - if messages: - for message in messages: + message_items = _replayable_sequence(messages) + if message_items: + for message in message_items: _add(get_helper_tag(message)) # Check content blocks within messages diff --git a/tests/lib/test_stainless_helper_iterables.py b/tests/lib/test_stainless_helper_iterables.py new file mode 100644 index 000000000..dc37f1f11 --- /dev/null +++ b/tests/lib/test_stainless_helper_iterables.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from typing import cast + +import httpx +import respx +import pytest + +from anthropic import Anthropic +from anthropic.types.beta import BetaToolParam +from anthropic.lib._stainless_helpers import STAINLESS_HELPER_HEADER, tag_helper, stainless_helper_header + +from ..conftest import base_url + + +class _TaggedDict(dict): # type: ignore[type-arg] + pass + + +def _message_json() -> dict[str, object]: + return { + "id": "msg_abc123", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + +def test_helper_collection_does_not_consume_generator() -> None: + tool = cast("BetaToolParam", _TaggedDict({"name": "t", "input_schema": {"type": "object"}})) + tag_helper(tool, "mcp_tool") + tools = (item for item in [tool]) + + assert stainless_helper_header(tools=tools) == {} + assert list(tools) == [tool] + + +def test_helper_collection_still_reads_replayable_sequences() -> None: + tool = cast("BetaToolParam", _TaggedDict({"name": "t", "input_schema": {"type": "object"}})) + tag_helper(tool, "mcp_tool") + + assert stainless_helper_header(tools=[tool]) == {STAINLESS_HELPER_HEADER: "mcp_tool"} + + +@pytest.mark.respx(base_url=base_url) +def test_message_generator_reaches_request_body(client: Anthropic, respx_mock: respx.MockRouter) -> None: + respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=_message_json())) + + messages = ({"role": "user", "content": text} for text in ["hello"]) + client.beta.messages.create( + model="claude-sonnet-4-5", + max_tokens=16, + messages=messages, + ) + + body = json.loads(respx_mock.calls.last.request.content) + assert body["messages"] == [{"role": "user", "content": "hello"}] + + +@pytest.mark.respx(base_url=base_url) +def test_tool_generator_reaches_request_body(client: Anthropic, respx_mock: respx.MockRouter) -> None: + respx_mock.post("/v1/messages").mock(return_value=httpx.Response(200, json=_message_json())) + + tool = cast( + "BetaToolParam", + _TaggedDict({"name": "t", "description": "d", "input_schema": {"type": "object"}}), + ) + tag_helper(tool, "mcp_tool") + tools = (item for item in [tool]) + + client.beta.messages.create( + model="claude-sonnet-4-5", + max_tokens=16, + messages=[{"role": "user", "content": "hello"}], + tools=tools, + ) + + body = json.loads(respx_mock.calls.last.request.content) + assert body["tools"] == [{"name": "t", "description": "d", "input_schema": {"type": "object"}}]