From 3c857d8ba6c8a07279020dcdf561bc97ee50171c Mon Sep 17 00:00:00 2001 From: Mark Daoust Date: Thu, 10 Sep 2026 10:08:50 -0700 Subject: [PATCH] feat(client): Support httpx2 with GOOGLE_GENAI_HTTP_CLIENT configuration Support httpx2 as an opt-in HTTP transport via GOOGLE_GENAI_HTTP_CLIENT=httpx2 or custom client injection, while preserving httpx as the default in minor releases for backward compatibility. https://github.com/googleapis/python-genai/issues/2680 PiperOrigin-RevId: 979252017 --- README.md | 27 + google/genai/_api_client.py | 345 ++++++++--- google/genai/_mcp_utils.py | 22 +- google/genai/errors.py | 60 +- .../genai/tests/client/test_async_stream.py | 5 +- .../genai/tests/client/test_custom_client.py | 1 - .../genai/tests/client/test_httpx2_client.py | 561 +++++++++++++++++- .../tests/interactions/test_httpx_compat.py | 63 ++ .../tests/mcp/test_mcp_to_gemini_tools.py | 3 +- pyproject.toml | 1 + 10 files changed, 965 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index af3209e3e..7d615d35f 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,32 @@ 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 @@ -266,6 +292,7 @@ http_options = types.HttpOptions( client=Client(..., http_options=http_options) ``` + ### Proxy Both httpx and aiohttp libraries use `urllib.request.getproxies` from diff --git a/google/genai/_api_client.py b/google/genai/_api_client.py index 27adcf813..f0cfc20ef 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -46,7 +46,6 @@ from google.auth.credentials import Credentials from google.auth.transport import mtls from google.auth import exceptions as auth_exceptions -import httpx from pydantic import BaseModel from pydantic import ValidationError import tenacity @@ -77,17 +76,63 @@ pass -try: - import httpx2 -except ImportError: - httpx2 = None # type: ignore[assignment] - - 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 @@ -98,27 +143,6 @@ _MULTI_REGIONAL_LOCATIONS = {'us', 'eu'} -# 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 httpx2 is -# installed. -_HTTPX_RESPONSE_TYPES = ( - (httpx.Response,) if httpx2 is None else (httpx.Response, httpx2.Response) -) -_HTTPX_HEADERS_TYPES = ( - (httpx.Headers,) if httpx2 is None else (httpx.Headers, httpx2.Headers) -) -_HTTPX_TRANSIENT_EXC = ( - (httpx.TimeoutException, httpx.ConnectError) - if httpx2 is None - else ( - httpx.TimeoutException, - httpx.ConnectError, - httpx2.TimeoutException, - httpx2.ConnectError, - ) -) - class EphemeralTokenAPIKeyError(ValueError): """Error raised when the API key is invalid.""" @@ -273,16 +297,18 @@ def __init__( self, headers: Union[ dict[str, str], - httpx.Headers, + Any, 'CIMultiDictProxy[str]', - 'CaseInsensitiveDict', + 'CaseInsensitiveDict[Any]', ], response_stream: Union[Any, str] = None, byte_stream: Union[Any, bytes] = None, ): if isinstance(headers, dict): self.headers = headers - elif isinstance(headers, _HTTPX_HEADERS_TYPES): + elif isinstance(headers, _HTTPX_HEADERS_TYPES) or hasattr( + headers, 'get_list' + ): self.headers = { key: ', '.join(headers.get_list(key)) for key in headers.keys() # type: ignore[attr-defined] } @@ -294,7 +320,8 @@ def __init__( self.headers = {key: value for key, value in headers.items()} elif type(headers).__name__ == 'CIMultiDictProxy': self.headers = { - key: ', '.join(headers.getall(key)) for key in headers.keys() + key: ', '.join(headers.getall(key)) # type: ignore[union-attr] + for key in headers.keys() # type: ignore[union-attr] } self.status_code: int = 200 @@ -377,7 +404,8 @@ def _iter_response_stream(self) -> Iterator[str]: ) ): raise TypeError( - 'Expected self.response_stream to be an httpx.Response object, ' + 'Expected self.response_stream to be an httpx.Response or' + ' httpx2.Response object, ' f'but got {type(self.response_stream).__name__}.' ) @@ -430,8 +458,8 @@ async def _aiter_response_stream(self) -> AsyncIterator[str]: ) if not is_valid_response: raise TypeError( - 'Expected self.response_stream to be an httpx.Response or' - ' aiohttp.ClientResponse object, but got' + 'Expected self.response_stream to be an httpx.Response,' + ' httpx2.Response, or aiohttp.ClientResponse object, but got' f' {type(self.response_stream).__name__}.' ) @@ -485,7 +513,7 @@ async def _aiter_response_stream(self) -> AsyncIterator[str]: # Read a line from the stream. This returns bytes. try: line_bytes = await self.response_stream.content.readline( - max_line_length=READ_BUFFER_SIZE + max_line_length=READ_BUFFER_SIZE # type: ignore[call-arg] ) except TypeError: # Ensure backwards compatibility with older versions of @@ -594,45 +622,150 @@ def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict: } -class SyncHttpxClient(httpx.Client): - """Sync httpx client.""" +if TYPE_CHECKING: - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) + class SyncHttpxClient(httpx.Client): + """Sync httpx client.""" - 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.""" + class SyncHttpx2Client(httpx2.Client): # type: ignore[misc] + """Sync httpx2 client.""" -class AsyncHttpxClient(httpx.AsyncClient): - """Async httpx client.""" + class AsyncHttpx2Client(httpx2.AsyncClient): # type: ignore[misc] + """Async httpx2 client.""" - def __init__(self, **kwargs: Any) -> None: - """Initializes the httpx client.""" - kwargs.setdefault('follow_redirects', True) - super().__init__(**kwargs) +else: + if httpx is not None: - 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 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) + + 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: + SyncHttpx2Client = None # type: ignore[assignment,misc] + AsyncHttpx2Client = None # type: ignore[assignment,misc] + + +def _get_http_client_backend() -> str: + """Determines whether to use 'httpx' or 'httpx2' as the default HTTP 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 + ) + + if httpx is not None: + return 'httpx' + if httpx2 is not None: + return 'httpx2' + raise ImportError( + 'Neither httpx nor httpx2 is installed. Please install httpx2 or httpx.' + ) class BaseApiClient: @@ -853,9 +986,19 @@ 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 @@ -865,19 +1008,14 @@ def __init__( elif self._http_options.httpx_client: self._httpx_client = self._http_options.httpx_client else: - self._httpx_client = SyncHttpxClient(**client_args) + self._httpx_client = sync_client_cls(**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 = AsyncHttpxClient(**async_client_args) - - if self._http_options.httpx_async_client: - self._async_httpx_client = self._http_options.httpx_async_client - else: - self._async_httpx_client = AsyncHttpxClient(**async_client_args) + self._async_httpx_client = async_client_cls(**async_client_args) # Initialize the aiohttp client sessions. self._aiohttp_sessions: dict[Any, Any] = {} @@ -1070,6 +1208,8 @@ 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. @@ -1078,6 +1218,8 @@ 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. @@ -1114,35 +1256,60 @@ def _ensure_httpx_ssl_ctx( ) def _maybe_set( - args: Optional[_common.StringDict], + raw_args: Optional[_common.StringDict], ctx: ssl.SSLContext, + target_cls: Optional[type[Any]] = None, ) -> _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: - args: The client args to to check for SSL context. + raw_args: The client args to check for SSL context. ctx: The SSL context to set. + target_cls: The client class whose __init__ parameters are allowed. Returns: The client args with the SSL context included. """ - args = (args or {}).copy() + args = (raw_args or {}).copy() if not args.get(verify): args[verify] = ctx if 'timeout' not in args: args['timeout'] = None - # Drop the args that isn't used by the httpx client. - copied_args = args.copy() - for key in copied_args.copy(): - if key not in inspect.signature(httpx.Client.__init__).parameters: - del copied_args[key] - return copied_args + 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 + + 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 ( - _maybe_set(args, ctx), - _maybe_set(async_args, ctx), + _maybe_set(args, ctx, sync_cls), + _maybe_set(async_args, ctx, async_cls), ) @staticmethod @@ -2505,8 +2672,8 @@ async def aclose(self) -> None: """Closes the API async client.""" # Let users close the custom client explicitly by themselves. Otherwise, # close the client when the object is garbage collected. - if not self._http_options.httpx_async_client: - await self._async_httpx_client.aclose() # type: ignore[union-attr] + if not self._http_options.httpx_async_client and self._async_httpx_client: + await self._async_httpx_client.aclose() if self._aiohttp_sessions and not self._http_options.aiohttp_client: try: current_loop = asyncio.get_running_loop() diff --git a/google/genai/_mcp_utils.py b/google/genai/_mcp_utils.py index 24ba79804..68ef0a82d 100644 --- a/google/genai/_mcp_utils.py +++ b/google/genai/_mcp_utils.py @@ -15,7 +15,10 @@ """Utils for working with MCP tools.""" import contextlib -import httpx +try: + import httpx +except ImportError: + httpx = None # type: ignore[assignment] try: import httpx2 @@ -29,7 +32,7 @@ from . import _common from . import types -from ._api_client import _MULTI_REGIONAL_LOCATIONS +from ._api_client import _MULTI_REGIONAL_LOCATIONS, _get_http_client_backend def _is_mcp_loaded() -> bool: return "mcp" in sys.modules @@ -210,10 +213,21 @@ async def _connect_agent_platform_mcp(api_client: Any, toolset_name: str) -> typ set_mcp_usage_header(headers) http_client: Any - if httpx2 is not None: + 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: + elif httpx is not None: http_client = httpx.AsyncClient(headers=headers, timeout=None) + elif httpx2 is not None: + http_client = httpx2.AsyncClient(headers=headers, timeout=None) + else: + raise ImportError("Neither httpx nor httpx2 is installed.") try: async with http_client: diff --git a/google/genai/errors.py b/google/genai/errors.py index d54b463ec..d1f434b49 100644 --- a/google/genai/errors.py +++ b/google/genai/errors.py @@ -16,31 +16,41 @@ """Error classes for the GenAI SDK.""" from typing import Any, Callable, Optional, TYPE_CHECKING, Union -import httpx import json -try: - import httpx2 -except ImportError: - httpx2 = None # type: ignore[assignment] - -from . import _common - - 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 + ) -# 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 httpx2 is -# installed. -_HTTPX_RESPONSE_TYPES = ( - (httpx.Response,) if httpx2 is None else (httpx.Response, httpx2.Response) -) +from . import _common class APIError(Exception): @@ -49,7 +59,8 @@ class APIError(Exception): response: Union[ 'requests.Response', 'ReplayResponse', - httpx.Response, + 'httpx.Response', + 'httpx2.Response', 'AsyncAuthorizedSessionResponse', ] @@ -64,7 +75,8 @@ def __init__( Union[ 'requests.Response', 'ReplayResponse', - httpx.Response, + 'httpx.Response', + 'httpx2.Response', 'AsyncAuthorizedSessionResponse', ] ] = None, @@ -139,7 +151,7 @@ def _to_replay_record(self) -> _common.StringDict: @classmethod def raise_for_response( cls, - response: Union['ReplayResponse', httpx.Response, 'requests.Response'], + response: Union['ReplayResponse', 'httpx.Response', 'httpx2.Response', 'requests.Response'], ) -> None: """Raises an error with detailed error message if the response has an error status.""" if response.status_code == 200: @@ -167,8 +179,10 @@ def raise_for_response( 'message': response.text, 'status': response.reason, } - else: + elif hasattr(response, 'body_segments'): response_json = response.body_segments[0].get('error', {}) + else: + response_json = {} cls.raise_error(response.status_code, response_json, response) @@ -180,7 +194,7 @@ def raise_error( response: Optional[ Union[ 'ReplayResponse', - httpx.Response, + 'httpx.Response', 'httpx2.Response', 'requests.Response', ] ], @@ -210,7 +224,7 @@ async def raise_for_async_response( cls, response: Union[ 'ReplayResponse', - httpx.Response, + 'httpx.Response', 'httpx2.Response', 'aiohttp.ClientResponse', 'AsyncAuthorizedSessionResponse', ], @@ -267,7 +281,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, 'aiohttp.ClientResponse'] + Union['ReplayResponse', 'httpx.Response', 'httpx2.Response', 'aiohttp.ClientResponse'] ] ) -> None: """Raises an appropriate APIError subclass based on the status code. diff --git a/google/genai/tests/client/test_async_stream.py b/google/genai/tests/client/test_async_stream.py index 25d9e92ca..71b626b9f 100644 --- a/google/genai/tests/client/test_async_stream.py +++ b/google/genai/tests/client/test_async_stream.py @@ -109,10 +109,7 @@ def test_invalid_response_stream_type(responses: api_client.HttpResponse): api_client.has_aiohttp = False with pytest.raises( TypeError, - match=( - "Expected self.response_stream to be an httpx.Response or" - " aiohttp.ClientResponse object" - ), + match="Expected self.response_stream", ): async def run(): diff --git a/google/genai/tests/client/test_custom_client.py b/google/genai/tests/client/test_custom_client.py index ebb360ea2..73b9de453 100644 --- a/google/genai/tests/client/test_custom_client.py +++ b/google/genai/tests/client/test_custom_client.py @@ -101,4 +101,3 @@ async def test_constructor_with_aiohttp_clients(): http_options=vertexai_http_options, ) assert not vertexai_client.models._api_client._aiohttp_session.trust_env - diff --git a/google/genai/tests/client/test_httpx2_client.py b/google/genai/tests/client/test_httpx2_client.py index 5471169de..43c855ca8 100644 --- a/google/genai/tests/client/test_httpx2_client.py +++ b/google/genai/tests/client/test_httpx2_client.py @@ -26,6 +26,7 @@ must recognize an `httpx2.Response`. """ +import httpx import pytest try: @@ -41,7 +42,7 @@ from ... import _api_client as api_client from ... import Client from ... import errors -from ...types import HttpOptions +from ...types import HttpOptions, HttpRetryOptions # WALL 1 — the client must be accepted at construction. @@ -147,3 +148,561 @@ async def test_httpx2_response_flows_through_async_stream(): chunks = [chunk async for chunk in http_response._aiter_response_stream()] assert chunks == ['{"first": 1}', '{"second": 2}'] + + +# WALL 2 — an httpx2.Response must flow through the sync streaming iterator. +def test_httpx2_response_flows_through_sync_stream(): + response = httpx2.Response( + status_code=200, + content=b'data: {"first": 1}\n\ndata: {"second": 2}\n\n', + ) + http_response = api_client.HttpResponse(headers={}, response_stream=response) + + chunks = list(http_response._iter_response_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') + 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 + ) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + assert isinstance( + client._api_client._async_httpx_client, target_api_client.AsyncHttpx2Client + ) + + +# 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 handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': { + 'parts': [{'text': 'Hello from httpx2 sync!'}], + 'role': 'model', + }, + 'finishReason': 'STOP', + }] + }, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + client_args={'transport': httpx2.MockTransport(handler)} + ), + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + response = client.models.generate_content( + model='gemini-2.5-flash', + contents='Hello', + ) + assert response.text == 'Hello from httpx2 sync!' + + +@pytest.mark.asyncio +async def test_generate_content_async_with_httpx2(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': { + 'parts': [{'text': 'Hello from httpx2 async!'}], + 'role': 'model', + }, + 'finishReason': 'STOP', + }] + }, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + async_client_args={'transport': httpx2.MockTransport(handler)} + ), + ) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + response = await client.aio.models.generate_content( + model='gemini-2.5-flash', + contents='Hello', + ) + assert response.text == 'Hello from httpx2 async!' + + +def test_generate_content_stream_sync_with_httpx2(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + + def handler(request: httpx2.Request) -> httpx2.Response: + stream_content = ( + b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' + b'data: {"candidates": [{"content": {"parts": [{"text": "streaming world!"}], "role": "model"}}]}\n\n' + ) + return httpx2.Response( + status_code=200, + content=stream_content, + headers={'content-type': 'text/event-stream'}, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + client_args={'transport': httpx2.MockTransport(handler)} + ), + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + stream = client.models.generate_content_stream( + model='gemini-2.5-flash', + contents='Hello', + ) + chunks = list(stream) + assert len(chunks) == 2 + assert chunks[0].text == 'Hello ' + assert chunks[1].text == 'streaming world!' + assert ''.join(c.text for c in chunks) == 'Hello streaming world!' + + +@pytest.mark.asyncio +async def test_generate_content_stream_async_with_httpx2(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + + def handler(request: httpx2.Request) -> httpx2.Response: + stream_content = ( + b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' + b'data: {"candidates": [{"content": {"parts": [{"text": "async streaming world!"}], "role": "model"}}]}\n\n' + ) + return httpx2.Response( + status_code=200, + content=stream_content, + headers={'content-type': 'text/event-stream'}, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + async_client_args={'transport': httpx2.MockTransport(handler)} + ), + ) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + stream = await client.aio.models.generate_content_stream( + model='gemini-2.5-flash', + contents='Hello', + ) + chunks = [chunk async for chunk in stream] + assert len(chunks) == 2 + assert chunks[0].text == 'Hello ' + assert chunks[1].text == 'async streaming world!' + assert ''.join(c.text for c in chunks) == 'Hello async streaming world!' + + +def test_generate_content_injected_httpx2_client(): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': { + 'parts': [{'text': 'Hello from injected httpx2!'}], + 'role': 'model', + }, + 'finishReason': 'STOP', + }] + }, + ) + + client = Client( + api_key='fake_api_key', + http_options={ + 'httpx_client': httpx2.Client(transport=httpx2.MockTransport(handler)), + }, + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + response = client.models.generate_content( + model='gemini-2.5-flash', + contents='Hello', + ) + assert response.text == 'Hello from injected httpx2!' + + +def test_retry_on_httpx2_transient_exceptions(): + """Verifies tenacity retry predicate retries on httpx2 ConnectError and TimeoutException.""" + retry_opts = HttpOptions( + retry_options=HttpRetryOptions(attempts=3, initial_delay=0.01) + ) + retry_dict = api_client.retry_args(retry_opts.retry_options) + predicate = retry_dict['retry'].predicate + + req = httpx2.Request('GET', 'https://example.com') + assert predicate(httpx2.ConnectError('connection failed', request=req)) + assert predicate(httpx2.TimeoutException('timeout', request=req)) + assert predicate(httpx2.ReadTimeout('read timeout', request=req)) + + +def test_retries_with_httpx2_client(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + attempts = 0 + + def handler(request: httpx2.Request) -> httpx2.Response: + nonlocal attempts + attempts += 1 + if attempts < 2: + raise httpx2.ConnectError('transient failure', request=request) + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': { + 'parts': [{'text': 'Retried successfully'}], + 'role': 'model', + }, + 'finishReason': 'STOP', + }] + }, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + client_args={'transport': httpx2.MockTransport(handler)}, + retry_options=HttpRetryOptions(attempts=3, initial_delay=0.01), + ), + ) + response = client.models.generate_content( + model='gemini-2.5-flash', + contents='Hello', + ) + assert response.text == 'Retried successfully' + assert attempts == 2 + + +@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) + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(status_code=200, content=b'file bytes content') + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + async_client_args={'transport': httpx2.MockTransport(handler)}, + ), + ) + downloaded = await client._api_client.async_download_file('download/file.bin') + assert downloaded == b'file bytes content' + + +def test_httpx2_filters_invalid_client_args(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + 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, httpx2.Client) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + 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') + client = Client(api_key='fake_api_key') + sync_client = client._api_client._httpx_client + async_client = client._api_client._async_httpx_client + assert not sync_client.is_closed + assert not async_client.is_closed + + client.close() + assert sync_client.is_closed + + await client.aio.aclose() + assert async_client.is_closed + + +@pytest.mark.asyncio +async def test_aclose_when_async_httpx_client_is_none(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + 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') + client = Client( + vertexai=True, + project='fake-project', + location='us-central1', + credentials=None, + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + 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, + json={ + 'error': { + 'message': 'Invalid argument', + 'code': 400, + 'status': 'INVALID_ARGUMENT', + } + }, + ) + with pytest.raises(errors.ClientError) as exc_info: + errors.APIError.raise_for_response(response) + assert exc_info.value.code == 400 + assert 'Invalid argument' in str(exc_info.value) + assert exc_info.value.response is response + + +def test_raise_for_response_with_httpx2_plain_text_error(): + response = httpx2.Response( + status_code=503, + text='Service Unavailable', + ) + with pytest.raises(errors.ServerError) as exc_info: + errors.APIError.raise_for_response(response) + assert exc_info.value.code == 503 + assert 'Service Unavailable' in str(exc_info.value) + assert exc_info.value.response is response + + +@pytest.mark.asyncio +async def test_raise_for_async_response_with_httpx2(): + response = httpx2.Response( + status_code=404, + json={'error': {'message': 'Resource not found', 'code': 404}}, + ) + with pytest.raises(errors.ClientError) as exc_info: + await errors.APIError.raise_for_async_response(response) + assert exc_info.value.code == 404 + assert 'Resource not found' in str(exc_info.value) + assert exc_info.value.response is response + + +def test_generate_content_api_error_with_httpx2(monkeypatch): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + status_code=400, + json={'error': {'message': 'Invalid prompt', 'code': 400}}, + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + client_args={'transport': httpx2.MockTransport(handler)}, + retry_options=HttpRetryOptions(attempts=1), + ), + ) + with pytest.raises(errors.ClientError) as exc_info: + client.models.generate_content( + model='gemini-2.5-flash', + contents='Hello', + ) + assert exc_info.value.code == 400 + assert 'Invalid prompt' in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_mcp_utils_respects_backend(monkeypatch): + from ... import _mcp_utils + from unittest import mock + import contextlib + + captured_client = None + + @contextlib.asynccontextmanager + async def mock_streamable_ctx(*args, **kwargs): + yield (mock.Mock(), mock.Mock()) + + def fake_streamable(*args, **kwargs): + nonlocal captured_client + captured_client = kwargs.get('http_client') + return mock_streamable_ctx(*args, **kwargs) + + class DummySession: + + async def initialize(self): + pass + + @contextlib.asynccontextmanager + async def mock_session_ctx(*args, **kwargs): + yield DummySession() + + 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, + '_async_access_token', + mock.AsyncMock(return_value='fake-token'), + ) + async with _mcp_utils._connect_agent_platform_mcp( + client_httpx2._api_client, 'endpoints' + ): + 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) + + + diff --git a/google/genai/tests/interactions/test_httpx_compat.py b/google/genai/tests/interactions/test_httpx_compat.py index ddf931ead..55d282184 100644 --- a/google/genai/tests/interactions/test_httpx_compat.py +++ b/google/genai/tests/interactions/test_httpx_compat.py @@ -152,6 +152,69 @@ def test_httpx2_client_protocols(): 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: + 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 + assert isinstance(interactions_client.sdk_configuration.client, httpx2.Client) + finally: + 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") + client = client_lib.Client(api_key="fake-key") + assert isinstance(client._api_client._httpx_client, httpx2.Client) + interactions_client = client.interactions + assert interactions_client.sdk_configuration.client is client._api_client._httpx_client + 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() + try: + client = client_lib.Client( + api_key="fake-key", + http_options={"httpx_async_client": http_async_client}, + ) + + assert client._api_client._async_httpx_client is http_async_client + interactions_client = client.aio.interactions + assert interactions_client.sdk_configuration.async_client is http_async_client + assert isinstance(interactions_client.sdk_configuration.async_client, httpx2.AsyncClient) + finally: + 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") + client = client_lib.Client(api_key="fake-key") + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + interactions_client = client.aio.interactions + assert interactions_client.sdk_configuration.async_client is client._api_client._async_httpx_client + assert isinstance(interactions_client.sdk_configuration.async_client, httpx2.AsyncClient) + + @pytest.mark.skipif( httpx2 is None, reason="httpx2 not installed in this environment" ) 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 a8e476c35..36d2e0583 100644 --- a/google/genai/tests/mcp/test_mcp_to_gemini_tools.py +++ b/google/genai/tests/mcp/test_mcp_to_gemini_tools.py @@ -323,11 +323,12 @@ def test_agent_platform_preserves_unknown_fields(): @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 + mock_auth_default, mock_session_cls, mock_streamable, mock_create_http, monkeypatch ): """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' diff --git a/pyproject.toml b/pyproject.toml index f4fc981ca..b7bf15ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ [project.optional-dependencies] aiohttp = ["aiohttp>=3.10.11, <4.0.0"] +httpx2 = ["httpx2>=2.0.0, <3.0.0"] local-tokenizer = [ "sentencepiece>=0.2.0", "protobuf",