feat: support OpenAI Python 3 and HTTPX2 (#4380)

This commit is contained in:
Kazuhiro Sera
2026-08-13 12:09:43 +09:00
committed by GitHub
parent 05d6850da5
commit fc461eebdf
24 changed files with 345 additions and 169 deletions
@@ -1,3 +1,3 @@
fastapi>=0.120.0
openai>=2.2,<3
openai>=3.0.0,<4
uvicorn[standard]>=0.38.0
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
import httpx
import httpx2
import pytest
from openai import APIConnectionError, AsyncOpenAI
@@ -37,7 +37,7 @@ async def test_retry_reaches_real_api_without_rewinding_session_input(
if attempts == 1:
raise APIConnectionError(
message="Controlled integration-test transport failure.",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
request=httpx2.Request("POST", "https://api.openai.com/v1/responses"),
)
return await original_fetch(*args, **kwargs)
@@ -6,7 +6,6 @@ import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from agents.mcp import MCPServerSse, MCPServerStdio, MCPServerStreamableHttp
@@ -57,6 +56,8 @@ def test_packaged_client_uses_mcp_v1_sse_transport() -> None:
def test_packaged_client_uses_mcp_v1_streamable_http_auth_and_factory() -> None:
import httpx
auth = httpx.BasicAuth("user", "pass")
def factory(headers=None, timeout=None, auth=None):
+1 -1
View File
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
license = "MIT"
authors = [{ name = "OpenAI", email = "support@openai.com" }]
dependencies = [
"openai>=2.45.0,<3",
"openai>=3.0.0,<4",
"pydantic>=2.12.2, <3",
"griffelib>=2, <3",
"typing-extensions>=4.12.2, <5",
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import sys
from functools import cache
from importlib import import_module
from types import ModuleType
from typing import Any, cast
@cache
def _load_legacy_httpx() -> ModuleType | None:
try:
return import_module("httpx")
except ModuleNotFoundError as exc:
if exc.name != "httpx":
raise
return None
def is_legacy_httpx_instance(value: Any, *type_names: str) -> bool:
legacy_httpx = sys.modules.get("httpx")
if not isinstance(legacy_httpx, ModuleType):
return False
types = tuple(cast(type[Any], getattr(legacy_httpx, name)) for name in type_names)
return isinstance(value, types)
def legacy_httpx_types(*type_names: str) -> tuple[type[Any], ...]:
legacy_httpx = _load_legacy_httpx()
if legacy_httpx is None:
return ()
return tuple(cast(type[Any], getattr(legacy_httpx, name)) for name in type_names)
def require_legacy_httpx() -> ModuleType:
legacy_httpx = _load_legacy_httpx()
if legacy_httpx is None: # pragma: no cover - MCP v1 declares the dependency
raise ImportError("The installed integration requires the legacy httpx package.")
return legacy_httpx
+38 -22
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from functools import cache
from importlib import import_module
from importlib.metadata import version
from types import ModuleType
from typing import Any, cast
import httpx
from pydantic import AnyUrl
from .._httpx_compat import legacy_httpx_types, require_legacy_httpx
def _major_version(distribution: str) -> int:
raw_version = version(distribution)
@@ -26,26 +27,41 @@ MCPError = cast(
vars(_mcp_exceptions).get("MCPError") or vars(_mcp_exceptions)["McpError"],
)
MCP_HTTPX: ModuleType = import_module("httpx2") if MCP_V2 else httpx
HTTP_STATUS_ERROR_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.HTTPStatusError, cast(type[Exception], MCP_HTTPX.HTTPStatusError)))
)
HTTP_REQUEST_ERROR_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.RequestError, cast(type[Exception], MCP_HTTPX.RequestError)))
)
HTTP_CONNECT_ERROR_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.ConnectError, cast(type[Exception], MCP_HTTPX.ConnectError)))
)
HTTP_TIMEOUT_ERROR_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.TimeoutException, cast(type[Exception], MCP_HTTPX.TimeoutException)))
)
HTTP_ERROR_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.HTTPError, cast(type[Exception], MCP_HTTPX.HTTPError)))
)
HTTP_INVALID_URL_TYPES: tuple[type[Exception], ...] = tuple(
dict.fromkeys((httpx.InvalidURL, cast(type[Exception], MCP_HTTPX.InvalidURL)))
)
MCP_HTTPX = import_module("httpx2") if MCP_V2 else require_legacy_httpx()
def _http_error_types(name: str) -> tuple[type[Exception], ...]:
return (cast(type[Exception], getattr(MCP_HTTPX, name)),)
HTTP_STATUS_ERROR_TYPES = _http_error_types("HTTPStatusError")
HTTP_REQUEST_ERROR_TYPES = _http_error_types("RequestError")
HTTP_CONNECT_ERROR_TYPES = _http_error_types("ConnectError")
HTTP_TIMEOUT_ERROR_TYPES = _http_error_types("TimeoutException")
HTTP_ERROR_TYPES = _http_error_types("HTTPError")
HTTP_INVALID_URL_TYPES = _http_error_types("InvalidURL")
@cache
def enable_legacy_httpx_compat() -> None:
global HTTP_STATUS_ERROR_TYPES
global HTTP_REQUEST_ERROR_TYPES
global HTTP_CONNECT_ERROR_TYPES
global HTTP_TIMEOUT_ERROR_TYPES
global HTTP_ERROR_TYPES
global HTTP_INVALID_URL_TYPES
def with_legacy(current: tuple[type[Exception], ...], name: str) -> tuple[type[Exception], ...]:
legacy = cast(tuple[type[Exception], ...], legacy_httpx_types(name))
return tuple(dict.fromkeys((*current, *legacy)))
HTTP_STATUS_ERROR_TYPES = with_legacy(HTTP_STATUS_ERROR_TYPES, "HTTPStatusError")
HTTP_REQUEST_ERROR_TYPES = with_legacy(HTTP_REQUEST_ERROR_TYPES, "RequestError")
HTTP_CONNECT_ERROR_TYPES = with_legacy(HTTP_CONNECT_ERROR_TYPES, "ConnectError")
HTTP_TIMEOUT_ERROR_TYPES = with_legacy(HTTP_TIMEOUT_ERROR_TYPES, "TimeoutException")
HTTP_ERROR_TYPES = with_legacy(HTTP_ERROR_TYPES, "HTTPError")
HTTP_INVALID_URL_TYPES = with_legacy(HTTP_INVALID_URL_TYPES, "InvalidURL")
def create_v2_client(
@@ -133,7 +149,7 @@ def mcp_error_message(error: BaseException) -> str:
def mcp_request_timeout_code() -> int:
return -32001 if MCP_V2 else int(httpx.codes.REQUEST_TIMEOUT)
return -32001 if MCP_V2 else int(MCP_HTTPX.codes.REQUEST_TIMEOUT)
def is_mcp_timeout_error(error: BaseException) -> bool:
+21 -24
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, NoReturn, TypeVar, Union, cast
import anyio
import httpx
if sys.version_info < (3, 11):
from exceptiongroup import BaseExceptionGroup # pyright: ignore[reportMissingImports]
@@ -46,13 +45,8 @@ from ..logger import (
from ..run_context import RunContextWrapper
from ..tool import ToolErrorFunction
from ..util._types import MaybeAwaitable
from . import _compat as mcp_compat
from ._compat import (
HTTP_CONNECT_ERROR_TYPES,
HTTP_ERROR_TYPES,
HTTP_INVALID_URL_TYPES,
HTTP_REQUEST_ERROR_TYPES,
HTTP_STATUS_ERROR_TYPES,
HTTP_TIMEOUT_ERROR_TYPES,
MCP_HTTPX,
MCP_V2,
MCPError,
@@ -202,7 +196,7 @@ def _transport_error_urls_are_safe(
if redirect_location is not None:
try:
request_urls.append(str(response_url.join(redirect_location)))
except HTTP_INVALID_URL_TYPES + (ValueError,):
except mcp_compat.HTTP_INVALID_URL_TYPES + (ValueError,):
return False
return all(get_mcp_server_log_name(url) == url for url in request_urls)
@@ -325,7 +319,7 @@ def _create_default_streamable_http_client(
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return httpx.AsyncClient(**kwargs)
return MCP_HTTPX.AsyncClient(**kwargs)
def _validate_v2_http_auth(auth: Any) -> None:
@@ -435,7 +429,7 @@ class _InitializedNotificationTolerantStreamableHTTPTransport(
try:
await super()._handle_post_request(ctx)
except HTTP_ERROR_TYPES as exc:
except mcp_compat.HTTP_ERROR_TYPES as exc:
_log_transport_warning(
"Ignoring initialized notification HTTP failure",
exc,
@@ -453,7 +447,7 @@ async def _streamablehttp_client_with_transport(
sse_read_timeout: float | timedelta = 60 * 5,
terminate_on_close: bool = True,
httpx_client_factory: HttpClientFactory = _create_default_streamable_http_client,
auth: httpx.Auth | None = None,
auth: Any = None,
transport_factory: Callable[[str], Any] = StreamableHTTPTransport,
) -> AsyncGenerator[MCPStreamTransport, None]:
timeout_seconds = timeout.total_seconds() if isinstance(timeout, timedelta) else timeout
@@ -465,7 +459,7 @@ async def _streamablehttp_client_with_transport(
client = httpx_client_factory(
headers=headers,
timeout=httpx.Timeout(timeout_seconds, read=sse_read_timeout_seconds),
timeout=MCP_HTTPX.Timeout(timeout_seconds, read=sse_read_timeout_seconds),
auth=auth,
)
transport = transport_factory(url)
@@ -916,6 +910,7 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
retry_backoff_seconds_max: The non-negative finite maximum delay, in seconds, between
retries. Defaults to `None`, which leaves exponential backoff uncapped.
"""
mcp_compat.enable_legacy_httpx_compat()
super().__init__(
use_structured_content=use_structured_content,
require_approval=require_approval,
@@ -1104,9 +1099,9 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
candidates = error.exceptions if isinstance(error, BaseExceptionGroup) else (error,)
for error_types in (
HTTP_STATUS_ERROR_TYPES,
HTTP_CONNECT_ERROR_TYPES,
HTTP_TIMEOUT_ERROR_TYPES,
mcp_compat.HTTP_STATUS_ERROR_TYPES,
mcp_compat.HTTP_CONNECT_ERROR_TYPES,
mcp_compat.HTTP_TIMEOUT_ERROR_TYPES,
):
selected_http_error = next(
(
@@ -1179,7 +1174,9 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
base_error_group: BaseExceptionGroup | None = None
try:
return await func()
except HTTP_STATUS_ERROR_TYPES + HTTP_REQUEST_ERROR_TYPES as http_error:
except (
mcp_compat.HTTP_STATUS_ERROR_TYPES + mcp_compat.HTTP_REQUEST_ERROR_TYPES
) as http_error:
transport_error = self._user_error_for_request_operation(operation, http_error)
except BaseExceptionGroup as error_group:
http_errors = self._extract_http_errors_from_exception(error_group)
@@ -1478,14 +1475,14 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
if self.tool_filter is not None:
filtered_tools = await self._apply_tool_filter(filtered_tools, run_context, agent)
return filtered_tools
except HTTP_STATUS_ERROR_TYPES as e:
except mcp_compat.HTTP_STATUS_ERROR_TYPES as e:
status_code = http_status_code(e)
transport_error = UserError(
f"Failed to list tools from MCP server '{self._error_name}': "
f"HTTP error {status_code}"
)
transport_cause = _safe_transport_cause(e)
except HTTP_REQUEST_ERROR_TYPES as e:
except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e:
transport_cause = _safe_transport_cause(e)
if transport_cause is not None and not is_http_connect_error(e):
raise
@@ -1534,14 +1531,14 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
lambda: cast(Any, session).call_tool(tool_name, arguments, meta=meta)
)
)
except HTTP_STATUS_ERROR_TYPES as e:
except mcp_compat.HTTP_STATUS_ERROR_TYPES as e:
status_code = http_status_code(e)
transport_error = UserError(
f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': "
f"HTTP error {status_code}"
)
transport_cause = _safe_transport_cause(e)
except HTTP_REQUEST_ERROR_TYPES as e:
except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e:
transport_cause = _safe_transport_cause(e)
if transport_cause is not None and not is_http_connect_error(e):
raise
@@ -1746,8 +1743,8 @@ class _MCPServerWithClientSession(MCPServer, abc.ABC):
raise
except ( # type: ignore[misc]
BaseExceptionGroup,
*HTTP_STATUS_ERROR_TYPES,
*HTTP_REQUEST_ERROR_TYPES,
*mcp_compat.HTTP_STATUS_ERROR_TYPES,
*mcp_compat.HTTP_REQUEST_ERROR_TYPES,
) as e:
selected_http_error = self._select_cleanup_transport_error(e)
if selected_http_error is not None:
@@ -2446,14 +2443,14 @@ class MCPServerStreamableHttp(_MCPServerWithClientSession):
backoffs_taken += 1
await asyncio.sleep(backoff)
first_attempt = False
except HTTP_STATUS_ERROR_TYPES as e:
except mcp_compat.HTTP_STATUS_ERROR_TYPES as e:
status_code = http_status_code(e)
transport_error = UserError(
f"Failed to call tool '{tool_name}' on MCP server '{self._error_name}': "
f"HTTP error {status_code}"
)
transport_cause = _safe_transport_cause(e)
except HTTP_REQUEST_ERROR_TYPES as e:
except mcp_compat.HTTP_REQUEST_ERROR_TYPES as e:
transport_cause = _safe_transport_cause(e)
if transport_cause is not None and not is_http_connect_error(e):
raise
+7 -5
View File
@@ -5,11 +5,13 @@ from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from email.utils import parsedate_to_datetime
from typing import Any
from typing import Any, cast
import httpx
import httpx2
from openai import APIStatusError
from .._httpx_compat import is_legacy_httpx_instance
def iter_error_chain(error: Exception) -> Iterator[Exception]:
current: Exception | None = error
@@ -23,7 +25,7 @@ def iter_error_chain(error: Exception) -> Iterator[Exception]:
def header_lookup(headers: Any, key: str) -> str | None:
normalized_key = key.lower()
if isinstance(headers, httpx.Headers):
if isinstance(headers, httpx2.Headers):
value = headers.get(key)
return value if isinstance(value, str) else None
if isinstance(headers, Mapping):
@@ -35,8 +37,8 @@ def header_lookup(headers: Any, key: str) -> str | None:
def _get_candidate_header(candidate: Exception, key: str) -> str | None:
response = getattr(candidate, "response", None)
if isinstance(response, httpx.Response):
header_value = header_lookup(response.headers, key)
if isinstance(response, httpx2.Response) or is_legacy_httpx_instance(response, "Response"):
header_value = header_lookup(cast(Any, response).headers, key)
if header_value is not None:
return header_value
+6 -6
View File
@@ -5,8 +5,8 @@ import os
import weakref
from typing import Any
import httpx
from openai import AsyncOpenAI, DefaultAsyncHttpxClient
import httpx2
from openai import AsyncOpenAI, DefaultAsyncHttpx2Client
from ..exceptions import UserError
from . import _openai_shared
@@ -28,17 +28,17 @@ from .openai_responses import (
DEFAULT_MODEL: str = "gpt-4o"
_http_client: httpx.AsyncClient | None = None
_http_client: httpx2.AsyncClient | None = None
_WSModelCacheKey = tuple[str, bool]
_WSLoopModelCache = dict[_WSModelCacheKey, Model]
# If we create a new httpx client for each request, that would mean no sharing of connection pools,
# If we create a new HTTP client for each request, that would mean no sharing of connection pools,
# which would mean worse latency and resource usage. So, we share the client across requests.
def shared_http_client() -> httpx.AsyncClient:
def shared_http_client() -> httpx2.AsyncClient:
global _http_client
if _http_client is None:
_http_client = DefaultAsyncHttpxClient()
_http_client = DefaultAsyncHttpx2Client()
return _http_client
+7 -3
View File
@@ -19,7 +19,7 @@ from typing import (
overload,
)
import httpx
import httpx2
from openai import AsyncOpenAI, NotGiven, Omit, omit
from openai.types import ChatModel
from openai.types.responses import (
@@ -41,6 +41,7 @@ from openai.types.responses.tool_param import LocalShell
from typing_extensions import NotRequired
from .. import _debug
from .._httpx_compat import is_legacy_httpx_instance
from .._tool_identity import (
get_explicit_function_tool_namespace,
get_function_tool_namespace_description,
@@ -1356,7 +1357,7 @@ class OpenAIResponsesWSModel(OpenAIResponsesModel):
if timeout is None or _is_openai_omitted_value(timeout):
return _WebsocketRequestTimeouts(lock=None, connect=None, send=None, recv=None)
if isinstance(timeout, httpx.Timeout):
if isinstance(timeout, httpx2.Timeout) or is_legacy_httpx_instance(timeout, "Timeout"):
return _WebsocketRequestTimeouts(
lock=None if timeout.pool is None else float(timeout.pool),
connect=None if timeout.connect is None else float(timeout.connect),
@@ -1478,7 +1479,10 @@ class OpenAIResponsesWSModel(OpenAIResponsesModel):
def _prepare_websocket_url(self, extra_query: Any) -> str:
if self._client.websocket_base_url is not None:
base_url = httpx.URL(self._client.websocket_base_url)
websocket_base_url = self._client.websocket_base_url
if is_legacy_httpx_instance(websocket_base_url, "URL"):
websocket_base_url = str(websocket_base_url)
base_url = httpx2.URL(websocket_base_url)
ws_scheme = {"http": "ws", "https": "wss"}.get(base_url.scheme, base_url.scheme)
base_url = base_url.copy_with(scheme=ws_scheme)
else:
+20 -10
View File
@@ -6,9 +6,10 @@ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable,
from inspect import isawaitable
from typing import Any
import httpx
import httpx2
from openai import APIConnectionError, APITimeoutError, BadRequestError
from .._httpx_compat import is_legacy_httpx_instance
from ..items import ModelResponse, TResponseStreamEvent
from ..logger import log_model_action_debug, logger
from ..models._retry_runtime import (
@@ -45,6 +46,20 @@ DEFAULT_BACKOFF_MULTIPLIER = 2.0
DEFAULT_BACKOFF_JITTER = True
COMPATIBILITY_CONVERSATION_LOCKED_RETRIES = 3
_RETRY_SAFE_STREAM_EVENT_TYPES = frozenset({"response.created", "response.in_progress"})
_NETWORK_ERROR_TYPES = (
httpx2.ConnectError,
httpx2.ReadError,
httpx2.RemoteProtocolError,
httpx2.TimeoutException,
httpx2.WriteError,
)
_LEGACY_NETWORK_ERROR_TYPE_NAMES = (
"ConnectError",
"ReadError",
"RemoteProtocolError",
"TimeoutException",
"WriteError",
)
def _is_conversation_locked_error(error: Exception) -> bool:
@@ -70,18 +85,13 @@ def _is_network_like_error(error: Exception) -> bool:
if isinstance(error, APIConnectionError | APITimeoutError | TimeoutError):
return True
network_error_types = (
httpx.ConnectError,
httpx.ReadError,
httpx.RemoteProtocolError,
httpx.TimeoutException,
httpx.WriteError,
)
if isinstance(error, network_error_types):
if isinstance(error, _NETWORK_ERROR_TYPES):
return True
for candidate in _iter_error_chain(error):
if isinstance(candidate, network_error_types):
if isinstance(candidate, _NETWORK_ERROR_TYPES):
return True
if is_legacy_httpx_instance(candidate, *_LEGACY_NETWORK_ERROR_TYPE_NAMES):
return True
if candidate.__class__.__module__.startswith(
"websockets"
+5 -5
View File
@@ -11,7 +11,7 @@ from collections.abc import Callable
from functools import cached_property
from typing import Any, cast
import httpx
import httpx2
from .. import _debug
from ..logger import (
@@ -87,7 +87,7 @@ class BackendSpanExporter(TracingExporter):
self._shutdown_event = threading.Event()
# Keep a client open for connection pooling across multiple export calls
self._client = httpx.Client(timeout=httpx.Timeout(timeout=60, connect=5.0))
self._client = httpx2.Client(timeout=httpx2.Timeout(timeout=60, connect=5.0))
def set_api_key(self, api_key: str):
"""Set the OpenAI API key for the exporter.
@@ -201,7 +201,7 @@ class BackendSpanExporter(TracingExporter):
logger.warning(
"[non-fatal] Tracing: server error %s, retrying.", response.status_code
)
except httpx.RequestError as exc:
except httpx2.RequestError as exc:
# Network or other I/O error, we'll retry
log_model_and_tool_action_warning(
logger, "[non-fatal] Tracing request failed", exc
@@ -220,7 +220,7 @@ class BackendSpanExporter(TracingExporter):
break
delay = min(delay * 2, self.max_delay)
def _timeout_for_deadline(self, deadline: float | None) -> httpx.Timeout | None:
def _timeout_for_deadline(self, deadline: float | None) -> httpx2.Timeout | None:
if deadline is None:
return None
@@ -229,7 +229,7 @@ class BackendSpanExporter(TracingExporter):
return None
connect_timeout = min(5.0, remaining)
return httpx.Timeout(remaining, connect=connect_timeout)
return httpx2.Timeout(remaining, connect=connect_timeout)
def _sleep_before_retry(self, sleep_time: float, deadline: float | None) -> bool:
if deadline is None:
@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import Any
import httpx
from openai import AsyncOpenAI, DefaultAsyncHttpxClient
import httpx2
from openai import AsyncOpenAI, DefaultAsyncHttpx2Client
from ...exceptions import UserError
from ...models import _openai_shared
@@ -16,15 +16,15 @@ from ..model import STTModel, TTSModel, VoiceModelProvider
from .openai_stt import OpenAISTTModel
from .openai_tts import OpenAITTSModel
_http_client: httpx.AsyncClient | None = None
_http_client: httpx2.AsyncClient | None = None
# If we create a new httpx client for each request, that would mean no sharing of connection pools,
# If we create a new HTTP client for each request, that would mean no sharing of connection pools,
# which would mean worse latency and resource usage. So, we share the client across requests.
def shared_http_client() -> httpx.AsyncClient:
def shared_http_client() -> httpx2.AsyncClient:
global _http_client
if _http_client is None:
_http_client = DefaultAsyncHttpxClient()
_http_client = DefaultAsyncHttpx2Client()
return _http_client
+2 -2
View File
@@ -1,6 +1,6 @@
from typing import Any
import httpx
import httpx2
import litellm
import pytest
from httpx import Headers, Response
@@ -441,7 +441,7 @@ def test_litellm_get_retry_advice_keeps_stateful_transport_failures_ambiguous()
model = LitellmModel(model="test-model")
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
request=httpx2.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
+10 -10
View File
@@ -4,7 +4,7 @@ import asyncio
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import httpx2
import pytest
from openai import APIConnectionError, APIStatusError, BadRequestError
from pydantic import ValidationError
@@ -80,13 +80,13 @@ def test_retry_capabilities_preserve_falsey_policy() -> None:
def _connection_error(message: str = "connection error") -> APIConnectionError:
return APIConnectionError(
message=message,
request=httpx.Request("POST", "https://example.com"),
request=httpx2.Request("POST", "https://example.com"),
)
def _conversation_locked_error() -> BadRequestError:
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
request = httpx2.Request("POST", "https://example.com")
response = httpx2.Response(
400,
request=request,
json={"error": {"code": "conversation_locked", "message": "locked"}},
@@ -101,8 +101,8 @@ def _conversation_locked_error() -> BadRequestError:
def _status_error(status_code: int, code: str = "server_error") -> APIStatusError:
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
request = httpx2.Request("POST", "https://example.com")
response = httpx2.Response(
status_code,
request=request,
json={"error": {"code": code, "message": code}},
@@ -117,8 +117,8 @@ def _status_error(status_code: int, code: str = "server_error") -> APIStatusErro
def _status_error_without_code(status_code: int, body_code: str = "server_error") -> APIStatusError:
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
request = httpx2.Request("POST", "https://example.com")
response = httpx2.Response(
status_code,
request=request,
json={"error": {"code": body_code, "message": body_code}},
@@ -778,8 +778,8 @@ async def test_get_response_with_retry_honors_explicit_none_retry_after_override
async def get_response() -> ModelResponse:
nonlocal calls
calls += 1
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
request = httpx2.Request("POST", "https://example.com")
response = httpx2.Response(
429,
request=request,
headers={"retry-after-ms": "1250"},
+13 -13
View File
@@ -4,7 +4,7 @@ import logging
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import httpx2
import pytest
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, omit
from openai._models import add_request_id
@@ -101,7 +101,7 @@ async def _run_chat_completions_model_with_custom_base_url(
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("https://custom.example.test/v1/")
self.base_url = httpx2.URL("https://custom.example.test/v1/")
completions = DummyCompletions()
model = OpenAIChatCompletionsModel(
@@ -588,7 +588,7 @@ async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> Non
class DummyClient:
def __init__(self) -> None:
self.chat = type("_Chat", (), {"completions": DummyCompletions()})()
self.base_url = httpx.URL("http://fake")
self.base_url = httpx2.URL("http://fake")
model = OpenAIChatCompletionsModel(
model="gpt-4",
@@ -639,7 +639,7 @@ async def test_get_response_warns_and_sends_placeholder_for_non_text_tool_output
def __init__(self) -> None:
self.completions = DummyCompletions()
self.chat = type("_Chat", (), {"completions": self.completions})()
self.base_url = httpx.URL("http://fake")
self.base_url = httpx2.URL("http://fake")
client = DummyClient()
model = OpenAIChatCompletionsModel(
@@ -899,7 +899,7 @@ async def test_get_response_rejects_custom_tool_call_in_strict_mode(monkeypatch)
def test_get_client_disables_provider_managed_retries_on_runner_retry() -> None:
class DummyChatCompletionsClient:
def __init__(self) -> None:
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.base_url = httpx2.URL("https://api.openai.com/v1/")
self.chat = type("ChatNamespace", (), {"completions": object()})()
self.with_options_calls: list[dict[str, Any]] = []
@@ -974,7 +974,7 @@ async def test_fetch_response_non_stream(monkeypatch) -> None:
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("http://fake")
self.base_url = httpx2.URL("http://fake")
msg = ChatCompletionMessage(role="assistant", content="ignored")
choice = Choice(index=0, finish_reason="stop", message=msg)
@@ -1163,7 +1163,7 @@ async def test_get_response_accepts_raw_chat_completions_image_content() -> None
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.base_url = httpx2.URL("https://api.openai.com/v1/")
msg = ChatCompletionMessage(role="assistant", content="ok")
choice = Choice(index=0, finish_reason="stop", message=msg)
@@ -1251,7 +1251,7 @@ async def test_fetch_response_stream(monkeypatch) -> None:
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("http://fake")
self.base_url = httpx2.URL("http://fake")
completions = DummyCompletions()
dummy_client = DummyClient(completions)
@@ -1313,8 +1313,8 @@ def test_clean_gemini_tool_call_id_removes_thought_suffix() -> None:
def test_get_retry_advice_uses_openai_headers() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx.Response(
request = httpx2.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx2.Response(
429,
request=request,
headers={
@@ -1351,7 +1351,7 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
request=httpx2.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
advice = model.get_retry_advice(
@@ -1371,8 +1371,8 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx.Response(
request = httpx2.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx2.Response(
429,
request=request,
json={"error": {"code": "rate_limit"}},
@@ -3,7 +3,7 @@ import logging
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import httpx2
import pytest
from openai.types.chat.chat_completion import ChatCompletion, Choice as ChatCompletionChoice
from openai.types.chat.chat_completion_chunk import (
@@ -178,7 +178,7 @@ async def test_stream_response_forwards_dictionary_agent_model_settings(
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.base_url = httpx2.URL("https://api.openai.com/v1/")
completions = DummyCompletions()
model = OpenAIChatCompletionsModel(
@@ -3713,10 +3713,10 @@ async def test_stream_response_propagates_request_id(monkeypatch) -> None:
"""Mimics `openai.AsyncStream`, which exposes the raw HTTP response."""
def __init__(self) -> None:
self.response = httpx.Response(
self.response = httpx2.Response(
200,
headers={"x-request-id": "req_streamed_456"},
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
request=httpx2.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
def __aiter__(self) -> AsyncIterator[ChatCompletionChunk]:
+57 -24
View File
@@ -5,7 +5,7 @@ import json
from types import SimpleNamespace
from typing import Any, cast
import httpx
import httpx2
import pytest
from openai import NOT_GIVEN, APIConnectionError, AsyncOpenAI, RateLimitError, omit
from openai.types.responses import ResponseCompletedEvent, ResponseErrorEvent
@@ -59,7 +59,7 @@ async def _run_responses_model_with_custom_base_url(
class DummyResponsesClient:
def __init__(self, responses: DummyResponses) -> None:
self.responses = responses
self.base_url = httpx.URL("https://custom.example.test/v1/")
self.base_url = httpx2.URL("https://custom.example.test/v1/")
responses = DummyResponses()
model = OpenAIResponsesModel(
@@ -75,19 +75,19 @@ async def _run_responses_model_with_custom_base_url(
async def _run_responses_model_with_official_client(
model_settings: ModelSettings | None = None,
) -> list[httpx.Request]:
requests: list[httpx.Request] = []
) -> list[httpx2.Request]:
requests: list[httpx2.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
async def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx.Response(
return httpx2.Response(
200,
content=get_response_obj([]).model_dump_json(),
headers={"content-type": "application/json"},
request=request,
)
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(handler))
try:
client = AsyncOpenAI(
api_key="test-key",
@@ -134,7 +134,7 @@ class DummyWSConnection:
class DummyWSClient:
def __init__(self):
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.base_url = httpx2.URL("https://api.openai.com/v1/")
self.websocket_base_url = None
self.default_query: dict[str, Any] = {}
self.auth_headers = {"Authorization": "Bearer test-key"}
@@ -2913,7 +2913,7 @@ async def test_websocket_model_does_not_retry_after_client_initiated_close(monke
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_preserves_non_tls_scheme_mapping():
client = DummyWSClient()
client.base_url = httpx.URL("http://127.0.0.1:8080/v1/")
client.base_url = httpx2.URL("http://127.0.0.1:8080/v1/")
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query=None)
@@ -2928,12 +2928,28 @@ def test_websocket_model_prepare_websocket_url_appends_path_with_existing_query(
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"})
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a"}
@pytest.mark.allow_call_model_methods
def test_websocket_model_prepare_websocket_url_accepts_legacy_httpx_url():
import httpx
client = DummyWSClient()
client.websocket_base_url = httpx.URL("https://proxy.example.test/v1?token=abc")
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"})
parsed = httpx2.URL(ws_url)
assert parsed.scheme == "wss"
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a"}
@pytest.mark.allow_call_model_methods
@pytest.mark.parametrize(
("configured_ws_base_url", "expected_scheme"),
@@ -2950,7 +2966,7 @@ def test_websocket_model_prepare_websocket_url_normalizes_explicit_http_schemes(
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"route": "team-a"})
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.scheme == expected_scheme
assert parsed.path == "/v1/responses"
@@ -2967,7 +2983,7 @@ def test_websocket_model_prepare_websocket_url_treats_top_level_omit_sentinels_a
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query=extra_query)
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc"}
@@ -2981,7 +2997,7 @@ def test_websocket_model_prepare_websocket_url_skips_not_given_query_values():
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"tenant": NOT_GIVEN, "region": "us"})
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"token": "abc", "route": "team-a", "region": "us"}
@@ -3264,7 +3280,7 @@ async def test_websocket_model_get_response_allows_zero_pool_timeout_when_lock_u
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=1.0, write=1.0, pool=0.0)
client.timeout = httpx2.Timeout(connect=1.0, read=1.0, write=1.0, pool=0.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws = DummyWSConnection([_response_completed_frame("resp-zero-pool", 1)])
@@ -3289,6 +3305,23 @@ async def test_websocket_model_get_response_allows_zero_pool_timeout_when_lock_u
assert len(ws.sent_messages) == 1
@pytest.mark.allow_call_model_methods
def test_websocket_model_request_timeouts_accept_legacy_httpx_timeout():
import httpx
client = DummyWSClient()
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
timeouts = model._get_websocket_request_timeouts(
httpx.Timeout(connect=1.0, read=2.0, write=3.0, pool=4.0)
)
assert timeouts.lock == 4.0
assert timeouts.connect == 1.0
assert timeouts.send == 3.0
assert timeouts.recv == 2.0
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_websocket_model_get_response_allows_zero_timeout_when_ws_ops_are_immediate(
@@ -3325,7 +3358,7 @@ async def test_websocket_model_get_response_uses_client_default_timeout_when_no_
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
client.timeout = httpx2.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class SlowRecvWSConnection(DummyWSConnection):
@@ -3362,7 +3395,7 @@ async def test_websocket_model_get_response_uses_client_default_timeout_when_ove
monkeypatch,
):
client = DummyWSClient()
client.timeout = httpx.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
client.timeout = httpx2.Timeout(connect=1.0, read=0.01, write=1.0, pool=1.0)
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
class SlowRecvWSConnection(DummyWSConnection):
@@ -3505,7 +3538,7 @@ def test_websocket_model_prepare_websocket_url_includes_client_default_query():
ws_url = model._prepare_websocket_url(
extra_query={"route": "team-a", "api-version": "2026-01-01-preview"}
)
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {
@@ -3523,7 +3556,7 @@ def test_websocket_model_prepare_websocket_url_omit_removes_inherited_query_para
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
ws_url = model._prepare_websocket_url(extra_query={"token": omit, "route": omit, "keep": "1"})
parsed = httpx.URL(ws_url)
parsed = httpx2.URL(ws_url)
assert parsed.path == "/v1/responses"
assert dict(parsed.params) == {"region": "us", "keep": "1"}
@@ -3685,8 +3718,8 @@ async def test_websocket_model_open_websocket_connection_honors_connect_timeout(
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_uses_openai_headers() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(
request = httpx2.Request("POST", "https://api.openai.com/v1/responses")
response = httpx2.Response(
429,
request=request,
headers={
@@ -3724,7 +3757,7 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
request=httpx2.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
@@ -3745,8 +3778,8 @@ def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
@pytest.mark.allow_call_model_methods
def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
response = httpx.Response(
request = httpx2.Request("POST", "https://api.openai.com/v1/responses")
response = httpx2.Response(
429,
request=request,
json={"error": {"code": "rate_limit"}},
@@ -3777,7 +3810,7 @@ def test_get_retry_advice_keeps_stateless_transport_failures_retryable() -> None
model = OpenAIResponsesModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
request=httpx2.Request("POST", "https://api.openai.com/v1/responses"),
)
advice = model.get_retry_advice(
+15
View File
@@ -11,6 +11,7 @@ from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
import httpx
import httpx2
from agents.models._openai_retry import get_openai_retry_advice
from agents.models._retry_runtime import (
@@ -51,6 +52,11 @@ def test_header_lookup_httpx_headers() -> None:
assert _header_lookup(None, "retry-after") is None
def test_header_lookup_httpx2_headers() -> None:
headers = httpx2.Headers({"retry-after": "7"})
assert _header_lookup(headers, "retry-after") == "7"
def test_get_header_value_reads_response_headers_attr() -> None:
class _Err(Exception):
response_headers = {"retry-after": "3"}
@@ -132,6 +138,15 @@ def test_provider_and_runner_retry_normalization_share_metadata() -> None:
assert runner_normalized.retry_after == 1.5
def test_runner_normalizes_both_http_transport_families_as_network_errors() -> None:
errors = (
httpx.ReadError("legacy", request=httpx.Request("GET", "https://example.com")),
httpx2.ReadError("native", request=httpx2.Request("GET", "https://example.com")),
)
assert all(_normalize_retry_error(error, None).is_network_error for error in errors)
def test_advice_unsafe_to_replay() -> None:
error = Exception("cannot replay")
error.unsafe_to_replay = True # type: ignore[attr-defined]
+6 -1
View File
@@ -4,6 +4,7 @@ import os
import weakref
from typing import Any, cast
import httpx2
import openai
import pytest
@@ -17,7 +18,7 @@ from agents import (
)
from agents.models import _openai_shared
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.openai_provider import OpenAIProvider
from agents.models.openai_provider import OpenAIProvider, shared_http_client
from agents.models.openai_responses import OpenAIResponsesModel, OpenAIResponsesWSModel
@@ -71,6 +72,10 @@ def test_resp_set_default_openai_client():
assert resp_model._client.api_key == "test_key" # type: ignore
def test_openai_provider_shared_http_client_uses_httpx2() -> None:
assert isinstance(shared_http_client(), httpx2.AsyncClient)
def test_set_default_openai_api():
assert isinstance(OpenAIProvider().get_model("gpt-4"), OpenAIResponsesModel), (
"Default should be responses"
+20 -20
View File
@@ -8,7 +8,7 @@ import time
from typing import Any, cast
from unittest.mock import MagicMock, patch
import httpx
import httpx2
import pytest
import agents._debug as _debug
@@ -444,7 +444,7 @@ def mock_processor():
return processor
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_no_items(mock_client):
exporter = BackendSpanExporter(api_key="test_key")
exporter.export([])
@@ -453,7 +453,7 @@ def test_backend_span_exporter_no_items(mock_client):
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_no_api_key(mock_client):
# Ensure that os.environ is empty (sometimes devs have the openai api key set in their env)
@@ -466,7 +466,7 @@ def test_backend_span_exporter_no_api_key(mock_client):
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_2xx_success(mock_client):
mock_response = MagicMock()
mock_response.status_code = 200
@@ -480,7 +480,7 @@ def test_backend_span_exporter_2xx_success(mock_client):
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
@pytest.mark.parametrize("redacted", [True, False])
def test_backend_span_exporter_4xx_client_error(mock_client, monkeypatch, caplog, redacted: bool):
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
@@ -509,7 +509,7 @@ def test_backend_span_exporter_4xx_client_error(mock_client, monkeypatch, caplog
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_5xx_retry(mock_client):
mock_response = MagicMock()
mock_response.status_code = 500
@@ -528,7 +528,7 @@ def test_backend_span_exporter_5xx_retry(mock_client):
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_deadline_stops_during_5xx_retry_backoff(mock_client):
mock_response = MagicMock()
mock_response.status_code = 504
@@ -547,7 +547,7 @@ def test_backend_span_exporter_deadline_stops_during_5xx_retry_backoff(mock_clie
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_batch_trace_processor_shutdown_interrupts_exporter_retry_backoff(mock_client):
post_called = threading.Event()
mock_response = MagicMock()
@@ -588,7 +588,7 @@ def test_batch_trace_processor_shutdown_interrupts_exporter_retry_backoff(mock_c
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_batch_trace_processor_shutdown_without_timeout_preserves_export_retries(mock_client):
mock_response = MagicMock()
mock_response.status_code = 504
@@ -645,7 +645,7 @@ def test_tracing_atexit_cleanup_timeout_preserves_process_exit_code_on_504() ->
pass
client = Always504Client()
with patch("agents.tracing.processors.httpx.Client", return_value=client):
with patch("agents.tracing.processors.httpx2.Client", return_value=client):
exporter = BackendSpanExporter(
api_key="test_key",
max_retries=100,
@@ -702,10 +702,10 @@ def test_tracing_atexit_cleanup_timeout_preserves_process_exit_code_on_504() ->
assert float(shutdown_elapsed_lines[0][len(shutdown_elapsed_prefix) :]) < 0.5
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_request_error(mock_client):
# Make post() raise a RequestError each time
mock_client.return_value.post.side_effect = httpx.RequestError("Network error")
mock_client.return_value.post.side_effect = httpx2.RequestError("Network error")
exporter = BackendSpanExporter(api_key="test_key", max_retries=2, base_delay=0.1, max_delay=0.2)
with patch.object(exporter._shutdown_event, "wait", return_value=False) as wait_for_retry:
@@ -718,7 +718,7 @@ def test_backend_span_exporter_request_error(mock_client):
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_close(mock_client):
exporter = BackendSpanExporter(api_key="test_key")
exporter.close()
@@ -727,7 +727,7 @@ def test_backend_span_exporter_close(mock_client):
mock_client.return_value.close.assert_called_once()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_sanitizes_generation_usage_for_openai_tracing(mock_client):
"""Unsupported usage keys should be stripped before POSTing to OpenAI tracing."""
@@ -782,7 +782,7 @@ def test_backend_span_exporter_sanitizes_generation_usage_for_openai_tracing(moc
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_truncates_large_input_for_openai_tracing(mock_client):
class DummyItem:
tracing_api_key = None
@@ -816,7 +816,7 @@ def test_backend_span_exporter_truncates_large_input_for_openai_tracing(mock_cli
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_truncates_large_structured_input_without_stringifying(mock_client):
class NoStringifyDict(dict[str, Any]):
def __str__(self) -> str:
@@ -856,7 +856,7 @@ def test_backend_span_exporter_truncates_large_structured_input_without_stringif
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_keeps_generation_usage_for_custom_endpoint(mock_client):
class DummyItem:
tracing_api_key = None
@@ -894,7 +894,7 @@ def test_backend_span_exporter_keeps_generation_usage_for_custom_endpoint(mock_c
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_drops_non_generation_usage_for_openai_endpoint(mock_client):
class DummyItem:
tracing_api_key = None
@@ -920,7 +920,7 @@ def test_backend_span_exporter_drops_non_generation_usage_for_openai_endpoint(mo
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_keeps_non_generation_usage_for_custom_endpoint(mock_client):
class DummyItem:
tracing_api_key = None
@@ -965,7 +965,7 @@ def test_sanitize_for_openai_tracing_api_keeps_allowed_generation_usage():
exporter.close()
@patch("httpx.Client")
@patch("httpx2.Client")
def test_backend_span_exporter_keeps_large_input_for_custom_endpoint(mock_client):
class DummyItem:
tracing_api_key = None
+52 -3
View File
@@ -40,17 +40,17 @@ def test_import_agents_has_no_tracing_side_effects() -> None:
payload = _run_python(
"""
import json
import httpx
import httpx2
client_init_calls = 0
original_client_init = httpx.Client.__init__
original_client_init = httpx2.Client.__init__
def tracking_client_init(self, *args, **kwargs):
global client_init_calls
client_init_calls += 1
original_client_init(self, *args, **kwargs)
httpx.Client.__init__ = tracking_client_init
httpx2.Client.__init__ = tracking_client_init
import agents # noqa: F401
from agents.tracing import processors as tracing_processors
@@ -77,6 +77,55 @@ print(
assert payload["shutdown_handler_registered"] is False
def test_core_imports_do_not_require_legacy_httpx() -> None:
payload = _run_python(
"""
import importlib.abc
import json
import sys
class BlockLegacyHttpx(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname == "httpx" or fullname.startswith("httpx."):
raise ModuleNotFoundError(
f"blocked undeclared core dependency: {fullname}",
name=fullname,
)
return None
sys.meta_path.insert(0, BlockLegacyHttpx())
import httpx2
import agents
from agents.mcp import MCPServerStreamableHttp
from agents.run_internal.model_retry import _normalize_retry_error
request = httpx2.Request("GET", "https://example.com")
error = httpx2.ReadError("connection dropped", request=request)
normalized = _normalize_retry_error(error, None)
generic = _normalize_retry_error(ValueError("not a transport error"), None)
print(
json.dumps(
{
"agents_name": agents.__name__,
"mcp_server_name": MCPServerStreamableHttp.__name__,
"legacy_httpx_loaded": "httpx" in sys.modules,
"network_error": normalized.is_network_error,
"generic_network_error": generic.is_network_error,
}
)
)
"""
)
assert payload["agents_name"] == "agents"
assert payload["mcp_server_name"] == "MCPServerStreamableHttp"
assert payload["legacy_httpx_loaded"] is False
assert payload["network_error"] is True
assert payload["generic_network_error"] is False
def test_import_agents_does_not_require_sqlite3() -> None:
payload = _run_python(
"""
+6 -1
View File
@@ -2,12 +2,13 @@
from typing import Any, cast
import httpx2
import openai
import pytest
from agents.exceptions import UserError
from agents.models import _openai_shared
from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider
from agents.voice.models.openai_model_provider import OpenAIVoiceModelProvider, shared_http_client
@pytest.mark.parametrize(
@@ -32,6 +33,10 @@ def test_voice_provider_accepts_client_without_conflicting_args():
assert provider._get_client() is client
def test_voice_provider_shared_http_client_uses_httpx2() -> None:
assert isinstance(shared_http_client(), httpx2.AsyncClient)
def test_voice_provider_preserves_falsy_default_client(monkeypatch):
class FalsyClient:
def __bool__(self) -> bool:
Generated
+5 -5
View File
@@ -2453,21 +2453,21 @@ wheels = [
[[package]]
name = "openai"
version = "2.45.0"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "httpx2" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/8c/2f500e8be09d1ae98c530467962535198b02cd4550cd418bbbaedc8b2910/openai-3.0.0.tar.gz", hash = "sha256:ffd00ef1678d70957e1f1ed98d5bfcf1d661f41ea4482f22e7d0144a66435a49", size = 1123740, upload-time = "2026-08-12T01:55:50.849Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" },
{ url = "https://files.pythonhosted.org/packages/7b/0d/9850e7eddb5e66da4439ed503e78e09ad1fd0195e6df51e4236c75763581/openai-3.0.0-py3-none-any.whl", hash = "sha256:8d32ac3a6647a66910d6cb8a64f0fa5a6c823604b6e82db83d9d055c6709bd51", size = 1665775, upload-time = "2026-08-12T01:55:48.678Z" },
]
[[package]]
@@ -2608,7 +2608,7 @@ requires-dist = [
{ name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.19.0,<3" },
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.4.3" },
{ name = "numpy", marker = "python_full_version >= '3.10' and extra == 'voice'", specifier = ">=2.2.0,<3" },
{ name = "openai", specifier = ">=2.45.0,<3" },
{ name = "openai", specifier = ">=3.0.0,<4" },
{ name = "pydantic", specifier = ">=2.12.2,<3" },
{ name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.14" },
{ name = "redis", marker = "extra == 'redis'", specifier = ">=7" },