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/.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/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). 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/_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/src/openai/_utils/_utils.py b/src/openai/_utils/_utils.py index 02046a352c..95641eb158 100644 --- a/src/openai/_utils/_utils.py +++ b/src/openai/_utils/_utils.py @@ -114,7 +114,7 @@ def _extract_items( try: # Remove the field if there are no more dict keys in the path, # only "" traversal markers or end. - if all(p == "" for p in path[index:]): + if all(p == "" for p in path[index:]) and (index == len(path) or is_list(obj[key])): item = obj.pop(key) else: item = obj[key] 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/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) 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", diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 54490e133f..6116c42c18 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -41,6 +41,16 @@ def test_top_level_file_array() -> None: assert query == {"title": "hello"} +@pytest.mark.parametrize("file", [b"zip contents", ("skill.zip", b"zip contents")]) +def test_single_file_fallback_after_array_path(file: FileTypes) -> None: + query = {"files": file, "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [] + assert query == {"files": file, "title": "hello"} + + assert extract_files(query, paths=[["files", ""], ["files"]]) == [("files", file)] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ diff --git a/tests/test_files.py b/tests/test_files.py index a94ddce0ef..51ca9f0acc 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,14 +1,49 @@ +import io +import zipfile from pathlib import Path import anyio +import httpx2 import pytest +from openai import OpenAI, AsyncOpenAI +from tests.respx2 import MockRouter from openai._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files from openai._utils import extract_files +from .conftest import base_url + readme_path = Path(__file__).parent.parent.joinpath("README.md") +@pytest.mark.respx2(base_url=base_url) +@pytest.mark.asyncio +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("as_list", [False, True]) +async def test_skills_upload_zip( + client: OpenAI, async_client: AsyncOpenAI, respx2_mock: MockRouter, is_async: bool, as_list: bool +) -> None: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("SKILL.md", "---\nname: example\ndescription: Test skill\n---\n# Example\n") + content = buffer.getvalue() + file = ("skill.zip", content) + files = [file] if as_list else file + respx2_mock.post("/skills").mock(return_value=httpx2.Response(200, json={})) + response = ( + await async_client.skills.with_raw_response.create(files=files) + if is_async + else client.skills.with_raw_response.create(files=files) + ) + request = response.http_request + body = await request.aread() if is_async else request.read() + assert request.headers["content-type"].startswith("multipart/form-data;") + expected_field = b'name="files[]"' if as_list else b'name="files"' + assert expected_field in body + assert b'filename="skill.zip"' in body + assert body.count(content) == 1 + + def test_pathlib_includes_file_name() -> None: result = to_httpx_files({"file": readme_path}) assert result == {"file": ("README.md", readme_path.read_bytes())} 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" },