From ead1fa2473257ba5d7be08484aca4349649e6245 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 23 Sep 2026 16:46:15 +0000 Subject: [PATCH 1/4] docs: clarify collaborator-only pull request policy (#3948) ## Summary Make the contribution guidance match the repository's collaborator-only pull request policy. The README links to CONTRIBUTING.md, both explicitly exclude PRs from non-collaborators, and community feedback goes through issues. Existing development instructions remain available to collaborators, and security reports continue through the private disclosure process. Remove the conflicting invitation to external PRs from the pull request template. Use absolute README links to the policy and security guide so package-page readers can follow them. ## Test plan - `git diff --check` and targeted policy/link checks pass. - Reviewed the complete diff and contributor, collaborator, and private security-reporting paths. Documentation only; no SDK runtime, repository settings, or generated source changes. --- .github/pull_request_template.md | 11 +++++------ CONTRIBUTING.md | 19 +++++++++++++++++++ README.md | 5 ++++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4416b1e547..a0d583cc18 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,8 @@ - - - - - -- [ ] I understand that this repository is auto-generated and my pull request may not be merged + + + + + ## Changes being requested diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e71fffe08..57ed4c4fd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,22 @@ +# Contributing + +## Contribution policy + +We welcome bug reports, feature requests, minimal reproductions, and root-cause +analysis through [GitHub issues](https://github.com/openai/openai-python/issues). + +**Pull requests are limited to repository collaborators. We do not accept pull +requests from non-collaborators**, including documentation or example changes. +If you are not a collaborator, please open an issue instead of preparing a pull +request. Include the affected version, expected and actual behavior, and a small, +sanitized reproduction when applicable. + +Report suspected security vulnerabilities privately as described in +[SECURITY.md](SECURITY.md), rather than in issues or pull requests. + +The development and pull request instructions below are for maintainers and +repository collaborators. + ## Setting up the environment The minimum supported runtime, contributor toolchain, CI matrix, and release diff --git a/README.md b/README.md index d278292354..c1001e7733 100644 --- a/README.md +++ b/README.md @@ -1249,4 +1249,7 @@ Python 3.10 or higher. ## Contributing -See [the contributing documentation](./CONTRIBUTING.md). +Please share bug reports and feature requests through [GitHub issues](https://github.com/openai/openai-python/issues). +Pull requests are limited to repository collaborators; we do not accept pull requests from non-collaborators. +See [CONTRIBUTING.md](https://github.com/openai/openai-python/blob/main/CONTRIBUTING.md) for the contribution policy and development guide. +For security vulnerabilities, follow [SECURITY.md](https://github.com/openai/openai-python/blob/main/SECURITY.md). From 5e39766dd7ba2802c97f5305721a621098cf0e38 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 23 Sep 2026 17:16:28 +0000 Subject: [PATCH 2/4] fix(client): merge HTTP headers case-insensitively (#3486) HTTP header names are case-insensitive, but differently cased overrides currently leave duplicate values, and `Omit()` can fail to remove an earlier value. Merge HTTP headers case-insensitively so later values replace earlier ones and explicit removal respects casing. The change is limited to HTTP header merging, with one parametrized regression test covering default overrides, per-request overrides, and removal for sync and async clients. Validation: 807 tests passed across client, authentication, Azure, Bedrock, redirect, and HTTPX compatibility coverage; focused Pydantic v1 checks, Ruff, Pyright, mypy, and the custom-code budget passed. Co-authored-by: Marcus Wood --- src/openai/_base_client.py | 16 +++++++++++++++- tests/test_client.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index be99ab7683..c8830a6a9f 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -530,7 +530,7 @@ def _custom_auth( def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx2.Headers: custom_headers = options.headers or {} - headers_dict = _merge_mappings({**self._auth_headers(options.security), **self.default_headers}, custom_headers) + headers_dict = _merge_headers(self._auth_headers(options.security), self.default_headers, custom_headers) self._validate_headers(headers_dict, custom_headers) # headers are case-insensitive while dictionaries are not. @@ -2342,3 +2342,17 @@ def _merge_mappings( """ merged = {**obj1, **obj2} return {key: value for key, value in merged.items() if not isinstance(value, Omit)} + + +def _merge_headers(*mappings: Headers) -> dict[str, str]: + """Merge headers case-insensitively, with later mappings taking precedence.""" + merged: dict[str, tuple[str, str]] = {} + for mapping in mappings: + for name, value in mapping.items(): + normalized_name = name.lower() + if isinstance(value, Omit): + merged.pop(normalized_name, None) + else: + merged[normalized_name] = (name, value) + + return {name: value for name, value in merged.values()} diff --git a/tests/test_client.py b/tests/test_client.py index 01626384b4..6e8b4d2e72 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -156,6 +156,40 @@ def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: return len(cast(Any, transport)._pool._requests) +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize( + "extra_headers,expected", + [ + ({}, ["Bearer fake-default"]), + ({"AUTHORIZATION": "Bearer fake-request"}, ["Bearer fake-request"]), + ({"AUTHORIZATION": Omit()}, []), + ], + ids=["default", "override", "omit"], +) +async def test_case_insensitive_auth_headers( + is_async: bool, extra_headers: dict[str, str | Omit], expected: list[str] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.headers.get_list("authorization") == expected + return httpx2.Response(200, json={"object": "list", "data": []}) + + transport = httpx2.MockTransport(handler) + if is_async: + async with AsyncOpenAI( + api_key="fake-original", + default_headers={"authorization": "Bearer fake-default"}, + http_client=httpx2.AsyncClient(transport=transport), + ) as async_client: + await async_client.models.list(extra_headers=extra_headers) + else: + with OpenAI( + api_key="fake-original", + default_headers={"authorization": "Bearer fake-default"}, + http_client=httpx2.Client(transport=transport), + ) as client: + client.models.list(extra_headers=extra_headers) + + class TestOpenAI: @pytest.mark.parametrize( "code_fields,expected_code", From 33ffa1f9fa4f49553ab0287b9359283be818fe40 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:40:07 +0000 Subject: [PATCH 3/4] fix(chat): preserve single-pass tool iterables (#3770) Passing a generator to `chat.completions.parse()` or `.stream()` can send an empty tools array or lose parsed function arguments. Materialize the iterable once and reuse it for validation, serialization, and response parsing across sync and async helpers. The public `Iterable` type stays unchanged. `.parse()` retains strict-tool validation; async `.stream()` now accepts non-strict tools without auto-parsing them, matching existing sync behavior. Two parametrized tests cover all four helper paths and non-strict streaming. Validation: chat suite passed with Pydantic v2 (77 tests) and v1 (76 passed, 1 skipped); all four strict-tool regressions fail against main. Ruff lint, changed-file formatting, Pyright, mypy, and the custom-code budget passed. Security review found no new concerns. --------- Co-authored-by: Marcus Wood --- 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 | 95 +++++++++++++++++++ 4 files changed, 122 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 ebea435e93..21be8ace54 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 594a908920..0920d50f5d 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, @@ -1633,6 +1634,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 {}), @@ -1671,7 +1674,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, @@ -1685,7 +1688,7 @@ def stream( return ChatCompletionStreamManager( api_request, response_format=response_format, - input_tools=tools, + input_tools=chat_completion_tools, ) @@ -1808,7 +1811,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", @@ -1819,7 +1822,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( @@ -1857,7 +1860,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, @@ -3265,7 +3268,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 = _materialize_input_tools(tools) extra_headers = { "X-Stainless-Helper-Method": "chat.completions.stream", @@ -3304,7 +3307,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, @@ -3318,7 +3321,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..9bd5225679 --- /dev/null +++ b/tests/lib/chat/test_single_pass_tools.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json + +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 + + +def mock_tool_call( + respx2_mock: MockRouter, *, streaming: bool = False, strict: bool = True +) -> ChatCompletionToolUnionParam: + tool: ChatCompletionToolUnionParam = { + "type": "function", + "function": { + "name": "get_weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + "additionalProperties": False, + }, + "strict": strict, + }, + } + tool_call: dict[str, object] = { + "id": "call-test", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"San Francisco"}'}, + } + message = {"role": "assistant", "content": None, "tool_calls": [tool_call]} + response: dict[str, object] = { + "id": "chatcmpl-test", + "object": "chat.completion.chunk" if streaming else "chat.completion", + "created": 0, + "model": "gpt-test", + "choices": [{"index": 0, "delta" if streaming else "message": message, "finish_reason": "tool_calls"}], + } + if streaming: + tool_call["index"] = 0 + + def handle_request(request: httpx2.Request) -> httpx2.Response: + assert json.loads(request.content)["tools"] == [tool] + if streaming: + return httpx2.Response( + 200, + text=f"data: {json.dumps(response)}\n\ndata: [DONE]\n\n", + headers={"content-type": "text/event-stream"}, + ) + return httpx2.Response(200, json=response) + + respx2_mock.post("/chat/completions").mock(side_effect=handle_request) + return tool + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.asyncio +async def test_parse_preserves_single_pass_tools( + client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, use_async: bool +) -> None: + tools = iter([mock_tool_call(respx2_mock)]) + if use_async: + completion = await async_client.chat.completions.parse(model="gpt-test", messages=[], tools=tools) + else: + completion = client.chat.completions.parse(model="gpt-test", messages=[], tools=tools) + + tool_calls = completion.choices[0].message.tool_calls + assert tool_calls is not None + assert tool_calls[0].function.parsed_arguments == {"city": "San Francisco"} + + +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.parametrize("strict", [False, True]) +@pytest.mark.asyncio +async def test_stream_preserves_single_pass_tools( + client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, use_async: bool, strict: bool +) -> None: + tools = iter([mock_tool_call(respx2_mock, streaming=True, strict=strict)]) + if use_async: + async with async_client.chat.completions.stream(model="gpt-test", messages=[], tools=tools) as async_stream: + completion = await async_stream.get_final_completion() + else: + with client.chat.completions.stream(model="gpt-test", messages=[], tools=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 == ({"city": "San Francisco"} if strict else None) From 4d1274681db7f34f671ff9e71adc1461d22a927b Mon Sep 17 00:00:00 2001 From: "openai-sdks[bot]" <284451331+openai-sdks[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:10:33 +0000 Subject: [PATCH 4/4] release: 3.19.1 (#3946) Automated Release PR --- ## [3.19.1](https://github.com/openai/openai-python/compare/v3.19.0...v3.19.1) (2026-09-23) ### Bug Fixes * **chat:** preserve single-pass tool iterables ([#3770](https://github.com/openai/openai-python/issues/3770)) ([33ffa1f](https://github.com/openai/openai-python/commit/33ffa1f9fa4f49553ab0287b9359283be818fe40)) * **client:** merge HTTP headers case-insensitively ([#3486](https://github.com/openai/openai-python/issues/3486)) ([5e39766](https://github.com/openai/openai-python/commit/5e39766dd7ba2802c97f5305721a621098cf0e38)) ### Chores * **api:** clarify Chat Completions seed limits ([#3945](https://github.com/openai/openai-python/issues/3945)) ([be9d666](https://github.com/openai/openai-python/commit/be9d66628ad7377bd36fe5a76ae6d735843f0e76)) ### Documentation * clarify collaborator-only pull request policy ([#3948](https://github.com/openai/openai-python/issues/3948)) ([ead1fa2](https://github.com/openai/openai-python/commit/ead1fa2473257ba5d7be08484aca4349649e6245)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: openai-sdks[bot] <284451331+openai-sdks[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- uv.lock | 2 +- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 08ebb64c87..3362d3a312 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.19.0" + ".": "3.19.1" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2ff2afdc..c54587372e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [3.19.1](https://github.com/openai/openai-python/compare/v3.19.0...v3.19.1) (2026-09-23) + + +### Bug Fixes + +* **chat:** preserve single-pass tool iterables ([#3770](https://github.com/openai/openai-python/issues/3770)) ([33ffa1f](https://github.com/openai/openai-python/commit/33ffa1f9fa4f49553ab0287b9359283be818fe40)) +* **client:** merge HTTP headers case-insensitively ([#3486](https://github.com/openai/openai-python/issues/3486)) ([5e39766](https://github.com/openai/openai-python/commit/5e39766dd7ba2802c97f5305721a621098cf0e38)) + + +### Chores + +* **api:** clarify Chat Completions seed limits ([#3945](https://github.com/openai/openai-python/issues/3945)) ([be9d666](https://github.com/openai/openai-python/commit/be9d66628ad7377bd36fe5a76ae6d735843f0e76)) + + +### Documentation + +* clarify collaborator-only pull request policy ([#3948](https://github.com/openai/openai-python/issues/3948)) ([ead1fa2](https://github.com/openai/openai-python/commit/ead1fa2473257ba5d7be08484aca4349649e6245)) + ## [3.19.0](https://github.com/openai/openai-python/compare/v3.18.0...v3.19.0) (2026-09-22) diff --git a/pyproject.toml b/pyproject.toml index 9b06ea0c2b..12fe29e078 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "3.19.0" +version = "3.19.1" description = "The official Python library for the openai API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/openai/_version.py b/src/openai/_version.py index 6dbc2a2d6c..02512a1a2a 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,2 +1,2 @@ __title__ = "openai" -__version__ = "3.19.0" # x-release-please-version +__version__ = "3.19.1" # x-release-please-version diff --git a/uv.lock b/uv.lock index fc908a349a..fba61f3f08 100644 --- a/uv.lock +++ b/uv.lock @@ -1535,7 +1535,7 @@ wheels = [ [[package]] name = "openai" -version = "3.19.0" # x-release-please-version +version = "3.19.1" # x-release-please-version source = { editable = "." } dependencies = [ { name = "anyio" },