From 4303e974f08394789c6075b6745357b2f1c36e21 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:16:47 +0000 Subject: [PATCH 1/3] feat(stlc): configurable CI runner and private-production-repo support in workflow templates --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91b6ed5e94..2229e3ae02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: lint: timeout-minutes: 10 name: lint - runs-on: ${{ github.repository == 'stainless-sdks/openai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -42,7 +42,7 @@ jobs: permissions: contents: read id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/openai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -83,7 +83,7 @@ jobs: test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/openai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -104,7 +104,7 @@ jobs: timeout-minutes: 10 name: examples environment: ci - runs-on: ${{ github.repository == 'stainless-sdks/openai-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: github.repository == 'openai/openai-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: From 317260cd16395b2338bcdaacd7daa0b179c90105 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Tue, 21 Jul 2026 16:57:23 -0400 Subject: [PATCH 2/3] feat(client): Add experimental runtime support for HTTPX2 clients (#3524) Refs https://github.com/openai/openai-python/issues/3375. ## Summary Adds experimental runtime support for using an HTTPX2 client with the 2.x SDK: ```sh pip install 'openai[httpx2]' ``` ```python from openai import OpenAI, AsyncOpenAI, DefaultHttpx2Client, DefaultAsyncHttpx2Client client = OpenAI(http_client=DefaultHttpx2Client()) async_client = AsyncOpenAI(http_client=DefaultAsyncHttpx2Client()) ``` HTTPX remains installed, imported, and authoritative for the SDK's public transport types. Existing HTTPX clients and helpers continue to work unchanged. This is an explicit runtime opt-in, not a default-transport or typing migration: generated/parsed API models remain accurately typed, while _raw requests, responses, streams, and transport exceptions can be HTTPX2 objects even where annotations still describe HTTPX_. The implementation keeps the compatibility boundary small: - accepts sync, async, and module-level HTTPX2 clients and preserves their native request/response/exception family; - supplies `DefaultHttpx2Client` and `DefaultAsyncHttpx2Client` with the SDK defaults, while retaining the existing HTTPX and aiohttp helpers; - handles the concrete request, timeout, URL, response-casting, retry, streaming, auth, and provider differences needed at runtime; - makes workload-identity token exchange follow an explicitly selected HTTPX2 client (including async clients, whose exchange still runs synchronously in the existing worker-thread path), without changing behavior merely because HTTPX2 happens to be installed. The `httpx2` extra is available on Python 3.10+ and scopes its resolver requirements to the opt-in path (`httpx>=0.25.1,<1`, `httpx2>=2.7,<3`, and `anyio>=4.10,<5`). Base installations retain the existing Python, HTTPX, and AnyIO floors. On Python 3.9 the extra is marker-skipped and invoking an HTTPX2 helper produces an actionable error. Intentionally mixed HTTPX/HTTPX2 transports or auth implementations are out of scope. ## Gaps - This pr does not address `aiohttp`+ `httpx2`; that can be a future addition if there is interest. This would require a separate addon and for us to vendor in some of the implementation; so avoiding in this PR. - Types stay on `httpx`, and `httpx` is still required to be an installed package. - reasoning: we considered `httpx | httpx2` as a migration shim, or returning `httpx2` type annotations. Will continue to evaluate the ecosystem, although `httpx2` types will unfortunately likely require many updates from downstream packages. Saving those considerations for potential future major versions. - For now, we do not address `httpx.timeout` annotations in generated code as well ## Testing The HTTPX2 CI lane runs the normal suite with native HTTPX2 sync/async clients in both Pydantic modes. Existing RESPX-backed cases are exercised through a small, test-only bridge: HTTPX2 uses a native `MockTransport`, the bridge translates only at the RESPX matching/callback boundary, and the SDK still builds and receives native HTTPX2 requests/responses. This keeps generated binary/raw/streaming, retry, auth, Azure, Bedrock, and snapshot coverage shared instead of maintaining duplicate tests; aiohttp remains a separate HTTPX-family path. Focused native tests also cover client defaults, direct injection, raw/SSE/multipart, retries and exception families, hooks/mounts/proxies, provider auth, workload-identity exchange/cache/401 refresh, and base-only/extra resolver behavior. Local full-suite runs pass with HTTPX2/Pydantic 2 and the HTTPX2 floor/Pydantic 1; the ordinary HTTPX suite and lint/type checks remain clean. --- .github/workflows/ci.yml | 40 + README.md | 27 + examples/httpx2_client.py | 10 + pyproject.toml | 5 + scripts/test | 4 + scripts/utils/validate-httpx2-wheel.py | 137 ++++ src/openai/__init__.py | 5 +- src/openai/_base_client.py | 87 ++- src/openai/_client.py | 21 +- src/openai/_httpx2.py | 151 ++++ src/openai/_legacy_response.py | 8 +- src/openai/_response.py | 12 +- src/openai/auth/_workload.py | 6 +- src/openai/lib/azure.py | 5 +- src/openai/lib/streaming/_assistants.py | 11 +- src/openai/providers/bedrock.py | 5 +- .../resources/beta/realtime/realtime.py | 5 +- .../resources/beta/responses/responses.py | 5 +- src/openai/resources/realtime/realtime.py | 5 +- src/openai/resources/responses/responses.py | 5 +- tests/_httpx2_respx.py | 92 +++ tests/conftest.py | 33 +- tests/lib/test_bedrock.py | 27 +- tests/test_client.py | 23 +- tests/test_httpx2.py | 719 ++++++++++++++++++ tests/test_httpx2_base.py | 112 +++ tests/test_httpx2_respx.py | 65 ++ tests/test_httpx2_workload.py | 305 ++++++++ uv.lock | 48 +- 29 files changed, 1892 insertions(+), 86 deletions(-) create mode 100644 examples/httpx2_client.py create mode 100644 scripts/utils/validate-httpx2-wheel.py create mode 100644 src/openai/_httpx2.py create mode 100644 tests/_httpx2_respx.py create mode 100644 tests/test_httpx2.py create mode 100644 tests/test_httpx2_base.py create mode 100644 tests/test_httpx2_respx.py create mode 100644 tests/test_httpx2_workload.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2229e3ae02..2d202ee627 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,17 @@ jobs: - name: Validate Bedrock wheel run: rye run python scripts/utils/validate-bedrock-wheel.py + - name: Validate HTTPX2 wheel on Python 3.9 + run: rye run python scripts/utils/validate-httpx2-wheel.py + + - name: Set up Python 3.12 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Validate HTTPX2 wheel + run: python scripts/utils/validate-httpx2-wheel.py + - name: Get GitHub OIDC Token if: |- github.repository == 'stainless-sdks/openai-python' && @@ -100,6 +111,35 @@ jobs: - name: Run tests run: ./scripts/test + test-httpx2: + timeout-minutes: 10 + name: test (HTTPX2) + runs-on: ${{ startsWith(github.repository, 'stainless-sdks/') && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Rye + uses: eifinger/setup-rye@c694239a43768373e87d0103d7f547027a23f3c8 + with: + version: '0.44.0' + enable-cache: true + + - name: Install HTTPX2 test dependencies + run: | + rye pin 3.12 + rye sync --all-features + + - name: Run tests with HTTPX and HTTPX2 installed + env: + OPENAI_TEST_HTTP_CLIENT: httpx + run: ./scripts/test + + - name: Run tests with HTTPX2 + env: + OPENAI_TEST_HTTP_CLIENT: httpx2 + run: ./scripts/test + examples: timeout-minutes: 10 name: examples diff --git a/README.md b/README.md index dd6c9d968d..9ca8fc90a4 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,33 @@ async def main() -> None: asyncio.run(main()) ``` +### Experimental HTTPX2 support + +To opt in to experimental HTTPX2 support, install the optional extra on Python 3.10 or later: + +```sh +pip install 'openai[httpx2]' +``` + +```python +from openai import OpenAI, AsyncOpenAI, DefaultHttpx2Client, DefaultAsyncHttpx2Client + +client = OpenAI(http_client=DefaultHttpx2Client()) +async_client = AsyncOpenAI(http_client=DefaultAsyncHttpx2Client()) +``` + +See [`examples/httpx2_client.py`](examples/httpx2_client.py) for a minimal runnable example. + +The module-level client can be configured in the same way: + +```python +import openai + +openai.http_client = openai.DefaultHttpx2Client() +``` + +Parsed API models are unchanged, but requests, raw and streaming responses, and transport-level exceptions may be HTTPX2 objects at runtime. Code that catches HTTPX exceptions or relies on HTTPX-specific mocks, transports, authentication, hooks, or instrumentation may need to be updated. Transport-facing type annotations may still describe HTTPX. + ## Streaming responses We provide support for streaming responses using Server Side Events (SSE). diff --git a/examples/httpx2_client.py b/examples/httpx2_client.py new file mode 100644 index 0000000000..eb4ab08f87 --- /dev/null +++ b/examples/httpx2_client.py @@ -0,0 +1,10 @@ +from openai import OpenAI, DefaultHttpx2Client + +client = OpenAI(http_client=DefaultHttpx2Client()) + +response = client.responses.create( + model="gpt-5.2", + input="Say this is a test", +) + +print(response.output_text) diff --git a/pyproject.toml b/pyproject.toml index 0256365772..7b8fae090d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,11 @@ aiohttp = [ "aiohttp>=3.14.1; python_version >= '3.10'", "httpx_aiohttp>=0.1.9; python_version >= '3.10'", ] +httpx2 = [ + "httpx>=0.25.1, <1; python_version >= '3.10'", + "httpx2>=2.7.0, <3; python_version >= '3.10'", + "anyio>=4.10.0, <5; python_version >= '3.10'", +] realtime = ["websockets >= 13, < 16"] datalib = ["numpy >= 1", "pandas >= 1.2.3", "pandas-stubs >= 1.1.0.11"] voice_helpers = ["sounddevice>=0.5.1", "numpy>=2.0.2"] diff --git a/scripts/test b/scripts/test index 7b05e44fd9..daff369239 100755 --- a/scripts/test +++ b/scripts/test @@ -54,6 +54,10 @@ fi export DEFER_PYDANTIC_BUILD=false +if [ "${OPENAI_TEST_HTTP_CLIENT:-httpx}" = "httpx2" ]; then + echo "==> Using HTTPX2 for sync and async API-resource clients (including RESPX-backed cases)" +fi + echo "==> Running tests" rye run pytest "$@" diff --git a/scripts/utils/validate-httpx2-wheel.py b/scripts/utils/validate-httpx2-wheel.py new file mode 100644 index 0000000000..681e443d4c --- /dev/null +++ b/scripts/utils/validate-httpx2-wheel.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import os +import sys +import email +import zipfile +import tempfile +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +BASE_TEST = ROOT / "tests/test_httpx2_base.py" +HTTPX2_TEST = ROOT / "tests/test_httpx2.py" + + +def validate_metadata(wheel: Path) -> None: + with zipfile.ZipFile(wheel) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + if len(metadata_names) != 1: + raise RuntimeError(f"Expected exactly one METADATA file in {wheel}, found: {metadata_names}") + metadata = email.message_from_bytes(archive.read(metadata_names[0])) + + requirements = metadata.get_all("Requires-Dist", []) + base = [value for value in requirements if "extra ==" not in value] + httpx2 = [value for value in requirements if "extra == 'httpx2'" in value] + aiohttp = [value for value in requirements if "extra == 'aiohttp'" in value] + + if metadata["Requires-Python"] != ">=3.9": + raise RuntimeError(f"Expected Python >=3.9, found: {metadata['Requires-Python']}") + if not any(value.startswith("httpx<1,>=0.23.0") for value in base): + raise RuntimeError(f"Expected the base wheel to require HTTPX >=0.23.0,<1: {base}") + if not any(value.startswith("anyio<5,>=3.5.0") for value in base): + raise RuntimeError(f"Expected the base wheel to require AnyIO >=3.5.0,<5: {base}") + if any(value.startswith("httpx2") for value in base): + raise RuntimeError(f"HTTPX2 leaked into the base wheel requirements: {base}") + + for expected in ("httpx<1,>=0.25.1", "httpx2<3,>=2.7.0", "anyio<5,>=4.10.0"): + if not any(value.startswith(expected) for value in httpx2): + raise RuntimeError(f"Expected the HTTPX2 extra to require {expected}: {httpx2}") + if not all("python_version >= '3.10'" in value for value in httpx2): + raise RuntimeError(f"HTTPX2 requirements must be marked for Python >=3.10: {httpx2}") + + if not any(value.startswith("aiohttp>=3.14.1") for value in aiohttp): + raise RuntimeError(f"Expected the unchanged aiohttp requirement: {aiohttp}") + if not any(value.startswith("httpx-aiohttp>=0.1.9") for value in aiohttp): + raise RuntimeError(f"Expected the unchanged httpx-aiohttp requirement: {aiohttp}") + + +def run_case(wheel: Path, *, extra: str | None, tests: list[Path], dependencies: list[str]) -> None: + with tempfile.TemporaryDirectory(prefix="openai-httpx2-wheel-") as directory: + environment_path = Path(directory) / "venv" + subprocess.run([sys.executable, "-m", "venv", str(environment_path)], check=True) + python = environment_path / "bin/python" + requirement = str(wheel.resolve()) if extra is None else f"{wheel.resolve()}[{extra}]" + environment = os.environ.copy() + environment.setdefault("PIP_DISABLE_CLIENT_CERTIFICATE", "1") + subprocess.run( + [str(python), "-m", "pip", "install", "--quiet", requirement, *dependencies], + cwd=directory, + env=environment, + check=True, + ) + subprocess.run( + [str(python), "-m", "pytest", "-o", "addopts=", *(str(test) for test in tests)], + cwd=directory, + env=environment, + check=True, + ) + + +def assert_incompatible_pin_fails(wheel: Path) -> None: + with tempfile.TemporaryDirectory(prefix="openai-httpx2-conflict-") as directory: + environment_path = Path(directory) / "venv" + subprocess.run([sys.executable, "-m", "venv", str(environment_path)], check=True) + python = environment_path / "bin/python" + result = subprocess.run( + [ + str(python), + "-m", + "pip", + "install", + "--dry-run", + f"{wheel.resolve()}[httpx2]", + "httpx==0.25.0", + ], + cwd=directory, + env=os.environ.copy(), + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + raise RuntimeError("Expected openai[httpx2] with httpx==0.25.0 to fail dependency resolution") + output = result.stdout + result.stderr + if "ResolutionImpossible" not in output and "conflicting dependencies" not in output: + raise RuntimeError(f"The incompatible resolution failed for an unexpected reason:\n{output}") + + +def supports_httpx2() -> bool: + return sys.version_info >= (3, 10) + + +def main() -> None: + wheels = list((ROOT / "dist").glob("*.whl")) + if len(wheels) != 1: + raise RuntimeError(f"Expected exactly one wheel in dist/, found: {wheels}") + wheel = wheels[0] + validate_metadata(wheel) + + common = ["pytest==8.4.1", "pytest-asyncio==1.1.0", "respx==0.22.0"] + run_case(wheel, extra=None, tests=[BASE_TEST], dependencies=common) + run_case( + wheel, + extra=None, + tests=[BASE_TEST], + dependencies=["pytest==8.4.1", "pytest-asyncio==1.1.0", "respx==0.20.2", "httpx==0.23.0", "anyio==3.5.0"], + ) + + if not supports_httpx2(): + run_case(wheel, extra="httpx2", tests=[BASE_TEST], dependencies=common) + print("Validated base HTTPX, RESPX, supported floors, and the Python 3.9 HTTPX2 marker") + return + + run_case(wheel, extra="aiohttp", tests=[BASE_TEST], dependencies=common) + run_case(wheel, extra="httpx2", tests=[BASE_TEST, HTTPX2_TEST], dependencies=common) + run_case( + wheel, + extra="httpx2", + tests=[BASE_TEST, HTTPX2_TEST], + dependencies=[*common, "httpx==0.25.1", "anyio==4.10.0", "pydantic<2", "botocore==1.42.97"], + ) + assert_incompatible_pin_fails(wheel) + print("Validated base, aiohttp, native HTTPX2, supported floors, Pydantic modes, and resolver conflicts") + + +if __name__ == "__main__": + main() diff --git a/src/openai/__init__.py b/src/openai/__init__.py index a3f3237c38..075a03a964 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -10,6 +10,7 @@ from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given from ._utils import file_from_path from ._client import Client, OpenAI, Stream, Timeout, Transport, AsyncClient, AsyncOpenAI, AsyncStream, RequestOptions +from ._httpx2 import DefaultHttpx2Client, DefaultAsyncHttpx2Client, normalize_httpx_url as _normalize_httpx_url from ._models import BaseModel from ._version import __title__, __version__ from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse @@ -89,6 +90,8 @@ "DefaultHttpxClient", "DefaultAsyncHttpxClient", "DefaultAioHttpClient", + "DefaultHttpx2Client", + "DefaultAsyncHttpx2Client", "ReconnectingEvent", "ReconnectingOverrides", "WebSocketQueueFullError", @@ -233,7 +236,7 @@ def webhook_secret(self, value: str | None) -> None: # type: ignore @override def base_url(self) -> _httpx.URL: if base_url is not None: - return _httpx.URL(base_url) + return _normalize_httpx_url(base_url) return super().base_url diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 4933c8e2fe..3d7dda5afd 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -63,6 +63,16 @@ ) from ._utils import SensitiveHeadersFilter, is_dict, is_list, asyncify, is_given, lru_cache, is_mapping from ._compat import PYDANTIC_V1, model_copy, model_dump +from ._httpx2 import ( + status_exceptions, + timeout_exceptions, + normalize_httpx_url, + is_httpx2_sync_client, + normalize_httpx2_auth, + is_httpx2_async_client, + normalize_httpx_timeout, + normalize_httpx2_timeout, +) from ._models import GenericModel, SecurityOptions, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -385,7 +395,7 @@ def __init__( custom_query: Mapping[str, object] | None = None, ) -> None: self._version = version - self._base_url = self._enforce_trailing_slash(URL(base_url)) + self._base_url = self._enforce_trailing_slash(normalize_httpx_url(base_url)) self.max_retries = max_retries self.timeout = timeout self._custom_headers = custom_headers or {} @@ -471,7 +481,9 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 if "x-stainless-retry-count" not in lower_custom_headers: headers["x-stainless-retry-count"] = str(retries_taken) if "x-stainless-read-timeout" not in lower_custom_headers: - timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + timeout = normalize_httpx_timeout( + self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + ) if isinstance(timeout, Timeout): timeout = timeout.read if timeout is not None: @@ -589,12 +601,20 @@ def _build_request( headers.pop("Content-Type", None) kwargs.pop("data", None) + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + request_url: str | URL = prepared_url + request_headers: httpx.Headers | list[tuple[str, str]] = headers + if is_httpx2_sync_client(self._client) or is_httpx2_async_client(self._client): + request_url = str(prepared_url) + request_headers = list(headers.multi_items()) + timeout = normalize_httpx2_timeout(timeout) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] - headers=headers, - timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, + headers=request_headers, + timeout=timeout, method=options.method, - url=prepared_url, + url=request_url, # the `Query` type that we use is incompatible with qs' # `Params` type as it needs to be typed as `Mapping[str, object]` # so that passing a `TypedDict` doesn't cause an error. @@ -726,7 +746,7 @@ def base_url(self) -> URL: @base_url.setter def base_url(self, url: URL | str) -> None: - self._base_url = self._enforce_trailing_slash(url if isinstance(url, URL) else URL(url)) + self._base_url = self._enforce_trailing_slash(normalize_httpx_url(url)) def platform_headers(self) -> Dict[str, str]: # the actual implementation is in a separate `lru_cache` decorated @@ -886,14 +906,20 @@ def __init__( # where they've explicitly set the timeout to match the default timeout # as this check is structural, meaning that we'll think they didn't # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: - timeout = http_client.timeout + client_timeout = normalize_httpx_timeout(http_client.timeout) if http_client else None + if http_client and client_timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = client_timeout else: timeout = DEFAULT_TIMEOUT - if http_client is not None and not isinstance(http_client, httpx.Client): # pyright: ignore[reportUnnecessaryIsInstance] + if ( + http_client is not None + and not is_httpx2_sync_client(http_client) + and not isinstance(http_client, httpx.Client) # pyright: ignore[reportUnnecessaryIsInstance] + ): raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}" + "Invalid `http_client` argument; Expected an instance of `httpx.Client` or `httpx2.Client` " + f"but got {type(http_client)}" ) super().__init__( @@ -1025,7 +1051,9 @@ def request( kwargs: HttpxSendArgs = {} custom_auth = self._custom_auth(options.security) if custom_auth is not None: - kwargs["auth"] = custom_auth + kwargs["auth"] = ( + normalize_httpx2_auth(custom_auth) if is_httpx2_sync_client(self._client) else custom_auth + ) if options.follow_redirects is not None: kwargs["follow_redirects"] = options.follow_redirects @@ -1039,8 +1067,8 @@ def request( stream=stream or self._should_stream_response_body(request=request), **kwargs, ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) + except timeout_exceptions() as err: + log.debug("Encountered a timeout exception", exc_info=True) if remaining_retries > 0: self._sleep_for_retry( @@ -1083,8 +1111,8 @@ def request( try: response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + except status_exceptions() as err: # thrown on 4xx and 5xx status code + log.debug("Encountered an HTTP status error", exc_info=True) if remaining_retries > 0 and self._should_retry(err.response): err.response.close() @@ -1427,6 +1455,7 @@ def __init__(self, **kwargs: Any) -> None: if sys.version_info < (3, 10): + class _DefaultAioHttpClient(httpx.AsyncClient): def __init__(self, **_kwargs: Any) -> None: raise RuntimeError("The aiohttp client requires Python 3.10 or later") @@ -1503,14 +1532,20 @@ def __init__( # where they've explicitly set the timeout to match the default timeout # as this check is structural, meaning that we'll think they didn't # pass in a timeout and will ignore it - if http_client and http_client.timeout != HTTPX_DEFAULT_TIMEOUT: - timeout = http_client.timeout + client_timeout = normalize_httpx_timeout(http_client.timeout) if http_client else None + if http_client and client_timeout != HTTPX_DEFAULT_TIMEOUT: + timeout = client_timeout else: timeout = DEFAULT_TIMEOUT - if http_client is not None and not isinstance(http_client, httpx.AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + if ( + http_client is not None + and not is_httpx2_async_client(http_client) + and not isinstance(http_client, httpx.AsyncClient) # pyright: ignore[reportUnnecessaryIsInstance] + ): raise TypeError( - f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}" + "Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` or " + f"`httpx2.AsyncClient` but got {type(http_client)}" ) super().__init__( @@ -1643,7 +1678,11 @@ async def request( kwargs: HttpxSendArgs = {} if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + kwargs["auth"] = ( + normalize_httpx2_auth(self.custom_auth) + if is_httpx2_async_client(self._client) + else self.custom_auth + ) if options.follow_redirects is not None: kwargs["follow_redirects"] = options.follow_redirects @@ -1657,8 +1696,8 @@ async def request( stream=stream or self._should_stream_response_body(request=request), **kwargs, ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) + except timeout_exceptions() as err: + log.debug("Encountered a timeout exception", exc_info=True) if remaining_retries > 0: await self._sleep_for_retry( @@ -1701,8 +1740,8 @@ async def request( try: response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + except status_exceptions() as err: # thrown on 4xx and 5xx status code + log.debug("Encountered an HTTP status error", exc_info=True) if remaining_retries > 0 and self._should_retry(err.response): await err.response.aclose() diff --git a/src/openai/_client.py b/src/openai/_client.py index 66d03b23dd..aeea9907a8 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -29,6 +29,7 @@ get_async_library, ) from ._compat import cached_property +from ._httpx2 import is_httpx2_sync_client, is_httpx2_async_client from ._models import SecurityOptions, FinalRequestOptions from ._version import __version__ from ._provider import _Provider, _provider_name, _ProviderRuntime, _configure_provider @@ -201,9 +202,7 @@ def __init__( elif workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None - self._workload_identity_auth = WorkloadIdentityAuth( - workload_identity=workload_identity, - ) + self._workload_identity_auth = None else: if api_key is None: api_key = os.environ.get("OPENAI_API_KEY") @@ -272,6 +271,12 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + if workload_identity is not None: + self._workload_identity_auth = WorkloadIdentityAuth( + workload_identity=workload_identity, + _use_httpx2=is_httpx2_sync_client(self._client), + ) + self._default_stream_cls = Stream @cached_property @@ -797,9 +802,7 @@ def __init__( elif workload_identity is not None: self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER self._api_key_provider = None - self._workload_identity_auth = WorkloadIdentityAuth( - workload_identity=workload_identity, - ) + self._workload_identity_auth = None else: if api_key is None: api_key = os.environ.get("OPENAI_API_KEY") @@ -868,6 +871,12 @@ def __init__( _strict_response_validation=_strict_response_validation, ) + if workload_identity is not None: + self._workload_identity_auth = WorkloadIdentityAuth( + workload_identity=workload_identity, + _use_httpx2=is_httpx2_async_client(self._client), + ) + self._default_stream_cls = AsyncStream @cached_property diff --git a/src/openai/_httpx2.py b/src/openai/_httpx2.py new file mode 100644 index 0000000000..7dd3d8d6d9 --- /dev/null +++ b/src/openai/_httpx2.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import sys +import importlib +from typing import Any, Protocol, cast + +import httpx + +from ._constants import DEFAULT_TIMEOUT, DEFAULT_CONNECTION_LIMITS + + +class _Httpx2Module(Protocol): + Auth: type[httpx.Auth] + Client: type[httpx.Client] + AsyncClient: type[httpx.AsyncClient] + URL: type[httpx.URL] + Response: type[httpx.Response] + Timeout: type[httpx.Timeout] + Limits: type[httpx.Limits] + TimeoutException: type[httpx.TimeoutException] + HTTPStatusError: type[httpx.HTTPStatusError] + StreamConsumed: type[httpx.StreamConsumed] + RequestNotRead: type[httpx.RequestNotRead] + + +def _loaded_httpx2() -> _Httpx2Module | None: + module = sys.modules.get("httpx2") + if module is None: + return None + return cast(_Httpx2Module, module) + + +def _supports_httpx2() -> bool: + return sys.version_info >= (3, 10) + + +def _require_httpx2() -> _Httpx2Module: + if not _supports_httpx2(): + raise RuntimeError( + "HTTPX2 requires Python 3.10 or later; install the httpx2 extra on a supported interpreter: " + "pip install 'openai[httpx2]'" + ) + + try: + module = importlib.import_module("httpx2") + except ImportError: + raise RuntimeError("To use HTTPX2, install the httpx2 extra: pip install 'openai[httpx2]'") from None + + return cast(_Httpx2Module, module) + + +def is_httpx2_sync_client(value: object) -> bool: + module = _loaded_httpx2() + return module is not None and isinstance(value, module.Client) + + +def is_httpx2_async_client(value: object) -> bool: + module = _loaded_httpx2() + return module is not None and isinstance(value, module.AsyncClient) + + +def normalize_httpx_url(value: str | httpx.URL) -> httpx.URL: + module = _loaded_httpx2() + if module is not None and isinstance(value, module.URL): + return httpx.URL(str(value)) + if isinstance(value, httpx.URL): + return value + return httpx.URL(value) + + +def http_response_types() -> tuple[type[httpx.Response], ...]: + module = _loaded_httpx2() + if module is None: + return (httpx.Response,) + return (httpx.Response, module.Response) + + +def normalize_httpx_timeout(value: float | httpx.Timeout | None) -> float | httpx.Timeout | None: + module = _loaded_httpx2() + if module is not None and isinstance(value, module.Timeout): + return httpx.Timeout(**value.as_dict()) + return value + + +def normalize_httpx2_timeout(value: float | httpx.Timeout | None) -> float | httpx.Timeout | None: + if isinstance(value, httpx.Timeout): + return _require_httpx2().Timeout(**value.as_dict()) + return value + + +def normalize_httpx2_auth(value: httpx.Auth) -> httpx.Auth: + if type(value) is httpx.Auth: + return _require_httpx2().Auth() + return value + + +def timeout_exceptions() -> tuple[type[httpx.TimeoutException], ...]: + module = _loaded_httpx2() + if module is None: + return (httpx.TimeoutException,) + return (httpx.TimeoutException, module.TimeoutException) + + +def status_exceptions() -> tuple[type[httpx.HTTPStatusError], ...]: + module = _loaded_httpx2() + if module is None: + return (httpx.HTTPStatusError,) + return (httpx.HTTPStatusError, module.HTTPStatusError) + + +def stream_consumed_exceptions() -> tuple[type[httpx.StreamConsumed], ...]: + module = _loaded_httpx2() + if module is None: + return (httpx.StreamConsumed,) + return (httpx.StreamConsumed, module.StreamConsumed) + + +def request_not_read_exceptions() -> tuple[type[httpx.RequestNotRead], ...]: + module = _loaded_httpx2() + if module is None: + return (httpx.RequestNotRead,) + return (httpx.RequestNotRead, module.RequestNotRead) + + +def _set_httpx2_defaults(kwargs: dict[str, Any]) -> _Httpx2Module: + module = _require_httpx2() + timeout = kwargs.get("timeout", DEFAULT_TIMEOUT) + kwargs["timeout"] = normalize_httpx2_timeout(timeout) + + limits = kwargs.get("limits", DEFAULT_CONNECTION_LIMITS) + if isinstance(limits, httpx.Limits): + kwargs["limits"] = module.Limits( + max_connections=limits.max_connections, + max_keepalive_connections=limits.max_keepalive_connections, + keepalive_expiry=limits.keepalive_expiry, + ) + + kwargs.setdefault("follow_redirects", True) + return module + + +def DefaultHttpx2Client(**kwargs: Any) -> httpx.Client: + """Create an experimental HTTPX2 client with the SDK's recommended defaults.""" + module = _set_httpx2_defaults(kwargs) + return module.Client(**kwargs) + + +def DefaultAsyncHttpx2Client(**kwargs: Any) -> httpx.AsyncClient: + """Create an experimental async HTTPX2 client with the SDK's recommended defaults.""" + module = _set_httpx2_defaults(kwargs) + return module.AsyncClient(**kwargs) diff --git a/src/openai/_legacy_response.py b/src/openai/_legacy_response.py index 1a58c2dfc3..542bd6c660 100644 --- a/src/openai/_legacy_response.py +++ b/src/openai/_legacy_response.py @@ -25,6 +25,7 @@ from ._types import NoneType from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type +from ._httpx2 import http_response_types from ._models import BaseModel, is_basemodel, add_request_id from ._constants import RAW_RESPONSE_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type @@ -272,16 +273,17 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if origin == LegacyAPIResponse: raise RuntimeError("Unexpected state - cast_to is `APIResponse`") + response_types = http_response_types() if inspect.isclass( origin # pyright: ignore[reportUnknownArgumentType] - ) and issubclass(origin, httpx.Response): + ) and issubclass(origin, response_types): # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response # and pass that class to our request functions. We cannot change the variance to be either # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct # the response class ourselves but that is something that should be supported directly in httpx # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. - if cast_to != httpx.Response: - raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + if cast_to not in response_types: + raise ValueError("Subclasses of HTTP response classes cannot be passed to `cast_to`") return cast(R, response) if ( diff --git a/src/openai/_response.py b/src/openai/_response.py index f286d38e6c..5a790d69a2 100644 --- a/src/openai/_response.py +++ b/src/openai/_response.py @@ -26,6 +26,7 @@ from ._types import NoneType from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base +from ._httpx2 import http_response_types, stream_consumed_exceptions from ._models import BaseModel, is_basemodel, add_request_id from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type @@ -207,14 +208,15 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if origin == APIResponse: raise RuntimeError("Unexpected state - cast_to is `APIResponse`") - if inspect.isclass(origin) and issubclass(origin, httpx.Response): + response_types = http_response_types() + if inspect.isclass(origin) and issubclass(origin, response_types): # Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response # and pass that class to our request functions. We cannot change the variance to be either # covariant or contravariant as that makes our usage of ResponseT illegal. We could construct # the response class ourselves but that is something that should be supported directly in httpx # as it would be easy to incorrectly construct the Response object due to the multitude of arguments. - if cast_to != httpx.Response: - raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") + if cast_to not in response_types: + raise ValueError("Subclasses of HTTP response classes cannot be passed to `cast_to`") return cast(R, response) if ( @@ -337,7 +339,7 @@ def read(self) -> bytes: """Read and return the binary response content.""" try: return self.http_response.read() - except httpx.StreamConsumed as exc: + except stream_consumed_exceptions() as exc: # The default error raised by httpx isn't very # helpful in our case so we re-raise it with # a different error message. @@ -444,7 +446,7 @@ async def read(self) -> bytes: """Read and return the binary response content.""" try: return await self.http_response.aread() - except httpx.StreamConsumed as exc: + except stream_consumed_exceptions() as exc: # the default error raised by httpx isn't very # helpful in our case so we re-raise it with # a different error message diff --git a/src/openai/auth/_workload.py b/src/openai/auth/_workload.py index 6b13ededb2..1c1efe536d 100644 --- a/src/openai/auth/_workload.py +++ b/src/openai/auth/_workload.py @@ -8,6 +8,7 @@ import httpx +from .._httpx2 import DefaultHttpx2Client from .._exceptions import OAuthError, OpenAIError, SubjectTokenProviderError from .._utils._sync import to_thread @@ -176,9 +177,11 @@ def __init__( *, workload_identity: WorkloadIdentity, token_exchange_url: str = DEFAULT_TOKEN_EXCHANGE_URL, + _use_httpx2: bool = False, ): self.workload_identity = workload_identity self.token_exchange_url = token_exchange_url + self._use_httpx2 = _use_httpx2 self._cached_token: str | None = None self._cached_token_expires_at_monotonic: float | None = None @@ -245,7 +248,8 @@ def _fetch_token_from_exchange(self) -> dict[str, Any]: f"Unsupported token type: {token_type!r}. Supported types: {', '.join(SUBJECT_TOKEN_TYPES.keys())}" ) - with httpx.Client() as client: + exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client() + with exchange_client as client: response = client.post( self.token_exchange_url, json={ diff --git a/src/openai/lib/azure.py b/src/openai/lib/azure.py index 888b480dbb..4ebe0a98aa 100644 --- a/src/openai/lib/azure.py +++ b/src/openai/lib/azure.py @@ -12,6 +12,7 @@ from .._utils import is_given, is_mapping from .._client import OpenAI, AsyncOpenAI from .._compat import model_copy +from .._httpx2 import normalize_httpx_url from .._models import SecurityOptions, FinalRequestOptions from .._provider import _Provider from .._streaming import Stream, AsyncStream @@ -407,7 +408,7 @@ def _configure_realtime(self, model: str, extra_query: Query) -> tuple[httpx.URL auth_headers = {"Authorization": f"Bearer {token}"} if self.websocket_base_url is not None: - base_url = httpx.URL(self.websocket_base_url) + base_url = normalize_httpx_url(self.websocket_base_url) merge_raw_path = base_url.raw_path.rstrip(b"/") + b"/realtime" realtime_url = base_url.copy_with(raw_path=merge_raw_path) else: @@ -733,7 +734,7 @@ async def _configure_realtime(self, model: str, extra_query: Query) -> tuple[htt auth_headers = {"Authorization": f"Bearer {token}"} if self.websocket_base_url is not None: - base_url = httpx.URL(self.websocket_base_url) + base_url = normalize_httpx_url(self.websocket_base_url) merge_raw_path = base_url.raw_path.rstrip(b"/") + b"/realtime" realtime_url = base_url.copy_with(raw_path=merge_raw_path) else: diff --git a/src/openai/lib/streaming/_assistants.py b/src/openai/lib/streaming/_assistants.py index 6efb3ca3f1..314961230d 100644 --- a/src/openai/lib/streaming/_assistants.py +++ b/src/openai/lib/streaming/_assistants.py @@ -5,10 +5,9 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar, Callable, Iterable, Iterator, cast from typing_extensions import Awaitable, AsyncIterable, AsyncIterator, assert_never -import httpx - from ..._utils import is_dict, is_list, consume_sync_iterator, consume_async_iterator from ..._compat import model_dump +from ..._httpx2 import timeout_exceptions from ..._models import construct_type from ..._streaming import Stream, AsyncStream from ...types.beta import AssistantStreamEvent @@ -25,6 +24,10 @@ from ...types.beta.threads.runs import RunStep, ToolCall, RunStepDelta, ToolCallDelta +def _timeout_exceptions() -> tuple[type[Exception], ...]: + return (*timeout_exceptions(), asyncio.TimeoutError) + + class AssistantEventHandler: text_deltas: Iterable[str] """Iterator over just the text deltas in the stream. @@ -407,7 +410,7 @@ def __stream__(self) -> Iterator[AssistantStreamEvent]: self._emit_sse_event(event) yield event - except (httpx.TimeoutException, asyncio.TimeoutError) as exc: + except _timeout_exceptions() as exc: self.on_timeout() self.on_exception(exc) raise @@ -839,7 +842,7 @@ async def __stream__(self) -> AsyncIterator[AssistantStreamEvent]: await self._emit_sse_event(event) yield event - except (httpx.TimeoutException, asyncio.TimeoutError) as exc: + except _timeout_exceptions() as exc: await self.on_timeout() await self.on_exception(exc) raise diff --git a/src/openai/providers/bedrock.py b/src/openai/providers/bedrock.py index 5cee093bfd..ed6be81c9e 100644 --- a/src/openai/providers/bedrock.py +++ b/src/openai/providers/bedrock.py @@ -10,6 +10,7 @@ from .._types import NOT_GIVEN, NotGiven from .._utils import asyncify +from .._httpx2 import normalize_httpx_url, request_not_read_exceptions from .._models import FinalRequestOptions from .._provider import _Provider, _create_provider, _ProviderRuntime from .._exceptions import OpenAIError @@ -34,7 +35,7 @@ def _normalize_optional_string(value: str | None) -> str | None: def _normalize_base_url(base_url: str | httpx.URL) -> httpx.URL: - url = httpx.URL(base_url) + url = normalize_httpx_url(base_url) path = url.path.rstrip("/") responses_match = re.search(r"/responses(?:/.*)?$", path) if responses_match is not None: @@ -50,7 +51,7 @@ def _same_origin(left: httpx.URL, right: httpx.URL) -> bool: def _body_for_signing(request: httpx.Request) -> bytes: try: return request.content - except httpx.RequestNotRead as exc: + except request_not_read_exceptions() as exc: raise OpenAIError( "Bedrock SigV4 authentication requires a replayable request body. " "Buffer the body before sending or use bearer authentication." diff --git a/src/openai/resources/beta/realtime/realtime.py b/src/openai/resources/beta/realtime/realtime.py index 4fa35963b6..98b717a184 100644 --- a/src/openai/resources/beta/realtime/realtime.py +++ b/src/openai/resources/beta/realtime/realtime.py @@ -28,6 +28,7 @@ is_async_azure_client, ) from ...._compat import cached_property +from ...._httpx2 import normalize_httpx_url from ...._models import construct_type_unchecked from ...._resource import SyncAPIResource, AsyncAPIResource from ...._exceptions import OpenAIError @@ -395,7 +396,7 @@ async def __aenter__(self) -> AsyncRealtimeConnection: def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: base_url = self.__client._base_url.copy_with(scheme="wss") @@ -578,7 +579,7 @@ def __enter__(self) -> RealtimeConnection: def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: base_url = self.__client._base_url.copy_with(scheme="wss") diff --git a/src/openai/resources/beta/responses/responses.py b/src/openai/resources/beta/responses/responses.py index 5ac018f744..4ec4723d20 100644 --- a/src/openai/resources/beta/responses/responses.py +++ b/src/openai/resources/beta/responses/responses.py @@ -17,6 +17,7 @@ from ...._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given from ...._utils import is_given, path_template, maybe_transform, strip_not_given, async_maybe_transform from ...._compat import cached_property +from ...._httpx2 import normalize_httpx_url from ...._models import construct_type_unchecked from .input_items import ( InputItems, @@ -4395,7 +4396,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" @@ -4840,7 +4841,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index e4c5bd8163..906884063c 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -30,6 +30,7 @@ is_async_azure_client, ) from ..._compat import cached_property +from ..._httpx2 import normalize_httpx_url from ..._models import construct_type_unchecked from ..._resource import SyncAPIResource, AsyncAPIResource from ..._exceptions import OpenAIError, WebSocketConnectionClosedError @@ -721,7 +722,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" @@ -1189,7 +1190,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 8131b57ca2..4fcf33a257 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -32,6 +32,7 @@ from ..._types import NOT_GIVEN, Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given from ..._utils import is_given, path_template, maybe_transform, strip_not_given, async_maybe_transform from ..._compat import cached_property +from ..._httpx2 import normalize_httpx_url from ..._models import construct_type_unchecked from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper @@ -4346,7 +4347,7 @@ async def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> Async def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" @@ -4791,7 +4792,7 @@ def _connect_ws(self, extra_query: Query, extra_headers: Headers) -> WebSocketCo def _prepare_url(self) -> httpx.URL: if self.__client.websocket_base_url is not None: - base_url = httpx.URL(self.__client.websocket_base_url) + base_url = normalize_httpx_url(self.__client.websocket_base_url) else: scheme = self.__client._base_url.scheme ws_scheme = "ws" if scheme == "http" else "wss" diff --git a/tests/_httpx2_respx.py b/tests/_httpx2_respx.py new file mode 100644 index 0000000000..3b66323daf --- /dev/null +++ b/tests/_httpx2_respx.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import os +from typing import Any, NoReturn + +import httpx +import pytest +from respx import MockRouter + +from openai import DefaultHttpx2Client, DefaultAsyncHttpx2Client + + +def httpx2_enabled() -> bool: + return os.environ.get("OPENAI_TEST_HTTP_CLIENT") == "httpx2" + + +def sync_http_client(**kwargs: Any) -> httpx.Client: + if httpx2_enabled(): + return DefaultHttpx2Client(**kwargs) + return httpx.Client(**kwargs) + + +def async_http_client(**kwargs: Any) -> httpx.AsyncClient: + if httpx2_enabled(): + return DefaultAsyncHttpx2Client(**kwargs) + return httpx.AsyncClient(**kwargs) + + +def enable_httpx2_respx( + router: MockRouter, monkeypatch: pytest.MonkeyPatch, *, replace_sdk_defaults: bool = True +) -> None: + httpx2 = pytest.importorskip("httpx2") + + # RESPX deliberately only understands HTTPX objects. Keep the SDK side native and + # translate at this single test-only boundary so existing routes, callbacks, and + # call assertions can be exercised against HTTPX2 without copied test cases. + def httpx_request(native_request: Any, *, content: bytes) -> httpx.Request: + return httpx.Request( + native_request.method, + str(native_request.url), + headers=list(native_request.headers.multi_items()), + content=content, + extensions=dict(native_request.extensions), + ) + + def httpx2_response(response: httpx.Response, *, native_request: Any, content: bytes) -> Any: + return httpx2.Response( + response.status_code, + headers=list(response.headers.multi_items()), + # Supplying a stream keeps streaming-response tests meaningful: the native + # response stays open until the SDK consumes or closes it. + stream=httpx2.ByteStream(content), + request=native_request, + extensions=dict(response.extensions), + ) + + def raise_native_request_error(exc: httpx.RequestError, native_request: Any) -> NoReturn: + native_error = getattr(httpx2, type(exc).__name__, httpx2.RequestError) + raise native_error(str(exc), request=native_request) from exc + + def handler(native_request: Any) -> Any: + try: + response = router.handler(httpx_request(native_request, content=native_request.read())) + except httpx.RequestError as exc: + raise_native_request_error(exc, native_request) + return httpx2_response(response, native_request=native_request, content=response.read()) + + async def async_handler(native_request: Any) -> Any: + try: + response = await router.async_handler(httpx_request(native_request, content=await native_request.aread())) + except httpx.RequestError as exc: + raise_native_request_error(exc, native_request) + return httpx2_response(response, native_request=native_request, content=await response.aread()) + + # Match RESPX's own patch point. This also catches clients created inside a test, + # while leaving every non-RESPX test on its ordinary transport. + def sync_transport(*_args: Any) -> Any: + return httpx2.MockTransport(handler) + + def async_transport(*_args: Any) -> Any: + return httpx2.MockTransport(async_handler) + + monkeypatch.setattr(httpx2.Client, "_transport_for_url", sync_transport) + monkeypatch.setattr(httpx2.AsyncClient, "_transport_for_url", async_transport) + + if replace_sdk_defaults: + import openai._base_client as base_client + + # Clients constructed inside a RESPX test should exercise the same native + # path as the shared fixtures; the base-compatibility tests opt out. + monkeypatch.setattr(base_client, "SyncHttpxClientWrapper", DefaultHttpx2Client) + monkeypatch.setattr(base_client, "AsyncHttpxClientWrapper", DefaultAsyncHttpx2Client) diff --git a/tests/conftest.py b/tests/conftest.py index 74aebd8a25..8128f1515b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,11 @@ import pytest from pytest_asyncio import is_async_test -from openai import OpenAI, AsyncOpenAI, DefaultAioHttpClient +from openai import OpenAI, AsyncOpenAI, DefaultHttpx2Client, DefaultAioHttpClient, DefaultAsyncHttpx2Client from openai._utils import is_dict +from ._httpx2_respx import enable_httpx2_respx + if TYPE_CHECKING: from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] @@ -30,10 +32,12 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: for async_test in pytest_asyncio_tests: async_test.add_marker(session_scope_marker, append=False) - # We skip tests that use both the aiohttp client and respx_mock as respx_mock - # doesn't support custom transports. + # RESPX cannot mock requests made by the aiohttp adapter. for item in items: - if "async_client" not in item.fixturenames or "respx_mock" not in item.fixturenames: + if "respx_mock" not in item.fixturenames: + continue + + if "async_client" not in item.fixturenames: continue if not hasattr(item, "callspec"): @@ -45,19 +49,35 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +test_http_client = os.environ.get("OPENAI_TEST_HTTP_CLIENT", "httpx") api_key = "My API Key" admin_api_key = "My Admin API Key" +@pytest.fixture(autouse=True) +def patch_httpx2_respx(request: FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + if test_http_client != "httpx2" or "respx_mock" not in request.fixturenames: + return + + router = request.getfixturevalue("respx_mock") + enable_httpx2_respx(router, monkeypatch, replace_sdk_defaults=request.path.name != "test_httpx2_base.py") + + @pytest.fixture(scope="session") def client(request: FixtureRequest) -> Iterator[OpenAI]: strict = getattr(request, "param", True) if not isinstance(strict, bool): raise TypeError(f"Unexpected fixture parameter type {type(strict)}, expected {bool}") + http_client = DefaultHttpx2Client() if test_http_client == "httpx2" else None + with OpenAI( - base_url=base_url, api_key=api_key, admin_api_key=admin_api_key, _strict_response_validation=strict + base_url=base_url, + api_key=api_key, + admin_api_key=admin_api_key, + _strict_response_validation=strict, + http_client=http_client, ) as client: yield client @@ -85,6 +105,9 @@ async def async_client(request: FixtureRequest) -> AsyncIterator[AsyncOpenAI]: else: raise TypeError(f"Unexpected fixture parameter type {type(param)}, expected bool or dict") + if http_client is None and test_http_client == "httpx2": + http_client = DefaultAsyncHttpx2Client() + async with AsyncOpenAI( base_url=base_url, api_key=api_key, diff --git a/tests/lib/test_bedrock.py b/tests/lib/test_bedrock.py index 14c91c0661..bf987b2fa9 100644 --- a/tests/lib/test_bedrock.py +++ b/tests/lib/test_bedrock.py @@ -14,6 +14,7 @@ from tests.utils import update_env from openai._types import Omit from openai.lib.bedrock import BedrockOpenAI, AsyncBedrockOpenAI +from tests._httpx2_respx import sync_http_client, async_http_client Client = Union[BedrockOpenAI, AsyncBedrockOpenAI] @@ -86,11 +87,11 @@ def __init__(self, access_key: str, secret_key: str, token: str | None = None) - def make_sync_client(**kwargs: Any) -> BedrockOpenAI: - return BedrockOpenAI(http_client=httpx.Client(trust_env=False), **kwargs) + return BedrockOpenAI(http_client=sync_http_client(trust_env=False), **kwargs) def make_async_client(**kwargs: Any) -> AsyncBedrockOpenAI: - return AsyncBedrockOpenAI(http_client=httpx.AsyncClient(trust_env=False), **kwargs) + return AsyncBedrockOpenAI(http_client=async_http_client(trust_env=False), **kwargs) def response_created_sse() -> str: @@ -333,7 +334,7 @@ def test_token_provider_refresh_sync(respx_mock: MockRouter) -> None: client = BedrockOpenAI( base_url="https://example.com/openai/v1", bedrock_token_provider=lambda: next(tokens), - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), max_retries=1, ) @@ -357,7 +358,7 @@ async def test_token_provider_refresh_async(respx_mock: MockRouter) -> None: client = AsyncBedrockOpenAI( base_url="https://example.com/openai/v1", bedrock_token_provider=lambda: next(tokens), - http_client=httpx.AsyncClient(trust_env=False), + http_client=async_http_client(trust_env=False), max_retries=1, ) @@ -380,7 +381,7 @@ def test_explicit_aws_credentials_override_ambient_bearer(respx_mock: MockRouter aws_access_key_id="access key", aws_secret_access_key="secret key", aws_session_token="session token", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) client.responses.create(model="gpt-4o", input="hello") @@ -408,7 +409,7 @@ def test_aws_credentials_provider_refreshes_before_retries(respx_mock: MockRoute base_url="https://example.com/openai/v1", aws_region="us-east-1", aws_credentials_provider=lambda: next(credentials), - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), max_retries=1, ) @@ -425,7 +426,7 @@ def test_preserves_token_provider_across_with_options() -> None: client = BedrockOpenAI( base_url="https://example.com/openai/v1", bedrock_token_provider=lambda: "provider token", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) copied_client = client.with_options(timeout=1) @@ -527,7 +528,7 @@ def test_explicit_aws_copy_override_wins_over_mutated_api_key() -> None: client = BedrockOpenAI( base_url="https://example.com/openai/v1", api_key="first token", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) client.api_key = "second token" @@ -569,7 +570,7 @@ def test_legacy_state_repr_does_not_expose_credentials() -> None: aws_access_key_id="secret access key id", aws_secret_access_key="secret access key", aws_session_token="secret session token", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) assert "secret" not in repr(client._bedrock_state) @@ -577,7 +578,7 @@ def test_legacy_state_repr_does_not_expose_credentials() -> None: bearer_client = BedrockOpenAI( base_url="https://example.com/openai/v1", api_key="secret bearer token", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) assert "secret bearer token" not in repr(bearer_client._bedrock_runtime_signature) @@ -647,7 +648,7 @@ def test_preserves_aws_credentials_across_with_options() -> None: aws_region="us-east-1", aws_access_key_id="access key", aws_secret_access_key="secret key", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) copied_client = client.with_options(timeout=1) @@ -941,7 +942,7 @@ def __init__( client = LegacyBedrockOpenAI( api_key="token", aws_region="us-east-1", - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), ) copied_client = client.with_options(timeout=1).with_options(aws_region="us-west-2") @@ -1121,7 +1122,7 @@ def test_refreshes_token_provider_for_admin_security_routes(respx_mock: MockRout client = BedrockOpenAI( base_url="https://example.com/openai/v1", bedrock_token_provider=lambda: next(tokens), - http_client=httpx.Client(trust_env=False), + http_client=sync_http_client(trust_env=False), max_retries=1, ) diff --git a/tests/test_client.py b/tests/test_client.py index 2d8955a58e..bdbc2ce26b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -117,10 +117,11 @@ async def _make_async_iterator(iterable: Iterable[T], counter: Optional[Counter] def _get_open_connections(client: OpenAI | AsyncOpenAI) -> int: transport = client._client._transport - assert isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport) + if isinstance(transport, httpx.HTTPTransport) or isinstance(transport, httpx.AsyncHTTPTransport): + return len(transport._pool._requests) - pool = transport._pool - return len(pool._requests) + assert type(transport).__module__ == "httpx2" + return len(cast(Any, transport)._pool._requests) class TestOpenAI: @@ -130,7 +131,7 @@ def test_raw_response(self, respx_mock: MockRouter, client: OpenAI) -> None: response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 - assert isinstance(response, httpx.Response) + assert type(response).__module__ == os.environ.get("OPENAI_TEST_HTTP_CLIENT", "httpx") assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) @@ -141,7 +142,7 @@ def test_raw_response_for_binary(self, respx_mock: MockRouter, client: OpenAI) - response = client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 - assert isinstance(response, httpx.Response) + assert type(response).__module__ == os.environ.get("OPENAI_TEST_HTTP_CLIENT", "httpx") assert response.json() == {"foo": "bar"} def test_copy(self, client: OpenAI) -> None: @@ -615,7 +616,7 @@ def test_default_query_option(self) -> None: def test_hardcoded_query_params_in_url(self, client: OpenAI) -> None: request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) - url = httpx.URL(request.url) + url = httpx.URL(str(request.url)) assert dict(url.params) == {"beta": "true"} request = client._build_request( @@ -625,7 +626,7 @@ def test_hardcoded_query_params_in_url(self, client: OpenAI) -> None: params={"limit": "10", "page": "abc"}, ) ) - url = httpx.URL(request.url) + url = httpx.URL(str(request.url)) assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} request = client._build_request( @@ -1393,7 +1394,7 @@ async def test_raw_response(self, respx_mock: MockRouter, async_client: AsyncOpe response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 - assert isinstance(response, httpx.Response) + assert type(response).__module__ == os.environ.get("OPENAI_TEST_HTTP_CLIENT", "httpx") assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) @@ -1404,7 +1405,7 @@ async def test_raw_response_for_binary(self, respx_mock: MockRouter, async_clien response = await async_client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 - assert isinstance(response, httpx.Response) + assert type(response).__module__ == os.environ.get("OPENAI_TEST_HTTP_CLIENT", "httpx") assert response.json() == {"foo": "bar"} def test_copy(self, async_client: AsyncOpenAI) -> None: @@ -1866,7 +1867,7 @@ async def test_default_query_option(self) -> None: async def test_hardcoded_query_params_in_url(self, async_client: AsyncOpenAI) -> None: request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) - url = httpx.URL(request.url) + url = httpx.URL(str(request.url)) assert dict(url.params) == {"beta": "true"} request = async_client._build_request( @@ -1876,7 +1877,7 @@ async def test_hardcoded_query_params_in_url(self, async_client: AsyncOpenAI) -> params={"limit": "10", "page": "abc"}, ) ) - url = httpx.URL(request.url) + url = httpx.URL(str(request.url)) assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} request = async_client._build_request( diff --git a/tests/test_httpx2.py b/tests/test_httpx2.py new file mode 100644 index 0000000000..3afd2d68d5 --- /dev/null +++ b/tests/test_httpx2.py @@ -0,0 +1,719 @@ +from __future__ import annotations + +import base64 +from typing import Any +from typing_extensions import override + +import httpx +import pytest + +import openai +from openai import ( + OpenAI, + AsyncOpenAI, + AzureOpenAI, + OpenAIError, + APIStatusError, + APITimeoutError, + AsyncAzureOpenAI, + APIConnectionError, +) +from openai._response import StreamAlreadyConsumed +from openai.providers import bedrock +from openai._constants import DEFAULT_TIMEOUT + +httpx2 = pytest.importorskip("httpx2") + + +@pytest.fixture(autouse=True) +def forbid_httpx_execution(monkeypatch: pytest.MonkeyPatch) -> None: + def forbidden(*_args: object, **_kwargs: object) -> None: + raise AssertionError("the experimental path unexpectedly executed an HTTPX client") + + monkeypatch.setattr(httpx.Client, "build_request", forbidden) + monkeypatch.setattr(httpx.Client, "send", forbidden) + monkeypatch.setattr(httpx.AsyncClient, "build_request", forbidden) + monkeypatch.setattr(httpx.AsyncClient, "send", forbidden) + + +def model_list(request: httpx.Request) -> httpx.Response: + return httpx2.Response(200, json={"object": "list", "data": []}, request=request) + + +def sse_response(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=( + b'data: {"type":"response.completed","response":{"id":"resp_test","object":"response",' + b'"created_at":0,"model":"gpt-4o","output":[],"parallel_tool_calls":false,' + b'"tool_choice":"auto","tools":[]}}\n\ndata: [DONE]\n\n' + ), + request=request, + ) + + +async def test_httpx2_helpers_supply_sdk_defaults_and_accept_native_proxy() -> None: + sync_client = openai.DefaultHttpx2Client(proxy="http://127.0.0.1:8080", trust_env=False) + async_client = openai.DefaultAsyncHttpx2Client(proxy="http://127.0.0.1:8080", trust_env=False) + try: + assert type(sync_client).__module__ == "httpx2" + assert type(async_client).__module__ == "httpx2" + assert sync_client.timeout.as_dict() == {"connect": 5.0, "read": 600, "write": 600, "pool": 600} + assert async_client.timeout.as_dict() == {"connect": 5.0, "read": 600, "write": 600, "pool": 600} + assert sync_client.follow_redirects + assert async_client.follow_redirects + finally: + sync_client.close() + await async_client.aclose() + + +def test_sync_helper_preserves_httpx2_family_for_parsed_raw_and_sse() -> None: + requests: list[httpx.Request] = [] + hooks: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return sse_response(request) if request.url.path.endswith("/responses") else model_list(request) + + def on_request(request: httpx.Request) -> None: + hooks.append(type(request).__module__) + + with OpenAI( + api_key="test", + base_url=httpx2.URL("https://example.test/v1"), + http_client=openai.DefaultHttpx2Client( + timeout=httpx.Timeout(30.0, read=10.0), + auth=httpx2.BasicAuth("fake-test-user", "fake-test-password"), + headers=[("x-repeated", "one"), ("x-repeated", "two")], + mounts={"https://example.test": httpx2.MockTransport(handler)}, + event_hooks={"request": [on_request]}, + trust_env=False, + ), + max_retries=0, + ) as client: + parsed = client.models.list(extra_query={"tag": ["one", "two"]}) + raw = client.models.with_raw_response.list() + stream = client.responses.create(model="gpt-4o", input="hello", stream=True) + events = list(stream) + multipart = client.post( + "/multipart", + files={"file": ("example.txt", b"body", "text/plain")}, + options={"headers": {"Content-Type": "multipart/form-data"}}, + cast_to=httpx.Response, + ) + + assert parsed.object == "list" + assert type(raw.http_response).__module__ == "httpx2" + assert type(raw.http_request).__module__ == "httpx2" + assert type(stream.response).__module__ == "httpx2" + assert [event.type for event in events] == ["response.completed"] + assert type(multipart).__module__ == "httpx2" + assert hooks == ["httpx2", "httpx2", "httpx2", "httpx2"] + assert all(type(request).__module__ == "httpx2" for request in requests) + assert ( + requests[0].headers["authorization"] + == f"Basic {base64.b64encode(b'fake-test-user:fake-test-password').decode()}" + ) + assert requests[0].headers.get_list("x-repeated") == ["one", "two"] + assert requests[0].url.params.get_list("tag[]") == ["one", "two"] + assert requests[0].extensions["timeout"] == {"connect": 30.0, "read": 10.0, "write": 30.0, "pool": 30.0} + assert requests[-1].headers["content-type"].startswith("multipart/form-data; boundary=") + + +async def test_async_helper_preserves_httpx2_family_for_parsed_raw_and_sse() -> None: + requests: list[httpx.Request] = [] + hooks: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return sse_response(request) if request.url.path.endswith("/responses") else model_list(request) + + async def on_request(request: httpx.Request) -> None: + hooks.append(type(request).__module__) + + async with AsyncOpenAI( + api_key="test", + base_url=httpx2.URL("https://example.test/v1"), + http_client=openai.DefaultAsyncHttpx2Client( + timeout=httpx.Timeout(30.0, read=10.0), + auth=httpx2.BasicAuth("fake-test-user", "fake-test-password"), + headers=[("x-repeated", "one"), ("x-repeated", "two")], + transport=httpx2.MockTransport(handler), + event_hooks={"request": [on_request]}, + trust_env=False, + ), + max_retries=0, + ) as client: + parsed = await client.models.list(extra_query={"tag": ["one", "two"]}) + raw = await client.models.with_raw_response.list() + stream = await client.responses.create(model="gpt-4o", input="hello", stream=True) + events = [event async for event in stream] + multipart = await client.post( + "/multipart", + files={"file": ("example.txt", b"body", "text/plain")}, + options={"headers": {"Content-Type": "multipart/form-data"}}, + cast_to=httpx.Response, + ) + + assert parsed.object == "list" + assert type(raw.http_response).__module__ == "httpx2" + assert type(raw.http_request).__module__ == "httpx2" + assert type(stream.response).__module__ == "httpx2" + assert [event.type for event in events] == ["response.completed"] + assert type(multipart).__module__ == "httpx2" + assert hooks == ["httpx2", "httpx2", "httpx2", "httpx2"] + assert all(type(request).__module__ == "httpx2" for request in requests) + assert ( + requests[0].headers["authorization"] + == f"Basic {base64.b64encode(b'fake-test-user:fake-test-password').decode()}" + ) + assert requests[0].headers.get_list("x-repeated") == ["one", "two"] + assert requests[0].url.params.get_list("tag[]") == ["one", "two"] + assert requests[0].extensions["timeout"] == {"connect": 30.0, "read": 10.0, "write": 30.0, "pool": 30.0} + assert requests[-1].headers["content-type"].startswith("multipart/form-data; boundary=") + + +def test_direct_sync_injection_and_module_configuration() -> None: + direct = httpx2.Client(transport=httpx2.MockTransport(model_list), trust_env=False) + with OpenAI(api_key="test", base_url="https://example.test/v1", http_client=direct, max_retries=0) as client: + assert client.timeout == DEFAULT_TIMEOUT + assert client.models.list().object == "list" + + openai.api_key = "test" + openai.base_url = httpx2.URL("https://example.test/v1") + openai.http_client = openai.DefaultHttpx2Client(transport=httpx2.MockTransport(model_list), trust_env=False) + try: + response = openai.models.with_raw_response.list() + finally: + openai._reset_client() + openai.http_client = None + + assert type(response.http_response).__module__ == "httpx2" + + +async def test_httpx2_urls_and_response_casts() -> None: + class ResponseSubclass(httpx2.Response): + pass + + def model(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, + request=request, + json={"id": "gpt-4o", "object": "model", "created": 1, "owned_by": "openai"}, + ) + + with OpenAI( + api_key="test", + base_url=httpx2.URL("https://example.test/v1"), + http_client=httpx2.Client(transport=httpx2.MockTransport(model), trust_env=False), + max_retries=0, + ) as client: + client.base_url = httpx2.URL("https://example.test/v1") + response = client.get("/models", cast_to=httpx2.Response) + legacy_raw = client.models.with_raw_response.retrieve("gpt-4o") + with client.models.with_streaming_response.retrieve("gpt-4o") as streaming_raw: + streaming_response = streaming_raw.parse(to=httpx2.Response) + + with pytest.raises(ValueError, match="Subclasses of HTTP response classes"): + client.get("/models", cast_to=ResponseSubclass) + + async def handler(request: httpx.Request) -> httpx.Response: + return model(request) + + async with AsyncOpenAI( + api_key="test", + base_url=httpx2.URL("https://example.test/v1"), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False), + max_retries=0, + ) as async_client: + async_client.base_url = httpx2.URL("https://example.test/v1") + async_response = await async_client.get("/models", cast_to=httpx2.Response) + async_legacy_raw = await async_client.models.with_raw_response.retrieve("gpt-4o") + async with async_client.models.with_streaming_response.retrieve("gpt-4o") as async_streaming_raw: + async_streaming_response = await async_streaming_raw.parse(to=httpx2.Response) + + with pytest.raises(ValueError, match="Subclasses of HTTP response classes"): + await async_client.get("/models", cast_to=ResponseSubclass) + + assert isinstance(response, httpx2.Response) + assert isinstance(legacy_raw.parse(to=httpx2.Response), httpx2.Response) + assert isinstance(streaming_response, httpx2.Response) + assert isinstance(async_response, httpx2.Response) + assert isinstance(async_legacy_raw.parse(to=httpx2.Response), httpx2.Response) + assert isinstance(async_streaming_response, httpx2.Response) + + +async def test_httpx2_urls_work_for_bedrock_and_azure_realtime() -> None: + with OpenAI( + provider=bedrock( + region="us-east-1", + api_key="bedrock-token", + base_url=httpx2.URL("https://bedrock.test/openai/v1/responses"), + ), + http_client=httpx2.Client(transport=httpx2.MockTransport(model_list), trust_env=False), + max_retries=0, + ) as client: + assert client.models.list().object == "list" + + azure = AzureOpenAI( + api_key="test", + api_version="2024-02-01", + azure_endpoint="https://azure.test", + websocket_base_url=httpx2.URL("https://azure.test/openai/v1"), + http_client=httpx2.Client(transport=httpx2.MockTransport(model_list), trust_env=False), + ) + realtime_url, _ = azure._configure_realtime("gpt-4o", {}) + azure.close() + + async_azure = AsyncAzureOpenAI( + api_key="test", + api_version="2024-02-01", + azure_endpoint="https://azure.test", + websocket_base_url=httpx2.URL("https://azure.test/openai/v1"), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(model_list), trust_env=False), + ) + async_realtime_url, _ = await async_azure._configure_realtime("gpt-4o", {}) + await async_azure.close() + + assert str(realtime_url).startswith("https://azure.test/openai/v1/realtime?") + assert str(async_realtime_url).startswith("https://azure.test/openai/v1/realtime?") + + +async def test_httpx2_urls_work_for_all_websocket_builders() -> None: + with OpenAI( + api_key="test", + websocket_base_url=httpx2.URL("wss://example.test/openai/v1"), + http_client=httpx2.Client(transport=httpx2.MockTransport(model_list), trust_env=False), + ) as client: + assert str(client.realtime.connect()._prepare_url()) == "wss://example.test/openai/v1/realtime" + assert str(client.responses.connect()._prepare_url()) == "wss://example.test/openai/v1/responses" + assert ( + str(client.beta.realtime.connect(model="gpt-4o")._prepare_url()) == "wss://example.test/openai/v1/realtime" + ) + assert str(client.beta.responses.connect()._prepare_url()) == "wss://example.test/openai/v1/responses" + + async with AsyncOpenAI( + api_key="test", + websocket_base_url=httpx2.URL("wss://example.test/openai/v1"), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(model_list), trust_env=False), + ) as async_client: + assert str(async_client.realtime.connect()._prepare_url()) == "wss://example.test/openai/v1/realtime" + assert str(async_client.responses.connect()._prepare_url()) == "wss://example.test/openai/v1/responses" + assert ( + str(async_client.beta.realtime.connect(model="gpt-4o")._prepare_url()) + == "wss://example.test/openai/v1/realtime" + ) + assert str(async_client.beta.responses.connect()._prepare_url()) == "wss://example.test/openai/v1/responses" + + +async def test_httpx2_native_timeouts_set_numeric_read_timeout_header() -> None: + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + + def sync_handler(request: httpx.Request) -> httpx.Response: + sync_requests.append(request) + return model_list(request) + + async def async_handler(request: httpx.Request) -> httpx.Response: + async_requests.append(request) + return model_list(request) + + with OpenAI( + api_key="test", + base_url="https://example.test/v1", + timeout=httpx2.Timeout(30.0, read=12.0), + http_client=httpx2.Client(transport=httpx2.MockTransport(sync_handler), trust_env=False), + max_retries=0, + ) as client: + client.models.list() + client.models.list(timeout=httpx2.Timeout(30.0, read=7.0)) + + async with AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + timeout=httpx2.Timeout(30.0, read=13.0), + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(async_handler), trust_env=False), + max_retries=0, + ) as async_client: + await async_client.models.list() + await async_client.models.list(timeout=httpx2.Timeout(30.0, read=8.0)) + + assert [request.headers["x-stainless-read-timeout"] for request in sync_requests] == ["12.0", "7.0"] + assert [request.headers["x-stainless-read-timeout"] for request in async_requests] == ["13.0", "8.0"] + + +async def test_direct_async_injection() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return model_list(request) + + direct = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False) + async with AsyncOpenAI( + api_key="test", base_url="https://example.test/v1", http_client=direct, max_retries=0 + ) as client: + assert client.timeout == DEFAULT_TIMEOUT + assert (await client.models.list()).object == "list" + + +@pytest.mark.parametrize("failure", ["timeout", "connection", "status"]) +def test_sync_retries_and_failure_families(failure: str, monkeypatch: pytest.MonkeyPatch) -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) > 1: + return model_list(request) + if failure == "timeout": + raise httpx2.ReadTimeout("timeout", request=request) + if failure == "connection": + raise httpx2.ConnectError("connection", request=request) + return httpx2.Response(500, json={"error": {"message": "bad", "type": "test"}}, request=request) + + client = OpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(handler), trust_env=False), + max_retries=1, + ) + + def no_sleep(**_kwargs: Any) -> None: + return None + + monkeypatch.setattr(client, "_sleep_for_retry", no_sleep) + try: + assert client.models.list().object == "list" + finally: + client.close() + + assert len(requests) == 2 + assert all(type(request).__module__ == "httpx2" for request in requests) + + def always_fail(request: httpx.Request) -> httpx.Response: + if failure == "timeout": + raise httpx2.ReadTimeout("timeout", request=request) + if failure == "connection": + raise httpx2.ConnectError("connection", request=request) + return httpx2.Response(500, json={"error": {"message": "bad", "type": "test"}}, request=request) + + expected: type[Exception] + if failure == "timeout": + expected = APITimeoutError + elif failure == "connection": + expected = APIConnectionError + else: + expected = APIStatusError + + with OpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(always_fail), trust_env=False), + max_retries=0, + ) as failing_client: + with pytest.raises(expected) as exc_info: + failing_client.models.list() + + error = exc_info.value + transport_value = getattr(error, "response", None) or getattr(error, "request", None) + assert type(transport_value).__module__ == "httpx2" + + +@pytest.mark.parametrize("failure", ["timeout", "connection", "status"]) +async def test_async_retries_and_failure_families(failure: str, monkeypatch: pytest.MonkeyPatch) -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) > 1: + return model_list(request) + if failure == "timeout": + raise httpx2.ReadTimeout("timeout", request=request) + if failure == "connection": + raise httpx2.ConnectError("connection", request=request) + return httpx2.Response(500, json={"error": {"message": "bad", "type": "test"}}, request=request) + + client = AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler), trust_env=False), + max_retries=1, + ) + + async def no_sleep(**_kwargs: Any) -> None: + return None + + monkeypatch.setattr(client, "_sleep_for_retry", no_sleep) + try: + assert (await client.models.list()).object == "list" + finally: + await client.close() + + assert len(requests) == 2 + assert all(type(request).__module__ == "httpx2" for request in requests) + + async def always_fail(request: httpx.Request) -> httpx.Response: + if failure == "timeout": + raise httpx2.ReadTimeout("timeout", request=request) + if failure == "connection": + raise httpx2.ConnectError("connection", request=request) + return httpx2.Response(500, json={"error": {"message": "bad", "type": "test"}}, request=request) + + expected: type[Exception] + if failure == "timeout": + expected = APITimeoutError + elif failure == "connection": + expected = APIConnectionError + else: + expected = APIStatusError + + async with AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(always_fail), trust_env=False), + max_retries=0, + ) as failing_client: + with pytest.raises(expected) as exc_info: + await failing_client.models.list() + + error = exc_info.value + transport_value = getattr(error, "response", None) or getattr(error, "request", None) + assert type(transport_value).__module__ == "httpx2" + + +async def test_provider_auth_and_stream_consumed_families() -> None: + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + + def sync_handler(request: httpx.Request) -> httpx.Response: + sync_requests.append(request) + return model_list(request) + + async def async_handler(request: httpx.Request) -> httpx.Response: + async_requests.append(request) + return model_list(request) + + with OpenAI( + provider=bedrock(region="us-east-1", api_key="bedrock-token", base_url="https://bedrock.test/openai/v1"), + http_client=openai.DefaultHttpx2Client(transport=httpx2.MockTransport(sync_handler), trust_env=False), + max_retries=0, + ) as sync_client: + assert sync_client.models.list().object == "list" + + async with AsyncOpenAI( + provider=bedrock(region="us-east-1", api_key="bedrock-token", base_url="https://bedrock.test/openai/v1"), + http_client=openai.DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(async_handler), trust_env=False), + max_retries=0, + ) as async_client: + assert (await async_client.models.list()).object == "list" + + assert sync_requests[0].headers["authorization"] == "Bearer bedrock-token" + assert async_requests[0].headers["authorization"] == "Bearer bedrock-token" + + class SyncStream(httpx2.SyncByteStream): + def __iter__(self): + yield b'{"object":"list","data":[]}' + + class AsyncStream(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b'{"object":"list","data":[]}' + + class FailingSyncStream(httpx2.SyncByteStream): + def __iter__(self): + yield b"partial" + raise httpx2.ReadTimeout("stream timeout") + + class FailingAsyncStream(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b"partial" + raise httpx2.ReadTimeout("stream timeout") + + def sync_stream_handler(request: httpx.Request) -> httpx.Response: + return httpx2.Response(200, headers={"content-type": "application/json"}, stream=SyncStream(), request=request) + + async def async_stream_handler(request: httpx.Request) -> httpx.Response: + return httpx2.Response(200, headers={"content-type": "application/json"}, stream=AsyncStream(), request=request) + + def sync_failing_stream_handler(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, headers={"content-type": "application/json"}, stream=FailingSyncStream(), request=request + ) + + async def async_failing_stream_handler(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, headers={"content-type": "application/json"}, stream=FailingAsyncStream(), request=request + ) + + with OpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(sync_stream_handler), trust_env=False), + max_retries=0, + ) as sync_client: + with sync_client.models.with_streaming_response.list() as response: + assert b"".join(response.iter_bytes()) == b'{"object":"list","data":[]}' + with pytest.raises(StreamAlreadyConsumed): + response.read() + + with OpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(sync_failing_stream_handler), trust_env=False), + max_retries=0, + ) as sync_client: + with sync_client.models.with_streaming_response.list() as response: + with pytest.raises(httpx2.ReadTimeout, match="stream timeout"): + list(response.iter_bytes()) + + async with AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(async_stream_handler), trust_env=False), + max_retries=0, + ) as async_client: + async with async_client.models.with_streaming_response.list() as response: + assert b"".join([chunk async for chunk in response.iter_bytes()]) == b'{"object":"list","data":[]}' + with pytest.raises(StreamAlreadyConsumed): + await response.read() + + async with AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(async_failing_stream_handler), trust_env=False), + max_retries=0, + ) as async_client: + async with async_client.models.with_streaming_response.list() as response: + with pytest.raises(httpx2.ReadTimeout, match="stream timeout"): + [chunk async for chunk in response.iter_bytes()] + + +@pytest.mark.filterwarnings("ignore:The Assistants API is deprecated in favor of the Responses API:DeprecationWarning") +async def test_assistant_stream_timeout_callbacks_preserve_httpx2_family() -> None: + class SyncHandler(openai.AssistantEventHandler): + def __init__(self) -> None: + super().__init__() + self.timed_out = False + self.exception: Exception | None = None + + @override + def on_timeout(self) -> None: + self.timed_out = True + + @override + def on_exception(self, exception: Exception) -> None: + self.exception = exception + + class AsyncHandler(openai.AsyncAssistantEventHandler): + def __init__(self) -> None: + super().__init__() + self.timed_out = False + self.exception: Exception | None = None + + @override + async def on_timeout(self) -> None: + self.timed_out = True + + @override + async def on_exception(self, exception: Exception) -> None: + self.exception = exception + + class FailingSyncStream(httpx2.SyncByteStream): + def __iter__(self): + yield b"partial" + raise httpx2.ReadTimeout("assistant stream timeout") + + class FailingAsyncStream(httpx2.AsyncByteStream): + async def __aiter__(self): + yield b"partial" + raise httpx2.ReadTimeout("assistant stream timeout") + + def sync_response(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, headers={"content-type": "text/event-stream"}, stream=FailingSyncStream(), request=request + ) + + async def async_response(request: httpx.Request) -> httpx.Response: + return httpx2.Response( + 200, headers={"content-type": "text/event-stream"}, stream=FailingAsyncStream(), request=request + ) + + sync_handler = SyncHandler() + with OpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=openai.DefaultHttpx2Client(transport=httpx2.MockTransport(sync_response), trust_env=False), + max_retries=0, + ) as sync_client: + with sync_client.beta.threads.runs.stream( # pyright: ignore[reportDeprecated] + assistant_id="asst_test", thread_id="thread_test", event_handler=sync_handler + ) as stream: + with pytest.raises(httpx2.ReadTimeout, match="assistant stream timeout"): + stream.until_done() + + assert sync_handler.timed_out + assert isinstance(sync_handler.exception, httpx2.ReadTimeout) + + async_handler = AsyncHandler() + async with AsyncOpenAI( + api_key="test", + base_url="https://example.test/v1", + http_client=openai.DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(async_response), trust_env=False), + max_retries=0, + ) as async_client: + async with async_client.beta.threads.runs.stream( # pyright: ignore[reportDeprecated] + assistant_id="asst_test", thread_id="thread_test", event_handler=async_handler + ) as async_stream: + with pytest.raises(httpx2.ReadTimeout, match="assistant stream timeout"): + await async_stream.until_done() + + assert async_handler.timed_out + assert isinstance(async_handler.exception, httpx2.ReadTimeout) + + +async def test_sigv4_provider_preserves_httpx2_family_and_rejects_one_shot_bodies() -> None: + pytest.importorskip("botocore") + + sync_requests: list[httpx.Request] = [] + async_requests: list[httpx.Request] = [] + + def sync_handler(request: httpx.Request) -> httpx.Response: + sync_requests.append(request) + return model_list(request) + + async def async_handler(request: httpx.Request) -> httpx.Response: + async_requests.append(request) + return model_list(request) + + provider = bedrock( + region="us-east-1", + access_key_id="fixture-access-key", + secret_access_key="fixture-secret-key", + session_token="fixture-session-token", + base_url="https://bedrock-mantle.us-east-1.api.aws/openai/v1", + ) + + with OpenAI( + provider=provider, + http_client=openai.DefaultHttpx2Client(transport=httpx2.MockTransport(sync_handler), trust_env=False), + max_retries=0, + ) as sync_client: + sync_client.post("/responses", content=b"body", cast_to=httpx.Response) + with pytest.raises(OpenAIError, match="requires a replayable request body"): + sync_client.post("/responses", content=iter([b"body"]), cast_to=httpx.Response) + + async def body(): + yield b"body" + + async with AsyncOpenAI( + provider=provider, + http_client=openai.DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(async_handler), trust_env=False), + max_retries=0, + ) as async_client: + await async_client.post("/responses", content=b"body", cast_to=httpx.Response) + with pytest.raises(OpenAIError, match="requires a replayable request body"): + await async_client.post("/responses", content=body(), cast_to=httpx.Response) + + assert len(sync_requests) == 1 + assert len(async_requests) == 1 + assert "Credential=fixture-access-key/" in sync_requests[0].headers["authorization"] + assert "Credential=fixture-access-key/" in async_requests[0].headers["authorization"] + assert sync_requests[0].headers["x-amz-security-token"] == "fixture-session-token" + assert async_requests[0].headers["x-amz-security-token"] == "fixture-session-token" diff --git a/tests/test_httpx2_base.py b/tests/test_httpx2_base.py new file mode 100644 index 0000000000..eb81c69a98 --- /dev/null +++ b/tests/test_httpx2_base.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sys +import asyncio +import warnings +import threading +import subprocess +import importlib.util +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from typing_extensions import override + +import httpx +import respx +import pytest + +import openai +from openai import OpenAI, AsyncOpenAI, _httpx2 as httpx2_helpers + + +def test_base_import_does_not_load_httpx2() -> None: + subprocess.run([sys.executable, "-c", "import sys; import openai; assert 'httpx2' not in sys.modules"], check=True) + + +def test_missing_httpx2_extra_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(httpx2_helpers.sys, "version_info", (3, 10)) + + def missing_httpx2(name: str) -> object: + raise ImportError(name) + + monkeypatch.setattr(httpx2_helpers.importlib, "import_module", missing_httpx2) + + for helper in (openai.DefaultHttpx2Client, openai.DefaultAsyncHttpx2Client): + with pytest.raises(RuntimeError, match=r"install the httpx2 extra: pip install 'openai\[httpx2\]'"): + helper() + + +def test_python39_httpx2_error_is_actionable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(httpx2_helpers.sys, "version_info", (3, 9)) + + for helper in (openai.DefaultHttpx2Client, openai.DefaultAsyncHttpx2Client): + with pytest.raises(RuntimeError, match=r"HTTPX2 requires Python 3\.10 or later.*openai\[httpx2\]"): + helper() + + +@pytest.mark.respx(base_url="https://example.test/v1") +def test_default_httpx_family_and_respx_are_unchanged(respx_mock: respx.MockRouter) -> None: + route = respx_mock.get("/models").mock(return_value=httpx.Response(200, json={"object": "list", "data": []})) + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + with OpenAI(api_key="test", base_url="https://example.test/v1") as client: + response = client.models.with_raw_response.list() + + assert route.called + assert captured == [] + assert isinstance(response.http_response, httpx.Response) + assert isinstance(response.http_request, httpx.Request) + + +async def test_existing_httpx_helpers_and_injected_clients_are_unchanged() -> None: + class SyncHttpxClient(openai.DefaultHttpxClient): + pass + + class AsyncHttpxClient(openai.DefaultAsyncHttpxClient): + pass + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"object": "list", "data": []}, request=request) + + with SyncHttpxClient(transport=httpx.MockTransport(handler), trust_env=False) as http_client: + with OpenAI(api_key="test", base_url="https://example.test/v1", http_client=http_client) as client: + response = client.models.with_raw_response.list() + assert isinstance(response.http_response, httpx.Response) + + async with AsyncHttpxClient(transport=httpx.MockTransport(handler), trust_env=False) as http_client: + async with AsyncOpenAI(api_key="test", base_url="https://example.test/v1", http_client=http_client) as client: + response = await client.models.with_raw_response.list() + assert isinstance(response.http_response, httpx.Response) + + +async def test_existing_aiohttp_adapter_is_unchanged_when_installed() -> None: + if importlib.util.find_spec("httpx_aiohttp") is None: + pytest.skip("the aiohttp extra is not installed") + + class ModelsHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"object":"list","data":[]}') + + @override + def log_message(self, format: str, *_args: object) -> None: # noqa: A002 + return None + + server = ThreadingHTTPServer(("127.0.0.1", 0), ModelsHandler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + async with AsyncOpenAI( + api_key="test", + base_url=f"http://127.0.0.1:{server.server_port}/v1", + http_client=openai.DefaultAioHttpClient(), + max_retries=0, + ) as client: + response = await client.models.with_raw_response.list() + finally: + await asyncio.to_thread(server.shutdown) + thread.join() + server.server_close() + + assert isinstance(response.http_response, httpx.Response) diff --git a/tests/test_httpx2_respx.py b/tests/test_httpx2_respx.py new file mode 100644 index 0000000000..37c42d9628 --- /dev/null +++ b/tests/test_httpx2_respx.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import os + +import httpx +import pytest +from respx import MockRouter + +from openai import OpenAI, AsyncOpenAI, APITimeoutError + +httpx2 = pytest.importorskip("httpx2") +base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") +pytestmark = pytest.mark.skipif( + os.environ.get("OPENAI_TEST_HTTP_CLIENT") != "httpx2", reason="requires the HTTPX2 test lane" +) + + +@pytest.mark.respx(base_url=base_url) +def test_respx_bridge_preserves_native_sync_family_and_request_content(client: OpenAI, respx_mock: MockRouter) -> None: + def mirror(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/upload" + assert request.headers["x-test"] == "sync" + return httpx.Response(200, content=request.content) + + respx_mock.post("/upload").mock(side_effect=mirror) + + response = client.post( + "/upload", content=b"sync body", options={"headers": {"x-test": "sync"}}, cast_to=httpx2.Response + ) + + assert isinstance(response, httpx2.Response) + assert isinstance(response.request, httpx2.Request) + assert response.content == b"sync body" + assert len(respx_mock.calls) == 1 + + +@pytest.mark.respx(base_url=base_url) +async def test_respx_bridge_preserves_native_async_family_and_request_content( + async_client: AsyncOpenAI, respx_mock: MockRouter +) -> None: + async def mirror(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/upload" + assert request.headers["x-test"] == "async" + return httpx.Response(200, content=await request.aread()) + + respx_mock.post("/upload").mock(side_effect=mirror) + + response = await async_client.post( + "/upload", content=b"async body", options={"headers": {"x-test": "async"}}, cast_to=httpx2.Response + ) + + assert isinstance(response, httpx2.Response) + assert isinstance(response.request, httpx2.Request) + assert response.content == b"async body" + assert len(respx_mock.calls) == 1 + + +@pytest.mark.respx(base_url=base_url) +def test_respx_bridge_maps_timeout_to_native_family(client: OpenAI, respx_mock: MockRouter) -> None: + respx_mock.get("/models").mock(side_effect=httpx.ReadTimeout("mock timeout")) + + with pytest.raises(APITimeoutError) as exc_info: + client.with_options(max_retries=0).models.list() + + assert isinstance(exc_info.value.__cause__, httpx2.ReadTimeout) diff --git a/tests/test_httpx2_workload.py b/tests/test_httpx2_workload.py new file mode 100644 index 0000000000..14bb997248 --- /dev/null +++ b/tests/test_httpx2_workload.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +from typing import Any, NoReturn, cast + +import httpx +import respx +import pytest +from respx.models import Call + +import openai._base_client as base_client +import openai.auth._workload as workload +from openai import ( + OpenAI, + OAuthError, + AsyncOpenAI, + APITimeoutError, + APIConnectionError, + DefaultHttpx2Client, + DefaultAsyncHttpx2Client, +) +from openai.auth import WorkloadIdentity + +httpx2 = pytest.importorskip("httpx2") + + +def workload_identity(get_token: Any = lambda: "subject-token") -> WorkloadIdentity: + return { + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + "provider": {"get_token": get_token, "token_type": "jwt"}, + } + + +def exchange_payload(access_token: str) -> dict[str, object]: + return {"access_token": access_token, "expires_in": 3600} + + +def forbidden_httpx_send(*_args: Any, **_kwargs: Any) -> NoReturn: + pytest.fail("HTTPX unexpectedly sent a request") + + +def test_sync_httpx2_workload_exchange_is_native_and_cached(monkeypatch: pytest.MonkeyPatch) -> None: + exchange_requests: list[Any] = [] + api_requests: list[Any] = [] + provider_calls = 0 + + def get_token() -> str: + nonlocal provider_calls + provider_calls += 1 + return "subject-token" + + def exchange_handler(request: Any) -> Any: + exchange_requests.append(request) + return httpx2.Response(200, request=request, json=exchange_payload("access-token")) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + def api_handler(request: Any) -> Any: + api_requests.append(request) + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + monkeypatch.setattr(httpx.Client, "send", forbidden_httpx_send) + + with OpenAI( + workload_identity=workload_identity(get_token), + base_url="https://api.example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(api_handler), trust_env=False), + max_retries=0, + ) as client: + assert client.models.list().object == "list" + assert client.models.list().object == "list" + + assert provider_calls == 1 + assert len(exchange_requests) == 1 + assert len(api_requests) == 2 + assert isinstance(exchange_requests[0], httpx2.Request) + assert str(exchange_requests[0].url) == "https://auth.openai.com/oauth/token" + assert json.loads(exchange_requests[0].content) == { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": "subject-token", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + } + assert all(isinstance(request, httpx2.Request) for request in api_requests) + assert [request.headers["authorization"] for request in api_requests] == ["Bearer access-token"] * 2 + + +async def test_async_httpx2_workload_401_reexchanges_with_sync_native_client(monkeypatch: pytest.MonkeyPatch) -> None: + exchange_requests: list[Any] = [] + api_requests: list[Any] = [] + api_authorizations: list[str] = [] + tokens = iter(["access-token-1", "access-token-2"]) + + def exchange_handler(request: Any) -> Any: + exchange_requests.append(request) + return httpx2.Response(200, request=request, json=exchange_payload(next(tokens))) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + async def api_handler(request: Any) -> Any: + api_requests.append(request) + api_authorizations.append(request.headers["authorization"]) + status_code = 401 if len(api_requests) == 1 else 200 + return httpx2.Response(status_code, request=request, json={"object": "list", "data": []}) + + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + monkeypatch.setattr(httpx.Client, "send", forbidden_httpx_send) + monkeypatch.setattr(httpx.AsyncClient, "send", forbidden_httpx_send) + + async with AsyncOpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(api_handler), trust_env=False), + max_retries=0, + ) as client: + assert (await client.models.list()).object == "list" + + assert len(exchange_requests) == 2 + assert all(isinstance(request, httpx2.Request) for request in exchange_requests) + assert len(api_requests) == 2 + assert all(isinstance(request, httpx2.Request) for request in api_requests) + assert api_authorizations == ["Bearer access-token-1", "Bearer access-token-2"] + + +def test_sync_httpx2_default_workload_exchange_is_native(monkeypatch: pytest.MonkeyPatch) -> None: + exchange_requests: list[Any] = [] + api_requests: list[Any] = [] + + def exchange_handler(request: Any) -> Any: + exchange_requests.append(request) + return httpx2.Response(200, request=request, json=exchange_payload("access-token")) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + def api_handler(request: Any) -> Any: + api_requests.append(request) + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + def default_client(**kwargs: Any) -> Any: + return DefaultHttpx2Client(transport=httpx2.MockTransport(api_handler), trust_env=False, **kwargs) + + monkeypatch.setattr(base_client, "SyncHttpxClientWrapper", default_client) + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + monkeypatch.setattr(httpx.Client, "send", forbidden_httpx_send) + + with OpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + max_retries=0, + ) as client: + assert isinstance(client._client, httpx2.Client) + assert client.models.list().object == "list" + + assert len(exchange_requests) == 1 + assert len(api_requests) == 1 + assert isinstance(exchange_requests[0], httpx2.Request) + assert isinstance(api_requests[0], httpx2.Request) + assert api_requests[0].headers["authorization"] == "Bearer access-token" + + +async def test_async_httpx2_default_workload_exchange_is_native(monkeypatch: pytest.MonkeyPatch) -> None: + exchange_requests: list[Any] = [] + api_requests: list[Any] = [] + + def exchange_handler(request: Any) -> Any: + exchange_requests.append(request) + return httpx2.Response(200, request=request, json=exchange_payload("access-token")) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + async def api_handler(request: Any) -> Any: + api_requests.append(request) + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + def default_client(**kwargs: Any) -> Any: + return DefaultAsyncHttpx2Client(transport=httpx2.MockTransport(api_handler), trust_env=False, **kwargs) + + monkeypatch.setattr(base_client, "AsyncHttpxClientWrapper", default_client) + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + monkeypatch.setattr(httpx.Client, "send", forbidden_httpx_send) + monkeypatch.setattr(httpx.AsyncClient, "send", forbidden_httpx_send) + + async with AsyncOpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + max_retries=0, + ) as client: + assert isinstance(client._client, httpx2.AsyncClient) + assert (await client.models.list()).object == "list" + + assert len(exchange_requests) == 1 + assert len(api_requests) == 1 + assert isinstance(exchange_requests[0], httpx2.Request) + assert isinstance(api_requests[0], httpx2.Request) + assert api_requests[0].headers["authorization"] == "Bearer access-token" + + +def test_httpx2_workload_oauth_error_preserves_native_response(monkeypatch: pytest.MonkeyPatch) -> None: + api_calls = 0 + + def exchange_handler(request: Any) -> Any: + return httpx2.Response( + 401, + request=request, + json={"error": "invalid_grant", "error_description": "invalid workload identity"}, + ) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + def api_handler(request: Any) -> Any: + nonlocal api_calls + api_calls += 1 + return httpx2.Response(200, request=request, json={"object": "list", "data": []}) + + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + + with OpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(api_handler), trust_env=False), + max_retries=0, + ) as client: + with pytest.raises(OAuthError) as exc_info: + client.models.list() + + assert exc_info.value.message == "invalid workload identity" + assert isinstance(exc_info.value.response, httpx2.Response) + assert isinstance(exc_info.value.request, httpx2.Request) + assert api_calls == 0 + + +@pytest.mark.parametrize( + ("failure", "expected"), + [("timeout", APITimeoutError), ("connection", APIConnectionError)], +) +def test_httpx2_workload_exchange_transport_failure( + failure: str, expected: type[Exception], monkeypatch: pytest.MonkeyPatch +) -> None: + def exchange_handler(request: Any) -> Any: + if failure == "timeout": + raise httpx2.ReadTimeout("exchange timeout", request=request) + raise httpx2.ConnectError("exchange unavailable", request=request) + + def exchange_client(**kwargs: Any) -> Any: + assert kwargs == {"follow_redirects": False} + return httpx2.Client(transport=httpx2.MockTransport(exchange_handler), trust_env=False) + + def api_handler(request: Any) -> Any: + return httpx2.Response(200, request=request) + + monkeypatch.setattr(workload, "DefaultHttpx2Client", exchange_client) + + with OpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + http_client=httpx2.Client(transport=httpx2.MockTransport(api_handler)), + max_retries=0, + ) as client: + with pytest.raises(expected) as exc_info: + client.models.list() + + assert type(exc_info.value.__cause__).__module__ == "httpx2" + + +def test_httpx_workload_exchange_stays_httpx_when_httpx2_is_installed(monkeypatch: pytest.MonkeyPatch) -> None: + api_requests: list[httpx.Request] = [] + + def forbidden_httpx2(**_kwargs: Any) -> Any: + pytest.fail("HTTPX2 unexpectedly created a workload exchange client") + + def api_handler(request: httpx.Request) -> httpx.Response: + api_requests.append(request) + return httpx.Response(200, request=request, json={"object": "list", "data": []}) + + monkeypatch.setattr(workload, "DefaultHttpx2Client", forbidden_httpx2) + + with respx.mock(assert_all_mocked=False) as router: + exchange = router.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response(200, json=exchange_payload("access-token")) + ) + with OpenAI( + workload_identity=workload_identity(), + base_url="https://api.example.test/v1", + http_client=httpx.Client(transport=httpx.MockTransport(api_handler), trust_env=False), + max_retries=0, + ) as client: + assert client.models.list().object == "list" + + assert exchange.call_count == 1 + assert len(api_requests) == 1 + assert isinstance(cast(Call, exchange.calls[0]).request, httpx.Request) + assert isinstance(api_requests[0], httpx.Request) diff --git a/uv.lock b/uv.lock index d3c4fe3ba4..f5d56c9c6e 100644 --- a/uv.lock +++ b/uv.lock @@ -571,6 +571,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version >= '3.10'" }, + { name = "truststore", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -600,6 +613,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/8d/85c9701e9af72ca132a1783e2a54364a90c6da832304416a30fc11196ab2/httpx_aiohttp-0.1.12-py3-none-any.whl", hash = "sha256:5b0eac39a7f360fa7867a60bcb46bb1024eada9c01cbfecdb54dc1edb3fb7141", size = 6367, upload-time = "2025-12-12T10:12:14.018Z" }, ] +[[package]] +name = "httpx2" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "httpcore2", marker = "python_full_version >= '3.10'" }, + { name = "idna", marker = "python_full_version >= '3.10'" }, + { name = "truststore", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -1132,6 +1161,11 @@ datalib = [ { name = "pandas-stubs", version = "2.3.3.260113", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "pandas-stubs", version = "3.0.3.260530", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +httpx2 = [ + { name = "anyio", version = "4.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "httpx", marker = "python_full_version >= '3.10'" }, + { name = "httpx2", marker = "python_full_version >= '3.10'" }, +] realtime = [ { name = "websockets" }, ] @@ -1146,11 +1180,14 @@ voice-helpers = [ requires-dist = [ { name = "aiohttp", marker = "python_full_version >= '3.10' and extra == 'aiohttp'", specifier = ">=3.14.1" }, { name = "anyio", specifier = ">=3.5.0,<5" }, + { name = "anyio", marker = "python_full_version >= '3.10' and extra == 'httpx2'", specifier = ">=4.10.0,<5" }, { name = "botocore", marker = "python_full_version >= '3.10' and extra == 'bedrock'", specifier = ">=1.40.0,<2" }, { name = "botocore", marker = "python_full_version < '3.10' and extra == 'bedrock'", specifier = ">=1.40.0,<1.43" }, { name = "distro", specifier = ">=1.7.0,<2" }, { name = "httpx", specifier = ">=0.23.0,<1" }, + { name = "httpx", marker = "python_full_version >= '3.10' and extra == 'httpx2'", specifier = ">=0.25.1,<1" }, { name = "httpx-aiohttp", marker = "python_full_version >= '3.10' and extra == 'aiohttp'", specifier = ">=0.1.9" }, + { name = "httpx2", marker = "python_full_version >= '3.10' and extra == 'httpx2'", specifier = ">=2.7.0,<3" }, { name = "jiter", specifier = ">=0.10.0,<1" }, { name = "numpy", marker = "extra == 'datalib'", specifier = ">=1" }, { name = "numpy", marker = "extra == 'voice-helpers'", specifier = ">=2.0.2" }, @@ -1163,7 +1200,7 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.14,<5" }, { name = "websockets", marker = "extra == 'realtime'", specifier = ">=13,<16" }, ] -provides-extras = ["aiohttp", "realtime", "datalib", "voice-helpers", "bedrock"] +provides-extras = ["aiohttp", "httpx2", "realtime", "datalib", "voice-helpers", "bedrock"] [[package]] name = "pandas" @@ -1728,6 +1765,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "types-pytz" version = "2025.2.0.20251108" From 638895dcb5d718d667f8777ccf816ab989a8636b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:57:58 +0000 Subject: [PATCH 3/3] release: 2.47.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7336785395..3ab3b9e3de 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.46.0" + ".": "2.47.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5747875ef1..90e3d203a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.47.0 (2026-07-21) + +Full Changelog: [v2.46.0...v2.47.0](https://github.com/openai/openai-python/compare/v2.46.0...v2.47.0) + +### Features + +* **client:** Add experimental runtime support for HTTPX2 clients ([#3524](https://github.com/openai/openai-python/issues/3524)) ([317260c](https://github.com/openai/openai-python/commit/317260cd16395b2338bcdaacd7daa0b179c90105)) +* **stlc:** configurable CI runner and private-production-repo support in workflow templates ([4303e97](https://github.com/openai/openai-python/commit/4303e974f08394789c6075b6745357b2f1c36e21)) + + +### Bug Fixes + +* **deps:** require patched aiohttp on Python 3.10+ ([#3515](https://github.com/openai/openai-python/issues/3515)) ([d4dceb2](https://github.com/openai/openai-python/commit/d4dceb221b9a92c55c232d5b330ae89beb539415)) + ## 2.46.0 (2026-07-17) Full Changelog: [v2.45.0...v2.46.0](https://github.com/openai/openai-python/compare/v2.45.0...v2.46.0) diff --git a/pyproject.toml b/pyproject.toml index 7b8fae090d..c99f41b56f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "2.46.0" +version = "2.47.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 a827c30678..33a4084e62 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "openai" -__version__ = "2.46.0" # x-release-please-version +__version__ = "2.47.0" # x-release-please-version