diff --git a/.castiron.stats.yml b/.castiron.stats.yml index 201a0a40c4..593f552e6d 100644 --- a/.castiron.stats.yml +++ b/.castiron.stats.yml @@ -1,8 +1,8 @@ schema_version: 1 -generation_id: c4a244c7-627d-4a8f-af05-30a24293e753 -openapi_spec_hash: 3a0031a277862428a7586125d5cec8c8 -openapi_transformed_spec_hash: 951060c87157ffcd2b99d6ec617d2f6b +generation_id: 55efa5f7-488d-441d-9d8a-122049a1529a +openapi_spec_hash: dd4d5f4645d390dcaa730d96ff8256b9 +openapi_transformed_spec_hash: 46069ae7031d4c47bc28d2e565e74465 config_hash: 18f602826c88bad76c6fdf08ab67247a -codegen_sha: db6da626ba89030cc5028be84182cfe435e58153 -codegen_hash: 18f31bca2b47a4635831babef446409eb6eccf6db702432e4a9cd0c0226a8b6b -public_codegen_sha: f85015561af80beec73f1c34169d9e502399d3a8 +codegen_sha: c3ce80b15a62c6073590d3d3dae24c3add7c078d +codegen_hash: 3558f0e3ddec29671f90a9da98e765a85fce601f27cc11fea5a4357490a845fb +public_codegen_sha: 37caadca9e7294870cac83ccc132d1df69d06b31 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 5522caae55..df3292ba24 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.17.0" + ".": "3.18.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f38f62919b..777b79f3ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.18.0](https://github.com/openai/openai-python/compare/v3.17.0...v3.18.0) (2026-09-22) + + +### Features + +* **api:** add GPT-6 Sol and Luna model identifiers ([#3935](https://github.com/openai/openai-python/issues/3935)) ([455ce1b](https://github.com/openai/openai-python/commit/455ce1b5da067e5d7631c86a571ca3b0d03b65cd)) + ## [3.17.0](https://github.com/openai/openai-python/compare/v3.16.2...v3.17.0) (2026-09-22) diff --git a/README.md b/README.md index 18c13f6230..d278292354 100644 --- a/README.md +++ b/README.md @@ -753,6 +753,8 @@ Certain errors are automatically retried 2 times by default, with a short expone Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, 429 Rate Limit, and >=500 Internal errors are all retried by default. +Requests are retried only when their body can be safely resent. + You can use the `max_retries` option to configure or disable retry settings: ```python @@ -780,8 +782,8 @@ client.with_options(max_retries=5).chat.completions.create( integer, such as `1000`, for a larger retry budget. Other values raise an error before a request is sent. -Transport failures are retried. Application exceptions raised by custom transports -or hooks propagate unchanged, including task-executor cancellation signals. +Application exceptions raised by custom transports or hooks propagate unchanged, +including task-executor cancellation signals. ## Timeouts diff --git a/api_reference/openapi.transformed.yml b/api_reference/openapi.transformed.yml index cb49098155..daa1172db5 100644 --- a/api_reference/openapi.transformed.yml +++ b/api_reference/openapi.transformed.yml @@ -96374,6 +96374,8 @@ components: type: string enum: - gpt-6-astra + - gpt-6-sol + - gpt-6-luna - gpt-5.6-sol - gpt-5.6-terra - gpt-5.6-luna diff --git a/pyproject.toml b/pyproject.toml index 08ad420ecd..49b53b0b8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "3.17.0" +version = "3.18.0" 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 c85f4f3e76..be99ab7683 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -111,6 +111,66 @@ log: logging.Logger = logging.getLogger(__name__) log.addFilter(SensitiveHeadersFilter()) + +class _RequestBodyReplay: + def __init__(self, options: FinalRequestOptions) -> None: + self._positions: list[tuple[object, int]] = [] + self._replayable = all( + self._capture(content) for content in (options.content, *_iter_file_contents(options.files)) + ) + + def _capture(self, content: object) -> bool: + if content is None or isinstance(content, (bytes, bytearray)): + return True + + if not callable(getattr(content, "read", None)): + # Iterable protocols do not guarantee a fresh iterator: a wrapper can + # return the same stored generator. Only trust known concrete containers. + return type(content) in (list, tuple) + + seekable = getattr(content, "seekable", None) + tell = getattr(content, "tell", None) + seek = getattr(content, "seek", None) + if not callable(seekable) or not callable(tell) or not callable(seek): + return False + try: + if not seekable(): + return False + position = tell() + except (OSError, ValueError): + return False + if not isinstance(position, int): + return False + self._positions.append((content, position)) + return True + + def rewind(self) -> bool: + # Reject a non-replayable body before moving any of its streams. + if not self._replayable: + return False + for content, position in self._positions: + seek = getattr(content, "seek", None) + if not callable(seek): + return False + try: + seek(position) + except (OSError, ValueError): + return False + return True + + +def _iter_file_contents(files: HttpxRequestFiles | None) -> Iterator[object]: + if files is None: + return + + entries = files.items() if isinstance(files, Mapping) else files + for _, file in entries: + if isinstance(file, tuple) and len(file) > 1: + yield file[1] + else: + yield file + + # TODO: make base page type vars covariant SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]") AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") @@ -1062,6 +1122,7 @@ def request( for retries_taken in range(max_retries + 1): options = model_copy(input_options) options = self._prepare_options(options) + request_body_replay = _RequestBodyReplay(options) remaining_retries = max_retries - retries_taken request = self._build_request(options, retries_taken=retries_taken) @@ -1091,7 +1152,7 @@ def request( except timeout_exceptions() as err: log.debug("Encountered a timeout exception: %s", type(err).__name__) - if remaining_retries > 0: + if remaining_retries > 0 and request_body_replay.rewind(): self._sleep_for_retry( retries_taken=retries_taken, max_retries=max_retries, @@ -1108,7 +1169,7 @@ def request( except request_exceptions() as err: log.debug("Encountered exception: %s", type(err).__name__) - if remaining_retries > 0: + if remaining_retries > 0 and request_body_replay.rewind(): self._sleep_for_retry( retries_taken=retries_taken, max_retries=max_retries, @@ -1132,7 +1193,7 @@ def request( except status_exceptions() as err: # thrown on 4xx and 5xx status code log.debug("Encountered an HTTP status error: %i", response.status_code) - if remaining_retries > 0 and self._should_retry(err.response): + if remaining_retries > 0 and self._should_retry(err.response) and request_body_replay.rewind(): err.response.close() self._sleep_for_retry( retries_taken=retries_taken, @@ -1687,6 +1748,7 @@ async def request( for retries_taken in range(max_retries + 1): options = model_copy(input_options) options = await self._prepare_options(options) + request_body_replay = _RequestBodyReplay(options) remaining_retries = max_retries - retries_taken request = self._build_request(options, retries_taken=retries_taken) @@ -1715,7 +1777,7 @@ async def request( except timeout_exceptions() as err: log.debug("Encountered a timeout exception: %s", type(err).__name__) - if remaining_retries > 0: + if remaining_retries > 0 and request_body_replay.rewind(): await self._sleep_for_retry( retries_taken=retries_taken, max_retries=max_retries, @@ -1732,7 +1794,7 @@ async def request( except request_exceptions() as err: log.debug("Encountered exception: %s", type(err).__name__) - if remaining_retries > 0: + if remaining_retries > 0 and request_body_replay.rewind(): await self._sleep_for_retry( retries_taken=retries_taken, max_retries=max_retries, @@ -1756,7 +1818,7 @@ async def request( except status_exceptions() as err: # thrown on 4xx and 5xx status code log.debug("Encountered an HTTP status error: %i", response.status_code) - if remaining_retries > 0 and self._should_retry(err.response): + if remaining_retries > 0 and self._should_retry(err.response) and request_body_replay.rewind(): await err.response.aclose() await self._sleep_for_retry( retries_taken=retries_taken, diff --git a/src/openai/_version.py b/src/openai/_version.py index 243ec74fbb..bbbb06e3ca 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,2 +1,2 @@ __title__ = "openai" -__version__ = "3.17.0" # x-release-please-version +__version__ = "3.18.0" # x-release-please-version diff --git a/src/openai/resources/beta/responses/responses.py b/src/openai/resources/beta/responses/responses.py index c37f90f518..0a47ebb47d 100644 --- a/src/openai/resources/beta/responses/responses.py +++ b/src/openai/resources/beta/responses/responses.py @@ -124,6 +124,8 @@ def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -528,6 +530,8 @@ def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -931,6 +935,8 @@ def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -1332,6 +1338,8 @@ def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -1816,6 +1824,8 @@ def compact( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -2111,6 +2121,8 @@ async def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -2515,6 +2527,8 @@ async def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -2918,6 +2932,8 @@ async def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -3319,6 +3335,8 @@ async def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -3803,6 +3821,8 @@ async def compact( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -5178,6 +5198,8 @@ def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -5429,6 +5451,8 @@ async def create( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 8125b9de1a..4d587f7884 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -1748,6 +1748,8 @@ def compact( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -3635,6 +3637,8 @@ async def compact( model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/beta/beta_response.py b/src/openai/types/beta/beta_response.py index 73c9e3d269..6089aee9af 100644 --- a/src/openai/types/beta/beta_response.py +++ b/src/openai/types/beta/beta_response.py @@ -341,6 +341,8 @@ class BetaResponse(BaseModel): model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/beta/beta_responses_client_event.py b/src/openai/types/beta/beta_responses_client_event.py index e2bdba3956..6d4815ea95 100644 --- a/src/openai/types/beta/beta_responses_client_event.py +++ b/src/openai/types/beta/beta_responses_client_event.py @@ -329,6 +329,8 @@ class ResponseCreate(BaseModel): model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/beta/beta_responses_client_event_param.py b/src/openai/types/beta/beta_responses_client_event_param.py index 15c68e9d5c..53a97177a8 100644 --- a/src/openai/types/beta/beta_responses_client_event_param.py +++ b/src/openai/types/beta/beta_responses_client_event_param.py @@ -329,6 +329,8 @@ class ResponseCreate(TypedDict, total=False): model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/beta/response_compact_params.py b/src/openai/types/beta/response_compact_params.py index da874ef2cf..3586df2143 100644 --- a/src/openai/types/beta/response_compact_params.py +++ b/src/openai/types/beta/response_compact_params.py @@ -16,6 +16,8 @@ class ResponseCompactParams(TypedDict, total=False): Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/beta/response_create_params.py b/src/openai/types/beta/response_create_params.py index b1d4b9addf..4d75cb93aa 100644 --- a/src/openai/types/beta/response_create_params.py +++ b/src/openai/types/beta/response_create_params.py @@ -129,6 +129,8 @@ class ResponseCreateParamsBase(TypedDict, total=False): model: Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/responses/response_compact_params.py b/src/openai/types/responses/response_compact_params.py index 62b4ccadcf..c881d4b6b1 100644 --- a/src/openai/types/responses/response_compact_params.py +++ b/src/openai/types/responses/response_compact_params.py @@ -15,6 +15,8 @@ class ResponseCompactParams(TypedDict, total=False): Union[ Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/shared/chat_model.py b/src/openai/types/shared/chat_model.py index 4801c46cb6..dac8f9e940 100644 --- a/src/openai/types/shared/chat_model.py +++ b/src/openai/types/shared/chat_model.py @@ -6,6 +6,8 @@ ChatModel: TypeAlias = Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/src/openai/types/shared_params/chat_model.py b/src/openai/types/shared_params/chat_model.py index 1a762feb97..5f97eeb351 100644 --- a/src/openai/types/shared_params/chat_model.py +++ b/src/openai/types/shared_params/chat_model.py @@ -8,6 +8,8 @@ ChatModel: TypeAlias = Literal[ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", diff --git a/tests/test_client.py b/tests/test_client.py index 3fd6b8fa41..01626384b4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import gc +import io import os import sys import json @@ -9,7 +10,7 @@ import inspect import dataclasses import tracemalloc -from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, cast +from typing import Any, Union, TypeVar, Callable, Iterable, Iterator, Optional, Coroutine, AsyncIterable, cast from unittest import mock from typing_extensions import Literal, AsyncIterator, override @@ -24,7 +25,7 @@ from openai._utils import asyncify from openai._models import BaseModel, FinalRequestOptions from openai._streaming import Stream, AsyncStream -from openai._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError +from openai._exceptions import APIStatusError, APITimeoutError, APIConnectionError, APIResponseValidationError from openai._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -114,6 +115,38 @@ async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] yield item +class _OneShotIterable(Iterable[T]): + def __init__(self, iterable: Iterable[T]) -> None: + self._iterator = iter(iterable) + + @override + def __iter__(self) -> Iterator[T]: + return self._iterator + + +class _OneShotAsyncIterable(AsyncIterable[T]): + def __init__(self, iterable: Iterable[T]) -> None: + self._iterator = _make_async_iterator(iterable) + + @override + def __aiter__(self) -> AsyncIterator[T]: + return self._iterator + + +class _NonSeekableBytesIO(io.BytesIO): + @override + def seekable(self) -> bool: + return False + + @override + def seek(self, offset: int, whence: int = 0) -> int: + raise io.UnsupportedOperation("seek") + + @override + def tell(self) -> int: + raise io.UnsupportedOperation("tell") + + def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: transport = client._client._transport if isinstance(transport, httpx2.HTTPTransport) or isinstance(transport, httpx2.AsyncHTTPTransport): @@ -842,6 +875,190 @@ def mock_handler(request: httpx2.Request) -> httpx2.Response: assert response.content == file_content assert counter.value == 1 + @pytest.mark.parametrize("content_factory", [_make_sync_iterator, _OneShotIterable]) + @pytest.mark.parametrize("failure_mode", ["status", "timeout", "connection"]) + def test_binary_content_retry_does_not_reuse_one_shot_iterable( + self, + content_factory: Callable[[Iterable[bytes]], Iterable[bytes]], + failure_mode: Literal["status", "timeout", "connection"], + ) -> None: + file_content = b"Hello, this is a test file." + request_bodies: list[bytes] = [] + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + if len(request_bodies) > 1: + return httpx2.Response(200) + if failure_mode == "timeout": + raise httpx2.ReadTimeout("timed out", request=request) + if failure_mode == "connection": + raise httpx2.ConnectError("connection failed", request=request) + return httpx2.Response(500, json={"error": {}}) + + expected_error = { + "status": APIStatusError, + "timeout": APITimeoutError, + "connection": APIConnectionError, + }[failure_mode] + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + with pytest.raises(expected_error): + client.post( + "/upload", + content=content_factory([file_content]), + cast_to=httpx2.Response, + ) + + assert request_bodies == [file_content] + + @pytest.mark.parametrize("content_type", [list, tuple]) + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + def test_binary_content_retry_reuses_known_repeatable_iterable( + self, content_type: type[list[bytes]] | type[tuple[bytes, ...]] + ) -> None: + file_content = b"Hello, this is a test file." + request_bodies: list[bytes] = [] + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + return httpx2.Response(500 if len(request_bodies) == 1 else 200, json={"error": {}}) + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + response = client.post( + "/upload", + content=content_type([file_content]), + cast_to=httpx2.Response, + ) + + assert response.status_code == 200 + assert request_bodies == [file_content, file_content] + + def test_binary_content_retry_checks_prepared_options(self) -> None: + file_content = b"Hello, this prepared body must not be replayed." + prepared_content = _OneShotIterable([file_content]) + request_bodies: list[bytes] = [] + + def prepare_options(options: FinalRequestOptions) -> FinalRequestOptions: + options.content = prepared_content + return options + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + return httpx2.Response(500 if len(request_bodies) == 1 else 200, json={"error": {}}) + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + with mock.patch.object(client, "_prepare_options", side_effect=prepare_options): + with pytest.raises(APIStatusError): + client.post("/upload", content=file_content, cast_to=httpx2.Response) + + assert request_bodies == [file_content] + + def test_multipart_retry_does_not_reuse_non_seekable_file(self) -> None: + file_content = b"Hello, this multipart file must not be replayed." + earlier_file = io.BytesIO(b"first file") + request_bodies: list[bytes] = [] + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + return httpx2.Response(500, json={"error": {}}) + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + with pytest.raises(APIStatusError): + client.post( + "/upload", + files={ + "first": ("first.txt", earlier_file), + "file": ("upload.txt", _NonSeekableBytesIO(file_content), "text/plain"), + }, + cast_to=httpx2.Response, + ) + + assert len(request_bodies) == 1 + assert file_content in request_bodies[0] + # The first send consumes this file; refusing a retry must not rewind it. + assert earlier_file.tell() == len(b"first file") + + @pytest.mark.parametrize("rewind_fails", [False, True], ids=["rewind", "closed-file"]) + def test_multipart_retry_rewinds_seekable_file(self, rewind_fails: bool) -> None: + file_content = b"Hello, this multipart file can be replayed." + file = io.BytesIO(file_content) + original_response = httpx2.Response(500) + request_bodies: list[bytes] = [] + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + if rewind_fails: + file.close() + return original_response if len(request_bodies) == 1 else httpx2.Response(200) + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + try: + response = client.post( + "/upload", + files={"file": ("upload.txt", file, "text/plain")}, + cast_to=httpx2.Response, + ) + except APIStatusError as exc: + assert rewind_fails + assert exc.response is original_response + else: + assert not rewind_fails + assert response.status_code == 200 + + assert len(request_bodies) == (1 if rewind_fails else 2) + assert all(file_content in body for body in request_bodies) + + @mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout) + def test_binary_content_retry_rewinds_seekable_stream(self) -> None: + file_content = b"Hello, this is a test file." + request_bodies: list[bytes] = [] + + def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(request.read()) + return httpx2.Response(500 if len(request_bodies) == 1 else 200, json={"error": {}}) + + with OpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.Client(transport=MockTransport(handler=mock_handler)), + ) as client: + content = io.BytesIO(b"prefix" + file_content) + content.seek(len(b"prefix")) + response = client.post( + "/upload", + content=content, + cast_to=httpx2.Response, + ) + + assert response.status_code == 200 + assert request_bodies == [file_content, file_content] + @pytest.mark.respx2(base_url=base_url) def test_binary_content_upload_with_body_is_deprecated(self, respx2_mock: MockRouter, client: OpenAI) -> None: respx2_mock.post("/upload").mock(side_effect=mirror_request_content) @@ -2197,6 +2414,137 @@ async def mock_handler(request: httpx2.Request) -> httpx2.Response: assert response.content == file_content assert counter.value == 1 + @pytest.mark.parametrize("content_factory", [_make_async_iterator, _OneShotAsyncIterable]) + @pytest.mark.parametrize("failure_mode", ["status", "timeout", "connection"]) + async def test_binary_content_retry_does_not_reuse_one_shot_asynciterable( + self, + content_factory: Callable[[Iterable[bytes]], AsyncIterable[bytes]], + failure_mode: Literal["status", "timeout", "connection"], + ) -> None: + file_content = b"Hello, this is a test file." + request_bodies: list[bytes] = [] + + async def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(await request.aread()) + if len(request_bodies) > 1: + return httpx2.Response(200) + if failure_mode == "timeout": + raise httpx2.ReadTimeout("timed out", request=request) + if failure_mode == "connection": + raise httpx2.ConnectError("connection failed", request=request) + return httpx2.Response(500, json={"error": {}}) + + expected_error = { + "status": APIStatusError, + "timeout": APITimeoutError, + "connection": APIConnectionError, + }[failure_mode] + + async with AsyncOpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + with pytest.raises(expected_error): + await client.post( + "/upload", + content=content_factory([file_content]), + cast_to=httpx2.Response, + ) + + assert request_bodies == [file_content] + + async def test_binary_content_retry_checks_prepared_options(self) -> None: + file_content = b"Hello, this prepared body must not be replayed." + prepared_content = _OneShotAsyncIterable([file_content]) + request_bodies: list[bytes] = [] + + async def prepare_options(options: FinalRequestOptions) -> FinalRequestOptions: + options.content = prepared_content + return options + + async def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(await request.aread()) + return httpx2.Response(500 if len(request_bodies) == 1 else 200, json={"error": {}}) + + async with AsyncOpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + with mock.patch.object(client, "_prepare_options", side_effect=prepare_options): + with pytest.raises(APIStatusError): + await client.post("/upload", content=file_content, cast_to=httpx2.Response) + + assert request_bodies == [file_content] + + async def test_multipart_retry_does_not_reuse_non_seekable_file(self) -> None: + file_content = b"Hello, this multipart file must not be replayed." + earlier_file = io.BytesIO(b"first file") + request_bodies: list[bytes] = [] + + async def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(await request.aread()) + return httpx2.Response(500, json={"error": {}}) + + async with AsyncOpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + with pytest.raises(APIStatusError): + await client.post( + "/upload", + files={ + "first": ("first.txt", earlier_file), + "file": ("upload.txt", _NonSeekableBytesIO(file_content), "text/plain"), + }, + cast_to=httpx2.Response, + ) + + assert len(request_bodies) == 1 + assert file_content in request_bodies[0] + # The first send consumes this file; refusing a retry must not rewind it. + assert earlier_file.tell() == len(b"first file") + + @pytest.mark.parametrize("rewind_fails", [False, True], ids=["rewind", "closed-file"]) + async def test_multipart_retry_rewinds_seekable_file(self, rewind_fails: bool) -> None: + file_content = b"Hello, this multipart file can be replayed." + file = io.BytesIO(file_content) + original_response = httpx2.Response(500) + request_bodies: list[bytes] = [] + + async def mock_handler(request: httpx2.Request) -> httpx2.Response: + request_bodies.append(await request.aread()) + if rewind_fails: + file.close() + return original_response if len(request_bodies) == 1 else httpx2.Response(200) + + async with AsyncOpenAI( + base_url=base_url, + api_key=api_key, + max_retries=1, + http_client=httpx2.AsyncClient(transport=MockTransport(handler=mock_handler)), + ) as client: + try: + response = await client.post( + "/upload", + files={"file": ("upload.txt", file, "text/plain")}, + cast_to=httpx2.Response, + ) + except APIStatusError as exc: + assert rewind_fails + assert exc.response is original_response + else: + assert not rewind_fails + assert response.status_code == 200 + + assert len(request_bodies) == (1 if rewind_fails else 2) + assert all(file_content in body for body in request_bodies) + @pytest.mark.respx2(base_url=base_url) async def test_binary_content_upload_with_body_is_deprecated( self, respx2_mock: MockRouter, async_client: AsyncOpenAI diff --git a/uv.lock b/uv.lock index 7ca3191634..6abb04281c 100644 --- a/uv.lock +++ b/uv.lock @@ -1535,7 +1535,7 @@ wheels = [ [[package]] name = "openai" -version = "3.17.0" # x-release-please-version +version = "3.18.0" # x-release-please-version source = { editable = "." } dependencies = [ { name = "anyio" },