feat: support OpenAI Python 3 and HTTPX2 (#4380)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
fastapi>=0.120.0
|
||||
openai>=2.2,<3
|
||||
openai>=3.0.0,<4
|
||||
uvicorn[standard]>=0.38.0
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user