diff --git a/examples/realtime/twilio_sip/requirements.txt b/examples/realtime/twilio_sip/requirements.txt index 943a72eb..913a3272 100644 --- a/examples/realtime/twilio_sip/requirements.txt +++ b/examples/realtime/twilio_sip/requirements.txt @@ -1,3 +1,3 @@ fastapi>=0.120.0 -openai>=2.2,<3 +openai>=3.0.0,<4 uvicorn[standard]>=0.38.0 diff --git a/integration_tests/openai/test_retry.py b/integration_tests/openai/test_retry.py index 85a0c1e4..5cb52424 100644 --- a/integration_tests/openai/test_retry.py +++ b/integration_tests/openai/test_retry.py @@ -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) diff --git a/integration_tests/packaging/test_mcp_compat.py b/integration_tests/packaging/test_mcp_compat.py index cdf7dc24..50ca59b3 100644 --- a/integration_tests/packaging/test_mcp_compat.py +++ b/integration_tests/packaging/test_mcp_compat.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index 3faa0fd0..d887efbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/agents/_httpx_compat.py b/src/agents/_httpx_compat.py new file mode 100644 index 00000000..00a4aba4 --- /dev/null +++ b/src/agents/_httpx_compat.py @@ -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 diff --git a/src/agents/mcp/_compat.py b/src/agents/mcp/_compat.py index 859d71f3..e264f816 100644 --- a/src/agents/mcp/_compat.py +++ b/src/agents/mcp/_compat.py @@ -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: diff --git a/src/agents/mcp/server.py b/src/agents/mcp/server.py index 4ce05c95..053159c4 100644 --- a/src/agents/mcp/server.py +++ b/src/agents/mcp/server.py @@ -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 diff --git a/src/agents/models/_retry_runtime.py b/src/agents/models/_retry_runtime.py index 2661c06e..718931f4 100644 --- a/src/agents/models/_retry_runtime.py +++ b/src/agents/models/_retry_runtime.py @@ -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 diff --git a/src/agents/models/openai_provider.py b/src/agents/models/openai_provider.py index 703be44e..acb944b5 100644 --- a/src/agents/models/openai_provider.py +++ b/src/agents/models/openai_provider.py @@ -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 diff --git a/src/agents/models/openai_responses.py b/src/agents/models/openai_responses.py index 1f5e41e9..4289d032 100644 --- a/src/agents/models/openai_responses.py +++ b/src/agents/models/openai_responses.py @@ -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: diff --git a/src/agents/run_internal/model_retry.py b/src/agents/run_internal/model_retry.py index d01b327d..5452cdad 100644 --- a/src/agents/run_internal/model_retry.py +++ b/src/agents/run_internal/model_retry.py @@ -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" diff --git a/src/agents/tracing/processors.py b/src/agents/tracing/processors.py index 4545d2e1..b61f3e79 100644 --- a/src/agents/tracing/processors.py +++ b/src/agents/tracing/processors.py @@ -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: diff --git a/src/agents/voice/models/openai_model_provider.py b/src/agents/voice/models/openai_model_provider.py index e58ebe9d..17cd1dc1 100644 --- a/src/agents/voice/models/openai_model_provider.py +++ b/src/agents/voice/models/openai_model_provider.py @@ -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 diff --git a/tests/models/test_kwargs_functionality.py b/tests/models/test_kwargs_functionality.py index 7c5438ad..5a0981d2 100644 --- a/tests/models/test_kwargs_functionality.py +++ b/tests/models/test_kwargs_functionality.py @@ -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( diff --git a/tests/models/test_model_retry.py b/tests/models/test_model_retry.py index a17e98c9..30efa2bb 100644 --- a/tests/models/test_model_retry.py +++ b/tests/models/test_model_retry.py @@ -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"}, diff --git a/tests/models/test_openai_chatcompletions.py b/tests/models/test_openai_chatcompletions.py index 6120aa9c..b8198e6e 100644 --- a/tests/models/test_openai_chatcompletions.py +++ b/tests/models/test_openai_chatcompletions.py @@ -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"}}, diff --git a/tests/models/test_openai_chatcompletions_stream.py b/tests/models/test_openai_chatcompletions_stream.py index 93c08717..af953399 100644 --- a/tests/models/test_openai_chatcompletions_stream.py +++ b/tests/models/test_openai_chatcompletions_stream.py @@ -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]: diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index 0d17eb17..8ce1c40a 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -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( diff --git a/tests/models/test_openai_retry_helpers.py b/tests/models/test_openai_retry_helpers.py index 2f429561..d3a3eed4 100644 --- a/tests/models/test_openai_retry_helpers.py +++ b/tests/models/test_openai_retry_helpers.py @@ -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] diff --git a/tests/test_config.py b/tests/test_config.py index 797580f1..845bf213 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_trace_processor.py b/tests/test_trace_processor.py index e1ebfbb5..2ede56d8 100644 --- a/tests/test_trace_processor.py +++ b/tests/test_trace_processor.py @@ -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 diff --git a/tests/tracing/test_import_side_effects.py b/tests/tracing/test_import_side_effects.py index c343f240..0131b791 100644 --- a/tests/tracing/test_import_side_effects.py +++ b/tests/tracing/test_import_side_effects.py @@ -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( """ diff --git a/tests/voice/test_openai_model_provider.py b/tests/voice/test_openai_model_provider.py index 9906b25b..64a31521 100644 --- a/tests/voice/test_openai_model_provider.py +++ b/tests/voice/test_openai_model_provider.py @@ -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: diff --git a/uv.lock b/uv.lock index c092e351..b39b877a 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },