Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,73 @@ async def main() -> None:
asyncio.run(main())
```

### With httpx2

`httpx2` is Pydantic's maintained fork of `httpx`. You may use it as the HTTP backend for either client.

You can enable this by installing `httpx2`:

```sh
# install from PyPI
pip install openai[httpx2]
```

Then you can enable it by instantiating the client with `http_client=DefaultHttpx2Client()`:

```python
import os
from openai import OpenAI, DefaultHttpx2Client

client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY"), # This is the default and can be omitted
http_client=DefaultHttpx2Client(),
)

chat_completion = client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Say this is a test",
}
],
model="gpt-5.5",
)
```

Or with the async client, using `DefaultAsyncHttpx2Client()`:

```python
import os
import asyncio
from openai import AsyncOpenAI, DefaultAsyncHttpx2Client


async def main() -> None:
async with AsyncOpenAI(
api_key=os.environ.get("OPENAI_API_KEY"), # This is the default and can be omitted
http_client=DefaultAsyncHttpx2Client(),
) as client:
chat_completion = await client.chat.completions.create(
messages=[
{
"role": "user",
"content": "Say this is a test",
}
],
model="gpt-5.5",
)


asyncio.run(main())
```

A few things to be aware of when using `httpx2`:

- `httpx2` requires Python 3.10 or newer (it dropped support for 3.9), so the `httpx2` extra installs nothing on Python 3.9.
- When streaming raw responses, the exceptions raised come from `httpx2` (e.g. `httpx2.StreamConsumed`), not from `httpx`.
- `cast_to=httpx.Response` still works but returns a duck-compatible `httpx2.Response`, which is not an `isinstance` of `httpx.Response`. Passing `cast_to=httpx2.Response` is not supported.
- As with classic `httpx`, a client whose timeout equals the library's own default (`httpx2.Timeout(5.0)`) is treated as "not customised" and the SDK's default of 10 minutes is used instead. To actually get a 5 second timeout, pass `timeout=5.0` to the `OpenAI(...)` constructor rather than to the http client.

## Streaming responses

We provide support for streaming responses using Server Side Events (SSE).
Expand Down
45 changes: 45 additions & 0 deletions examples/httpx2_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env -S poetry run python

import asyncio

from openai import OpenAI, AsyncOpenAI, DefaultHttpx2Client, DefaultAsyncHttpx2Client

# gets API Key from environment variable OPENAI_API_KEY
client = OpenAI(http_client=DefaultHttpx2Client())

# Non-streaming, synchronous client backed by httpx2:
print("----- standard request -----")
completion = client.chat.completions.create(
model="gpt-5.5",
messages=[
{
"role": "user",
"content": "Say this is a test",
},
],
)
print(completion.choices[0].message.content)


async def main() -> None:
async with AsyncOpenAI(http_client=DefaultAsyncHttpx2Client()) as async_client:
print("----- async streaming request -----")
stream = await async_client.chat.completions.create(
model="gpt-5.5",
messages=[
{
"role": "user",
"content": "How do I output all files in a directory using Python?",
},
],
stream=True,
)
async for chunk in stream:
if not chunk.choices:
continue

print(chunk.choices[0].delta.content, end="")
print()


asyncio.run(main())
21 changes: 21 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,24 @@ def test_pydantic_v1(session: nox.Session) -> None:
session.install("pydantic<2")

session.run("pytest", "--showlocals", "--ignore=tests/functional", *session.posargs)


# httpx2 is a Python 3.10+ optional extra and is absent from the 3.9-floored dev lock,
# so its tests are skipped by the default session. This session installs a real httpx2
# on a supported interpreter and runs the httpx2 suite so the widened exception tuples,
# timeout handling and URL coercion are actually exercised in CI.
#
# We install the package (with the httpx2 extra) plus the test tooling unpinned rather
# than `requirements-dev.lock`: that lock is resolved at the 3.9 floor and some pins
# (e.g. backports-asyncio-runner) refuse to install on Python 3.12+.
@nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"], name="test-httpx2")
def test_httpx2(session: nox.Session) -> None:
session.install("-e", ".[httpx2]")
session.install("pytest", "pytest-asyncio", "pytest-xdist")

session.run(
"pytest",
"tests/test_httpx2_client.py",
"tests/test_httpx2_not_installed.py",
*session.posargs,
)
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Repository = "https://github.com/openai/openai-python"

[project.optional-dependencies]
aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]
httpx2 = ["httpx2>=2.5.0,<3; 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"]
Expand Down
10 changes: 9 additions & 1 deletion src/openai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@
ContentFilterFinishReasonError,
WebSocketConnectionClosedError,
)
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
from ._base_client import (
DefaultHttpxClient,
DefaultHttpx2Client,
DefaultAioHttpClient,
DefaultAsyncHttpxClient,
DefaultAsyncHttpx2Client,
)
from ._utils._logs import setup_logging as _setup_logging
from ._legacy_response import HttpxBinaryResponseContent as HttpxBinaryResponseContent
from .types.websocket_reconnection import ReconnectingEvent, ReconnectingOverrides
Expand Down Expand Up @@ -89,6 +95,8 @@
"DefaultHttpxClient",
"DefaultAsyncHttpxClient",
"DefaultAioHttpClient",
"DefaultHttpx2Client",
"DefaultAsyncHttpx2Client",
"ReconnectingEvent",
"ReconnectingOverrides",
"WebSocketQueueFullError",
Expand Down
46 changes: 35 additions & 11 deletions src/openai/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@
APIResponseValidationError,
)
from ._utils._json import openapi_dumps
from ._httpx2_compat import (
HTTP_STATUS_ERRORS,
TIMEOUT_EXCEPTIONS,
HTTPX2_DEFAULT_TIMEOUT,
SYNC_HTTP_CLIENT_NAMES,
SYNC_HTTP_CLIENT_TYPES,
ASYNC_HTTP_CLIENT_NAMES,
ASYNC_HTTP_CLIENT_TYPES,
DefaultHttpx2Client as DefaultHttpx2Client,
DefaultAsyncHttpx2Client as DefaultAsyncHttpx2Client,
coerce_httpx2_request_url,
)
from ._legacy_response import LegacyAPIResponse

log: logging.Logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -594,7 +606,7 @@ def _build_request(
headers=headers,
timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout,
method=options.method,
url=prepared_url,
url=coerce_httpx2_request_url(self._client, prepared_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.
Expand Down Expand Up @@ -886,14 +898,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:
if (
http_client
and http_client.timeout != HTTPX_DEFAULT_TIMEOUT
# a bare httpx2 client reports httpx2's own default (5s), which is `!=` the
# classic default across the class boundary; treat it as "not customised" too
and http_client.timeout != HTTPX2_DEFAULT_TIMEOUT
):
timeout = http_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 isinstance(http_client, SYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(
f"Invalid `http_client` argument; Expected an instance of `httpx.Client` but got {type(http_client)}"
f"Invalid `http_client` argument; Expected an instance of {SYNC_HTTP_CLIENT_NAMES} but got {type(http_client)}"
)

super().__init__(
Expand Down Expand Up @@ -1039,7 +1057,7 @@ def request(
stream=stream or self._should_stream_response_body(request=request),
**kwargs,
)
except httpx.TimeoutException as err:
except TIMEOUT_EXCEPTIONS as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)

if remaining_retries > 0:
Expand Down Expand Up @@ -1083,7 +1101,7 @@ def request(

try:
response.raise_for_status()
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
except HTTP_STATUS_ERRORS as err: # thrown on 4xx and 5xx status code
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)

if remaining_retries > 0 and self._should_retry(err.response):
Expand Down Expand Up @@ -1496,14 +1514,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:
if (
http_client
and http_client.timeout != HTTPX_DEFAULT_TIMEOUT
# a bare httpx2 client reports httpx2's own default (5s), which is `!=` the
# classic default across the class boundary; treat it as "not customised" too
and http_client.timeout != HTTPX2_DEFAULT_TIMEOUT
):
timeout = http_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 isinstance(http_client, ASYNC_HTTP_CLIENT_TYPES): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError(
f"Invalid `http_client` argument; Expected an instance of `httpx.AsyncClient` but got {type(http_client)}"
f"Invalid `http_client` argument; Expected an instance of {ASYNC_HTTP_CLIENT_NAMES} but got {type(http_client)}"
)

super().__init__(
Expand Down Expand Up @@ -1650,7 +1674,7 @@ async def request(
stream=stream or self._should_stream_response_body(request=request),
**kwargs,
)
except httpx.TimeoutException as err:
except TIMEOUT_EXCEPTIONS as err:
log.debug("Encountered httpx.TimeoutException", exc_info=True)

if remaining_retries > 0:
Expand Down Expand Up @@ -1694,7 +1718,7 @@ async def request(

try:
response.raise_for_status()
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
except HTTP_STATUS_ERRORS as err: # thrown on 4xx and 5xx status code
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)

if remaining_retries > 0 and self._should_retry(err.response):
Expand Down
5 changes: 3 additions & 2 deletions src/openai/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
SyncAPIClient,
AsyncAPIClient,
)
from ._httpx2_compat import noop_auth_for

if TYPE_CHECKING:
from .resources import (
Expand Down Expand Up @@ -560,7 +561,7 @@ def _prepare_request(self, request: httpx.Request) -> None:
@override
def _custom_auth(self, security: SecurityOptions) -> httpx.Auth | None:
if self._provider_runtime is not None:
return httpx.Auth()
return noop_auth_for(self._client)

return super()._custom_auth(security)

Expand Down Expand Up @@ -1167,7 +1168,7 @@ async def _prepare_request(self, request: httpx.Request) -> None:
@override
def custom_auth(self) -> httpx.Auth | None:
if self._provider_runtime is not None:
return httpx.Auth()
return noop_auth_for(self._client)

return super().custom_auth

Expand Down
Loading