From 455ce1b5da067e5d7631c86a571ca3b0d03b65cd Mon Sep 17 00:00:00 2001 From: Mark Stuart Date: Tue, 22 Sep 2026 18:07:00 +0000 Subject: [PATCH 1/3] feat(api): add GPT-6 Sol and Luna model identifiers (#3935) ## Summary Adds gpt-6-sol and gpt-6-luna to the known Chat Completions and Responses model identifiers. ## Changes - Add the two identifiers to Python model type hints. - Continue accepting existing model identifiers and arbitrary model strings. Co-authored-by: markstuart-oai <323302876+markstuart-oai@users.noreply.github.com> --- .castiron.stats.yml | 12 +++++----- api_reference/openapi.transformed.yml | 2 ++ .../resources/beta/responses/responses.py | 24 +++++++++++++++++++ src/openai/resources/responses/responses.py | 4 ++++ src/openai/types/beta/beta_response.py | 2 ++ .../types/beta/beta_responses_client_event.py | 2 ++ .../beta/beta_responses_client_event_param.py | 2 ++ .../types/beta/response_compact_params.py | 2 ++ .../types/beta/response_create_params.py | 2 ++ .../responses/response_compact_params.py | 2 ++ src/openai/types/shared/chat_model.py | 2 ++ src/openai/types/shared_params/chat_model.py | 2 ++ 12 files changed, 52 insertions(+), 6 deletions(-) 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/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/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", From b56e6d89d877309c479cb36fd4f077ce72baf3af Mon Sep 17 00:00:00 2001 From: "openai-sdks[bot]" <284451331+openai-sdks[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:20:59 +0000 Subject: [PATCH 2/3] release: 3.18.0 (#3936) Automated Release PR --- ## [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)) --- 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 | 7 +++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- uv.lock | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) 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/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/_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/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" }, From 6e0c2beeb639e42ff74f14135151d8bcb1e5b245 Mon Sep 17 00:00:00 2001 From: HughChaw <146055770+Hughhhhcoder@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:08:34 +0000 Subject: [PATCH 3/3] fix(client): retry only replayable request content (#3771) - [x] I understand that this repository is auto-generated and my pull request may not be merged ## Changes being requested Automatic retries can reuse a consumed request body. A one-shot iterable or non-seekable multipart file part may then send an empty body on the retry; if that retry succeeds, the SDK reports success without resending the original payload. Seekable streams also need their original offset restored. The expected behavior is to retry only when every body part can be replayed. The retry paths currently decide whether to retry based on response status or request failure, but do not check the replayability of `options.content` and `options.files` or rewind seekable parts. This patch adds one replayability guard for both synchronous and asynchronous clients: - bytes, bytearrays, and exact list/tuple containers remain retryable; - opaque iterables, including wrappers that return one stored iterator, and non-seekable streams are treated as non-replayable; - seekable content and multipart file parts are restored to their starting offset before retrying; - retryable status responses, timeouts, and connection failures all use the guard. The patch does not buffer request bodies or change the public API. [#3484](https://github.com/openai/openai-python/pull/3484) proposes spooling non-seekable multipart streams to preserve retries; this PR takes the smaller, conservative approach of not retrying a consumed stream. The multipart checks here were added in response to review feedback, so they are no longer out of scope. ## Additional context & links The one-shot-content and multipart regression cases fail on the earlier base and pass with this change. The branch has been normally merged with current `main`, retaining the separate retry-limit and application-error changes from [#3867](https://github.com/openai/openai-python/pull/3867). Validation after that merge: - `PYTHONPATH=src ./scripts/test tests/test_client.py` with the repository's pinned Steady mock server: 275 passed, 2 skipped in each Pydantic lane; - `PYTHONPATH=src ./scripts/lint`: Ruff, Pyright, mypy, and import checks passed using the documented Node.js and pnpm versions; - `git diff --check`: passed. --------- Co-authored-by: Marcus Wood --- README.md | 6 +- src/openai/_base_client.py | 74 +++++++- tests/test_client.py | 352 ++++++++++++++++++++++++++++++++++++- 3 files changed, 422 insertions(+), 10 deletions(-) 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/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/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