Skip to content
Merged
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
29 changes: 14 additions & 15 deletions examples/realtime/push_to_talk_app.py.lock
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,20 @@ wheels = [

[[package]]
name = "httpcore2"
version = "2.10.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
{ name = "truststore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" },
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
]

[[package]]
name = "httpx2"
version = "2.10.0"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
Expand All @@ -200,9 +200,9 @@ dependencies = [
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" },
{ url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
]

[[package]]
Expand Down Expand Up @@ -597,7 +597,7 @@ wheels = [

[[package]]
name = "openai"
version = "3.3.0"
version = "3.16.2"
source = { editable = "../../" }
dependencies = [
{ name = "anyio" },
Expand All @@ -618,12 +618,12 @@ requires-dist = [
{ name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.14.3" },
{ name = "anyio", specifier = ">=4.10.0,<5" },
{ name = "botocore", marker = "extra == 'bedrock'", specifier = ">=1.40.0,<2" },
{ name = "httpx2", specifier = ">=2.7.0,<3" },
{ name = "jiter", specifier = ">=0.10.0,<1" },
{ name = "httpx2", specifier = ">=2.12.0,<3" },
{ name = "jiter", specifier = ">=0.16.0,<1" },
{ name = "numpy", marker = "extra == 'datalib'", specifier = ">=1" },
{ name = "numpy", marker = "extra == 'voice-helpers'", specifier = ">=2.0.2" },
{ name = "pandas", marker = "extra == 'datalib'", specifier = ">=1.2.3" },
{ name = "pydantic", specifier = ">=1.9.0,<3" },
{ name = "pydantic", specifier = ">=1.10.13,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,<3" },
{ name = "sniffio" },
{ name = "sounddevice", marker = "extra == 'voice-helpers'", specifier = ">=0.5.1" },
{ name = "typing-extensions", specifier = ">=4.14,<5" },
Expand All @@ -634,7 +634,7 @@ provides-extras = ["aiohttp", "realtime", "datalib", "voice-helpers", "bedrock"]

[package.metadata.requires-dev]
build = [
{ name = "hatchling", specifier = "==1.26.3" },
{ name = "hatchling", specifier = "==1.27.0" },
{ name = "packaging", specifier = "==26.3" },
{ name = "pathspec", specifier = "==1.1.1" },
{ name = "pluggy", specifier = "==1.6.0" },
Expand All @@ -646,11 +646,10 @@ dev = [
{ name = "botocore", specifier = "==1.42.97" },
{ name = "griffe", specifier = ">=1" },
{ name = "inline-snapshot", specifier = ">=0.28.0" },
{ name = "mypy", specifier = "==1.17" },
{ name = "mypy", specifier = "==2.3.1" },
{ name = "pandas-stubs", specifier = ">=1.1.0.11" },
{ name = "pyright", specifier = "==1.1.399" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "ruff" },
Expand Down
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors = [
]

dependencies = [
"httpx2>=2.7.0, <3",
"httpx2>=2.12.0, <3",
"pydantic>=1.10.13, <3, !=2.0.*, !=2.1.*, !=2.2.*, !=2.3.*",
"typing-extensions>=4.14, <5",
"anyio>=4.10.0, <5",
Expand Down Expand Up @@ -84,9 +84,11 @@ required-version = ">=0.12.1"
exclude-newer = "8 days"
default-groups = ["dev", "pydantic-v2"]
conflicts = [[{ group = "pydantic-v1" }, { group = "pydantic-v2" }]]
# Security floors for transitive development dependencies. Constraints do not
# Security floors for transitive dependencies. Constraints do not
# install these packages into the SDK's runtime environment.
constraint-dependencies = [
# HTTPX2 also publishes an exact pin to its matching httpcore2 release.
"httpcore2>=2.12.0",
"azure-core>=1.38.0",
"cryptography>=50.0.0",
"msal>=1.37.0",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-python-version-policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
PRERELEASE = "3.15"
UNMARKED_DEPENDENCIES = (
"aiohttp>=3.14.3",
"httpx2>=2.7.0, <3",
"httpx2>=2.12.0, <3",
"anyio>=4.10.0, <5",
"botocore>=1.40.0,<2",
)
Expand Down
2 changes: 1 addition & 1 deletion scripts/utils/validate-httpx2-wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def validate_metadata(wheel: Path) -> None:

if metadata["Requires-Python"] != ">=3.10":
raise RuntimeError(f"Expected Python >=3.10, found: {metadata['Requires-Python']}")
for expected in ("httpx2<3,>=2.7.0", "anyio<5,>=4.10.0"):
for expected in ("httpx2<3,>=2.12.0", "anyio<5,>=4.10.0"):
if not any(value.startswith(expected) for value in base):
raise RuntimeError(f"Expected the base wheel to require {expected}: {base}")
if any(requirement_name(value) == "httpx" for value in requirements):
Expand Down
8 changes: 4 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
)

assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success

@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
Expand Down Expand Up @@ -1374,7 +1374,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
model="gpt-5.4",
) as response:
assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success

def test_proxy_environment_variables(self, monkeypatch: pytest.MonkeyPatch) -> None:
# Test that the proxy environment variables are set correctly
Expand Down Expand Up @@ -2629,7 +2629,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
)

assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success

@pytest.mark.parametrize("failures_before_success", [0, 2, 4])
@mock.patch("openai._base_client.BaseClient._calculate_retry_timeout", _low_retry_timeout)
Expand Down Expand Up @@ -2724,7 +2724,7 @@ def retry_handler(_request: httpx2.Request) -> httpx2.Response:
model="gpt-5.4",
) as response:
assert response.retries_taken == failures_before_success
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
assert int(response.http_request.headers["x-stainless-retry-count"]) == failures_before_success

async def test_get_platform(self) -> None:
platform = await asyncify(get_platform)()
Expand Down
40 changes: 40 additions & 0 deletions tests/test_httpx2_decompression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import httpx2
import pytest

from openai import OpenAI, AsyncOpenAI

from .test_large_payload_contract import CompressedStream, AsyncCompressedStream, compressed_response


@pytest.mark.parametrize("encoding", ["gzip", "deflate"])
async def test_decoding_failure_closes_response_stream(encoding: str) -> None:
stream = CompressedStream(b"not a compressed stream")
with OpenAI(
api_key="test-key",
max_retries=0,
http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, encoding))),
) as client:
with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response:
with pytest.raises(httpx2.DecodingError):
list(response.iter_bytes())
# Check before context-manager cleanup can hide an upstream leak.
assert stream.closed
assert response.http_response.is_closed

async_stream = AsyncCompressedStream(b"not a compressed stream")
async with AsyncOpenAI(
api_key="test-key",
max_retries=0,
http_client=httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, encoding))
),
) as async_client:
async with async_client.responses.with_streaming_response.create(
model="gpt-4o-mini", input="Hello"
) as async_response:
with pytest.raises(httpx2.DecodingError):
_ = [chunk async for chunk in async_response.iter_bytes()]
assert async_stream.closed
assert async_response.http_response.is_closed
79 changes: 79 additions & 0 deletions tests/test_large_payload_contract.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import gzip
import json
from typing import Any, Callable, Iterator, AsyncIterator
from typing_extensions import override
Expand Down Expand Up @@ -224,9 +225,87 @@ def events() -> Iterator[dict[str, Any]]:
assert_intact(message.parsed.value, value)


