diff --git a/README.md b/README.md index 6cca3cd0a..e60e0d6aa 100644 --- a/README.md +++ b/README.md @@ -250,37 +250,11 @@ client = genai.Client( ) ``` -### HTTP client option: httpx2 - -By default, the SDK uses `httpx` for its HTTP transport. You can opt in to -[`httpx2`](https://github.com/pydantic/httpx2) by installing `google-genai[httpx2]` -and setting the `GOOGLE_GENAI_HTTP_CLIENT` environment variable: - -```bash -export GOOGLE_GENAI_HTTP_CLIENT=httpx2 -``` - -Alternatively, you can directly inject an `httpx2.Client` or `httpx2.AsyncClient` -via `HttpOptions`: - -```python -import httpx2 -from google import genai -from google.genai import types - -client = genai.Client( - http_options=types.HttpOptions( - httpx_client=httpx2.Client(), - httpx_async_client=httpx2.AsyncClient(), - ) -) -``` - ### Faster async client option: Aiohttp -By default we use httpx for both sync and async client implementations. In order +By default we use httpx2 for both sync and async client implementations. In order to have faster performance, you may install `google-genai[aiohttp]`. In Gen AI -SDK we configure `trust_env=True` to match with the default behavior of httpx. +SDK we configure `trust_env=True` to match with the default behavior of httpx2. Additional args of `aiohttp.ClientSession.request()` ([see `_RequestOptions` args](https://github.com/aio-libs/aiohttp/blob/v3.12.13/aiohttp/client.py#L170)) can be passed through the following way: @@ -295,7 +269,7 @@ client=Client(..., http_options=http_options) ### Proxy -Both httpx and aiohttp libraries use `urllib.request.getproxies` from +Both httpx2 and aiohttp libraries use `urllib.request.getproxies` from environment variables. Before client initialization, you may set proxy (and optional `SSL_CERT_FILE`) by setting the environment variables: @@ -304,8 +278,8 @@ export HTTPS_PROXY='http://username:password@proxy_uri:port' export SSL_CERT_FILE='client.pem' ``` -If you need `socks5` proxy, httpx [supports](https://www.python-httpx.org/advanced/proxies/#socks) `socks5` proxy if you pass it via -args to `httpx.Client()`. You may install `httpx[socks]` to use it. +If you need `socks5` proxy, httpx2 [supports](https://www.python-httpx.org/advanced/proxies/#socks) `socks5` proxy if you pass it via +args to `httpx2.Client()`. You may install `httpx2[socks]` to use it. Then, you can pass it through the following way: ```python diff --git a/docs/source/index.rst b/docs/source/index.rst index a285fcf80..2d8be4b0d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -218,9 +218,9 @@ To set the API version to `v1alpha` for the Gemini Developer API: Faster async client option: Aiohttp ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -By default we use httpx for both sync and async client implementations. In order +By default we use httpx2 for both sync and async client implementations. In order to have faster performance, you may install `google-genai[aiohttp]`. In Gen AI -SDK we configure `trust_env=True` to match with the default behavior of httpx. +SDK we configure `trust_env=True` to match with the default behavior of httpx2. Additional args of `aiohttp.ClientSession.request()` (`see _RequestOptions args `_) can be passed through the following way: @@ -237,7 +237,7 @@ can be passed through the following way: Proxy ^^^^^^^ -Both httpx and aiohttp libraries use `urllib.request.getproxies` from +Both httpx2 and aiohttp libraries use `urllib.request.getproxies` from environment variables. Before client initialization, you may set proxy (and optional SSL_CERT_FILE) by setting the environment variables: @@ -248,9 +248,9 @@ optional SSL_CERT_FILE) by setting the environment variables: export SSL_CERT_FILE='client.pem' -If you need `socks5` proxy, httpx `supports `_ -`socks5` proxy if you pass it via args to httpx.Client(). You may install -`httpx[socks]` to use it. Then you can pass it through the following way: +If you need `socks5` proxy, httpx2 `supports `_ +`socks5` proxy if you pass it via args to httpx2.Client(). You may install +`httpx2[socks]` to use it. Then you can pass it through the following way: .. code:: python diff --git a/google/genai/_api_client.py b/google/genai/_api_client.py index b722ac4b9..070848819 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -47,6 +47,7 @@ import google.auth.credentials from google.auth.credentials import Credentials from google.auth.transport import mtls +import httpx2 from pydantic import BaseModel from pydantic import ValidationError import tenacity @@ -86,61 +87,9 @@ if TYPE_CHECKING: from google.auth.transport.requests import AuthorizedSession # pylint: disable=g-import-not-at-top - import httpx - import httpx2 from multidict import CIMultiDictProxy from requests.structures import CaseInsensitiveDict # pylint: disable=g-import-not-at-top - _HTTPX_RESPONSE_TYPES = (httpx.Response, httpx2.Response) - _HTTPX_HEADERS_TYPES = (httpx.Headers, httpx2.Headers) - _HTTPX_TRANSIENT_EXC = ( - httpx.TimeoutException, - httpx.ConnectError, - httpx2.TimeoutException, - httpx2.ConnectError, - ) -else: - try: - import httpx - except ImportError: - httpx = None # type: ignore[assignment] - - try: - import httpx2 - except ImportError: - httpx2 = None # type: ignore[assignment] - - # httpx2 (https://github.com/pydantic/httpx2) is a drop-in fork of httpx under a - # separate import namespace, so its classes are not instances of the httpx - # equivalents. Widen the runtime type checks to accept either when httpx or - # httpx2 is installed. - _HTTPX_RESPONSE_TYPES = tuple( - cls - for cls in ( - getattr(httpx, 'Response', None), - getattr(httpx2, 'Response', None), - ) - if cls is not None - ) - _HTTPX_HEADERS_TYPES = tuple( - cls - for cls in ( - getattr(httpx, 'Headers', None), - getattr(httpx2, 'Headers', None), - ) - if cls is not None - ) - _HTTPX_TRANSIENT_EXC = tuple( - cls - for cls in ( - getattr(httpx, 'TimeoutException', None), - getattr(httpx, 'ConnectError', None), - getattr(httpx2, 'TimeoutException', None), - getattr(httpx2, 'ConnectError', None), - ) - if cls is not None - ) - logger = logging.getLogger('google_genai._api_client') CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB chunk size @@ -368,8 +317,7 @@ def __init__( self, headers: Union[ dict[str, str], - 'httpx.Headers', - 'httpx2.Headers', + httpx2.Headers, 'CIMultiDictProxy[str]', 'CaseInsensitiveDict[Any]', ], @@ -378,11 +326,9 @@ def __init__( ): if isinstance(headers, dict): self.headers = headers - elif isinstance(headers, _HTTPX_HEADERS_TYPES) or hasattr( - headers, 'get_list' - ): + elif isinstance(headers, httpx2.Headers): self.headers = { - key: ', '.join(headers.get_list(key)) for key in headers.keys() # type: ignore[attr-defined] + key: ', '.join(headers.get_list(key)) for key in headers.keys() } elif ( requests_module := _common.loaded_requests() @@ -469,22 +415,21 @@ def _iter_response_stream(self) -> Iterator[str]: """Iterates over chunks retrieved from the API.""" requests_module = _common.loaded_requests() if not ( - isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES) + isinstance(self.response_stream, httpx2.Response) or ( requests_module is not None and isinstance(self.response_stream, requests_module.Response) ) ): raise TypeError( - 'Expected self.response_stream to be an httpx.Response or' - ' httpx2.Response object, ' + 'Expected self.response_stream to be an httpx2.Response object, ' f'but got {type(self.response_stream).__name__}.' ) chunk = '' balance = 0 data_buffer: list[str] = [] - if isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES): + if isinstance(self.response_stream, httpx2.Response): response_stream = self.response_stream.iter_lines() else: response_stream = self.response_stream.iter_lines(decode_unicode=True) @@ -524,22 +469,22 @@ def _iter_response_stream(self) -> Iterator[str]: async def _aiter_response_stream(self) -> AsyncIterator[str]: """Asynchronously iterates over chunks retrieved from the API.""" is_valid_response = isinstance( - self.response_stream, _HTTPX_RESPONSE_TYPES + self.response_stream, httpx2.Response ) or ( has_aiohttp and isinstance(self.response_stream, aiohttp.ClientResponse) ) if not is_valid_response: raise TypeError( - 'Expected self.response_stream to be an httpx.Response,' - ' httpx2.Response, or aiohttp.ClientResponse object, but got' + 'Expected self.response_stream to be an httpx2.Response' + ' or aiohttp.ClientResponse object, but got' f' {type(self.response_stream).__name__}.' ) chunk = '' balance = 0 data_buffer: list[str] = [] - # httpx.Response has a dedicated async line iterator. - if isinstance(self.response_stream, _HTTPX_RESPONSE_TYPES): + # httpx2.Response has a dedicated async line iterator. + if isinstance(self.response_stream, httpx2.Response): try: response_stream: Any = self.response_stream async for line in response_stream.aiter_lines(): @@ -682,7 +627,7 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict: retriable_codes = options.http_status_codes or _RETRY_HTTP_STATUS_CODES retry = tenacity.retry_if_exception( lambda e: (isinstance(e, errors.APIError) and e.code in retriable_codes) - or isinstance(e, _HTTPX_TRANSIENT_EXC) + or isinstance(e, (httpx2.TimeoutException, httpx2.ConnectError)) or isinstance(e, _AIOHTTP_TRANSIENT_EXC), ) wait = tenacity.wait_exponential_jitter( @@ -700,154 +645,45 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict: } -if TYPE_CHECKING: - - class SyncHttpxClient(httpx.Client): - """Sync httpx client.""" - - class AsyncHttpxClient(httpx.AsyncClient): - """Async httpx client.""" - - class SyncHttpx2Client(httpx2.Client): # type: ignore[misc] - """Sync httpx2 client.""" - - class AsyncHttpx2Client(httpx2.AsyncClient): # type: ignore[misc] - """Async httpx2 client.""" - -else: - if httpx is not None: - - class SyncHttpxClient(httpx.Client): - """Sync httpx client.""" - - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) - - def __del__(self) -> None: - """Closes the httpx client.""" - try: - if self.is_closed: - return - except Exception: - pass - try: - self.close() - except Exception: - pass - - - class AsyncHttpxClient(httpx.AsyncClient): - """Async httpx client.""" - - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) - - def __del__(self) -> None: - try: - if self.is_closed: - return - except Exception: - pass - try: - asyncio.get_running_loop().create_task(self.aclose()) - except Exception: - pass - - else: - SyncHttpxClient = None # type: ignore[assignment,misc] - AsyncHttpxClient = None # type: ignore[assignment,misc] - - - if httpx2 is not None: - - class SyncHttpx2Client(httpx2.Client): - """Sync httpx2 client.""" - - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx2 client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) - - def __del__(self) -> None: - """Closes the httpx2 client.""" - try: - if self.is_closed: - return - except Exception: - pass - try: - self.close() - except Exception: - pass - - - class AsyncHttpx2Client(httpx2.AsyncClient): - """Async httpx2 client.""" - - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx2 client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) +class SyncHttpxClient(httpx2.Client): + """Sync httpx client.""" - def __del__(self) -> None: - try: - if self.is_closed: - return - except Exception: - pass - try: - asyncio.get_running_loop().create_task(self.aclose()) - except Exception: - pass + def __init__(self, **kwargs: Any) -> None: + """Initializes the httpx client.""" + kwargs.setdefault('follow_redirects', True) + super().__init__(**kwargs) - else: - SyncHttpx2Client = None # type: ignore[assignment,misc] - AsyncHttpx2Client = None # type: ignore[assignment,misc] + def __del__(self) -> None: + """Closes the httpx client.""" + try: + if self.is_closed: + return + except Exception: + pass + try: + self.close() + except Exception: + pass -def _get_http_client_backend() -> str: - """Determines whether to use 'httpx' or 'httpx2' as the default HTTP client. +class AsyncHttpxClient(httpx2.AsyncClient): + """Async httpx client.""" - Can be controlled via the GOOGLE_GENAI_HTTP_CLIENT environment variable: - - 'httpx2': Use httpx2 (raises ImportError if not installed). - - 'httpx': Use httpx (raises ImportError if not installed). - - 'auto' or unset: Default to httpx if installed, otherwise fall back to httpx2. - """ - env = os.environ.get('GOOGLE_GENAI_HTTP_CLIENT', '').lower().strip() - if env == 'httpx2': - if httpx2 is None: - raise ImportError( - 'httpx2 is configured via GOOGLE_GENAI_HTTP_CLIENT=httpx2, ' - 'but httpx2 is not installed.' - ) - return 'httpx2' - elif env == 'httpx': - if httpx is None: - raise ImportError( - 'httpx is configured via GOOGLE_GENAI_HTTP_CLIENT=httpx, ' - 'but httpx is not installed.' - ) - return 'httpx' - elif env and env != 'auto': - logger.warning( - 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT=%r; falling back to default.', env - ) + def __init__(self, **kwargs: Any) -> None: + """Initializes the httpx client.""" + kwargs.setdefault('follow_redirects', True) + super().__init__(**kwargs) - if httpx is not None: - return 'httpx' - # httpx is a required dependency so it _should_ always be present in a - # standard install, but if a user has uninstalled httpx in an environment - # that only has httpx2 installed, fall back to httpx2 before raising - # ImportError. - if httpx2 is not None: - return 'httpx2' - raise ImportError( - 'Neither httpx nor httpx2 is installed. Please install httpx2 or httpx.' - ) + def __del__(self) -> None: + try: + if self.is_closed: + return + except Exception: + pass + try: + asyncio.get_running_loop().create_task(self.aclose()) + except Exception: + pass class BaseApiClient: @@ -1068,19 +904,9 @@ def __init__( if self._http_options.headers is not None: append_library_version_headers(self._http_options.headers) - backend = _get_http_client_backend() - if backend == 'httpx2': - sync_client_cls: type[Any] = SyncHttpx2Client - async_client_cls: type[Any] = AsyncHttpx2Client - else: - sync_client_cls = SyncHttpxClient - async_client_cls = AsyncHttpxClient - client_args, async_client_args = self._ensure_httpx_ssl_ctx( self._http_options, vertexai=bool(self.vertexai), - client_cls=sync_client_cls, - async_client_cls=async_client_cls, ) self._async_httpx_client_args = async_client_args self._authorized_session: Optional['AuthorizedSession'] = None @@ -1088,14 +914,14 @@ def __init__( if self._http_options.httpx_client: self._httpx_client = self._http_options.httpx_client else: - self._httpx_client = sync_client_cls(**client_args) + self._httpx_client = SyncHttpxClient(**client_args) if self._use_google_auth_async(): self._async_httpx_client = None elif self._http_options.httpx_async_client: self._async_httpx_client = self._http_options.httpx_async_client else: - self._async_httpx_client = async_client_cls(**async_client_args) + self._async_httpx_client = AsyncHttpxClient(**async_client_args) # Initialize the aiohttp client sessions. self._aiohttp_sessions: dict[Any, Any] = {} @@ -1349,8 +1175,6 @@ def __del__(self, _warnings: Any = warnings) -> None: def _ensure_httpx_ssl_ctx( options: HttpOptions, vertexai: bool = False, - client_cls: Optional[type[Any]] = None, - async_client_cls: Optional[type[Any]] = None, ) -> Tuple[_common.StringDict, _common.StringDict]: """Ensures the SSL context is present in the HTTPX client args. @@ -1359,8 +1183,6 @@ def _ensure_httpx_ssl_ctx( Args: options: The http options to check for SSL context. vertexai: Whether Vertex AI is enabled. - client_cls: The client class to inspect parameters for sync client args (default: httpx.Client or httpx2.Client). - async_client_cls: The client class to inspect parameters for async client args (default: httpx.AsyncClient or httpx2.AsyncClient). Returns: A tuple of sync/async httpx client args. @@ -1378,74 +1200,63 @@ def _ensure_httpx_ssl_ctx( ) if ctx is None: - # Initialize the SSL context for the httpx client. - # Unlike requests, the httpx package does not automatically pull in the - # environment variables SSL_CERT_FILE or SSL_CERT_DIR. They need to be - # enabled explicitly. + # Initialize the SSL context for the httpx2 client. + # When SSL_CERT_FILE or SSL_CERT_DIR is set, honour it via + # ssl.create_default_context; otherwise delegate to + # httpx2.create_ssl_context(), which uses truststore (OS native trust + # store). if vertexai: ctx = _get_client_cert_ssl_ctx() if ctx is None: - ctx = ssl.create_default_context( - cafile=os.environ.get('SSL_CERT_FILE', certifi.where()), - capath=os.environ.get('SSL_CERT_DIR'), - ) + if os.environ.get('SSL_CERT_FILE') or os.environ.get('SSL_CERT_DIR'): + ctx = ssl.create_default_context( + cafile=os.environ.get('SSL_CERT_FILE'), + capath=os.environ.get('SSL_CERT_DIR'), + ) + else: + ctx = httpx2.create_ssl_context() def _maybe_set( - raw_args: Optional[_common.StringDict], + args: Optional[_common.StringDict], ctx: ssl.SSLContext, - target_cls: Optional[type[Any]] = None, + is_async: bool = False, ) -> _common.StringDict: """Sets the SSL context in the client args if not set. Does not override the SSL context if it is already set. Args: - raw_args: The client args to check for SSL context. + args: The client args to to check for SSL context. ctx: The SSL context to set. - target_cls: The client class whose __init__ parameters are allowed. + is_async: Whether the client is async or not. Returns: The client args with the SSL context included. """ - args = (raw_args or {}).copy() - if not args.get(verify): + if not args or not args.get(verify): + args = (args or {}).copy() args[verify] = ctx - if 'timeout' not in args: - args['timeout'] = None - if target_cls is not None: - for cls in getattr(target_cls, '__mro__', [target_cls]): - if cls is object: - continue - try: - allowed_params = inspect.signature(cls.__init__).parameters - except (ValueError, TypeError): - continue - has_var_keyword = any( - p.kind == inspect.Parameter.VAR_KEYWORD - for p in allowed_params.values() - ) - if not has_var_keyword: - for key in list(args.keys()): - if key not in allowed_params: - del args[key] - break - return args + # Drop the args that isn't in the httpx2.Client or httpx2.AsyncClient. + copied_args = args.copy() + if 'timeout' not in copied_args: + copied_args['timeout'] = None + for key in copied_args.copy(): + if not is_async: + if key not in inspect.signature(httpx2.Client.__init__).parameters: + del copied_args[key] + else: + if ( + key + not in inspect.signature(httpx2.AsyncClient.__init__).parameters + ): + del copied_args[key] - sync_cls = client_cls or ( - SyncHttpxClient - if SyncHttpxClient is not None - else (httpx.Client if httpx is not None else None) - ) - async_cls = async_client_cls or ( - AsyncHttpxClient - if AsyncHttpxClient is not None - else (httpx.AsyncClient if httpx is not None else None) - ) + return copied_args return ( - _maybe_set(args, ctx, sync_cls), - _maybe_set(async_args, ctx, async_cls), + _maybe_set(args, ctx, is_async=False), + _maybe_set(async_args, ctx, is_async=True), ) @staticmethod diff --git a/google/genai/_gaos/_hooks/adapters.py b/google/genai/_gaos/_hooks/adapters.py index 5a9c41c70..85520e9c0 100644 --- a/google/genai/_gaos/_hooks/adapters.py +++ b/google/genai/_gaos/_hooks/adapters.py @@ -34,7 +34,7 @@ BeforeRequestHook, ) import asyncio -import httpx +import httpx2 as httpx from typing import Optional, Tuple, Union # ============================================================================ diff --git a/google/genai/_gaos/_hooks/asyncregistration.py b/google/genai/_gaos/_hooks/asyncregistration.py index 5612ebaa6..1009594b1 100644 --- a/google/genai/_gaos/_hooks/asyncregistration.py +++ b/google/genai/_gaos/_hooks/asyncregistration.py @@ -42,7 +42,7 @@ def init_async_hooks(hooks: AsyncHooks): Example: from .asynctypes import AsyncBeforeRequestHook, BeforeRequestContext - import httpx + import httpx2 as httpx class MyAsyncHook(AsyncBeforeRequestHook): async def before_request(self, hook_ctx: BeforeRequestContext, request: httpx.Request): diff --git a/google/genai/_gaos/_hooks/asyncsdkhooks.py b/google/genai/_gaos/_hooks/asyncsdkhooks.py index ad81b1d06..6ed1a56f0 100644 --- a/google/genai/_gaos/_hooks/asyncsdkhooks.py +++ b/google/genai/_gaos/_hooks/asyncsdkhooks.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx from .asynctypes import ( AsyncBeforeRequestHook, AsyncAfterSuccessHook, diff --git a/google/genai/_gaos/_hooks/asynctypes.py b/google/genai/_gaos/_hooks/asynctypes.py index 0869abee1..a8b53b060 100644 --- a/google/genai/_gaos/_hooks/asynctypes.py +++ b/google/genai/_gaos/_hooks/asynctypes.py @@ -24,7 +24,7 @@ BeforeRequestContext, ) from abc import ABC, abstractmethod -import httpx +import httpx2 as httpx from typing import Optional, Tuple, Union diff --git a/google/genai/_gaos/_hooks/google_genai_auth.py b/google/genai/_gaos/_hooks/google_genai_auth.py index 7ec30b9d8..4faeab80b 100644 --- a/google/genai/_gaos/_hooks/google_genai_auth.py +++ b/google/genai/_gaos/_hooks/google_genai_auth.py @@ -21,7 +21,7 @@ from typing import Any, Callable, Mapping, Optional, Union, cast -import httpx +import httpx2 as httpx from .. import types, utils from .types import BeforeRequestContext, BeforeRequestHook diff --git a/google/genai/_gaos/_hooks/sdkhooks.py b/google/genai/_gaos/_hooks/sdkhooks.py index 5dc2b7bc3..f872d2361 100644 --- a/google/genai/_gaos/_hooks/sdkhooks.py +++ b/google/genai/_gaos/_hooks/sdkhooks.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx from .types import ( SDKInitHook, BeforeRequestContext, diff --git a/google/genai/_gaos/_hooks/types.py b/google/genai/_gaos/_hooks/types.py index fdee86829..80a609166 100644 --- a/google/genai/_gaos/_hooks/types.py +++ b/google/genai/_gaos/_hooks/types.py @@ -19,7 +19,7 @@ from ..sdkconfiguration import SDKConfiguration from abc import ABC, abstractmethod -import httpx +import httpx2 as httpx from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union diff --git a/google/genai/_gaos/_internal.py b/google/genai/_gaos/_internal.py index abd8077f0..bea4a1ba8 100644 --- a/google/genai/_gaos/_internal.py +++ b/google/genai/_gaos/_internal.py @@ -22,7 +22,7 @@ from .basesdk import AsyncBaseSDK, BaseSDK from .types import OptionalNullable, UNSET from .utils import get_security_from_env, response_helpers -import httpx +import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/agents.py b/google/genai/_gaos/agents.py index 1118eeed9..f38663462 100644 --- a/google/genai/_gaos/agents.py +++ b/google/genai/_gaos/agents.py @@ -24,7 +24,7 @@ from .types.agents import agent as agents_agent, agenttool as agents_agenttool from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/basesdk.py b/google/genai/_gaos/basesdk.py index 0306654c1..2cbafcf4f 100644 --- a/google/genai/_gaos/basesdk.py +++ b/google/genai/_gaos/basesdk.py @@ -26,18 +26,7 @@ ) from .sdkconfiguration import SDKConfiguration from .utils import RetryConfig, SerializedRequestBody, get_body_content -import httpx - -try: - import httpx2 -except ImportError: - httpx2 = None - -_HTTPX_TIMEOUT_TYPES = ( - (httpx.Timeout,) - if httpx2 is None - else (httpx.Timeout, httpx2.Timeout) -) +import httpx2 as httpx from typing import Any, Callable, List, Mapping, Optional, Tuple from urllib.parse import parse_qs, urlparse @@ -74,7 +63,7 @@ def _coerce_timeout_ms(self, timeout: Optional[Any]) -> Optional[int]: return None if isinstance(timeout, (int, float)): return int(timeout * 1000) - if isinstance(timeout, _HTTPX_TIMEOUT_TYPES): + if isinstance(timeout, httpx.Timeout): values = [timeout.connect, timeout.read, timeout.write, timeout.pool] finite_values = [value for value in values if value is not None] if not finite_values: @@ -341,7 +330,7 @@ def _coerce_timeout_ms(self, timeout: Optional[Any]) -> Optional[int]: return None if isinstance(timeout, (int, float)): return int(timeout * 1000) - if isinstance(timeout, _HTTPX_TIMEOUT_TYPES): + if isinstance(timeout, httpx.Timeout): values = [timeout.connect, timeout.read, timeout.write, timeout.pool] finite_values = [value for value in values if value is not None] if not finite_values: diff --git a/google/genai/_gaos/credentials.py b/google/genai/_gaos/credentials.py index f132a9d5d..fd712fd00 100644 --- a/google/genai/_gaos/credentials.py +++ b/google/genai/_gaos/credentials.py @@ -23,7 +23,7 @@ from .types import BaseModel, OptionalNullable, UNSET, credentials, interactions from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, List, Literal, Mapping, Optional, Union, cast, overload diff --git a/google/genai/_gaos/environments.py b/google/genai/_gaos/environments.py index 92846eb14..273ac3d82 100644 --- a/google/genai/_gaos/environments.py +++ b/google/genai/_gaos/environments.py @@ -30,7 +30,7 @@ from .types.interactions import source as interactions_source from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/errors/cancelinteractionbyid.py b/google/genai/_gaos/errors/cancelinteractionbyid.py index 8fe786cbf..3b86900f7 100644 --- a/google/genai/_gaos/errors/cancelinteractionbyid.py +++ b/google/genai/_gaos/errors/cancelinteractionbyid.py @@ -22,7 +22,7 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/createinteraction.py b/google/genai/_gaos/errors/createinteraction.py index 776c4ed29..e7b16b849 100644 --- a/google/genai/_gaos/errors/createinteraction.py +++ b/google/genai/_gaos/errors/createinteraction.py @@ -22,7 +22,7 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/deleteinteraction.py b/google/genai/_gaos/errors/deleteinteraction.py index 05f2536ef..1351ed4d4 100644 --- a/google/genai/_gaos/errors/deleteinteraction.py +++ b/google/genai/_gaos/errors/deleteinteraction.py @@ -22,7 +22,7 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/genaidefaulterror.py b/google/genai/_gaos/errors/genaidefaulterror.py index 318be1050..6fccd8f49 100644 --- a/google/genai/_gaos/errors/genaidefaulterror.py +++ b/google/genai/_gaos/errors/genaidefaulterror.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx from typing import Optional from dataclasses import dataclass diff --git a/google/genai/_gaos/errors/genaierror.py b/google/genai/_gaos/errors/genaierror.py index 516f13a5d..01d2788d3 100644 --- a/google/genai/_gaos/errors/genaierror.py +++ b/google/genai/_gaos/errors/genaierror.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx from typing import Optional from dataclasses import dataclass, field diff --git a/google/genai/_gaos/errors/getinteractionbyid.py b/google/genai/_gaos/errors/getinteractionbyid.py index a29d02716..1a0bb7993 100644 --- a/google/genai/_gaos/errors/getinteractionbyid.py +++ b/google/genai/_gaos/errors/getinteractionbyid.py @@ -22,7 +22,7 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +import httpx2 as httpx from typing import Optional diff --git a/google/genai/_gaos/errors/responsevalidationerror.py b/google/genai/_gaos/errors/responsevalidationerror.py index 24fe08c10..64049787e 100644 --- a/google/genai/_gaos/errors/responsevalidationerror.py +++ b/google/genai/_gaos/errors/responsevalidationerror.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx from typing import Optional from dataclasses import dataclass diff --git a/google/genai/_gaos/files.py b/google/genai/_gaos/files.py index 6155254a3..58918f4f9 100644 --- a/google/genai/_gaos/files.py +++ b/google/genai/_gaos/files.py @@ -23,7 +23,7 @@ from .types import OptionalNullable, UNSET, environments from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/google_genai.py b/google/genai/_gaos/google_genai.py index 95c9fdf7c..95aeb152d 100644 --- a/google/genai/_gaos/google_genai.py +++ b/google/genai/_gaos/google_genai.py @@ -31,7 +31,7 @@ import mimetypes import os -import httpx +import httpx2 as httpx from ._hooks.google_genai_auth import ( GOOGLE_GENAI_API_REVISION as _GOOGLE_GENAI_API_REVISION, diff --git a/google/genai/_gaos/httpclient.py b/google/genai/_gaos/httpclient.py index 0e1a8dcd7..a2aefcfa0 100644 --- a/google/genai/_gaos/httpclient.py +++ b/google/genai/_gaos/httpclient.py @@ -20,7 +20,7 @@ # pyright: reportReturnType = false import asyncio from typing_extensions import Protocol, runtime_checkable -import httpx +import httpx2 as httpx from typing import Any, Optional, Union diff --git a/google/genai/_gaos/interactions.py b/google/genai/_gaos/interactions.py index d8951ad44..bf2d64e41 100644 --- a/google/genai/_gaos/interactions.py +++ b/google/genai/_gaos/interactions.py @@ -24,7 +24,7 @@ from .utils import get_security_from_env, response_helpers from .utils.eventstreaming import AsyncStream, Stream from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Dict, List, Literal, Mapping, Optional, Union, cast, overload diff --git a/google/genai/_gaos/lib/compat_errors.py b/google/genai/_gaos/lib/compat_errors.py index 7bd524927..fdbebca8b 100644 --- a/google/genai/_gaos/lib/compat_errors.py +++ b/google/genai/_gaos/lib/compat_errors.py @@ -29,23 +29,7 @@ import json from typing import Any, Awaitable, Callable, Optional, TypeVar, cast -try: - import httpx2 -except ImportError: - httpx2 = None - -import httpx - -_HTTPX_TIMEOUT_ERRORS = ( - (httpx.TimeoutException,) - if httpx2 is None - else (httpx.TimeoutException, httpx2.TimeoutException) -) -_HTTPX_HTTP_ERRORS = ( - (httpx.HTTPError,) - if httpx2 is None - else (httpx.HTTPError, httpx2.HTTPError) -) +import httpx2 as httpx from ..errors.genaierror import GenAiError from ..errors.no_response_error import NoResponseError @@ -288,7 +272,7 @@ def _wrap_httpx_error(error: BaseException) -> APIConnectionError: tolerates the edge case rather than fabricating a misleading stand-in. """ request = getattr(error, "_request", None) - if isinstance(error, _HTTPX_TIMEOUT_ERRORS): + if isinstance(error, httpx.TimeoutException): wrapped: APIConnectionError = APITimeoutError(request=request) # type: ignore[arg-type] else: wrapped = APIConnectionError( @@ -317,7 +301,7 @@ def wrap_sdk_error(error: BaseException) -> BaseException: return _wrap_validation_error(error) if isinstance(error, NoResponseError): return _wrap_no_response_error(error) - if isinstance(error, _HTTPX_HTTP_ERRORS): + if isinstance(error, httpx.HTTPError): return _wrap_httpx_error(error) if not isinstance(error, GenAiError): return error @@ -334,11 +318,7 @@ def wrap_sdk_error(error: BaseException) -> BaseException: return wrapped -_WRAP_EXCEPTIONS = ( - (GenAiError, NoResponseError, httpx.HTTPError) - if httpx2 is None - else (GenAiError, NoResponseError, httpx.HTTPError, httpx2.HTTPError) -) +_WRAP_EXCEPTIONS = (GenAiError, NoResponseError, httpx.HTTPError) class CompatErrorHook: diff --git a/google/genai/_gaos/sdk.py b/google/genai/_gaos/sdk.py index 2f493dcf7..2b926a9fd 100644 --- a/google/genai/_gaos/sdk.py +++ b/google/genai/_gaos/sdk.py @@ -31,7 +31,7 @@ from .types import OptionalNullable, UNSET from .utils.logger import Logger, get_default_logger from .utils.retries import RetryConfig -import httpx +import httpx2 as httpx import importlib import importlib.util import sys diff --git a/google/genai/_gaos/triggers.py b/google/genai/_gaos/triggers.py index df24c6920..900c81abb 100644 --- a/google/genai/_gaos/triggers.py +++ b/google/genai/_gaos/triggers.py @@ -27,7 +27,7 @@ from .types.triggers import triggerupdate as triggers_triggerupdate from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/utils/eventstreaming.py b/google/genai/_gaos/utils/eventstreaming.py index 423689b29..17830f4df 100644 --- a/google/genai/_gaos/utils/eventstreaming.py +++ b/google/genai/_gaos/utils/eventstreaming.py @@ -31,7 +31,7 @@ AsyncGenerator, Tuple, ) -import httpx +import httpx2 as httpx T = TypeVar("T") diff --git a/google/genai/_gaos/utils/headers.py b/google/genai/_gaos/utils/headers.py index b95606938..ba52a76ac 100644 --- a/google/genai/_gaos/utils/headers.py +++ b/google/genai/_gaos/utils/headers.py @@ -23,7 +23,7 @@ List, Optional, ) -from httpx import Headers +from httpx2 import Headers from pydantic import BaseModel from pydantic.fields import FieldInfo diff --git a/google/genai/_gaos/utils/logger.py b/google/genai/_gaos/utils/logger.py index 1085e0b04..05e77d411 100644 --- a/google/genai/_gaos/utils/logger.py +++ b/google/genai/_gaos/utils/logger.py @@ -17,7 +17,7 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +import httpx2 as httpx import logging import os from typing import Any, Protocol diff --git a/google/genai/_gaos/utils/response_helpers.py b/google/genai/_gaos/utils/response_helpers.py index bbb844d39..88d78ee96 100644 --- a/google/genai/_gaos/utils/response_helpers.py +++ b/google/genai/_gaos/utils/response_helpers.py @@ -46,7 +46,7 @@ overload, ) -import httpx +import httpx2 as httpx from pydantic import BaseModel from typing_extensions import TypeAliasType diff --git a/google/genai/_gaos/utils/retries.py b/google/genai/_gaos/utils/retries.py index 1dc009996..5d957a236 100644 --- a/google/genai/_gaos/utils/retries.py +++ b/google/genai/_gaos/utils/retries.py @@ -24,7 +24,7 @@ from email.utils import parsedate_to_datetime from typing import List, Optional -import httpx +import httpx2 as httpx class BackoffStrategy: diff --git a/google/genai/_gaos/utils/serializers.py b/google/genai/_gaos/utils/serializers.py index 99747b4f6..cd469f6c3 100644 --- a/google/genai/_gaos/utils/serializers.py +++ b/google/genai/_gaos/utils/serializers.py @@ -25,7 +25,7 @@ import typing_extensions from typing_extensions import get_origin -import httpx +import httpx2 as httpx from pydantic import ConfigDict, create_model from pydantic_core import from_json diff --git a/google/genai/_gaos/utils/unmarshal_json_response.py b/google/genai/_gaos/utils/unmarshal_json_response.py index 4ba1a154b..216d28f12 100644 --- a/google/genai/_gaos/utils/unmarshal_json_response.py +++ b/google/genai/_gaos/utils/unmarshal_json_response.py @@ -19,7 +19,7 @@ from typing import Any, Optional, Type, TypeVar, overload -import httpx +import httpx2 as httpx from .serializers import unmarshal_json, construct_unvalidated from .. import errors diff --git a/google/genai/_gaos/utils/values.py b/google/genai/_gaos/utils/values.py index cc953332d..2c8fe8a2e 100644 --- a/google/genai/_gaos/utils/values.py +++ b/google/genai/_gaos/utils/values.py @@ -24,7 +24,7 @@ import os from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast -from httpx import Response +from httpx2 import Response from pydantic import BaseModel from pydantic.fields import FieldInfo diff --git a/google/genai/_gaos/voices.py b/google/genai/_gaos/voices.py index 454f466f5..cf1db9978 100644 --- a/google/genai/_gaos/voices.py +++ b/google/genai/_gaos/voices.py @@ -24,7 +24,7 @@ from .types.voices import voice_input as voices_voice_input from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_gaos/webhooks.py b/google/genai/_gaos/webhooks.py index 145a39259..d07f82e27 100644 --- a/google/genai/_gaos/webhooks.py +++ b/google/genai/_gaos/webhooks.py @@ -29,7 +29,7 @@ ) from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_mcp_utils.py b/google/genai/_mcp_utils.py index 413b4ed98..067fadf9f 100644 --- a/google/genai/_mcp_utils.py +++ b/google/genai/_mcp_utils.py @@ -15,15 +15,7 @@ """Utils for working with MCP tools.""" import contextlib -try: - import httpx -except ImportError: - httpx = None # type: ignore[assignment] - -try: - import httpx2 -except ImportError: - httpx2 = None # type: ignore[assignment] +import httpx2 import sys from importlib.metadata import PackageNotFoundError, version @@ -32,7 +24,7 @@ from . import _common from . import types -from ._api_client import _MULTI_REGIONAL_LOCATIONS, _get_http_client_backend +from ._api_client import _MULTI_REGIONAL_LOCATIONS def _is_mcp_loaded() -> bool: return "mcp" in sys.modules @@ -212,18 +204,8 @@ async def _connect_agent_platform_mcp(api_client: Any, toolset_name: str) -> typ set_mcp_usage_header(headers) - http_client: Any - backend = _get_http_client_backend() - async_client = getattr(api_client, "_async_httpx_client", None) - is_httpx2_client = ( - httpx2 is not None - and isinstance(getattr(httpx2, "AsyncClient", None), type) - and isinstance(async_client, httpx2.AsyncClient) - ) - if (backend == "httpx2" or is_httpx2_client) and httpx2 is not None: - http_client = httpx2.AsyncClient(headers=headers, timeout=None) - else: - http_client = httpx.AsyncClient(headers=headers, timeout=None) + # `mcp` is typed against httpx (v1); httpx2's AsyncClient is API-compatible. + http_client: Any = httpx2.AsyncClient(headers=headers, timeout=None) try: async with http_client: diff --git a/google/genai/errors.py b/google/genai/errors.py index d1f434b49..4266c5691 100644 --- a/google/genai/errors.py +++ b/google/genai/errors.py @@ -17,39 +17,14 @@ from typing import Any, Callable, Optional, TYPE_CHECKING, Union import json +import httpx2 if TYPE_CHECKING: from .replay_api_client import ReplayResponse import aiohttp from google.auth.aio.transport.aiohttp import Response as AsyncAuthorizedSessionResponse - import httpx - import httpx2 import requests # pylint: disable=g-import-not-at-top - _HTTPX_RESPONSE_TYPES = (httpx.Response, httpx2.Response) -else: - try: - import httpx - except ImportError: - httpx = None # type: ignore[assignment] - - try: - import httpx2 - except ImportError: - httpx2 = None # type: ignore[assignment] - - # httpx2 (https://github.com/pydantic/httpx2) is a drop-in fork of httpx under a - # separate import namespace, so its Response is not an instance of - # httpx.Response. Widen the runtime type checks to accept either when installed. - _HTTPX_RESPONSE_TYPES = tuple( - cls - for cls in ( - getattr(httpx, 'Response', None), - getattr(httpx2, 'Response', None), - ) - if cls is not None - ) - from . import _common @@ -59,8 +34,7 @@ class APIError(Exception): response: Union[ 'requests.Response', 'ReplayResponse', - 'httpx.Response', - 'httpx2.Response', + httpx2.Response, 'AsyncAuthorizedSessionResponse', ] @@ -75,8 +49,7 @@ def __init__( Union[ 'requests.Response', 'ReplayResponse', - 'httpx.Response', - 'httpx2.Response', + httpx2.Response, 'AsyncAuthorizedSessionResponse', ] ] = None, @@ -151,13 +124,13 @@ def _to_replay_record(self) -> _common.StringDict: @classmethod def raise_for_response( cls, - response: Union['ReplayResponse', 'httpx.Response', 'httpx2.Response', 'requests.Response'], + response: Union['ReplayResponse', httpx2.Response, 'requests.Response'], ) -> None: """Raises an error with detailed error message if the response has an error status.""" if response.status_code == 200: return - if isinstance(response, _HTTPX_RESPONSE_TYPES): + if isinstance(response, httpx2.Response): try: response.read() response_json = response.json() @@ -194,7 +167,7 @@ def raise_error( response: Optional[ Union[ 'ReplayResponse', - 'httpx.Response', 'httpx2.Response', + httpx2.Response, 'requests.Response', ] ], @@ -224,13 +197,13 @@ async def raise_for_async_response( cls, response: Union[ 'ReplayResponse', - 'httpx.Response', 'httpx2.Response', + httpx2.Response, 'aiohttp.ClientResponse', 'AsyncAuthorizedSessionResponse', ], ) -> None: """Raises an error with detailed error message if the response has an error status.""" - if isinstance(response, _HTTPX_RESPONSE_TYPES): + if isinstance(response, httpx2.Response): if response.status_code == 200: return try: @@ -281,7 +254,7 @@ async def raise_for_async_response( @classmethod async def raise_error_async( cls, status_code: int, response_json: Any, response: Optional[ - Union['ReplayResponse', 'httpx.Response', 'httpx2.Response', 'aiohttp.ClientResponse'] + Union['ReplayResponse', httpx2.Response, 'aiohttp.ClientResponse'] ] ) -> None: """Raises an appropriate APIError subclass based on the status code. diff --git a/google/genai/tests/chats/test_continuation_token.py b/google/genai/tests/chats/test_continuation_token.py index 366db2e4c..44ec7d950 100644 --- a/google/genai/tests/chats/test_continuation_token.py +++ b/google/genai/tests/chats/test_continuation_token.py @@ -17,7 +17,7 @@ from unittest import mock -import httpx +import httpx2 as httpx import pydantic import pytest diff --git a/google/genai/tests/client/test_async_stream.py b/google/genai/tests/client/test_async_stream.py index 71b626b9f..382a3190d 100644 --- a/google/genai/tests/client/test_async_stream.py +++ b/google/genai/tests/client/test_async_stream.py @@ -32,7 +32,7 @@ AIOHTTP_NOT_INSTALLED = True aiohttp = mock.MagicMock() -import httpx +import httpx2 as httpx from ... import _api_client as api_client from ... import Client @@ -387,7 +387,7 @@ def mock_response(chunks): ) -@patch('httpx.Client.send') +@patch('httpx2.Client.send') def test_error_event_in_streamed_responses_bad_json(mock_send_method): with_bad_json = [ b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}', @@ -412,7 +412,7 @@ def test_error_event_in_streamed_responses_bad_json(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch('httpx2.Client.send') def test_error_event_in_streamed_responses(mock_send_method): mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR) @@ -432,7 +432,7 @@ def test_error_event_in_streamed_responses(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch('httpx2.Client.send') def test_error_event_in_generate_content_stream(mock_send_method): mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR) @@ -464,8 +464,12 @@ def test_error_event_in_generate_content_stream(mock_send_method): async def _async_httpx_response(_): + async def _aiter(): + for chunk in EVENT_STREAM_DATA_WITH_ERROR: + yield chunk + mock_stream = MagicMock(spec=httpx.AsyncByteStream) - mock_stream.__aiter__.return_value = EVENT_STREAM_DATA_WITH_ERROR + mock_stream.__aiter__.side_effect = _aiter mock_stream.aclose = AsyncMock() return httpx.Response( status_code=200, @@ -473,7 +477,7 @@ async def _async_httpx_response(_): ) -@patch('httpx.AsyncBaseTransport') +@patch('httpx2.AsyncBaseTransport') @pytest.mark.asyncio async def test_error_event_in_streamed_responses_async(mock_transport): client = api_client.BaseApiClient( @@ -502,7 +506,7 @@ async def test_error_event_in_streamed_responses_async(mock_transport): await anext(resp) -@patch('httpx.AsyncBaseTransport') +@patch('httpx2.AsyncBaseTransport') @pytest.mark.asyncio async def test_error_event_in_generate_content_stream_async(mock_transport): client = Client( diff --git a/google/genai/tests/client/test_client_initialization.py b/google/genai/tests/client/test_client_initialization.py index 5df241a7a..084dc6fdd 100644 --- a/google/genai/tests/client/test_client_initialization.py +++ b/google/genai/tests/client/test_client_initialization.py @@ -28,7 +28,7 @@ import certifi import google.auth from google.auth import credentials -import httpx +import httpx2 as httpx import pytest import requests diff --git a/google/genai/tests/client/test_custom_client.py b/google/genai/tests/client/test_custom_client.py index 73b9de453..3114148a4 100644 --- a/google/genai/tests/client/test_custom_client.py +++ b/google/genai/tests/client/test_custom_client.py @@ -18,7 +18,7 @@ from unittest import mock from google.oauth2 import credentials -import httpx +import httpx2 as httpx import pytest from ... import _api_client as api_client diff --git a/google/genai/tests/client/test_http_options.py b/google/genai/tests/client/test_http_options.py index f15cde97a..8a951f3e7 100644 --- a/google/genai/tests/client/test_http_options.py +++ b/google/genai/tests/client/test_http_options.py @@ -16,7 +16,7 @@ """Tests for client behavior when issuing requests.""" -import httpx +import httpx2 as httpx import pytest from ... import _api_client diff --git a/google/genai/tests/client/test_httpx2_client.py b/google/genai/tests/client/test_httpx2_client.py index dbb148002..9443730c2 100644 --- a/google/genai/tests/client/test_httpx2_client.py +++ b/google/genai/tests/client/test_httpx2_client.py @@ -29,18 +29,9 @@ import io import ssl -import httpx +import httpx2 import pytest - -try: - import httpx2 -except ImportError: - httpx2 = None - -# Mark all tests in this module to be skipped if httpx2 is not available -pytestmark = pytest.mark.skipif( - httpx2 is None, reason='httpx2 is not available' -) +import truststore from ... import _api_client as api_client from ... import Client @@ -166,99 +157,21 @@ def test_httpx2_response_flows_through_sync_stream(): assert chunks == ['{"first": 1}', '{"second": 2}'] -def test_default_client_uses_httpx(monkeypatch): - monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) - target_api_client = getattr(api_client, 'public_api_client', api_client) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpxClient - ) - assert isinstance(client._api_client._async_httpx_client, httpx.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpxClient - ) - - -def test_env_var_override_to_httpx(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') - target_api_client = getattr(api_client, 'public_api_client', api_client) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpxClient - ) - assert isinstance(client._api_client._async_httpx_client, httpx.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpxClient - ) - - -def test_env_var_override_to_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - target_api_client = getattr(api_client, 'public_api_client', api_client) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx2.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpx2Client - ) - assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpx2Client - ) - - -def test_env_var_auto(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'auto') +def test_default_client_uses_httpx2(): target_api_client = getattr(api_client, 'public_api_client', api_client) client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - - -def test_env_var_unrecognized_falls_back_to_default(monkeypatch, caplog): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'unknown_client') - target_api_client = getattr(api_client, 'public_api_client', api_client) - with caplog.at_level('WARNING'): - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT' in caplog.text - - -def test_env_var_httpx2_missing_raises(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - target_api_client = getattr(api_client, 'public_api_client', api_client) - monkeypatch.setattr(target_api_client, 'httpx2', None) - with pytest.raises(ImportError, match='httpx2 is configured'): - Client(api_key='fake_api_key') - - -def test_env_var_httpx_missing_raises(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') - target_api_client = getattr(api_client, 'public_api_client', api_client) - monkeypatch.setattr(target_api_client, 'httpx', None) - with pytest.raises(ImportError, match='httpx is configured'): - Client(api_key='fake_api_key') - - -def test_default_falls_back_to_httpx2_when_httpx_missing(monkeypatch): - monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) - target_api_client = getattr(api_client, 'public_api_client', api_client) - monkeypatch.setattr(target_api_client, 'httpx', None) - client = Client(api_key='fake_api_key') assert isinstance(client._api_client._httpx_client, httpx2.Client) assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpx2Client + client._api_client._httpx_client, target_api_client.SyncHttpxClient ) assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpx2Client + client._api_client._async_httpx_client, target_api_client.AsyncHttpxClient ) # High-level feature tests: end-to-end generate_content and streaming with httpx2 -def test_generate_content_sync_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - +def test_generate_content_sync_with_httpx2(): def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response( status_code=200, @@ -288,9 +201,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio -async def test_generate_content_async_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - +async def test_generate_content_async_with_httpx2(): def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response( status_code=200, @@ -319,9 +230,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert response.text == 'Hello from httpx2 async!' -def test_generate_content_stream_sync_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - +def test_generate_content_stream_sync_with_httpx2(): def handler(request: httpx2.Request) -> httpx2.Response: stream_content = ( b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' @@ -352,9 +261,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio -async def test_generate_content_stream_async_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - +async def test_generate_content_stream_async_with_httpx2(): def handler(request: httpx2.Request) -> httpx2.Response: stream_content = ( b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' @@ -427,8 +334,7 @@ def test_retry_on_httpx2_transient_exceptions(): assert predicate(httpx2.ReadTimeout('read timeout', request=req)) -def test_retries_with_httpx2_client(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_retries_with_httpx2_client(): attempts = 0 def handler(request: httpx2.Request) -> httpx2.Response: @@ -466,7 +372,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio async def test_async_download_file_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') target_api_client = getattr(api_client, 'public_api_client', api_client) monkeypatch.setattr(target_api_client, 'has_aiohttp', False) @@ -483,8 +388,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert downloaded == b'file bytes content' -def test_httpx2_filters_invalid_client_args(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_httpx2_filters_invalid_client_args(): client = Client( api_key='fake_api_key', http_options=HttpOptions( @@ -497,23 +401,8 @@ def test_httpx2_filters_invalid_client_args(monkeypatch): assert 'unsupported_dummy_arg' not in client._api_client._async_httpx_client_args -def test_httpx_filters_invalid_client_args(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') - client = Client( - api_key='fake_api_key', - http_options=HttpOptions( - client_args={'unsupported_dummy_arg': 123}, - async_client_args={'unsupported_dummy_arg': 123}, - ), - ) - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert isinstance(client._api_client._async_httpx_client, httpx.AsyncClient) - assert 'unsupported_dummy_arg' not in client._api_client._async_httpx_client_args - - @pytest.mark.asyncio -async def test_httpx2_client_close(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +async def test_httpx2_client_close(): client = Client(api_key='fake_api_key') sync_client = client._api_client._httpx_client async_client = client._api_client._async_httpx_client @@ -528,16 +417,14 @@ async def test_httpx2_client_close(monkeypatch): @pytest.mark.asyncio -async def test_aclose_when_async_httpx_client_is_none(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +async def test_aclose_when_async_httpx_client_is_none(): client = Client(api_key='fake_api_key') client._api_client._async_httpx_client = None # Should not raise AttributeError when _async_httpx_client is None await client.aio.aclose() -def test_vertexai_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_vertexai_with_httpx2(): client = Client( vertexai=True, project='fake-project', @@ -548,23 +435,6 @@ def test_vertexai_with_httpx2(monkeypatch): assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) -def test_env_var_case_insensitivity_and_whitespace(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', ' HTTPX2 ') - target_api_client = getattr(api_client, 'public_api_client', api_client) - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx2.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpx2Client - ) - - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', ' HttPx ') - client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) - assert isinstance( - client._api_client._httpx_client, target_api_client.SyncHttpxClient - ) - - def test_raise_for_response_with_httpx2_json_error(): response = httpx2.Response( status_code=400, @@ -608,9 +478,7 @@ async def test_raise_for_async_response_with_httpx2(): assert exc_info.value.response is response -def test_generate_content_api_error_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - +def test_generate_content_api_error_with_httpx2(): def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response( status_code=400, @@ -634,7 +502,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio -async def test_mcp_utils_respects_backend(monkeypatch): +async def test_mcp_utils_uses_httpx2(monkeypatch): from ... import _mcp_utils from unittest import mock import contextlib @@ -662,8 +530,6 @@ async def mock_session_ctx(*args, **kwargs): monkeypatch.setattr(_mcp_utils, 'streamable_http_client', fake_streamable) monkeypatch.setattr(_mcp_utils, 'McpClientSession', mock_session_ctx) - # Test httpx2 backend - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') client_httpx2 = Client(api_key='fake_key') monkeypatch.setattr( client_httpx2._api_client, @@ -676,40 +542,8 @@ async def mock_session_ctx(*args, **kwargs): pass assert isinstance(captured_client, httpx2.AsyncClient) - # Test httpx backend - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx') - client_httpx = Client(api_key='fake_key') - monkeypatch.setattr( - client_httpx._api_client, - '_async_access_token', - mock.AsyncMock(return_value='fake-token'), - ) - async with _mcp_utils._connect_agent_platform_mcp( - client_httpx._api_client, 'endpoints' - ): - pass - assert isinstance(captured_client, httpx.AsyncClient) - - # Test when httpx is None (auto-fallback to httpx2) - target_api_client = getattr(api_client, 'public_api_client', api_client) - monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) - monkeypatch.setattr(_mcp_utils, 'httpx', None) - monkeypatch.setattr(target_api_client, 'httpx', None) - client_fallback = Client(api_key='fake_key') - monkeypatch.setattr( - client_fallback._api_client, - '_async_access_token', - mock.AsyncMock(return_value='fake-token'), - ) - async with _mcp_utils._connect_agent_platform_mcp( - client_fallback._api_client, 'endpoints' - ): - pass - assert isinstance(captured_client, httpx2.AsyncClient) - -def test_sync_upload_file_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_sync_upload_file_with_httpx2(): recorded_requests = [] def handler(request: httpx2.Request) -> httpx2.Response: @@ -744,7 +578,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio async def test_async_upload_file_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') target_api_client = getattr(api_client, 'public_api_client', api_client) monkeypatch.setattr(target_api_client, 'has_aiohttp', False) recorded_requests = [] @@ -780,8 +613,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert response.response_stream == ['{"name": "files/async-test-file"}'] -def test_retries_on_http_status_codes_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_retries_on_http_status_codes_with_httpx2(): attempts = 0 def handler(request: httpx2.Request) -> httpx2.Response: @@ -826,8 +658,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.asyncio -async def test_async_retries_on_http_status_codes_with_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +async def test_async_retries_on_http_status_codes_with_httpx2(): attempts = 0 def handler(request: httpx2.Request) -> httpx2.Response: @@ -866,6 +697,19 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert attempts == 2 +def test_ensure_httpx2_ssl_ctx_default_uses_truststore(monkeypatch): + monkeypatch.delenv('SSL_CERT_FILE', raising=False) + monkeypatch.delenv('SSL_CERT_DIR', raising=False) + client_args, async_client_args = ( + api_client.BaseApiClient._ensure_httpx_ssl_ctx( + HttpOptions(), + vertexai=False, + ) + ) + assert isinstance(client_args['verify'], truststore.SSLContext) + assert isinstance(async_client_args['verify'], truststore.SSLContext) + + def test_ensure_httpx2_ssl_ctx_custom_verify(): custom_ctx = ssl.create_default_context() client_args, async_client_args = ( @@ -875,8 +719,6 @@ def test_ensure_httpx2_ssl_ctx_custom_verify(): async_client_args={'verify': custom_ctx}, ), vertexai=False, - client_cls=api_client.SyncHttpx2Client, - async_client_cls=api_client.AsyncHttpx2Client, ) ) assert client_args['verify'] is custom_ctx @@ -892,8 +734,6 @@ def test_ensure_httpx2_ssl_ctx_filters_unsupported_params(): async_client_args={'unsupported_dummy_key': 'foo'}, ), vertexai=False, - client_cls=api_client.SyncHttpx2Client, - async_client_cls=api_client.AsyncHttpx2Client, ) ) assert 'unsupported_dummy_key' not in client_args @@ -901,8 +741,7 @@ def test_ensure_httpx2_ssl_ctx_filters_unsupported_params(): assert isinstance(client_args['verify'], ssl.SSLContext) -def test_client_init_with_custom_ssl_context_httpx2(monkeypatch): - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') +def test_client_init_with_custom_ssl_context_httpx2(): custom_ctx = ssl.create_default_context() client = Client( api_key='fake_api_key', diff --git a/google/genai/tests/client/test_retries.py b/google/genai/tests/client/test_retries.py index 6332f2091..b4167edd2 100644 --- a/google/genai/tests/client/test_retries.py +++ b/google/genai/tests/client/test_retries.py @@ -36,7 +36,7 @@ AsyncAuthorizedSession = mock.MagicMock() from google.oauth2 import credentials -import httpx +import httpx2 as httpx import tenacity from ... import _api_client as api_client diff --git a/google/genai/tests/client/test_upload_errors.py b/google/genai/tests/client/test_upload_errors.py index 897913ca7..0462d19c3 100644 --- a/google/genai/tests/client/test_upload_errors.py +++ b/google/genai/tests/client/test_upload_errors.py @@ -20,7 +20,7 @@ from unittest import mock import pytest -import httpx +import httpx2 as httpx from ... import _api_client as api_client from ... import errors diff --git a/google/genai/tests/errors/test_api_error.py b/google/genai/tests/errors/test_api_error.py index bb269da43..cda5e0646 100644 --- a/google/genai/tests/errors/test_api_error.py +++ b/google/genai/tests/errors/test_api_error.py @@ -21,7 +21,7 @@ import pickle -import httpx +import httpx2 as httpx import pytest from ... import errors diff --git a/google/genai/tests/files/test_download.py b/google/genai/tests/files/test_download.py index fa7d030cf..b6e2b4eba 100644 --- a/google/genai/tests/files/test_download.py +++ b/google/genai/tests/files/test_download.py @@ -24,7 +24,7 @@ from unittest import mock from typing import Any import google.auth.transport.requests -import httpx +import httpx2 as httpx import pytest import requests from ... import _transformers as t diff --git a/google/genai/tests/files/test_register.py b/google/genai/tests/files/test_register.py index eeebe90ac..0d3430e3f 100644 --- a/google/genai/tests/files/test_register.py +++ b/google/genai/tests/files/test_register.py @@ -20,7 +20,7 @@ from unittest import mock from google.auth import credentials -import httpx +import httpx2 as httpx import pytest from ... import _api_client diff --git a/google/genai/tests/interactions/test_auth.py b/google/genai/tests/interactions/test_auth.py index c96e7592f..58ca1d9ab 100644 --- a/google/genai/tests/interactions/test_auth.py +++ b/google/genai/tests/interactions/test_auth.py @@ -20,9 +20,9 @@ from ... import _base_url from unittest import mock import pytest -from httpx import Request, Response +from httpx2 import Request, Response from ..._api_client import AsyncHttpxClient, BaseApiClient -from httpx import Client as HTTPClient +from httpx2 import Client as HTTPClient import os ENV_VARS = [ @@ -102,7 +102,7 @@ def test_interactions_gemini_extra_headers(monkeypatch): def test_interactions_vertex_auth_header(): from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient + from httpx2 import Client as HTTPClient creds = mock.Mock() creds.quota_project_id = "test-quota-project" @@ -136,7 +136,7 @@ def test_interactions_vertex_auth_header(): def test_interactions_vertex_key_no_auth_header(): from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient + from httpx2 import Client as HTTPClient creds = mock.Mock() client = Client(vertexai=True, api_key='test-api-key') @@ -171,7 +171,7 @@ def test_interactions_vertex_url(): with ( mock.patch.object(BaseApiClient, "_access_token", return_value="fake-token") as mock_access_token, - mock.patch("httpx.Client.send") as mock_send, + mock.patch("httpx2.Client.send") as mock_send, ): mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') client.interactions.create( @@ -184,7 +184,7 @@ def test_interactions_vertex_url(): def test_interactions_vertex_auth_refresh_on_retry(): from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient + from httpx2 import Client as HTTPClient creds = mock.Mock() creds.quota_project_id = "test-quota-project" @@ -224,7 +224,7 @@ def my_send(req, *args, **kwargs): def test_interactions_vertex_extra_headers_override(): from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient + from httpx2 import Client as HTTPClient creds = mock.Mock() creds.quota_project_id = "test-quota-project" diff --git a/google/genai/tests/interactions/test_httpx_compat.py b/google/genai/tests/interactions/test_httpx_compat.py index ea867f6a0..1ce751947 100644 --- a/google/genai/tests/interactions/test_httpx_compat.py +++ b/google/genai/tests/interactions/test_httpx_compat.py @@ -12,16 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for httpx and httpx2 compatibility across the GAOS client.""" +"""Tests for httpx2 across the GAOS client.""" -import httpx +import httpx2 import pytest -try: - import httpx2 -except ImportError: - httpx2 = None - from ... import client as client_lib from ..._gaos.basesdk import AsyncBaseSDK, BaseSDK from ..._gaos.httpclient import AsyncHttpClient, HttpClient @@ -29,96 +24,6 @@ from ..._gaos.utils import eventstreaming, retries -# --- Standard httpx tests --- - - -def test_error_wrapping_httpx_errors(): - req = httpx.Request("GET", "https://example.com") - err = httpx.ConnectError("failed to connect", request=req) - wrapped = compat_errors.wrap_sdk_error(err) - assert isinstance(wrapped, compat_errors.APIConnectionError) - assert wrapped.__cause__ is err - - timeout_err = httpx.TimeoutException("timed out", request=req) - wrapped_timeout = compat_errors.wrap_sdk_error(timeout_err) - assert isinstance(wrapped_timeout, compat_errors.APITimeoutError) - assert wrapped_timeout.__cause__ is timeout_err - - -def test_base_sdk_timeout_coercion(): - sdk = BaseSDK.__new__(BaseSDK) - - assert sdk._coerce_timeout_ms(None) is None - assert sdk._coerce_timeout_ms(5) == 5000 - assert sdk._coerce_timeout_ms(2.5) == 2500 - assert ( - sdk._coerce_timeout_ms( - httpx.Timeout(connect=1.0, read=4.0, write=2.0, pool=None) - ) - == 4000 - ) - - with pytest.raises( - TypeError, match="timeout must be a float, int, httpx.Timeout, or None" - ): - sdk._coerce_timeout_ms("invalid_timeout") - - -def test_injected_client_passed_to_gaos(): - http_client = httpx.Client() - try: - client = client_lib.Client( - api_key="fake-key", - http_options={"httpx_client": http_client}, - ) - - assert client._api_client._httpx_client is http_client - interactions_client = client.interactions - assert interactions_client.sdk_configuration.client is http_client - finally: - http_client.close() - - -def test_stream_error_wrapping_httpx_errors(): - def failing_gen(): - yield "chunk1" - raise httpx.ConnectError("stream network broken") - - stream = eventstreaming.Stream.__new__(eventstreaming.Stream) - stream.generator = failing_gen() - wrapped_stream = compat_errors.wrap_stream_errors(stream) - - gen = wrapped_stream.generator - assert next(gen) == "chunk1" - with pytest.raises(compat_errors.APIConnectionError) as exc_info: - next(gen) - assert "stream network broken" in str(exc_info.value) - - -@pytest.mark.asyncio -async def test_async_stream_error_wrapping_httpx_errors(): - async def failing_agen(): - yield "async_chunk1" - raise httpx.ConnectError("async stream network broken") - - stream = eventstreaming.AsyncStream.__new__(eventstreaming.AsyncStream) - stream.generator = failing_agen() - wrapped_stream = compat_errors.wrap_async_stream_errors(stream) - - agen = wrapped_stream.generator - chunk = await agen.__anext__() - assert chunk == "async_chunk1" - with pytest.raises(compat_errors.APIConnectionError) as exc_info: - await agen.__anext__() - assert "async stream network broken" in str(exc_info.value) - - -# --- httpx2 tests (run in CI when httpx2 is installed) --- - - -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_httpx2_error_wrapping(): req = httpx2.Request("GET", "https://example.com") err = httpx2.ConnectError("failed to connect", request=req) @@ -132,29 +37,28 @@ def test_httpx2_error_wrapping(): assert wrapped_timeout.__cause__ is timeout_err -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_httpx2_timeout_coercion(): sdk = BaseSDK.__new__(BaseSDK) + assert sdk._coerce_timeout_ms(None) is None + assert sdk._coerce_timeout_ms(5) == 5000 + assert sdk._coerce_timeout_ms(2.5) == 2500 timeout = httpx2.Timeout(connect=1.0, read=4.0, write=2.0, pool=None) assert sdk._coerce_timeout_ms(timeout) == 4000 async_sdk = AsyncBaseSDK.__new__(AsyncBaseSDK) assert async_sdk._coerce_timeout_ms(timeout) == 4000 + with pytest.raises( + TypeError, match="timeout must be a float, int, httpx.Timeout, or None" + ): + sdk._coerce_timeout_ms("invalid_timeout") + -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_httpx2_client_protocols(): assert issubclass(httpx2.Client, HttpClient) assert issubclass(httpx2.AsyncClient, AsyncHttpClient) -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_injected_httpx2_client_passed_to_gaos(): http_client = httpx2.Client() try: @@ -171,11 +75,7 @@ def test_injected_httpx2_client_passed_to_gaos(): http_client.close() -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) -def test_env_var_httpx2_passed_to_gaos(monkeypatch): - monkeypatch.setenv("GOOGLE_GENAI_HTTP_CLIENT", "httpx2") +def test_default_httpx2_passed_to_gaos(): client = client_lib.Client(api_key="fake-key") assert isinstance(client._api_client._httpx_client, httpx2.Client) interactions_client = client.interactions @@ -183,9 +83,6 @@ def test_env_var_httpx2_passed_to_gaos(monkeypatch): assert isinstance(interactions_client.sdk_configuration.client, httpx2.Client) -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) @pytest.mark.asyncio async def test_injected_httpx2_async_client_passed_to_gaos(): http_async_client = httpx2.AsyncClient() @@ -203,11 +100,7 @@ async def test_injected_httpx2_async_client_passed_to_gaos(): await http_async_client.aclose() -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) -def test_env_var_httpx2_async_passed_to_gaos(monkeypatch): - monkeypatch.setenv("GOOGLE_GENAI_HTTP_CLIENT", "httpx2") +def test_default_httpx2_async_passed_to_gaos(): client = client_lib.Client(api_key="fake-key") assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) interactions_client = client.aio.interactions @@ -215,9 +108,6 @@ def test_env_var_httpx2_async_passed_to_gaos(monkeypatch): assert isinstance(interactions_client.sdk_configuration.async_client, httpx2.AsyncClient) -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_httpx2_retries(): attempts = 0 @@ -244,9 +134,6 @@ def flaky_operation(_attempt): assert attempts == 3 -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) @pytest.mark.asyncio async def test_httpx2_async_retries(): attempts = 0 @@ -276,9 +163,6 @@ async def flaky_async_operation(_attempt): assert attempts == 3 -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_httpx2_stream_error_wrapping(): def failing_gen(): yield "chunk1" @@ -295,9 +179,6 @@ def failing_gen(): assert "stream network broken" in str(exc_info.value) -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) @pytest.mark.asyncio async def test_httpx2_async_stream_error_wrapping(): async def failing_agen(): @@ -316,9 +197,6 @@ async def failing_agen(): assert "async stream network broken" in str(exc_info.value) -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) def test_interactions_create_with_httpx2_mock_transport(): recorded_requests = [] @@ -348,9 +226,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: http_client.close() -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) @pytest.mark.asyncio async def test_async_interactions_create_with_httpx2_mock_transport(): recorded_requests = [] @@ -383,11 +258,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: await http_async_client.aclose() -@pytest.mark.skipif( - httpx2 is None, reason="httpx2 not installed in this environment" -) -def test_interactions_create_env_var_httpx2_mock_transport(monkeypatch): - monkeypatch.setenv("GOOGLE_GENAI_HTTP_CLIENT", "httpx2") +def test_interactions_create_client_args_httpx2_mock_transport(): recorded_requests = [] def handler(request: httpx2.Request) -> httpx2.Response: @@ -404,8 +275,204 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) result = client.interactions.create( model="gemini-1.5-flash", - input="Hello from env var httpx2", + input="Hello from httpx2", ) assert len(recorded_requests) == 1 assert isinstance(recorded_requests[0], httpx2.Request) assert result.status == "completed" + + +def test_interactions_api_error_handling_with_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 404, + headers={"content-type": "application/json"}, + content=b'{"error": {"code": 404, "message": "Interaction not found"}}', + ) + + http_client = httpx2.Client(transport=httpx2.MockTransport(handler)) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_client": http_client}, + ) + with pytest.raises(compat_errors.NotFoundError) as exc_info: + client.interactions.create( + model="gemini-1.5-flash", + input="Hello", + ) + assert exc_info.value.status_code == 404 + assert isinstance(exc_info.value.response, httpx2.Response) + assert isinstance(exc_info.value.request, httpx2.Request) + assert exc_info.value.response.status_code == 404 + finally: + http_client.close() + + +@pytest.mark.asyncio +async def test_async_interactions_api_error_handling_with_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 400, + headers={"content-type": "application/json"}, + content=b'{"error": {"code": 400, "message": "Invalid request"}}', + ) + + http_async_client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler) + ) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_async_client": http_async_client}, + ) + with pytest.raises(compat_errors.BadRequestError) as exc_info: + await client.aio.interactions.create( + model="gemini-1.5-flash", + input="Hello", + ) + assert exc_info.value.status_code == 400 + assert isinstance(exc_info.value.response, httpx2.Response) + assert isinstance(exc_info.value.request, httpx2.Request) + finally: + await http_async_client.aclose() + + +def test_interactions_with_raw_response_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, + headers={"content-type": "application/json"}, + content=b'{"id": "raw-interaction-id", "status": "completed"}', + ) + + http_client = httpx2.Client(transport=httpx2.MockTransport(handler)) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_client": http_client}, + ) + raw = client.interactions.with_raw_response.create( + model="gemini-1.5-flash", + input="Hello", + ) + assert isinstance(raw.http_response, httpx2.Response) + assert raw.http_response.status_code == 200 + parsed = raw.parse() + assert parsed.id == "raw-interaction-id" + assert parsed.status == "completed" + finally: + http_client.close() + + +@pytest.mark.asyncio +async def test_async_interactions_with_raw_response_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, + headers={"content-type": "application/json"}, + content=b'{"id": "async-raw-id", "status": "completed"}', + ) + + http_async_client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler) + ) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_async_client": http_async_client}, + ) + raw = await client.aio.interactions.with_raw_response.create( + model="gemini-1.5-flash", + input="Hello", + ) + assert isinstance(raw.http_response, httpx2.Response) + assert raw.http_response.status_code == 200 + parsed = await raw.parse() + assert parsed.id == "async-raw-id" + assert parsed.status == "completed" + finally: + await http_async_client.aclose() + + +def test_interactions_streaming_events_with_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + sse_content = ( + b'data: {"event_id": "e1", "interaction": {"id": "int-1", "status": "in_progress"}}\n\n' + b'data: [DONE]\n\n' + ) + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=sse_content, + ) + + http_client = httpx2.Client(transport=httpx2.MockTransport(handler)) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_client": http_client}, + ) + stream = client.interactions.create( + model="gemini-1.5-flash", + input="Hello", + stream=True, + ) + events = list(stream) + assert len(events) == 1 + assert events[0].event_id == "e1" + assert events[0].interaction["id"] == "int-1" + finally: + http_client.close() + + +@pytest.mark.asyncio +async def test_async_interactions_streaming_events_with_httpx2(): + def handler(request: httpx2.Request) -> httpx2.Response: + sse_content = ( + b'data: {"event_id": "e1", "interaction": {"id": "int-async-1", "status": "in_progress"}}\n\n' + b'data: [DONE]\n\n' + ) + return httpx2.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=sse_content, + ) + + http_async_client = httpx2.AsyncClient( + transport=httpx2.MockTransport(handler) + ) + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_async_client": http_async_client}, + ) + stream = await client.aio.interactions.create( + model="gemini-1.5-flash", + input="Hello", + stream=True, + ) + events = [event async for event in stream] + assert len(events) == 1 + assert events[0].event_id == "e1" + assert events[0].interaction["id"] == "int-async-1" + finally: + await http_async_client.aclose() + + +def test_genaierror_raw_response_typing_httpx2(): + from ..._gaos.errors.genaierror import GenAiError + + resp = httpx2.Response( + 400, + headers={"x-test-header": "test-val"}, + content=b'{"error": "bad request"}', + ) + err = GenAiError("bad request error", raw_response=resp) + assert isinstance(err.raw_response, httpx2.Response) + assert isinstance(err.headers, httpx2.Headers) + assert err.status_code == 400 + assert err.body == '{"error": "bad request"}' + assert err.headers.get("x-test-header") == "test-val" + + diff --git a/google/genai/tests/interactions/test_integration.py b/google/genai/tests/interactions/test_integration.py index 22edf727c..75ea18dc3 100644 --- a/google/genai/tests/interactions/test_integration.py +++ b/google/genai/tests/interactions/test_integration.py @@ -14,7 +14,7 @@ from unittest import mock -import httpx +import httpx2 as httpx import pytest from ... import client as client_lib diff --git a/google/genai/tests/interactions/test_paths.py b/google/genai/tests/interactions/test_paths.py index 118a8e62d..fbf2c5917 100644 --- a/google/genai/tests/interactions/test_paths.py +++ b/google/genai/tests/interactions/test_paths.py @@ -19,9 +19,9 @@ from unittest import mock import pytest -from httpx import Request, Response +from httpx2 import Request, Response from ..._api_client import AsyncHttpxClient -from httpx import Client as HTTPClient +from httpx2 import Client as HTTPClient from .. import pytest_helper import google.auth diff --git a/google/genai/tests/mcp/test_mcp_to_gemini_tools.py b/google/genai/tests/mcp/test_mcp_to_gemini_tools.py index 36d2e0583..850e22420 100644 --- a/google/genai/tests/mcp/test_mcp_to_gemini_tools.py +++ b/google/genai/tests/mcp/test_mcp_to_gemini_tools.py @@ -22,10 +22,7 @@ from ... import types from ..._api_client import BaseApiClient -try: - import httpx2 -except ImportError: - httpx2 = None +import httpx2 try: from mcp import types as mcp_types @@ -314,22 +311,17 @@ def test_agent_platform_preserves_unknown_fields(): assert schema['some_new_future_field'] == 'value' -@pytest.mark.skipif( - httpx2 is None, reason='httpx2 is not available' -) @pytest.mark.asyncio @mock.patch('httpx2.AsyncClient') @mock.patch.object(_mcp_utils, 'streamable_http_client') @mock.patch.object(_mcp_utils, 'McpClientSession') @mock.patch('google.auth.default') async def test_connect_agent_platform_mcp_url_and_headers( - mock_auth_default, mock_session_cls, mock_streamable, mock_create_http, monkeypatch + mock_auth_default, mock_session_cls, mock_streamable, mock_create_http ): """Tests that _mcp_utils._connect_agent_platform_mcp builds the correct regional URL and injects auth headers. """ - monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') - mock_creds = mock.Mock() mock_creds.token = 'fake-oauth-token' mock_auth_default.return_value = (mock_creds, 'fake-project') diff --git a/google/genai/tests/models/test_gc_continuation_token.py b/google/genai/tests/models/test_gc_continuation_token.py index 46dcdfcc5..5b036c89a 100644 --- a/google/genai/tests/models/test_gc_continuation_token.py +++ b/google/genai/tests/models/test_gc_continuation_token.py @@ -19,7 +19,7 @@ import json from unittest import mock -import httpx +import httpx2 as httpx import pydantic import pytest diff --git a/google/genai/types.py b/google/genai/types.py index 70fdf9227..d3426cca3 100644 --- a/google/genai/types.py +++ b/google/genai/types.py @@ -108,36 +108,16 @@ def __getattr__(name: str) -> Any: _is_httpx_imported = False if typing.TYPE_CHECKING: - import httpx - import httpx2 + from httpx2 import AsyncClient as HttpxAsyncClient, Client as HttpxClient - HttpxClient = Union[httpx.Client, httpx2.Client] - HttpxAsyncClient = Union[httpx.AsyncClient, httpx2.AsyncClient] _is_httpx_imported = True else: HttpxClient: typing.Type = Any HttpxAsyncClient: typing.Type = Any - try: - import httpx + from httpx2 import AsyncClient as HttpxAsyncClient, Client as HttpxClient - HttpxClient = httpx.Client - HttpxAsyncClient = httpx.AsyncClient _is_httpx_imported = True - except ImportError: - HttpxClient = None - HttpxAsyncClient = None - - try: - import httpx2 - - if _is_httpx_imported: - HttpxClient = Union[httpx.Client, httpx2.Client] - HttpxAsyncClient = Union[httpx.AsyncClient, httpx2.AsyncClient] - else: - HttpxClient = httpx2.Client - HttpxAsyncClient = httpx2.AsyncClient - _is_httpx_imported = True except ImportError: pass diff --git a/pyproject.toml b/pyproject.toml index 0ca787c15..21b63e167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "anyio>=4.8.0, <5.0.0", "google-auth[requests]>=2.56.0, <3.0.0", - "httpx>=0.28.1, <1.0.0", + "httpx2>=2.0.0, <3.0.0", "pydantic>=2.12.5, <3.0.0", "requests>=2.28.1, <3.0.0", "tenacity>=8.2.3, <9.2.0", @@ -39,7 +39,9 @@ dependencies = [ [project.optional-dependencies] aiohttp = ["aiohttp>=3.10.11, <4.0.0"] -httpx2 = ["httpx2>=2.0.0, <3.0.0"] +# httpx2 is now a required dependency. This extra is kept as a no-op so that +# existing `pip install google-genai[httpx2]` invocations keep working. +httpx2 = [] local-tokenizer = [ "sentencepiece>=0.2.0", "protobuf", diff --git a/requirements.txt b/requirements.txt index 4583be06a..997c78d9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,6 @@ certifi==2024.8.30 charset-normalizer==3.4.0 coverage==7.6.9 distro==1.9.0 -httpx==0.28.1 httpx2==2.7.0; python_version >= '3.10' google-auth==2.56.0 idna==3.18