From a82e012a83c4f17942ad0bed4f460954b3ff77cf Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Mon, 31 Aug 2026 17:43:55 +0800 Subject: [PATCH 1/5] fix: preserve single-pass chat tool iterables --- src/openai/lib/_parsing/__init__.py | 1 + src/openai/lib/_parsing/_completions.py | 18 ++- .../resources/chat/completions/completions.py | 21 +-- tests/lib/chat/test_single_pass_tools.py | 146 ++++++++++++++++++ 4 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 tests/lib/chat/test_single_pass_tools.py diff --git a/src/openai/lib/_parsing/__init__.py b/src/openai/lib/_parsing/__init__.py index 08591f43f4..206e91e05f 100644 --- a/src/openai/lib/_parsing/__init__.py +++ b/src/openai/lib/_parsing/__init__.py @@ -6,6 +6,7 @@ validate_input_tools as validate_input_tools, parse_chat_completion as parse_chat_completion, get_input_tool_by_name as get_input_tool_by_name, + materialize_input_tools as materialize_input_tools, parse_function_tool_arguments as parse_function_tool_arguments, type_to_response_format_param as type_to_response_format_param, ) diff --git a/src/openai/lib/_parsing/_completions.py b/src/openai/lib/_parsing/_completions.py index 7a1bded1de..9b78173ff2 100644 --- a/src/openai/lib/_parsing/_completions.py +++ b/src/openai/lib/_parsing/_completions.py @@ -62,13 +62,23 @@ def select_strict_chat_completion_tools( return [t for t in tools if is_strict_chat_completion_tool_param(t)] -def validate_input_tools( +def materialize_input_tools( tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit, -) -> Iterable[ChatCompletionFunctionToolParam] | Omit: +) -> list[ChatCompletionToolUnionParam] | Omit: if not is_given(tools): return omit - for tool in tools: + return list(tools) + + +def validate_input_tools( + tools: Iterable[ChatCompletionToolUnionParam] | Omit = omit, +) -> list[ChatCompletionFunctionToolParam] | Omit: + input_tools = materialize_input_tools(tools) + if not is_given(input_tools): + return omit + + for tool in input_tools: if tool["type"] != "function": raise ValueError( f"Currently only `function` tool types support auto-parsing; Received `{tool['type']}`", @@ -80,7 +90,7 @@ def validate_input_tools( f"`{tool['function']['name']}` is not strict. Only `strict` function tools can be auto-parsed" ) - return cast(Iterable[ChatCompletionFunctionToolParam], tools) + return cast(list[ChatCompletionFunctionToolParam], input_tools) def parse_chat_completion( diff --git a/src/openai/resources/chat/completions/completions.py b/src/openai/resources/chat/completions/completions.py index a2e36b6ad2..8317459e49 100644 --- a/src/openai/resources/chat/completions/completions.py +++ b/src/openai/resources/chat/completions/completions.py @@ -37,6 +37,7 @@ ResponseFormatT, validate_input_tools as _validate_input_tools, parse_chat_completion as _parse_chat_completion, + materialize_input_tools as _materialize_input_tools, type_to_response_format_param as _type_to_response_format, ) from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager @@ -225,7 +226,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma "stream_options": stream_options, "temperature": temperature, "tool_choice": tool_choice, - "tools": tools, + "tools": chat_completion_tools, "top_logprobs": top_logprobs, "top_p": top_p, "user": user, @@ -1612,6 +1613,8 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ + chat_completion_tools = _materialize_input_tools(tools) + extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", **(extra_headers or {}), @@ -1650,7 +1653,7 @@ def stream( stream_options=stream_options, temperature=temperature, tool_choice=tool_choice, - tools=tools, + tools=chat_completion_tools, top_logprobs=top_logprobs, top_p=top_p, user=user, @@ -1664,7 +1667,7 @@ def stream( return ChatCompletionStreamManager( api_request, response_format=response_format, - input_tools=tools, + input_tools=chat_completion_tools, ) @@ -1787,7 +1790,7 @@ class MathResponse(BaseModel): print("answer: ", message.parsed.final_answer) ``` """ - _validate_input_tools(tools) + chat_completion_tools = _validate_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.parse", @@ -1798,7 +1801,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma return _parse_chat_completion( response_format=response_format, chat_completion=raw_completion, - input_tools=tools, + input_tools=chat_completion_tools, ) return await self._post( @@ -1836,7 +1839,7 @@ def parser(raw_completion: ChatCompletion) -> ParsedChatCompletion[ResponseForma "stream_options": stream_options, "temperature": temperature, "tool_choice": tool_choice, - "tools": tools, + "tools": chat_completion_tools, "top_logprobs": top_logprobs, "top_p": top_p, "user": user, @@ -3223,7 +3226,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - _validate_input_tools(tools) + chat_completion_tools = _validate_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", @@ -3262,7 +3265,7 @@ def stream( stream_options=stream_options, temperature=temperature, tool_choice=tool_choice, - tools=tools, + tools=chat_completion_tools, top_logprobs=top_logprobs, top_p=top_p, user=user, @@ -3276,7 +3279,7 @@ def stream( return AsyncChatCompletionStreamManager( api_request, response_format=response_format, - input_tools=tools, + input_tools=chat_completion_tools, ) diff --git a/tests/lib/chat/test_single_pass_tools.py b/tests/lib/chat/test_single_pass_tools.py new file mode 100644 index 0000000000..2b2df6d7c2 --- /dev/null +++ b/tests/lib/chat/test_single_pass_tools.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +from typing import Protocol, cast +from collections.abc import Iterator + +import httpx2 +import pytest + +from openai import OpenAI, AsyncOpenAI +from tests.respx2 import MockRouter +from openai.types.chat import ChatCompletionToolUnionParam + +from ...conftest import base_url + + +class MockRequestCall(Protocol): + request: httpx2.Request + + +def single_pass_tools() -> Iterator[ChatCompletionToolUnionParam]: + yield { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "strict": True, + }, + } + + +TOOL_CALL = { + "id": "call-test", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"San Francisco"}'}, +} + + +def completion_response() -> httpx2.Response: + return httpx2.Response( + 200, + json={ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-test", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": None, "tool_calls": [TOOL_CALL], "refusal": None}, + "logprobs": None, + "finish_reason": "tool_calls", + } + ], + }, + ) + + +STREAM_RESPONSE = """\ +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":0,"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call-test","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"San Francisco\\"}"}}],"refusal":null},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":0,"model":"gpt-test","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]} + +data: [DONE] + +""" + + +def assert_request_and_parsed_tool(respx2_mock: MockRouter, parsed_arguments: object) -> None: + calls = cast("list[MockRequestCall]", respx2_mock.calls) + body = json.loads(calls[0].request.content) + assert body["tools"] == list(single_pass_tools()) + assert parsed_arguments == {"city": "San Francisco"} + + +@pytest.mark.respx2(base_url=base_url) +def test_parse_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock(return_value=completion_response()) + + completion = client.chat.completions.parse( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=single_pass_tools(), + ) + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.asyncio +async def test_async_parse_preserves_single_pass_tools(async_client: AsyncOpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock(return_value=completion_response()) + + completion = await async_client.chat.completions.parse( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=single_pass_tools(), + ) + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) + + +@pytest.mark.respx2(base_url=base_url) +def test_stream_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock( + return_value=httpx2.Response(200, text=STREAM_RESPONSE, headers={"content-type": "text/event-stream"}) + ) + + with client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=single_pass_tools(), + ) as stream: + completion = stream.get_final_completion() + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.asyncio +async def test_async_stream_preserves_single_pass_tools(async_client: AsyncOpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock( + return_value=httpx2.Response(200, text=STREAM_RESPONSE, headers={"content-type": "text/event-stream"}) + ) + + async with async_client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=single_pass_tools(), + ) as stream: + completion = await stream.get_final_completion() + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) From a0a133b1659b1cd3827a9d216c1886477830df01 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 17:18:19 +0800 Subject: [PATCH 2/5] fix: validate sync chat stream tools consistently --- .../resources/chat/completions/completions.py | 3 +-- tests/lib/chat/test_single_pass_tools.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/openai/resources/chat/completions/completions.py b/src/openai/resources/chat/completions/completions.py index 8317459e49..7b269f53f5 100644 --- a/src/openai/resources/chat/completions/completions.py +++ b/src/openai/resources/chat/completions/completions.py @@ -37,7 +37,6 @@ ResponseFormatT, validate_input_tools as _validate_input_tools, parse_chat_completion as _parse_chat_completion, - materialize_input_tools as _materialize_input_tools, type_to_response_format_param as _type_to_response_format, ) from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager @@ -1613,7 +1612,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - chat_completion_tools = _materialize_input_tools(tools) + chat_completion_tools = _validate_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", diff --git a/tests/lib/chat/test_single_pass_tools.py b/tests/lib/chat/test_single_pass_tools.py index 2b2df6d7c2..97de175327 100644 --- a/tests/lib/chat/test_single_pass_tools.py +++ b/tests/lib/chat/test_single_pass_tools.py @@ -34,6 +34,17 @@ def single_pass_tools() -> Iterator[ChatCompletionToolUnionParam]: } +def non_strict_tools() -> Iterator[ChatCompletionToolUnionParam]: + yield { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}, + "strict": False, + }, + } + + TOOL_CALL = { "id": "call-test", "type": "function", @@ -127,6 +138,15 @@ def test_stream_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRou assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) +def test_stream_rejects_non_strict_tools(client: OpenAI) -> None: + with pytest.raises(ValueError, match="Only `strict` function tools can be auto-parsed"): + client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=non_strict_tools(), + ) + + @pytest.mark.respx2(base_url=base_url) @pytest.mark.asyncio async def test_async_stream_preserves_single_pass_tools(async_client: AsyncOpenAI, respx2_mock: MockRouter) -> None: From afbb2920a16f4ddc72c3921cf8d1a7e74f34faad Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Thu, 3 Sep 2026 17:47:48 +0800 Subject: [PATCH 3/5] fix: preserve non-strict sync stream tools --- .../resources/chat/completions/completions.py | 3 ++- tests/lib/chat/test_single_pass_tools.py | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/openai/resources/chat/completions/completions.py b/src/openai/resources/chat/completions/completions.py index 7b269f53f5..8317459e49 100644 --- a/src/openai/resources/chat/completions/completions.py +++ b/src/openai/resources/chat/completions/completions.py @@ -37,6 +37,7 @@ ResponseFormatT, validate_input_tools as _validate_input_tools, parse_chat_completion as _parse_chat_completion, + materialize_input_tools as _materialize_input_tools, type_to_response_format_param as _type_to_response_format, ) from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager @@ -1612,7 +1613,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - chat_completion_tools = _validate_input_tools(tools) + chat_completion_tools = _materialize_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", diff --git a/tests/lib/chat/test_single_pass_tools.py b/tests/lib/chat/test_single_pass_tools.py index 97de175327..a67a2936a9 100644 --- a/tests/lib/chat/test_single_pass_tools.py +++ b/tests/lib/chat/test_single_pass_tools.py @@ -138,13 +138,24 @@ def test_stream_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRou assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) -def test_stream_rejects_non_strict_tools(client: OpenAI) -> None: - with pytest.raises(ValueError, match="Only `strict` function tools can be auto-parsed"): - client.chat.completions.stream( - model="gpt-test", - messages=[{"role": "user", "content": "weather"}], - tools=non_strict_tools(), - ) +@pytest.mark.respx2(base_url=base_url) +def test_stream_preserves_non_strict_single_pass_tools(client: OpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock( + return_value=httpx2.Response(200, text=STREAM_RESPONSE, headers={"content-type": "text/event-stream"}) + ) + + with client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=non_strict_tools(), + ) as stream: + completion = stream.get_final_completion() + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + body = json.loads(cast("list[MockRequestCall]", respx2_mock.calls)[0].request.content) + assert body["tools"] == list(non_strict_tools()) + assert tool_calls[0].function.parsed_arguments is None @pytest.mark.respx2(base_url=base_url) From 369db3fbdc9badbadd509b8f0f561ae1e4c37a21 Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Fri, 4 Sep 2026 14:31:47 +0800 Subject: [PATCH 4/5] fix: validate sync chat stream tools --- .../resources/chat/completions/completions.py | 3 +- tests/lib/chat/test_single_pass_tools.py | 28 +++++++------------ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/openai/resources/chat/completions/completions.py b/src/openai/resources/chat/completions/completions.py index 8317459e49..7b269f53f5 100644 --- a/src/openai/resources/chat/completions/completions.py +++ b/src/openai/resources/chat/completions/completions.py @@ -37,7 +37,6 @@ ResponseFormatT, validate_input_tools as _validate_input_tools, parse_chat_completion as _parse_chat_completion, - materialize_input_tools as _materialize_input_tools, type_to_response_format_param as _type_to_response_format, ) from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager @@ -1613,7 +1612,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - chat_completion_tools = _materialize_input_tools(tools) + chat_completion_tools = _validate_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", diff --git a/tests/lib/chat/test_single_pass_tools.py b/tests/lib/chat/test_single_pass_tools.py index a67a2936a9..cf8d3fce26 100644 --- a/tests/lib/chat/test_single_pass_tools.py +++ b/tests/lib/chat/test_single_pass_tools.py @@ -138,24 +138,16 @@ def test_stream_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRou assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) -@pytest.mark.respx2(base_url=base_url) -def test_stream_preserves_non_strict_single_pass_tools(client: OpenAI, respx2_mock: MockRouter) -> None: - respx2_mock.post("/chat/completions").mock( - return_value=httpx2.Response(200, text=STREAM_RESPONSE, headers={"content-type": "text/event-stream"}) - ) - - with client.chat.completions.stream( - model="gpt-test", - messages=[{"role": "user", "content": "weather"}], - tools=non_strict_tools(), - ) as stream: - completion = stream.get_final_completion() - - tool_calls = completion.choices[0].message.tool_calls - assert tool_calls is not None - body = json.loads(cast("list[MockRequestCall]", respx2_mock.calls)[0].request.content) - assert body["tools"] == list(non_strict_tools()) - assert tool_calls[0].function.parsed_arguments is None +def test_stream_rejects_non_strict_single_pass_tools(client: OpenAI) -> None: + with pytest.raises( + ValueError, + match=r"`get_weather` is not strict\. Only `strict` function tools can be auto-parsed", + ): + client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=non_strict_tools(), + ) @pytest.mark.respx2(base_url=base_url) From f16aee4265d23c2c4c39e50af9056059c649e77c Mon Sep 17 00:00:00 2001 From: HughhhhCoder Date: Fri, 4 Sep 2026 20:42:00 +0800 Subject: [PATCH 5/5] fix(stream): preserve non-strict chat tools --- .../resources/chat/completions/completions.py | 5 ++-- tests/lib/chat/test_single_pass_tools.py | 28 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/openai/resources/chat/completions/completions.py b/src/openai/resources/chat/completions/completions.py index 7623318d42..563f9e3da2 100644 --- a/src/openai/resources/chat/completions/completions.py +++ b/src/openai/resources/chat/completions/completions.py @@ -37,6 +37,7 @@ ResponseFormatT, validate_input_tools as _validate_input_tools, parse_chat_completion as _parse_chat_completion, + materialize_input_tools as _materialize_input_tools, type_to_response_format_param as _type_to_response_format, ) from ....lib.streaming.chat import ChatCompletionStreamManager, AsyncChatCompletionStreamManager @@ -1612,7 +1613,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - chat_completion_tools = _validate_input_tools(tools) + chat_completion_tools = _materialize_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", @@ -3225,7 +3226,7 @@ def stream( When the context manager exits, the response will be closed, however the `stream` instance is still available outside the context manager. """ - chat_completion_tools = _validate_input_tools(tools) + chat_completion_tools = _materialize_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", diff --git a/tests/lib/chat/test_single_pass_tools.py b/tests/lib/chat/test_single_pass_tools.py index cf8d3fce26..b886a9173e 100644 --- a/tests/lib/chat/test_single_pass_tools.py +++ b/tests/lib/chat/test_single_pass_tools.py @@ -138,16 +138,24 @@ def test_stream_preserves_single_pass_tools(client: OpenAI, respx2_mock: MockRou assert_request_and_parsed_tool(respx2_mock, tool_calls[0].function.parsed_arguments) -def test_stream_rejects_non_strict_single_pass_tools(client: OpenAI) -> None: - with pytest.raises( - ValueError, - match=r"`get_weather` is not strict\. Only `strict` function tools can be auto-parsed", - ): - client.chat.completions.stream( - model="gpt-test", - messages=[{"role": "user", "content": "weather"}], - tools=non_strict_tools(), - ) +@pytest.mark.respx2(base_url=base_url) +def test_stream_preserves_non_strict_single_pass_tools(client: OpenAI, respx2_mock: MockRouter) -> None: + respx2_mock.post("/chat/completions").mock( + return_value=httpx2.Response(200, text=STREAM_RESPONSE, headers={"content-type": "text/event-stream"}) + ) + + with client.chat.completions.stream( + model="gpt-test", + messages=[{"role": "user", "content": "weather"}], + tools=non_strict_tools(), + ) as stream: + completion = stream.get_final_completion() + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert tool_calls[0].function.parsed_arguments is None + calls = cast("list[MockRequestCall]", respx2_mock.calls) + assert json.loads(calls[0].request.content)["tools"] == list(non_strict_tools()) @pytest.mark.respx2(base_url=base_url)