From 474fbea81fdc1e9c0e103584bedde59c9c6fd379 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 13:20:28 -0700 Subject: [PATCH 1/2] test(e2e): let the together tool tests accept parallel calls The together backend is picked as the cheapest chat row that supports both tools and reasoning, which currently resolves to together_ai/openai/gpt-oss-120b. That row is marked supports_parallel_function_calling, so one weather prompt can legitimately come back as several get_weather calls. Both tool tests asserted exactly one call, so a parallel answer failed them even though the gateway handled it correctly. They now check every returned call instead of counting them: each one has to be a get_weather naming Paris, with an id a tool result can answer. Dropping, misnaming, or mangling a call is still red; only the count is the model's business. The round trips answer every call rather than just the first, which is also what the Anthropic Messages spec asks for. --- .../llm_translation/test_together_ai_e2e.py | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/e2e/llm_translation/test_together_ai_e2e.py b/tests/e2e/llm_translation/test_together_ai_e2e.py index 788b1858b73..a3ca7f0fc3b 100644 --- a/tests/e2e/llm_translation/test_together_ai_e2e.py +++ b/tests/e2e/llm_translation/test_together_ai_e2e.py @@ -210,16 +210,24 @@ def _deltas(result: StreamingResponse) -> list[_StreamDelta]: ] -def _single_weather_call(message: OutMessage) -> ToolCall: - assert message.tool_calls, f"Together dropped the tool call: {message}" - assert len(message.tool_calls) == 1, f"expected one tool call, got {message.tool_calls}" - call = message.tool_calls[0] +def _validated_weather_call_id(call: ToolCall) -> str: assert call.id, f"tool call carries no id, so a tool result cannot answer it: {call}" assert call.function.name == "get_weather", f"wrong tool called: {call}" assert call.function.arguments, f"tool call carries no arguments: {call}" args = _WeatherArgs.model_validate_json(call.function.arguments) assert "paris" in args.location.lower(), f"tool arguments lost the location: {args}" - return call + return call.id + + +def _weather_call_ids(message: OutMessage) -> tuple[str, ...]: + """The id of every tool call the model made, each one checked for the fields a + caller needs to answer it. The backend is whichever together_ai row is cheapest + with tools and reasoning, and those rows carry supports_parallel_function_calling, + so one weather prompt can legitimately come back as several get_weather calls. + What the gateway owes us is that each call survives translation intact; how many + the model chose to make is the model's business.""" + assert message.tool_calls, f"Together dropped the tool call: {message}" + return tuple(_validated_weather_call_id(call) for call in message.tool_calls) def _weather_call(client: PassthroughClient, key: str, model: str) -> OutMessage: @@ -289,7 +297,7 @@ class TestTogetherChatCompletions: self, client: PassthroughClient, resources: ResourceManager, reasoning_tool_backend: str ) -> None: model, key = _register(client, resources, reasoning_tool_backend) - _single_weather_call(_weather_call(client, key, model)) + _ = _weather_call_ids(_weather_call(client, key, model)) @pytest.mark.covers("llm.chat_completions.together_ai.tool_use.stream.works") def test_tool_call_is_streamed( @@ -328,8 +336,7 @@ class TestTogetherChatCompletions: ) -> None: model, key = _register(client, resources, reasoning_tool_backend) first = _weather_call(client, key, model) - call = _single_weather_call(first) - assert call.id is not None + call_ids = _weather_call_ids(first) answer = _message( unwrap( @@ -344,7 +351,10 @@ class TestTogetherChatCompletions: reasoning_content=first.reasoning_content, tool_calls=first.tool_calls, ), - ChatToolResultTurn(tool_call_id=call.id, content=WEATHER_REPORT), + *( + ChatToolResultTurn(tool_call_id=call_id, content=WEATHER_REPORT) + for call_id in call_ids + ), ], tools=[WEATHER_TOOL], max_tokens=512, @@ -470,9 +480,18 @@ def _tool_use_blocks(content: list[AnthropicContentBlock] | None) -> list[Anthro return [block for block in content if block.type == "tool_use"] +def _validated_tool_use_id(block: AnthropicContentBlock) -> str: + assert block.name == "get_weather", f"wrong tool called: {block}" + assert block.id, f"tool_use block carries no id, so a tool_result cannot answer it: {block}" + return block.id + + def _messages_weather_call( client: PassthroughClient, key: str, model: str -) -> tuple[list[AnthropicContentBlock], AnthropicContentBlock]: +) -> tuple[list[AnthropicContentBlock], tuple[str, ...]]: + """The blocks /v1/messages returned and the id of every tool_use among them. The + count is the model's choice (see _weather_call_ids); what this surface owes us is + that each tool_use arrives named and addressable.""" response = unwrap( client.proxy.messages( key, @@ -485,12 +504,9 @@ def _messages_weather_call( ) ) tool_uses = _tool_use_blocks(response.content) - assert len(tool_uses) == 1, f"expected one tool_use block, got {response.content}" - block = tool_uses[0] - assert block.name == "get_weather", f"wrong tool called: {block}" - assert block.id, f"tool_use block carries no id, so a tool_result cannot answer it: {block}" + assert tool_uses, f"/v1/messages carried no tool_use block: {response.content}" assert response.content is not None - return response.content, block + return response.content, tuple(_validated_tool_use_id(block) for block in tool_uses) class TestTogetherMessages: @@ -506,8 +522,7 @@ class TestTogetherMessages: self, client: PassthroughClient, resources: ResourceManager, reasoning_tool_backend: str ) -> None: model, key = _register(client, resources, reasoning_tool_backend) - first_content, block = _messages_weather_call(client, key, model) - assert block.id is not None + first_content, tool_use_ids = _messages_weather_call(client, key, model) response = unwrap( client.proxy.messages( @@ -520,7 +535,10 @@ class TestTogetherMessages: ChatMessage(role="user", content=WEATHER_PROMPT), AnthropicAssistantTurn(content=first_content), AnthropicToolResultTurn( - content=[AnthropicToolResultBlock(tool_use_id=block.id, content=WEATHER_REPORT)] + content=[ + AnthropicToolResultBlock(tool_use_id=tool_use_id, content=WEATHER_REPORT) + for tool_use_id in tool_use_ids + ] ), ], ), From 2cab010c73a6befe3e403a6eeb3d33df1ac6f897 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 27 Aug 2026 13:34:09 -0700 Subject: [PATCH 2/2] test(e2e): check the tool input on the messages path too The /v1/messages validator checked a tool_use block's name and id but not its input, so a block whose location came back empty or wrong still passed, while the chat side rejected the same damage. That gap predates this branch; it is worth closing here because the point of the change is that every parallel call is checked rather than counted. AnthropicContentBlock now declares input as a typed field. It already survived on extra="allow", but reaching it from a test needs a real field to keep the e2e basedpyright gate at zero. Serialization is unchanged: bodies are dumped with exclude_none, so a block without an input still replays exactly as before. --- tests/e2e/llm_translation/test_together_ai_e2e.py | 3 +++ tests/e2e/models.py | 1 + 2 files changed, 4 insertions(+) diff --git a/tests/e2e/llm_translation/test_together_ai_e2e.py b/tests/e2e/llm_translation/test_together_ai_e2e.py index a3ca7f0fc3b..90581aadd43 100644 --- a/tests/e2e/llm_translation/test_together_ai_e2e.py +++ b/tests/e2e/llm_translation/test_together_ai_e2e.py @@ -483,6 +483,9 @@ def _tool_use_blocks(content: list[AnthropicContentBlock] | None) -> list[Anthro def _validated_tool_use_id(block: AnthropicContentBlock) -> str: assert block.name == "get_weather", f"wrong tool called: {block}" assert block.id, f"tool_use block carries no id, so a tool_result cannot answer it: {block}" + assert block.input is not None, f"tool_use block carries no input: {block}" + args = _WeatherArgs.model_validate(block.input) + assert "paris" in args.location.lower(), f"tool input lost the location: {args}" return block.id diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 8d1b17ca256..e8a392599fd 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -421,6 +421,7 @@ class AnthropicContentBlock(BaseModel): text: str | None = None id: str | None = None name: str | None = None + input: dict[str, object] | None = None class AnthropicToolResultBlock(BaseModel):