Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/anthropic/lib/_stainless_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

from typing import Any, cast
from collections.abc import Sequence
from typing_extensions import Literal

__all__ = [
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions tests/lib/test_stainless_helper_iterables.py
Original file line number Diff line number Diff line change
@@ -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"}}]