diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index fd7b15449c9..0750e741f11 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -30,7 +30,7 @@ detect_media_type_from_base64, validate_tool_mode, ) -from agent_framework._settings import SecretString, load_settings +from agent_framework._settings import SecretString from agent_framework._telemetry import mark_feature_used from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage] from agent_framework.exceptions import ( @@ -51,6 +51,7 @@ from ._sdk_client import ( GoogleGeminiSettings, create_genai_client, + load_gemini_settings, ) if sys.version_info >= (3, 13): @@ -303,7 +304,10 @@ def __init__( Application Default Credentials. env_file_path: Path to a ``.env`` file for credential loading. env_file_encoding: Encoding for the ``.env`` file. - client: Pre-built ``genai.Client`` instance. When provided, connector auth settings are not required. + client: Pre-built ``genai.Client`` instance. When provided, it is used as-is: the API key, + project, and location arguments and connection environment settings are not read. + ``GOOGLE_MODEL`` still applies. Explicit mode arguments only apply when the client does not + expose its own mode. additional_properties: Extra properties stored on the client instance. max_tracked_thought_signatures: Maximum number of Gemini 3 thought signatures retained for replay, keyed by call ID. Least-recently-used entries are evicted beyond this. @@ -313,9 +317,8 @@ def __init__( """ if max_tracked_thought_signatures < 1: raise ValueError("max_tracked_thought_signatures must be greater than 0.") - google_settings = load_settings( - GoogleGeminiSettings, - env_prefix="GOOGLE_", + google_settings = load_gemini_settings( + client=client, api_key=api_key, model=model, genai_use_enterprise=enterprise, @@ -1393,7 +1396,10 @@ def __init__( Application Default Credentials. env_file_path: Path to a ``.env`` file for credential loading. env_file_encoding: Encoding for the ``.env`` file. - client: Pre-built ``genai.Client`` instance. When provided, connector auth settings are not required. + client: Pre-built ``genai.Client`` instance. When provided, it is used as-is: the API key, + project, and location arguments and connection environment settings are not read. + ``GOOGLE_MODEL`` still applies. Explicit mode arguments only apply when the client does not + expose its own mode. additional_properties: Extra properties stored on the client instance. middleware: Optional middleware chain applied to every call. function_invocation_configuration: Optional configuration for the function invocation loop. diff --git a/python/packages/gemini/agent_framework_gemini/_embedding_client.py b/python/packages/gemini/agent_framework_gemini/_embedding_client.py index fcbce481d87..8ffbce8849d 100644 --- a/python/packages/gemini/agent_framework_gemini/_embedding_client.py +++ b/python/packages/gemini/agent_framework_gemini/_embedding_client.py @@ -13,7 +13,6 @@ EmbeddingGenerationOptions, GeneratedEmbeddings, UsageDetails, - load_settings, ) from agent_framework._settings import SecretString from agent_framework._telemetry import mark_feature_used @@ -32,8 +31,8 @@ from ._feature_usage import FeatureIndex from ._sdk_client import ( - GoogleGeminiSettings, create_genai_client, + load_gemini_settings, ) if sys.version_info >= (3, 13): @@ -137,7 +136,10 @@ class RawGeminiEmbeddingClient( project: Enterprise (Vertex AI) project, or ``GOOGLE_CLOUD_PROJECT``. location: Enterprise (Vertex AI) region, or ``GOOGLE_CLOUD_LOCATION``. credentials: Google Cloud credentials for Enterprise; the SDK can also use ADC. - client: Preconfigured ``genai.Client``; the caller retains ownership of it. + client: Preconfigured ``genai.Client``; the caller retains ownership of it. When provided, the + API key, project, and location arguments and connection environment settings are not read. + ``GOOGLE_EMBEDDING_MODEL`` still applies. Explicit mode arguments only apply when the client + does not expose its own mode. additional_properties: Extra properties stored on the client instance. env_file_path: Optional ``.env`` file for settings. env_file_encoding: Encoding for the ``.env`` file. @@ -161,9 +163,8 @@ def __init__( env_file_encoding: str | None = None, ) -> None: """Initialize a raw Gemini embedding client.""" - google_settings = load_settings( - GoogleGeminiSettings, - env_prefix="GOOGLE_", + google_settings = load_gemini_settings( + client=client, api_key=api_key, embedding_model=model, genai_use_enterprise=enterprise, diff --git a/python/packages/gemini/agent_framework_gemini/_sdk_client.py b/python/packages/gemini/agent_framework_gemini/_sdk_client.py index f4978a15463..481df79158f 100644 --- a/python/packages/gemini/agent_framework_gemini/_sdk_client.py +++ b/python/packages/gemini/agent_framework_gemini/_sdk_client.py @@ -4,7 +4,7 @@ from typing import Any -from agent_framework._settings import SecretString +from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent from google import genai from google.auth.credentials import Credentials @@ -23,6 +23,34 @@ class GoogleGeminiSettings(TypedDict, total=False): cloud_location: str | None +class _GoogleGeminiModelSettings(TypedDict, total=False): + """The only connector settings an injected ``genai.Client`` still needs.""" + + model: str | None + embedding_model: str | None + + +def load_gemini_settings(*, client: genai.Client | None, **kwargs: Any) -> GoogleGeminiSettings: + """Load ``GOOGLE_*`` settings, skipping auth, mode, project, and location for an injected client. + + For an injected client, explicit mode arguments are kept (environment mode is not read) so they + remain the fallback when the SDK client does not expose its mode. + """ + if client is None: + return load_settings(GoogleGeminiSettings, env_prefix="GOOGLE_", **kwargs) + settings: GoogleGeminiSettings = load_settings(_GoogleGeminiModelSettings, env_prefix="GOOGLE_", **kwargs) # type: ignore[assignment] + for key in ("genai_use_enterprise", "genai_use_vertexai"): + if (value := kwargs.get(key)) is not None: + # Match the override type check an owned client gets from load_settings. + if not isinstance(value, bool): + raise ValueError( + f"Invalid type for setting '{key}' from explicit override: " + f"expected bool, got {type(value).__name__}." + ) + settings[key] = value + return settings + + _GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com" _VERTEX_AI_BASE_URL = "https://aiplatform.googleapis.com" diff --git a/python/packages/gemini/tests/gemini/test_gemini_embedding_client.py b/python/packages/gemini/tests/gemini/test_gemini_embedding_client.py index 8ef332cfb31..182352c790a 100644 --- a/python/packages/gemini/tests/gemini/test_gemini_embedding_client.py +++ b/python/packages/gemini/tests/gemini/test_gemini_embedding_client.py @@ -219,6 +219,56 @@ def test_injected_vertex_client_controls_service_url() -> None: assert client.service_url() == "https://aiplatform.googleapis.com" +@pytest.mark.parametrize("env_var", ["GOOGLE_GENAI_USE_ENTERPRISE", "GOOGLE_GENAI_USE_VERTEXAI"]) +def test_injected_sdk_client_ignores_invalid_connection_settings( + clear_google_env: None, + env_var: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An injected SDK client is used as-is, so connection settings it does not need are never parsed.""" + monkeypatch.setenv(env_var, "not-a-bool") + monkeypatch.setenv("GOOGLE_EMBEDDING_MODEL", "gemini-embedding-2-preview") + sdk = _make_sdk_client() + + client = RawGeminiEmbeddingClient(client=sdk) + + assert client._genai_client is sdk + assert client._vertexai is False + assert client.service_url() == "https://generativelanguage.googleapis.com" + assert client.model == "gemini-embedding-2-preview" + + +@pytest.mark.parametrize("mode_arg", ["enterprise", "vertexai"]) +def test_injected_sdk_client_without_mode_uses_explicit_mode( + clear_google_env: None, + mode_arg: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An explicit mode stays the fallback when the injected SDK client does not expose its mode.""" + monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "not-a-bool") + sdk = _make_sdk_client() + sdk._api_client.vertexai = None + + client = RawGeminiEmbeddingClient( + client=sdk, model="gemini-embedding-2-preview", **cast(dict[str, Any], {mode_arg: True}) + ) + + assert client._genai_client is sdk + assert client._vertexai is True + + +@pytest.mark.parametrize("mode_arg", ["enterprise", "vertexai"]) +def test_injected_sdk_client_rejects_non_bool_explicit_mode(mode_arg: str) -> None: + """Explicit mode arguments are type-checked for an injected client, like for an owned one.""" + sdk = MagicMock() + sdk._api_client.vertexai = None + + with pytest.raises(ValueError, match=f"genai_use_{mode_arg}"): + RawGeminiEmbeddingClient( + client=sdk, model="gemini-embedding-2-preview", **cast(dict[str, Any], {mode_arg: "false"}) + ) + + async def test_close_only_closes_owned_sdk_client(clear_google_env: None) -> None: owned_sdk = _make_sdk_client() with patch("agent_framework_gemini._sdk_client.genai.Client", return_value=owned_sdk): diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index c13f00b4bd2..5d442bdf67e 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -2999,6 +2999,66 @@ def test_injected_sdk_routing_is_shared_with_embeddings() -> None: assert chat.service_url() == embeddings.service_url() == "https://custom.example.test" +@pytest.mark.parametrize("client_type", [RawGeminiChatClient, GeminiChatClient]) +@pytest.mark.parametrize("env_var", ["GOOGLE_GENAI_USE_ENTERPRISE", "GOOGLE_GENAI_USE_VERTEXAI"]) +def test_injected_sdk_client_ignores_invalid_connection_settings( + client_type: type[RawGeminiChatClient], + env_var: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An injected SDK client is used as-is, so connection settings it does not need are never parsed.""" + monkeypatch.setenv(env_var, "not-a-bool") + monkeypatch.setenv("GOOGLE_MODEL", "env-model") + sdk = MagicMock() + sdk._api_client.vertexai = False + sdk._api_client._http_options.base_url = "https://generativelanguage.googleapis.com/" + + client = client_type(client=sdk) + + assert client._genai_client is sdk + assert client._vertexai is False + assert client.service_url() == "https://generativelanguage.googleapis.com" + assert client.model == "env-model" + + +@pytest.mark.parametrize("client_type", [RawGeminiChatClient, GeminiChatClient]) +@pytest.mark.parametrize("mode_arg", ["enterprise", "vertexai"]) +def test_injected_sdk_client_without_mode_uses_explicit_mode( + client_type: type[RawGeminiChatClient], + mode_arg: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An explicit mode stays the fallback when the injected SDK client does not expose its mode.""" + monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "not-a-bool") + sdk = MagicMock() + sdk._api_client.vertexai = None + sdk._api_client._http_options.base_url = None + + client = client_type(client=sdk, model="gemini-2.5-flash", **cast(dict[str, Any], {mode_arg: True})) + + assert client._genai_client is sdk + assert client._vertexai is True + assert client.service_url() == "https://aiplatform.googleapis.com" + + +@pytest.mark.parametrize("mode_arg", ["enterprise", "vertexai"]) +def test_injected_sdk_client_rejects_non_bool_explicit_mode(mode_arg: str) -> None: + """Explicit mode arguments are type-checked for an injected client, like for an owned one.""" + sdk = MagicMock() + sdk._api_client.vertexai = None + + with pytest.raises(ValueError, match=f"genai_use_{mode_arg}"): + RawGeminiChatClient(client=sdk, model="gemini-2.5-flash", **cast(dict[str, Any], {mode_arg: "false"})) + + +def test_owned_client_still_validates_connection_settings(monkeypatch: pytest.MonkeyPatch) -> None: + """Without an injected SDK client, invalid connection settings still fail construction.""" + monkeypatch.setenv("GOOGLE_GENAI_USE_ENTERPRISE", "not-a-bool") + + with pytest.raises(ValueError, match="GOOGLE_GENAI_USE_ENTERPRISE"): + GeminiChatClient(api_key="test-key", model="gemini-2.5-flash") + + # integration tests