class CompressedStream(httpx2.SyncByteStream):
def __init__(self, data: bytes) -> None:
self.data = data
self.closed = False

@override
def __iter__(self) -> Iterator[bytes]:
# One small network chunk must not inflate into one enormous chunk.
yield self.data

@override
def close(self) -> None:
self.closed = True


class AsyncCompressedStream(httpx2.AsyncByteStream):
def __init__(self, data: bytes) -> None:
self.data = data
self.closed = False

@override
async def __aiter__(self) -> AsyncIterator[bytes]:
yield self.data

@override
async def aclose(self) -> None:
self.closed = True


def compressed_response(stream: CompressedStream | AsyncCompressedStream, encoding: str) -> httpx2.Response:
# Passing a stream avoids eager decoding in Response's constructor.
return httpx2.Response(
200, headers={"content-encoding": encoding, "content-type": "application/json"}, stream=stream
)


async def check_large_compressed_responses() -> None:
text = "x" * PAYLOAD_SIZE
body = json.dumps(response_body(text)).encode()
data = gzip.compress(body)
stream = CompressedStream(data)
with OpenAI(
api_key="test-key",
max_retries=0,
http_client=httpx2.Client(transport=httpx2.MockTransport(lambda _: compressed_response(stream, "gzip"))),
) as client:
with client.responses.with_streaming_response.create(model="gpt-4o-mini", input="Hello") as response:
size = 0
for chunk in response.iter_bytes():
# The upstream decoder's chunk bound is not a response-size cap.
assert len(chunk) <= 1024 * 1024
assert chunk == body[size : size + len(chunk)]
size += len(chunk)
assert size == len(body)
assert stream.closed

async_stream = AsyncCompressedStream(data)
async with AsyncOpenAI(
api_key="test-key",
max_retries=0,
http_client=httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: compressed_response(async_stream, "gzip"))
),
) as async_client:
async with async_client.responses.with_streaming_response.create(
model="gpt-4o-mini", input="Hello"
) as async_response:
size = 0
async for chunk in async_response.iter_bytes():
assert len(chunk) <= 1024 * 1024
assert chunk == body[size : size + len(chunk)]
size += len(chunk)
assert size == len(body)
assert async_stream.closed


async def test_large_payload_contract() -> None:
# One test prevents pytest-xdist from running high-memory cases together.
check_blocking_json()
await check_structured_json()
await check_responses_stream()
check_chat_stream()

await check_large_compressed_responses()
Loading
Loading