Skip to content

fix(lib): stop the helper-tag collector draining a one-shot messages iterable - #1992

Open
v0ropaev wants to merge 1 commit into
anthropics:mainfrom
v0ropaev:fix/helper-header-one-shot-iterable
Open

v0ropaev wants to merge 1 commit into
anthropics:mainfrom
v0ropaev:fix/helper-header-one-shot-iterable

Conversation

@v0ropaev

@v0ropaev v0ropaev commented Oct 6, 2026

Copy link
Copy Markdown

What was wrong

messages is typed Iterable[BetaMessageParam], so a generator is a legal argument. collect_helpers walks it to gather helper tags, and the request body is built from the same object a few lines later, so the body gets an iterable that has already been consumed.

client = Anthropic()

def history():
    yield {"role": "user", "content": "hello"}

client.beta.messages.create(model="claude-sonnet-5-5", max_tokens=16, messages=history())

Before, the request goes out as {"messages": [], ...} and the API answers with a 400 about messages. Nothing is logged on the client side, so it reads like a server-side complaint about a field the caller did fill in. After, the request carries the message.

It hits beta.messages.create, parse, stream and tool_runner. tool_runner is the worst of them: BetaToolRunner.__init__ does keep its own copy with [message for message in params["messages"]], but the header is built first, so the copy is already empty. count_tokens is unaffected, it does not build the header, and the stable messages resource does not either.

What changed

collect_helpers only reads messages when it is a Sequence, which can be read twice. A one-shot iterable is left untouched, so the body keeps its contents. The cost is that a helper tag carried by a message inside a generator is not collected, which affects the telemetry header only, never the request. If you would rather keep tag fidelity, the alternative is a messages = list(messages) next to the existing tools = _to_tool_params(tools) at each of the eight call sites, and I will switch it over.

The tool runner now passes self._params["messages"], the list it has just materialized, to the header builder instead of the caller's iterable it consumed building that list.

Wire-level: a request that used to be sent with an empty messages array is now sent with the messages. The header can lose a tag in the generator case described above.

How I know it works

Six new tests in tests/lib/test_stainless_helpers.py, covering create, parse and tool_runner on the sync and async clients, asserting on the wire body through respx. All six fail on main and pass with the change. stream shares the same path and is left to the fixture-based tests.

tests/lib is green other than test_aws* and test_bedrock*, which need botocore that I do not have installed, and tests/test_client.py passes, 197 tests.

No cross-SDK port needed. collectStainlessHelpers in the TypeScript SDK takes readonly unknown[], an array it can read again, so it has no equivalent of this.

…iterable

- collect_helpers walked messages before the request body was built from the
  same object, so a generator reached the body exhausted and beta create,
  parse, stream and tool_runner sent messages: []
- only a sequence is inspected now, which can be read twice; a one-shot
  iterable is left alone and keeps its contents
- the tool runner collects tags from the list it already materialized
- tests for create, parse and tool_runner on both clients
@v0ropaev
v0ropaev requested a review from a team as a code owner October 6, 2026 17:59

@sigley sigley left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated exact head 0264c11c independently. A generator is a valid Iterable[BetaMessageParam], and current main can consume it while collecting helper tags before the same iterable is serialized into the request body. This head leaves one-shot iterables untouched in the helper collector and makes the tool runner use its already-materialized message list, so the wire body retains the caller's messages. The branch merges cleanly with current main; tests/lib/test_stainless_helpers.py passes 20/20 and compile/diff checks are clean. The possible loss of a helper telemetry tag for generator-backed messages is an acceptable non-wire tradeoff here. I do not see a blocking correctness issue.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants