From 51285c87a4394d3e895df07af7664df5cd1c30de Mon Sep 17 00:00:00 2001 From: Mark Daoust Date: Tue, 3 Nov 2026 17:43:07 -0800 Subject: [PATCH] feat!: Switch default HTTP client to httpx2 and use OS trust store by default BREAKING CHANGE: `httpx2` is now the required dependency and default HTTP client instead of `httpx`, and default SSL verification with `httpx2` uses the OS certificate trust store (via `truststore` / `httpx2.create_ssl_context()`) when `SSL_CERT_FILE` and `SSL_CERT_DIR` are not set. To maintain baseline behavior, install `google-genai[httpx]` and set `GOOGLE_GENAI_HTTP_CLIENT=httpx`. Summary of impact: - If your code uses `httpx` or `httpx2` to interact with the SDK, make sure it uses the same package as the active SDK client. - In most cases, updating your code with `import httpx2 as httpx` is sufficient. - The SDK still fully supports both `httpx` and `httpx2`; only the default has changed. - `httpx` is no longer installed by default (use `pip install google-genai[httpx]` to install a compatible version; `google-genai[httpx2]` remains as a no-op extra). - You can set the SDK's default backend with `GOOGLE_GENAI_HTTP_CLIENT` (`httpx` or `httpx2` strictly requires that package; unset or `auto` prefers `httpx2` and falls back to `httpx` if only `httpx` is installed). - Regardless of the default, passing `HttpOptions(httpx_client=..., httpx_async_client=...)` uses those objects directly (sync and async are configured independently). Where `httpx`/`httpx2` types must match the active client: - `HttpOptions(client_args=..., async_client_args=...)`: package-specific objects such as `Timeout`, `MockTransport` / `BaseTransport` (and `Response` objects from mock handlers), and custom `Auth` subclasses must match the active client. Primitives (numeric timeouts, auth tuples, proxy URL strings) work with either. - Transport exceptions on `client.models`, `files`, etc.: network and timeout errors (`TimeoutException`, `ConnectError`, `HTTPError`) are reraised unwrapped after retries, so `except` clauses catching them directly must match the active client. (4xx/5xx `APIError` exceptions and `client.interactions` `APIConnectionError` / `APITimeoutError` are unchanged, though `.response` / `.request` attributes come from the active client.) Fixes: https://github.com/googleapis/python-genai/issues/2680 PiperOrigin-RevId: 1013424989 --- README.md | 26 +- docs/source/index.rst | 12 +- google/genai/_api_client.py | 140 ++-- google/genai/_gaos/_hooks/adapters.py | 5 +- google/genai/_gaos/_hooks/asyncsdkhooks.py | 5 +- google/genai/_gaos/_hooks/asynctypes.py | 5 +- .../genai/_gaos/_hooks/google_genai_auth.py | 5 +- google/genai/_gaos/_hooks/sdkhooks.py | 5 +- google/genai/_gaos/_hooks/types.py | 5 +- google/genai/_gaos/_internal.py | 5 +- google/genai/_gaos/agents.py | 5 +- google/genai/_gaos/basesdk.py | 25 +- google/genai/_gaos/credentials.py | 5 +- google/genai/_gaos/environments.py | 5 +- .../_gaos/errors/cancelinteractionbyid.py | 5 +- .../genai/_gaos/errors/createinteraction.py | 5 +- .../genai/_gaos/errors/deleteinteraction.py | 5 +- .../genai/_gaos/errors/genaidefaulterror.py | 5 +- google/genai/_gaos/errors/genaierror.py | 5 +- .../genai/_gaos/errors/getinteractionbyid.py | 5 +- .../_gaos/errors/responsevalidationerror.py | 5 +- google/genai/_gaos/files.py | 5 +- google/genai/_gaos/google_genai.py | 5 +- google/genai/_gaos/httpclient.py | 5 +- google/genai/_gaos/interactions.py | 5 +- google/genai/_gaos/lib/compat_errors.py | 5 +- google/genai/_gaos/sdk.py | 5 +- google/genai/_gaos/triggers.py | 5 +- google/genai/_gaos/utils/eventstreaming.py | 5 +- google/genai/_gaos/utils/headers.py | 5 +- google/genai/_gaos/utils/logger.py | 5 +- google/genai/_gaos/utils/response_helpers.py | 5 +- google/genai/_gaos/utils/retries.py | 5 +- google/genai/_gaos/utils/serializers.py | 5 +- .../_gaos/utils/unmarshal_json_response.py | 5 +- google/genai/_gaos/utils/values.py | 5 +- google/genai/_gaos/voices.py | 5 +- google/genai/_gaos/webhooks.py | 5 +- google/genai/_interactions/__init__.py | 15 +- google/genai/_mcp_utils.py | 2 +- .../genai/tests/client/test_async_stream.py | 23 +- .../client/test_client_initialization.py | 18 +- .../genai/tests/client/test_http_options.py | 6 +- .../genai/tests/client/test_httpx2_client.py | 652 +++++++++++++++++- google/genai/tests/client/test_retries.py | 6 +- .../tests/imports/test_no_optional_imports.py | 98 ++- google/genai/tests/interactions/test_auth.py | 37 +- .../tests/interactions/test_integration.py | 9 +- google/genai/tests/interactions/test_paths.py | 23 +- google/genai/tests/live/test_live.py | 6 +- pyproject.toml | 5 +- 51 files changed, 1097 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index 4eb7f7bd3..54ce64c40 100644 --- a/README.md +++ b/README.md @@ -250,37 +250,37 @@ client = genai.Client( ) ``` -### HTTP client option: httpx2 +### HTTP client option: httpx -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]` +By default, the SDK uses [`httpx2`](https://github.com/pydantic/httpx2) for its HTTP transport. You can opt in to +legacy `httpx` by installing `google-genai[httpx]` and setting the `GOOGLE_GENAI_HTTP_CLIENT` environment variable: ```bash -export GOOGLE_GENAI_HTTP_CLIENT=httpx2 +export GOOGLE_GENAI_HTTP_CLIENT=httpx ``` -Alternatively, you can directly inject an `httpx2.Client` or `httpx2.AsyncClient` +Alternatively, you can directly inject an `httpx.Client` or `httpx.AsyncClient` via `HttpOptions`: ```python -import httpx2 +import httpx 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(), + httpx_client=httpx.Client(), + httpx_async_client=httpx.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 +295,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 +304,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 01dee579e..372b33771 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 36d7a2638..8bd708600 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -809,13 +809,43 @@ def __del__(self) -> None: AsyncHttpx2Client = None # type: ignore[assignment,misc] -def _get_http_client_backend() -> str: +# The HTTP client backend used when GOOGLE_GENAI_HTTP_CLIENT is unset or 'auto'. +# If it is not installed, the other backend is used instead. +_DEFAULT_HTTP_CLIENT_BACKEND = 'httpx2' + + +def _detect_http_backend(http_options: HttpOptions) -> Optional[str]: + """Returns 'httpx' or 'httpx2' if `http_options` holds objects from either. + + Checks the injected clients and the top-level `client_args` / + `async_client_args` values (e.g. `transport`, `timeout`, `auth`). The MRO is + walked so user subclasses (e.g. of `httpx.AsyncBaseTransport`) are detected. + """ + candidates = [http_options.httpx_client, http_options.httpx_async_client] + candidates += (http_options.client_args or {}).values() + candidates += (http_options.async_client_args or {}).values() + for obj in candidates: + for cls in type(obj).__mro__: + package = (cls.__module__ or '').split('.')[0] + if package == 'httpx2' and httpx2 is not None: + return 'httpx2' + if package == 'httpx' and httpx is not None: + return 'httpx' + return None + + +def _get_http_client_backend( + http_options: Optional[HttpOptions] = None, +) -> 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. + - 'auto' or unset: Auto-detect from `http_options` (`httpx_client`, + `httpx_async_client`, `client_args`, `async_client_args`) if provided, + otherwise use `_DEFAULT_HTTP_CLIENT_BACKEND` if installed, or fall back + to the other backend. """ env = os.environ.get('GOOGLE_GENAI_HTTP_CLIENT', '').lower().strip() if env == 'httpx2': @@ -837,19 +867,33 @@ def _get_http_client_backend() -> str: 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT=%r; falling back to default.', env ) - 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 + if http_options is not None: + detected = _detect_http_backend(http_options) + if detected is not None: + return detected + + installed = {'httpx': httpx is not None, 'httpx2': httpx2 is not None} + fallback = 'httpx' if _DEFAULT_HTTP_CLIENT_BACKEND == 'httpx2' else 'httpx2' + # Either backend may be missing (httpx2 is an optional extra, and a user may + # have uninstalled httpx), so fall back to the other one before raising # ImportError. - if httpx2 is not None: - return 'httpx2' + for backend in (_DEFAULT_HTTP_CLIENT_BACKEND, fallback): + if installed[backend]: + return backend raise ImportError( 'Neither httpx nor httpx2 is installed. Please install httpx2 or httpx.' ) +def _get_http_client_classes( + http_options: Optional[HttpOptions] = None, +) -> Tuple[Any, Any]: + """Returns (sync_client_cls, async_client_cls) for the resolved backend.""" + if _get_http_client_backend(http_options) == 'httpx2': + return SyncHttpx2Client, AsyncHttpx2Client + return SyncHttpxClient, AsyncHttpxClient + + class BaseApiClient: """Client for calling HTTP APIs sending and receiving JSON.""" @@ -1068,20 +1112,21 @@ 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, + sync_client_cls, async_client_cls = _get_http_client_classes( + self._http_options ) + + if ( + self._http_options.httpx_client + and self._http_options.httpx_async_client + ): + client_args: _common.StringDict = {} + async_client_args: _common.StringDict = {} + else: + client_args, async_client_args = self._ensure_httpx_ssl_ctx( + self._http_options, + vertexai=bool(self.vertexai), + ) self._async_httpx_client_args = async_client_args self._authorized_session: Optional['AuthorizedSession'] = None @@ -1377,19 +1422,47 @@ def _ensure_httpx_ssl_ctx( else None ) + default_sync_cls, default_async_cls = _get_http_client_classes(options) + sync_cls = client_cls or default_sync_cls + async_cls = async_client_cls or default_async_cls + + use_httpx2 = httpx2 is not None and ( + ( + sync_cls is not None + and isinstance(getattr(httpx2, 'Client', None), type) + and issubclass(sync_cls, httpx2.Client) + ) + or ( + async_cls is not None + and isinstance(getattr(httpx2, 'AsyncClient', None), type) + and issubclass(async_cls, httpx2.AsyncClient) + ) + ) + if ctx is None: - # Initialize the SSL context for the httpx client. + # Initialize the SSL context for the httpx/httpx2 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. + # enabled explicitly. When using httpx2 without SSL_CERT_FILE or + # SSL_CERT_DIR, 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 use_httpx2: + 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() + else: + ctx = ssl.create_default_context( + cafile=os.environ.get('SSL_CERT_FILE', certifi.where()), + capath=os.environ.get('SSL_CERT_DIR'), + ) def _maybe_set( raw_args: Optional[_common.StringDict], @@ -1432,17 +1505,6 @@ def _maybe_set( 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, sync_cls), _maybe_set(async_args, ctx, async_cls), diff --git a/google/genai/_gaos/_hooks/adapters.py b/google/genai/_gaos/_hooks/adapters.py index 5a9c41c70..a2809297d 100644 --- a/google/genai/_gaos/_hooks/adapters.py +++ b/google/genai/_gaos/_hooks/adapters.py @@ -34,7 +34,10 @@ BeforeRequestHook, ) import asyncio -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Optional, Tuple, Union # ============================================================================ diff --git a/google/genai/_gaos/_hooks/asyncsdkhooks.py b/google/genai/_gaos/_hooks/asyncsdkhooks.py index ad81b1d06..bfcdff468 100644 --- a/google/genai/_gaos/_hooks/asyncsdkhooks.py +++ b/google/genai/_gaos/_hooks/asyncsdkhooks.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..a2464421b 100644 --- a/google/genai/_gaos/_hooks/asynctypes.py +++ b/google/genai/_gaos/_hooks/asynctypes.py @@ -24,7 +24,10 @@ BeforeRequestContext, ) from abc import ABC, abstractmethod -import httpx +try: + import httpx +except ImportError: + 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 aba5cb0d4..d78ca0c81 100644 --- a/google/genai/_gaos/_hooks/google_genai_auth.py +++ b/google/genai/_gaos/_hooks/google_genai_auth.py @@ -21,7 +21,10 @@ from typing import Any, Callable, Mapping, Optional, Union, cast -import httpx +try: + import httpx +except ImportError: + 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..dcb716b34 100644 --- a/google/genai/_gaos/_hooks/sdkhooks.py +++ b/google/genai/_gaos/_hooks/sdkhooks.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..a1336373b 100644 --- a/google/genai/_gaos/_hooks/types.py +++ b/google/genai/_gaos/_hooks/types.py @@ -19,7 +19,10 @@ from ..sdkconfiguration import SDKConfiguration from abc import ABC, abstractmethod -import httpx +try: + import httpx +except ImportError: + 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..2d65147fa 100644 --- a/google/genai/_gaos/_internal.py +++ b/google/genai/_gaos/_internal.py @@ -22,7 +22,10 @@ from .basesdk import AsyncBaseSDK, BaseSDK from .types import OptionalNullable, UNSET from .utils import get_security_from_env, response_helpers -import httpx +try: + import httpx +except ImportError: + 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..784af73a8 100644 --- a/google/genai/_gaos/agents.py +++ b/google/genai/_gaos/agents.py @@ -24,7 +24,10 @@ 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 +try: + import httpx +except ImportError: + 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..8d77ec76c 100644 --- a/google/genai/_gaos/basesdk.py +++ b/google/genai/_gaos/basesdk.py @@ -26,7 +26,12 @@ ) from .sdkconfiguration import SDKConfiguration from .utils import RetryConfig, SerializedRequestBody, get_body_content -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx +from typing import Any, Callable, List, Mapping, Optional, Tuple +from urllib.parse import parse_qs, urlparse try: import httpx2 @@ -38,8 +43,14 @@ if httpx2 is None else (httpx.Timeout, httpx2.Timeout) ) -from typing import Any, Callable, List, Mapping, Optional, Tuple -from urllib.parse import parse_qs, urlparse + + +def _use_client_default(client: Any) -> Any: + if httpx2 is not None and isinstance( + client, (httpx2.Client, httpx2.AsyncClient) + ): + return httpx2.USE_CLIENT_DEFAULT + return httpx.USE_CLIENT_DEFAULT class BaseSDK: @@ -234,7 +245,9 @@ def _build_request_with_client( data=serialized_request_body.data, files=serialized_request_body.files, headers=headers, - timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT, + timeout=( + timeout if timeout is not None else _use_client_default(client) + ), ) def do_request( @@ -501,7 +514,9 @@ def _build_request_with_client( data=serialized_request_body.data, files=serialized_request_body.files, headers=headers, - timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT, + timeout=( + timeout if timeout is not None else _use_client_default(client) + ), ) async def do_request_async( diff --git a/google/genai/_gaos/credentials.py b/google/genai/_gaos/credentials.py index e40a638ae..8cd0f6b45 100644 --- a/google/genai/_gaos/credentials.py +++ b/google/genai/_gaos/credentials.py @@ -23,7 +23,10 @@ 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 +try: + import httpx +except ImportError: + 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..ab216003f 100644 --- a/google/genai/_gaos/environments.py +++ b/google/genai/_gaos/environments.py @@ -30,7 +30,10 @@ 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 +try: + import httpx +except ImportError: + 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..f106e8604 100644 --- a/google/genai/_gaos/errors/cancelinteractionbyid.py +++ b/google/genai/_gaos/errors/cancelinteractionbyid.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + 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..7bdb6ce90 100644 --- a/google/genai/_gaos/errors/createinteraction.py +++ b/google/genai/_gaos/errors/createinteraction.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + 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..146864402 100644 --- a/google/genai/_gaos/errors/deleteinteraction.py +++ b/google/genai/_gaos/errors/deleteinteraction.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + 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..6094a2729 100644 --- a/google/genai/_gaos/errors/genaidefaulterror.py +++ b/google/genai/_gaos/errors/genaidefaulterror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..4fdf49a5a 100644 --- a/google/genai/_gaos/errors/genaierror.py +++ b/google/genai/_gaos/errors/genaierror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..deea47d7a 100644 --- a/google/genai/_gaos/errors/getinteractionbyid.py +++ b/google/genai/_gaos/errors/getinteractionbyid.py @@ -22,7 +22,10 @@ from ..types import BaseModel from ..types.interactions import error as interactions_error from dataclasses import dataclass, field -import httpx +try: + import httpx +except ImportError: + 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..d9aa158c0 100644 --- a/google/genai/_gaos/errors/responsevalidationerror.py +++ b/google/genai/_gaos/errors/responsevalidationerror.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..693ea60aa 100644 --- a/google/genai/_gaos/files.py +++ b/google/genai/_gaos/files.py @@ -23,7 +23,10 @@ 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 +try: + import httpx +except ImportError: + 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 74efdce22..d97e89141 100644 --- a/google/genai/_gaos/google_genai.py +++ b/google/genai/_gaos/google_genai.py @@ -32,7 +32,10 @@ import mimetypes import os -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from .. import _interactions_continuation from ._hooks.google_genai_auth import ( diff --git a/google/genai/_gaos/httpclient.py b/google/genai/_gaos/httpclient.py index 0e1a8dcd7..c3ed9fe35 100644 --- a/google/genai/_gaos/httpclient.py +++ b/google/genai/_gaos/httpclient.py @@ -20,7 +20,10 @@ # pyright: reportReturnType = false import asyncio from typing_extensions import Protocol, runtime_checkable -import httpx +try: + import httpx +except ImportError: + 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 f34d65f85..9acf7fc51 100644 --- a/google/genai/_gaos/interactions.py +++ b/google/genai/_gaos/interactions.py @@ -24,7 +24,10 @@ 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 +try: + import httpx +except ImportError: + 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..358521972 100644 --- a/google/genai/_gaos/lib/compat_errors.py +++ b/google/genai/_gaos/lib/compat_errors.py @@ -34,7 +34,10 @@ except ImportError: httpx2 = None -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx _HTTPX_TIMEOUT_ERRORS = ( (httpx.TimeoutException,) diff --git a/google/genai/_gaos/sdk.py b/google/genai/_gaos/sdk.py index 2f493dcf7..61920a08c 100644 --- a/google/genai/_gaos/sdk.py +++ b/google/genai/_gaos/sdk.py @@ -31,7 +31,10 @@ from .types import OptionalNullable, UNSET from .utils.logger import Logger, get_default_logger from .utils.retries import RetryConfig -import httpx +try: + import httpx +except ImportError: + 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..3840d6f61 100644 --- a/google/genai/_gaos/triggers.py +++ b/google/genai/_gaos/triggers.py @@ -27,7 +27,10 @@ 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 +try: + import httpx +except ImportError: + 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..6fc316c11 100644 --- a/google/genai/_gaos/utils/eventstreaming.py +++ b/google/genai/_gaos/utils/eventstreaming.py @@ -31,7 +31,10 @@ AsyncGenerator, Tuple, ) -import httpx +try: + import httpx +except ImportError: + 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..f4b326aa1 100644 --- a/google/genai/_gaos/utils/headers.py +++ b/google/genai/_gaos/utils/headers.py @@ -23,7 +23,10 @@ List, Optional, ) -from httpx import Headers +try: + from httpx import Headers +except ImportError: + 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..65f61771a 100644 --- a/google/genai/_gaos/utils/logger.py +++ b/google/genai/_gaos/utils/logger.py @@ -17,7 +17,10 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" -import httpx +try: + import httpx +except ImportError: + 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..c3a2f6c65 100644 --- a/google/genai/_gaos/utils/response_helpers.py +++ b/google/genai/_gaos/utils/response_helpers.py @@ -46,7 +46,10 @@ overload, ) -import httpx +try: + import httpx +except ImportError: + 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..a2bb0879d 100644 --- a/google/genai/_gaos/utils/retries.py +++ b/google/genai/_gaos/utils/retries.py @@ -24,7 +24,10 @@ from email.utils import parsedate_to_datetime from typing import List, Optional -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx class BackoffStrategy: diff --git a/google/genai/_gaos/utils/serializers.py b/google/genai/_gaos/utils/serializers.py index 00e3f1315..0b6a95b1b 100644 --- a/google/genai/_gaos/utils/serializers.py +++ b/google/genai/_gaos/utils/serializers.py @@ -25,7 +25,10 @@ import typing_extensions from typing_extensions import get_origin -import httpx +try: + import httpx +except ImportError: + 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..744af872c 100644 --- a/google/genai/_gaos/utils/unmarshal_json_response.py +++ b/google/genai/_gaos/utils/unmarshal_json_response.py @@ -19,7 +19,10 @@ from typing import Any, Optional, Type, TypeVar, overload -import httpx +try: + import httpx +except ImportError: + 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..63fe86570 100644 --- a/google/genai/_gaos/utils/values.py +++ b/google/genai/_gaos/utils/values.py @@ -24,7 +24,10 @@ import os from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast -from httpx import Response +try: + from httpx import Response +except ImportError: + 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 1684c55f3..71158d783 100644 --- a/google/genai/_gaos/voices.py +++ b/google/genai/_gaos/voices.py @@ -24,7 +24,10 @@ 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 +try: + import httpx +except ImportError: + 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..5017450e5 100644 --- a/google/genai/_gaos/webhooks.py +++ b/google/genai/_gaos/webhooks.py @@ -29,7 +29,10 @@ ) from .utils import get_security_from_env, response_helpers from .utils.unmarshal_json_response import unmarshal_json_response -import httpx +try: + import httpx +except ImportError: + import httpx2 as httpx from typing import Any, Iterable, List, Mapping, Optional, Union, cast diff --git a/google/genai/_interactions/__init__.py b/google/genai/_interactions/__init__.py index 75b79b061..3c0227410 100644 --- a/google/genai/_interactions/__init__.py +++ b/google/genai/_interactions/__init__.py @@ -19,10 +19,23 @@ import typing as _typing from abc import ABC, abstractmethod -import httpx as _httpx import pydantic as _pydantic from .. import types +from .._api_client import _DEFAULT_HTTP_CLIENT_BACKEND + +if _typing.TYPE_CHECKING: + import httpx as _httpx +elif _DEFAULT_HTTP_CLIENT_BACKEND == "httpx2": + try: + import httpx2 as _httpx + except ImportError: + import httpx as _httpx +else: + try: + import httpx as _httpx + except ImportError: + import httpx2 as _httpx from ..version import __version__ from .._gaos.utils.eventstreaming import AsyncStream, Stream from .core.error import ( diff --git a/google/genai/_mcp_utils.py b/google/genai/_mcp_utils.py index 413b4ed98..e38cd4fa1 100644 --- a/google/genai/_mcp_utils.py +++ b/google/genai/_mcp_utils.py @@ -213,7 +213,7 @@ 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() + backend = _get_http_client_backend(getattr(api_client, "_http_options", None)) async_client = getattr(api_client, "_async_httpx_client", None) is_httpx2_client = ( httpx2 is not None diff --git a/google/genai/tests/client/test_async_stream.py b/google/genai/tests/client/test_async_stream.py index 71b626b9f..43b57eb03 100644 --- a/google/genai/tests/client/test_async_stream.py +++ b/google/genai/tests/client/test_async_stream.py @@ -32,13 +32,16 @@ AIOHTTP_NOT_INSTALLED = True aiohttp = mock.MagicMock() -import httpx - from ... import _api_client as api_client from ... import Client from ... import errors from ... import types +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + EVENT_STREAM_DATA_WITH_ERROR = [ b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}', @@ -387,7 +390,7 @@ def mock_response(chunks): ) -@patch('httpx.Client.send') +@patch.object(httpx.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 +415,7 @@ def test_error_event_in_streamed_responses_bad_json(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch.object(httpx.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 +435,7 @@ def test_error_event_in_streamed_responses(mock_send_method): next(stream) -@patch('httpx.Client.send') +@patch.object(httpx.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 +467,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 +480,7 @@ async def _async_httpx_response(_): ) -@patch('httpx.AsyncBaseTransport') +@patch.object(httpx, 'AsyncBaseTransport') @pytest.mark.asyncio async def test_error_event_in_streamed_responses_async(mock_transport): client = api_client.BaseApiClient( @@ -502,7 +509,7 @@ async def test_error_event_in_streamed_responses_async(mock_transport): await anext(resp) -@patch('httpx.AsyncBaseTransport') +@patch.object(httpx, '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..d36706b27 100644 --- a/google/genai/tests/client/test_client_initialization.py +++ b/google/genai/tests/client/test_client_initialization.py @@ -28,7 +28,6 @@ import certifi import google.auth from google.auth import credentials -import httpx import pytest import requests @@ -38,6 +37,15 @@ from ... import Client from ... import types +if api_client._get_http_client_backend() == "httpx2": + import httpx2 as httpx # pylint: disable=g-import-not-at-top + _SyncHttpxClient = api_client.SyncHttpx2Client + _AsyncHttpxClient = api_client.AsyncHttpx2Client +else: + import httpx # pylint: disable=g-import-not-at-top + _SyncHttpxClient = api_client.SyncHttpxClient + _AsyncHttpxClient = api_client.AsyncHttpxClient + try: import aiohttp @@ -1550,7 +1558,7 @@ def refresh_side_effect(request): text='{"candidates": [{"content": {"parts": [{"text": "response"}]}}]}', ) mock_request = mock.Mock(return_value=mock_httpx_response) - monkeypatch.setattr(api_client.SyncHttpxClient, "send", mock_request) + monkeypatch.setattr(_SyncHttpxClient, "send", mock_request) # Reset credentials to test initialization to ensure the sync lock is tested. client._api_client._credentials = None @@ -2161,7 +2169,7 @@ def test_sync_httpx_uses_mtls_endpoint_with_client_cert(mock_client_cert): assert not client._api_client._use_google_auth_sync() mock_send = mock.Mock(return_value=httpx.Response(200, text="{}")) - with mock.patch.object(api_client.SyncHttpxClient, "send", mock_send): + with mock.patch.object(_SyncHttpxClient, "send", mock_send): client._api_client.request("post", "models/gemini:generateContent", {}) assert mock_send.call_args[0][0].url.host == ( @@ -2177,7 +2185,7 @@ async def test_async_httpx_uses_mtls_endpoint_with_client_cert( client = _mtls_test_client() mock_request = mock.AsyncMock(return_value=httpx.Response(200, text="{}")) - with mock.patch.object(api_client.AsyncHttpxClient, "request", mock_request): + with mock.patch.object(_AsyncHttpxClient, "request", mock_request): await client._api_client.async_request( "post", "models/gemini:generateContent", {} ) @@ -2195,7 +2203,7 @@ async def test_async_httpx_download_uses_mtls_endpoint_with_client_cert( client = _mtls_test_client() mock_request = mock.AsyncMock(return_value=httpx.Response(200, content=b"")) - with mock.patch.object(api_client.AsyncHttpxClient, "request", mock_request): + with mock.patch.object(_AsyncHttpxClient, "request", mock_request): await client._api_client.async_download_file("files/abc:download") assert mock_request.call_args.kwargs["url"].startswith( diff --git a/google/genai/tests/client/test_http_options.py b/google/genai/tests/client/test_http_options.py index c5c08de54..2dec9790b 100644 --- a/google/genai/tests/client/test_http_options.py +++ b/google/genai/tests/client/test_http_options.py @@ -18,12 +18,16 @@ from unittest import mock -import httpx import pytest from ... import _api_client from ... import types +if _api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + def test_patch_http_options_with_copies_all_fields(): patch_options = types.HttpOptions( diff --git a/google/genai/tests/client/test_httpx2_client.py b/google/genai/tests/client/test_httpx2_client.py index dbb148002..aa220742d 100644 --- a/google/genai/tests/client/test_httpx2_client.py +++ b/google/genai/tests/client/test_httpx2_client.py @@ -34,8 +34,10 @@ try: import httpx2 + import truststore except ImportError: httpx2 = None + truststore = None # Mark all tests in this module to be skipped if httpx2 is not available pytestmark = pytest.mark.skipif( @@ -166,20 +168,44 @@ 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) +def _client_classes(backend): 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 + if backend == 'httpx2': + return ( + httpx2.Client, + httpx2.AsyncClient, + target_api_client.SyncHttpx2Client, + target_api_client.AsyncHttpx2Client, + ) + return ( + httpx.Client, + httpx.AsyncClient, + target_api_client.SyncHttpxClient, + target_api_client.AsyncHttpxClient, ) - assert isinstance(client._api_client._async_httpx_client, httpx.AsyncClient) - assert isinstance( - client._api_client._async_httpx_client, target_api_client.AsyncHttpxClient + + +def _set_default_backend(monkeypatch, backend): + target_api_client = getattr(api_client, 'public_api_client', api_client) + monkeypatch.setattr( + target_api_client, '_DEFAULT_HTTP_CLIENT_BACKEND', backend ) +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_default_client_uses_default_backend(monkeypatch, default_backend): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + _set_default_backend(monkeypatch, default_backend) + sync_cls, async_cls, sdk_sync_cls, sdk_async_cls = _client_classes( + default_backend + ) + client = Client(api_key='fake_api_key') + assert isinstance(client._api_client._httpx_client, sync_cls) + assert isinstance(client._api_client._httpx_client, sdk_sync_cls) + assert isinstance(client._api_client._async_httpx_client, async_cls) + assert isinstance(client._api_client._async_httpx_client, sdk_async_cls) + + 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) @@ -194,6 +220,7 @@ def test_env_var_override_to_httpx(monkeypatch): ) + 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) @@ -208,19 +235,27 @@ def test_env_var_override_to_httpx2(monkeypatch): ) -def test_env_var_auto(monkeypatch): + + +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_env_var_auto(monkeypatch, default_backend): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'auto') - target_api_client = getattr(api_client, 'public_api_client', api_client) + _set_default_backend(monkeypatch, default_backend) + sync_cls, *_ = _client_classes(default_backend) client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) + assert isinstance(client._api_client._httpx_client, sync_cls) -def test_env_var_unrecognized_falls_back_to_default(monkeypatch, caplog): +@pytest.mark.parametrize('default_backend', ['httpx', 'httpx2']) +def test_env_var_unrecognized_falls_back_to_default( + monkeypatch, caplog, default_backend +): monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'unknown_client') - target_api_client = getattr(api_client, 'public_api_client', api_client) + _set_default_backend(monkeypatch, default_backend) + sync_cls, *_ = _client_classes(default_backend) with caplog.at_level('WARNING'): client = Client(api_key='fake_api_key') - assert isinstance(client._api_client._httpx_client, httpx.Client) + assert isinstance(client._api_client._httpx_client, sync_cls) assert 'Unrecognized GOOGLE_GENAI_HTTP_CLIENT' in caplog.text @@ -232,6 +267,7 @@ def test_env_var_httpx2_missing_raises(monkeypatch): 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) @@ -240,19 +276,36 @@ def test_env_var_httpx_missing_raises(monkeypatch): Client(api_key='fake_api_key') -def test_default_falls_back_to_httpx2_when_httpx_missing(monkeypatch): + + +@pytest.mark.parametrize( + 'default_backend,fallback_backend', + [('httpx', 'httpx2'), ('httpx2', 'httpx')], +) +def test_default_falls_back_when_default_backend_missing( + monkeypatch, default_backend, fallback_backend +): 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 + _set_default_backend(monkeypatch, default_backend) + monkeypatch.setattr(target_api_client, default_backend, None) + sync_cls, async_cls, sdk_sync_cls, sdk_async_cls = _client_classes( + fallback_backend ) + client = Client(api_key='fake_api_key') + assert isinstance(client._api_client._httpx_client, sync_cls) + assert isinstance(client._api_client._httpx_client, sdk_sync_cls) + assert isinstance(client._api_client._async_httpx_client, async_cls) + assert isinstance(client._api_client._async_httpx_client, sdk_async_cls) + + +def test_default_raises_when_no_backend_installed(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) + monkeypatch.setattr(target_api_client, 'httpx2', None) + with pytest.raises(ImportError, match='Neither httpx nor httpx2'): + target_api_client._get_http_client_backend() # High-level feature tests: end-to-end generate_content and streaming with httpx2 @@ -866,6 +919,21 @@ 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, + client_cls=api_client.SyncHttpx2Client, + async_client_cls=api_client.AsyncHttpx2Client, + ) + ) + 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 = ( @@ -913,3 +981,537 @@ def test_client_init_with_custom_ssl_context_httpx2(monkeypatch): ) assert isinstance(client._api_client._httpx_client, httpx2.Client) assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + + +def test_ensure_httpx2_ssl_ctx_with_ssl_cert_file_env(monkeypatch): + import certifi + + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + monkeypatch.setenv('SSL_CERT_FILE', certifi.where()) + 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'], ssl.SSLContext) + assert not isinstance(client_args['verify'], truststore.SSLContext) + assert isinstance(async_client_args['verify'], ssl.SSLContext) + + +def test_auto_detect_httpx_from_injected_client_when_default_httpx2( + monkeypatch, +): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + _set_default_backend(monkeypatch, 'httpx2') + + # Only sync httpx.Client injected -> async client should also use httpx + sync_injected = httpx.Client() + client_sync = Client( + api_key='fake_api_key', + http_options=HttpOptions(httpx_client=sync_injected), + ) + assert client_sync._api_client._httpx_client is sync_injected + assert isinstance( + client_sync._api_client._async_httpx_client, httpx.AsyncClient + ) + + # Only async httpx.AsyncClient injected -> sync client should also use httpx + async_injected = httpx.AsyncClient() + client_async = Client( + api_key='fake_api_key', + http_options=HttpOptions(httpx_async_client=async_injected), + ) + assert isinstance(client_async._api_client._httpx_client, httpx.Client) + assert client_async._api_client._async_httpx_client is async_injected + + +def test_auto_detect_httpx2_from_injected_client_when_default_httpx( + monkeypatch, +): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + _set_default_backend(monkeypatch, 'httpx') + + # Only sync httpx2.Client injected -> async client should also use httpx2 + sync_injected = httpx2.Client() + client_sync = Client( + api_key='fake_api_key', + http_options=HttpOptions(httpx_client=sync_injected), + ) + assert client_sync._api_client._httpx_client is sync_injected + assert isinstance( + client_sync._api_client._async_httpx_client, httpx2.AsyncClient + ) + + # Only async httpx2.AsyncClient injected -> sync client should also use httpx2 + async_injected = httpx2.AsyncClient() + client_async = Client( + api_key='fake_api_key', + http_options=HttpOptions(httpx_async_client=async_injected), + ) + assert isinstance(client_async._api_client._httpx_client, httpx2.Client) + assert client_async._api_client._async_httpx_client is async_injected + + +@pytest.mark.parametrize( + 'arg_key,httpx_val_fn,httpx2_val_fn', + [ + ( + 'transport', + lambda: httpx.MockTransport(lambda req: httpx.Response(200)), + lambda: httpx2.MockTransport(lambda req: httpx2.Response(200)), + ), + ('timeout', lambda: httpx.Timeout(5.0), lambda: httpx2.Timeout(5.0)), + ( + 'auth', + lambda: httpx.BasicAuth('u', 'p'), + lambda: httpx2.BasicAuth('u', 'p'), + ), + ( + 'limits', + lambda: httpx.Limits(max_connections=5), + lambda: httpx2.Limits(max_connections=5), + ), + ( + 'proxy', + lambda: httpx.Proxy('http://localhost:8080'), + lambda: httpx2.Proxy('http://localhost:8080'), + ), + ], +) +def test_auto_detect_backend_from_client_args( + monkeypatch, arg_key, httpx_val_fn, httpx2_val_fn +): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'auto') + + # When default is httpx2, passing httpx.* object in client_args selects httpx + _set_default_backend(monkeypatch, 'httpx2') + client_httpx = Client( + api_key='fake_api_key', + http_options=HttpOptions(client_args={arg_key: httpx_val_fn()}), + ) + assert isinstance(client_httpx._api_client._httpx_client, httpx.Client) + assert isinstance( + client_httpx._api_client._async_httpx_client, httpx.AsyncClient + ) + + # When default is httpx, passing httpx2.* object in client_args selects httpx2 + _set_default_backend(monkeypatch, 'httpx') + client_httpx2 = Client( + api_key='fake_api_key', + http_options=HttpOptions(client_args={arg_key: httpx2_val_fn()}), + ) + assert isinstance(client_httpx2._api_client._httpx_client, httpx2.Client) + assert isinstance( + client_httpx2._api_client._async_httpx_client, httpx2.AsyncClient + ) + + +def test_auto_detect_backend_from_async_client_args(monkeypatch): + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + + class CustomAsyncTransport(httpx.AsyncBaseTransport): + + async def handle_async_request( + self, request: httpx.Request + ) -> httpx.Response: + return httpx.Response(200) + + class CustomAsyncTransport2(httpx2.AsyncBaseTransport): + + async def handle_async_request( + self, request: httpx2.Request + ) -> httpx2.Response: + return httpx2.Response(200) + + _set_default_backend(monkeypatch, 'httpx2') + client_httpx = Client( + api_key='fake_api_key', + http_options=HttpOptions( + async_client_args={'transport': CustomAsyncTransport()} + ), + ) + assert isinstance(client_httpx._api_client._httpx_client, httpx.Client) + assert isinstance( + client_httpx._api_client._async_httpx_client, httpx.AsyncClient + ) + + _set_default_backend(monkeypatch, 'httpx') + client_httpx2 = Client( + api_key='fake_api_key', + http_options=HttpOptions( + async_client_args={'transport': CustomAsyncTransport2()} + ), + ) + assert isinstance(client_httpx2._api_client._httpx_client, httpx2.Client) + assert isinstance( + client_httpx2._api_client._async_httpx_client, httpx2.AsyncClient + ) + + +def test_skips_ensure_httpx_ssl_ctx_when_both_custom_clients_provided( + monkeypatch, +): + from unittest import mock + + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + with ( + mock.patch.object( + api_client.BaseApiClient, + '_ensure_httpx_ssl_ctx', + wraps=api_client.BaseApiClient._ensure_httpx_ssl_ctx, + ) as spy_ensure, + mock.patch.object( + httpx2, 'create_ssl_context', wraps=httpx2.create_ssl_context + ) as spy_create_ssl, + ): + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + httpx_client=httpx2.Client(trust_env=False), + httpx_async_client=httpx2.AsyncClient(trust_env=False), + ), + ) + spy_ensure.assert_not_called() + spy_create_ssl.assert_not_called() + assert client._api_client._async_httpx_client_args == {} + + +def test_gaos_basesdk_use_client_default_and_timeout(): + from ..._gaos import basesdk + + httpx_client = httpx.Client() + httpx2_client = httpx2.Client() + assert ( + basesdk._use_client_default(httpx_client) is httpx.USE_CLIENT_DEFAULT + ) + assert ( + basesdk._use_client_default(httpx2_client) is httpx2.USE_CLIENT_DEFAULT + ) + + client = Client( + api_key='fake_api_key', + http_options=HttpOptions( + httpx_client=httpx2_client, + httpx_async_client=httpx2.AsyncClient(), + ), + ) + req_default = client.interactions._build_request( + method='GET', + path='/v1beta/interactions/123', + base_url='https://example.com', + url_variables=None, + request=None, + request_body_required=False, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header='user-agent', + accept_header_value='application/json', + timeout_ms=None, + ) + assert req_default.extensions['timeout'] == { + 'connect': 5.0, + 'read': 5.0, + 'write': 5.0, + 'pool': 5.0, + } + + req_timeout = client.interactions._build_request( + method='GET', + path='/v1beta/interactions/123', + base_url='https://example.com', + url_variables=None, + request=None, + request_body_required=False, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header='user-agent', + accept_header_value='application/json', + timeout_ms=2500, + ) + assert req_timeout.extensions['timeout'] == { + 'connect': 2.5, + 'read': 2.5, + 'write': 2.5, + 'pool': 2.5, + } + + async_req_default = client.aio.interactions._build_request_async( + method='GET', + path='/v1beta/interactions/123', + base_url='https://example.com', + url_variables=None, + request=None, + request_body_required=False, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header='user-agent', + accept_header_value='application/json', + timeout_ms=None, + ) + assert async_req_default.extensions['timeout'] == { + 'connect': 5.0, + 'read': 5.0, + 'write': 5.0, + 'pool': 5.0, + } + + async_req_timeout = client.aio.interactions._build_request_async( + method='GET', + path='/v1beta/interactions/123', + base_url='https://example.com', + url_variables=None, + request=None, + request_body_required=False, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header='user-agent', + accept_header_value='application/json', + timeout_ms=2500, + ) + assert async_req_timeout.extensions['timeout'] == { + 'connect': 2.5, + 'read': 2.5, + 'write': 2.5, + 'pool': 2.5, + } + + + +# SSL / mTLS security tests. +# +# These record the kwargs passed to the SDK-built client constructors, so they +# assert on the `verify` SSL context each real client is created with (rather +# than on intermediate helper return values). + +_VERTEX_URL = 'https://us-central1-aiplatform.googleapis.com/v1' +_VERTEX_MTLS_URL = 'https://us-central1-aiplatform.mtls.googleapis.com/v1' + + +def _target_api_client(): + return getattr(api_client, 'public_api_client', api_client) + + +def _record_client_init(monkeypatch, backend): + """Records constructor kwargs for the SDK's sync/async clients of `backend`.""" + _, _, sdk_sync_cls, sdk_async_cls = _client_classes(backend) + calls = {'sync': [], 'async': []} + for key, cls in (('sync', sdk_sync_cls), ('async', sdk_async_cls)): + original_init = cls.__init__ + + def _init(self, _original_init=original_init, _key=key, **kwargs): + calls[_key].append(kwargs) + _original_init(self, **kwargs) + + monkeypatch.setattr(cls, '__init__', _init) + return calls + + +@pytest.fixture +def no_ssl_env(monkeypatch): + monkeypatch.delenv('SSL_CERT_FILE', raising=False) + monkeypatch.delenv('SSL_CERT_DIR', raising=False) + + +@pytest.fixture +def client_cert(monkeypatch): + """Simulates a configured default client certificate; returns its context.""" + from google.auth.transport import mtls + + cert_ctx = ssl.create_default_context() + monkeypatch.delenv('GOOGLE_API_USE_MTLS_ENDPOINT', raising=False) + monkeypatch.setattr( + mtls, 'should_use_client_cert', lambda: True, raising=False + ) + monkeypatch.setattr(mtls, 'has_default_client_cert_source', lambda: True) + monkeypatch.setattr(mtls, 'get_default_ssl_context', lambda: cert_ctx) + # Keep the async path on httpx (not AsyncAuthorizedSession/aiohttp) so the + # SDK-built async httpx client is exercised. + monkeypatch.setattr(_target_api_client(), 'has_aiohttp', False) + return cert_ctx + + +def _vertex_client(**http_options): + return Client( + vertexai=True, + project='fake-project', + location='us-central1', + http_options=http_options, + ) + + +@pytest.mark.parametrize('backend', ['httpx', 'httpx2']) +@pytest.mark.parametrize('injected', ['sync', 'async']) +def test_single_injected_client_other_client_still_gets_sdk_ssl_ctx( + monkeypatch, no_ssl_env, backend, injected +): + """Injecting one client must not skip SSL setup for the SDK-built one.""" + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', backend) + sync_cls, async_cls, _, _ = _client_classes(backend) + calls = _record_client_init(monkeypatch, backend) + + if injected == 'sync': + http_options = HttpOptions(httpx_client=sync_cls()) + built = 'async' + else: + http_options = HttpOptions(httpx_async_client=async_cls()) + built = 'sync' + Client(api_key='fake_api_key', http_options=http_options) + + assert calls[injected] == [] + assert len(calls[built]) == 1 + verify = calls[built][0]['verify'] + assert isinstance(verify, ssl.SSLContext) + assert isinstance(verify, truststore.SSLContext) == (backend == 'httpx2') + + +@pytest.mark.parametrize('backend', ['httpx', 'httpx2']) +@pytest.mark.parametrize('injected', ['sync', 'async']) +def test_vertex_single_injected_client_other_client_keeps_client_cert( + monkeypatch, client_cert, backend, injected +): + """The SDK-built client keeps the client cert and uses the mTLS endpoint.""" + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', backend) + sync_cls, async_cls, _, _ = _client_classes(backend) + calls = _record_client_init(monkeypatch, backend) + + if injected == 'sync': + client = _vertex_client(httpx_client=sync_cls()) + built = 'async' + else: + client = _vertex_client(httpx_async_client=async_cls()) + built = 'sync' + + assert calls[injected] == [] + assert len(calls[built]) == 1 + assert calls[built][0]['verify'] is client_cert + assert client._api_client._httpx_url( + _VERTEX_URL, is_async=(built == 'async') + ) == _VERTEX_MTLS_URL + # The caller-supplied client does not present the SDK's client cert, so it + # must not be routed to the mTLS endpoint. + assert client._api_client._httpx_url( + _VERTEX_URL, is_async=(injected == 'async') + ) == _VERTEX_URL + + +@pytest.mark.parametrize('backend', ['httpx', 'httpx2']) +def test_vertex_both_injected_clients_skip_ssl_and_mtls_endpoint( + monkeypatch, client_cert, backend +): + """With both clients injected, no httpx SSL setup and no mTLS routing.""" + from unittest import mock + + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', backend) + sync_cls, async_cls, _, _ = _client_classes(backend) + calls = _record_client_init(monkeypatch, backend) + sync_injected = sync_cls() + async_injected = async_cls() + + with mock.patch.object( + _target_api_client().BaseApiClient, + '_ensure_httpx_ssl_ctx', + wraps=_target_api_client().BaseApiClient._ensure_httpx_ssl_ctx, + ) as spy_ensure: + client = _vertex_client( + httpx_client=sync_injected, httpx_async_client=async_injected + ) + + spy_ensure.assert_not_called() + assert calls == {'sync': [], 'async': []} + assert client._api_client._httpx_client is sync_injected + assert client._api_client._async_httpx_client is async_injected + assert client._api_client._httpx_url(_VERTEX_URL, is_async=False) == ( + _VERTEX_URL + ) + assert client._api_client._httpx_url(_VERTEX_URL, is_async=True) == ( + _VERTEX_URL + ) + + +@pytest.mark.parametrize('backend', ['httpx', 'httpx2']) +def test_vertex_client_cert_takes_precedence_over_default_ctx( + monkeypatch, client_cert, no_ssl_env, backend +): + """The client-cert context wins over truststore/certifi for both clients.""" + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', backend) + calls = _record_client_init(monkeypatch, backend) + + client = _vertex_client() + + assert calls['sync'][0]['verify'] is client_cert + assert calls['async'][0]['verify'] is client_cert + assert client._api_client._httpx_url(_VERTEX_URL, is_async=False) == ( + _VERTEX_MTLS_URL + ) + assert client._api_client._httpx_url(_VERTEX_URL, is_async=True) == ( + _VERTEX_MTLS_URL + ) + + +@pytest.mark.parametrize( + 'default_backend,detected_backend', [('httpx2', 'httpx'), ('httpx', 'httpx2')] +) +def test_auto_detected_backend_selects_matching_ssl_ctx( + monkeypatch, no_ssl_env, default_backend, detected_backend +): + """Auto-detection picks the SSL context for the detected backend. + + httpx -> certifi-based `ssl.SSLContext`; httpx2 -> truststore. Both the sync + client (with the detected transport) and the async client (built from empty + async_client_args, i.e. a real network client) must get it. + """ + monkeypatch.delenv('GOOGLE_GENAI_HTTP_CLIENT', raising=False) + _set_default_backend(monkeypatch, default_backend) + calls = _record_client_init(monkeypatch, detected_backend) + module = httpx2 if detected_backend == 'httpx2' else httpx + transport = module.MockTransport(lambda req: module.Response(200)) + + Client(api_key='fake_api_key', http_options={'client_args': {'transport': transport}}) + + for key in ('sync', 'async'): + assert len(calls[key]) == 1 + verify = calls[key][0]['verify'] + assert isinstance(verify, ssl.SSLContext) + assert isinstance(verify, truststore.SSLContext) == ( + detected_backend == 'httpx2' + ) + assert calls['sync'][0]['transport'] is transport + + +def test_ensure_httpx2_ssl_ctx_with_ssl_cert_dir_env(monkeypatch, tmp_path): + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', 'httpx2') + monkeypatch.delenv('SSL_CERT_FILE', raising=False) + monkeypatch.setenv('SSL_CERT_DIR', str(tmp_path)) + client_args, async_client_args = ( + api_client.BaseApiClient._ensure_httpx_ssl_ctx(HttpOptions()) + ) + for args in (client_args, async_client_args): + assert isinstance(args['verify'], ssl.SSLContext) + assert not isinstance(args['verify'], truststore.SSLContext) + + +@pytest.mark.parametrize('backend', ['httpx', 'httpx2']) +def test_custom_verify_in_client_args_is_not_overridden( + monkeypatch, client_cert, backend +): + """A caller's `verify` SSL context is never replaced by the SDK's.""" + monkeypatch.setenv('GOOGLE_GENAI_HTTP_CLIENT', backend) + calls = _record_client_init(monkeypatch, backend) + custom_ctx = ssl.create_default_context() + + client = _vertex_client( + client_args={'verify': custom_ctx}, + async_client_args={'verify': custom_ctx}, + ) + + assert calls['sync'][0]['verify'] is custom_ctx + assert calls['async'][0]['verify'] is custom_ctx + # The custom context doesn't carry the SDK's client cert: no mTLS routing. + assert client._api_client._httpx_url(_VERTEX_URL, is_async=False) == ( + _VERTEX_URL + ) + assert client._api_client._httpx_url(_VERTEX_URL, is_async=True) == ( + _VERTEX_URL + ) diff --git a/google/genai/tests/client/test_retries.py b/google/genai/tests/client/test_retries.py index 6332f2091..a70244da6 100644 --- a/google/genai/tests/client/test_retries.py +++ b/google/genai/tests/client/test_retries.py @@ -36,13 +36,17 @@ AsyncAuthorizedSession = mock.MagicMock() from google.oauth2 import credentials -import httpx import tenacity from ... import _api_client as api_client from ... import errors from ... import types +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + requires_aiohttp = pytest.mark.skipif( AIOHTTP_NOT_INSTALLED, reason="aiohttp is not installed, skipping test." diff --git a/google/genai/tests/imports/test_no_optional_imports.py b/google/genai/tests/imports/test_no_optional_imports.py index a65a3d4a9..2fe658d6c 100644 --- a/google/genai/tests/imports/test_no_optional_imports.py +++ b/google/genai/tests/imports/test_no_optional_imports.py @@ -13,7 +13,9 @@ # limitations under the License. # +import asyncio import os +import sys import pytest IS_NOT_GITHUB_ACTIONS = os.getenv('GITHUB_ACTIONS') != 'true' @@ -22,7 +24,99 @@ @pytest.mark.skipif(IS_NOT_GITHUB_ACTIONS, reason='This test is only run on GitHub Actions.') def test_library_can_be_imported_without_optional_dependencies(): - """Tests that the library can be imported without optional dependencies. - """ + """Tests that the library works without optional dependencies (including httpx).""" + with pytest.raises(ImportError): + import httpx # pylint: disable=g-import-not-at-top,unused-import + assert 'httpx' not in sys.modules + from google import genai + from google.genai import errors + from google.genai import interactions from google.genai import types + import httpx2 + + assert interactions is not None + + def handler(request: httpx2.Request) -> httpx2.Response: + path = request.url.path + if path.endswith(':streamGenerateContent'): + return httpx2.Response( + status_code=200, + content=( + b'data: {"candidates": [{"content": {"parts": [{"text": "Hello "}], "role": "model"}}]}\n\n' + b'data: {"candidates": [{"content": {"parts": [{"text": "world"}], "role": "model"}}]}\n\n' + ), + headers={'content-type': 'text/event-stream'}, + ) + if path.endswith('/models/bad-model:generateContent'): + return httpx2.Response( + status_code=400, + json={'error': {'code': 400, 'message': 'Bad request', 'status': 'INVALID_ARGUMENT'}}, + ) + if '/interactions/' in path: + return httpx2.Response( + status_code=200, + headers={'content-type': 'application/json'}, + content=b'{"id": "test-id", "status": "completed"}', + ) + return httpx2.Response( + status_code=200, + json={ + 'candidates': [{ + 'content': {'parts': [{'text': 'ok'}], 'role': 'model'}, + 'finishReason': 'STOP', + }] + }, + ) + + transport = httpx2.MockTransport(handler) + client = genai.Client( + api_key='test-api-key', + http_options=types.HttpOptions( + client_args={'transport': transport}, + async_client_args={'transport': transport}, + ), + ) + assert isinstance(client._api_client._httpx_client, httpx2.Client) + assert isinstance(client._api_client._async_httpx_client, httpx2.AsyncClient) + + # Sync generate_content, streaming, error handling, and interactions. + resp = client.models.generate_content(model='gemini-2.5-flash', contents='hi') + assert resp.text == 'ok' + + chunks = list( + client.models.generate_content_stream( + model='gemini-2.5-flash', contents='hi' + ) + ) + assert ''.join(c.text for c in chunks) == 'Hello world' + + with pytest.raises(errors.ClientError): + client.models.generate_content(model='bad-model', contents='hi') + + interaction = client.interactions.get(id='test-id') + assert interaction.status == 'completed' + + # Async generate_content, streaming, error handling, and interactions. + async def _run_async() -> None: + async_resp = await client.aio.models.generate_content( + model='gemini-2.5-flash', contents='hi' + ) + assert async_resp.text == 'ok' + + async_chunks = [ + c + async for c in await client.aio.models.generate_content_stream( + model='gemini-2.5-flash', contents='hi' + ) + ] + assert ''.join(c.text for c in async_chunks) == 'Hello world' + + with pytest.raises(errors.ClientError): + await client.aio.models.generate_content(model='bad-model', contents='hi') + + async_interaction = await client.aio.interactions.get(id='test-id') + assert async_interaction.status == 'completed' + + asyncio.run(_run_async()) + assert 'httpx' not in sys.modules diff --git a/google/genai/tests/interactions/test_auth.py b/google/genai/tests/interactions/test_auth.py index 5ca8298f2..9c2f18576 100644 --- a/google/genai/tests/interactions/test_auth.py +++ b/google/genai/tests/interactions/test_auth.py @@ -17,14 +17,20 @@ """Tests for Interactions API.""" from ... import Client +from ... import _api_client from ... import _base_url from unittest import mock import pytest -from httpx import Request, Response -from ..._api_client import AsyncHttpxClient, BaseApiClient -from httpx import Client as HTTPClient +from ..._api_client import BaseApiClient import os +if _api_client._get_http_client_backend() == "httpx2": + from httpx2 import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpx2Client +else: + from httpx import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpxClient + ENV_VARS = [ "GOOGLE_CLOUD_PROJECT", "GEMINI_API_KEY", @@ -105,9 +111,6 @@ def test_interactions_gemini_extra_headers(monkeypatch): def test_interactions_vertex_auth_header(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -139,9 +142,6 @@ def test_interactions_vertex_auth_header(): for key, value in headers.items()) def test_interactions_vertex_key_no_auth_header(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() client = Client(vertexai=True, api_key='test-api-key') @@ -175,7 +175,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.object(HTTPClient, "send") as mock_send, ): mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') client.interactions.create( @@ -187,9 +187,6 @@ def test_interactions_vertex_url(): assert str(request.url) == 'https://us-central1-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/us-central1/interactions' def test_interactions_vertex_auth_refresh_on_retry(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -227,9 +224,6 @@ def my_send(req, *args, **kwargs): def test_interactions_vertex_extra_headers_override(): - from ..._api_client import BaseApiClient - from httpx import Client as HTTPClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -334,9 +328,6 @@ async def test_async_interactions_gemini_extra_headers(monkeypatch): @pytest.mark.asyncio async def test_async_interactions_vertex_auth_header(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -369,7 +360,6 @@ async def test_async_interactions_vertex_auth_header(): @pytest.mark.asyncio async def test_async_interactions_vertex_key_no_auth_header(): - from ..._api_client import BaseApiClient client = Client(vertexai=True, api_key='test-api-key') with ( @@ -397,7 +387,6 @@ async def test_async_interactions_vertex_key_no_auth_header(): @pytest.mark.asyncio async def test_async_interactions_vertex_url(): - from ..._api_client import AsyncHttpxClient creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -417,9 +406,6 @@ async def test_async_interactions_vertex_url(): @pytest.mark.asyncio async def test_async_interactions_vertex_auth_refresh_on_retry(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) @@ -456,9 +442,6 @@ async def my_send(req, *args, **kwargs): @pytest.mark.asyncio async def test_async_interactions_vertex_extra_headers_override(): - from ..._api_client import BaseApiClient - from ..._api_client import AsyncHttpxClient - creds = mock.Mock() creds.quota_project_id = "test-quota-project" client = Client(vertexai=True, project='test-project', location='us-central1', credentials=creds) diff --git a/google/genai/tests/interactions/test_integration.py b/google/genai/tests/interactions/test_integration.py index 22edf727c..d142509dd 100644 --- a/google/genai/tests/interactions/test_integration.py +++ b/google/genai/tests/interactions/test_integration.py @@ -14,14 +14,19 @@ from unittest import mock -import httpx import pytest +from ... import _api_client from ... import client as client_lib from ..._gaos import google_genai as gaos_google_genai from ..._gaos.types.interactions import interaction as gaos_interaction from ..._gaos.utils import serializers as gaos_serializers +if _api_client._get_http_client_backend() == "httpx2": + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top + pytest_plugins = ("pytest_asyncio",) @@ -235,7 +240,7 @@ def test_interaction_lenient_parsing(): def test_interactions_default_timeout(): from ... import _interactions - assert _interactions.DEFAULT_TIMEOUT == httpx.Timeout( + assert _interactions.DEFAULT_TIMEOUT == _interactions.Timeout( timeout=None, connect=5.0 ) diff --git a/google/genai/tests/interactions/test_paths.py b/google/genai/tests/interactions/test_paths.py index 118a8e62d..335a058ad 100644 --- a/google/genai/tests/interactions/test_paths.py +++ b/google/genai/tests/interactions/test_paths.py @@ -19,12 +19,17 @@ from unittest import mock import pytest -from httpx import Request, Response -from ..._api_client import AsyncHttpxClient -from httpx import Client as HTTPClient +from ... import _api_client from .. import pytest_helper import google.auth +if _api_client._get_http_client_backend() == "httpx2": + from httpx2 import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpx2Client +else: + from httpx import Client as HTTPClient, Request, Response # pylint: disable=g-import-not-at-top + AsyncHttpxClient = _api_client.AsyncHttpxClient + @mock.patch.object(google.auth, "default", autospec=True) def test_interactions_paths(mock_auth_default, client): interaction_id = "test-interaction-id" @@ -50,6 +55,12 @@ def test_interactions_paths(mock_auth_default, client): mock_send.assert_called_once() request = mock_send.call_args[0][0] assert str(request.url) == f'{expected_base_url}/interactions/{interaction_id}?stream=false' + assert request.extensions.get("timeout") == { + "connect": None, + "read": None, + "write": None, + "pool": None, + } mock_send.reset_mock() mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') @@ -91,6 +102,12 @@ async def test_async_interactions_paths(mock_auth_default, client): mock_send.assert_called_once() request = mock_send.call_args[0][0] assert str(request.url) == f'{expected_base_url}/interactions/{interaction_id}?stream=false' + assert request.extensions.get("timeout") == { + "connect": None, + "read": None, + "write": None, + "pool": None, + } mock_send.reset_mock() mock_send.return_value = Response(200, request=Request('POST', ''), headers={'content-type': 'application/json'}, content='{"status": "completed"}') diff --git a/google/genai/tests/live/test_live.py b/google/genai/tests/live/test_live.py index dda2286cd..1297a0a0c 100644 --- a/google/genai/tests/live/test_live.py +++ b/google/genai/tests/live/test_live.py @@ -31,7 +31,6 @@ import certifi from google.oauth2.credentials import Credentials -import httpx import pytest from websockets import client @@ -42,6 +41,11 @@ from ... import client as gl_client from ... import live from ... import types + +if api_client._get_http_client_backend() == 'httpx2': + import httpx2 as httpx # pylint: disable=g-import-not-at-top +else: + import httpx # pylint: disable=g-import-not-at-top try: import aiohttp AIOHTTP_NOT_INSTALLED = False diff --git a/pyproject.toml b/pyproject.toml index 7614ae8ad..ab378ee62 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,8 @@ dependencies = [ [project.optional-dependencies] aiohttp = ["aiohttp>=3.10.11, <4.0.0"] -httpx2 = ["httpx2>=2.0.0, <3.0.0"] +httpx = ["httpx>=0.28.1, <1.0.0"] +httpx2 = [] local-tokenizer = [ "sentencepiece>=0.2.0", "protobuf",