From 0e35f04fe879a5458140fb383afa50fb39d9826d Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:21:39 +0100 Subject: [PATCH 01/51] Add agent-framework-gemini package --- python/packages/gemini/pyproject.toml | 102 ++++++++++++++++++++++++++ python/uv.lock | 42 +++++++++++ 2 files changed, 144 insertions(+) create mode 100644 python/packages/gemini/pyproject.toml diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml new file mode 100644 index 0000000000..b8fb764c48 --- /dev/null +++ b/python/packages/gemini/pyproject.toml @@ -0,0 +1,102 @@ +[project] +name = "agent-framework-gemini" +description = "Google Gemini integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260319" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: Pydantic :: 2", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0rc5", + "google-genai>=1.0.0,<2.0.0", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +markers = [ + "integration: marks tests as integration tests that require external services", + "flaky: marks tests as flaky and eligible for automatic retry", +] +timeout = 120 + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_gemini"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_gemini"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_gemini" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_gemini --cov-report=term-missing:skip-covered tests' + +[tool.uv.build-backend] +module-name = "agent_framework_gemini" +module-root = "" + +[build-system] +requires = ["uv_build>=0.8.2,<0.9.0"] +build-backend = "uv_build" diff --git a/python/uv.lock b/python/uv.lock index f55686893e..b6af46120d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -43,6 +43,7 @@ members = [ "agent-framework-devui", "agent-framework-durabletask", "agent-framework-foundry-local", + "agent-framework-gemini", "agent-framework-github-copilot", "agent-framework-lab", "agent-framework-mem0", @@ -510,6 +511,21 @@ requires-dist = [ { name = "foundry-local-sdk", specifier = ">=0.5.1,<0.5.2" }, ] +[[package]] +name = "agent-framework-gemini" +version = "1.0.0b260319" +source = { editable = "packages/gemini" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-genai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "google-genai", specifier = ">=1.0.0,<2.0.0" }, +] + [[package]] name = "agent-framework-github-copilot" version = "1.0.0b260319" @@ -2313,6 +2329,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, ] +[package.optional-dependencies] +requests = [ + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[package]] +name = "google-genai" +version = "1.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "google-auth", extra = ["requests"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.73.0" From 33f437feb96329fd043115b79b1d39557e0c985a Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:22:07 +0100 Subject: [PATCH 02/51] Add AGENTS.md documentation --- python/packages/gemini/AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 python/packages/gemini/AGENTS.md diff --git a/python/packages/gemini/AGENTS.md b/python/packages/gemini/AGENTS.md new file mode 100644 index 0000000000..6e8dbe8647 --- /dev/null +++ b/python/packages/gemini/AGENTS.md @@ -0,0 +1,24 @@ +# Gemini Package (agent-framework-gemini) + +Integration with Google's Gemini API via the `google-genai` SDK. + +## Main Classes + +- **`GeminiChatClient`** - Chat client for Google Gemini models +- **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters +- **`ThinkingConfig`** - Configuration for extended thinking (Gemini 2.5+) + +## Usage + +```python +from agent_framework_gemini import GeminiChatClient + +client = GeminiChatClient(model_id="gemini-2.5-flash") +response = await client.get_response("Hello") +``` + +## Import Path + +```python +from agent_framework_gemini import GeminiChatClient +``` From 38d3d2810c5d879d28bf08e201d9a0b62b08e779 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:22:21 +0100 Subject: [PATCH 03/51] Add LICENSE file --- python/packages/gemini/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 python/packages/gemini/LICENSE diff --git a/python/packages/gemini/LICENSE b/python/packages/gemini/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/gemini/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE From 29ed60cea626640c567d08b6f18e85fb7eebbe35 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:22:31 +0100 Subject: [PATCH 04/51] Add README.md for agent-framework-gemini package --- python/packages/gemini/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 python/packages/gemini/README.md diff --git a/python/packages/gemini/README.md b/python/packages/gemini/README.md new file mode 100644 index 0000000000..807dd470f4 --- /dev/null +++ b/python/packages/gemini/README.md @@ -0,0 +1,29 @@ +# Get Started with Microsoft Agent Framework Gemini + +Install the provider package: + +```bash +pip install agent-framework-gemini --pre +``` + +## Gemini Integration + +The Gemini integration enables Microsoft Agent Framework applications to call Google Gemini models with familiar chat abstractions, including streaming, tool/function calling, and structured output. + +## Authentication + +Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) and set it via environment variable: + +```bash +export GEMINI_API_KEY="your-api-key" +export GEMINI_CHAT_MODEL_ID="gemini-2.5-flash" +``` + +## Examples + +See the [Google Gemini samples](../../samples/02-agents/providers/google/) for runnable end-to-end scripts covering: + +- Basic agent with tool calling and streaming +- Extended thinking with `ThinkingConfig` +- Google Search grounding +- Built-in code execution From 0dace12fcf23473a6f72f0164ed9e5f4d93de030 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:22:49 +0100 Subject: [PATCH 05/51] Add Google Gemini API keys to .env.example --- python/.env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/python/.env.example b/python/.env.example index c09300d775..ad6f9b2423 100644 --- a/python/.env.example +++ b/python/.env.example @@ -29,6 +29,9 @@ COPILOTSTUDIOAGENT__AGENTAPPID="" # Anthropic ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="" +# Google Gemini +GEMINI_API_KEY="" +GEMINI_CHAT_MODEL_ID="" # Ollama OLLAMA_ENDPOINT="" OLLAMA_MODEL="" From 06de5cf4a305359730f1a56831aafcf6476d53f6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:24:25 +0100 Subject: [PATCH 06/51] Add Google Gemini chat client implementation --- .../gemini/agent_framework_gemini/__init__.py | 18 + .../agent_framework_gemini/_chat_client.py | 687 ++++++++++++++++++ .../gemini/agent_framework_gemini/py.typed | 0 3 files changed, 705 insertions(+) create mode 100644 python/packages/gemini/agent_framework_gemini/__init__.py create mode 100644 python/packages/gemini/agent_framework_gemini/_chat_client.py create mode 100644 python/packages/gemini/agent_framework_gemini/py.typed diff --git a/python/packages/gemini/agent_framework_gemini/__init__.py b/python/packages/gemini/agent_framework_gemini/__init__.py new file mode 100644 index 0000000000..acf8a70103 --- /dev/null +++ b/python/packages/gemini/agent_framework_gemini/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._chat_client import GeminiChatClient, GeminiChatOptions, GeminiSettings, ThinkingConfig + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "GeminiChatClient", + "GeminiChatOptions", + "GeminiSettings", + "ThinkingConfig", + "__version__", +] diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py new file mode 100644 index 0000000000..7ca34b22ee --- /dev/null +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -0,0 +1,687 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import logging +import sys +from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence +from typing import Any, ClassVar, Generic, cast +from uuid import uuid4 + +from agent_framework import ( + AGENT_FRAMEWORK_USER_AGENT, + BaseChatClient, + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + Content, + FinishReasonLiteral, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + FunctionTool, + Message, + ResponseStream, + UsageDetails, + validate_tool_mode, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from google import genai +from google.genai import types +from pydantic import BaseModel + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover + +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore # pragma: no cover + +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + +logger = logging.getLogger("agent_framework.gemini") + +__all__ = [ + "GeminiChatClient", + "GeminiChatOptions", + "GeminiSettings", + "ThinkingConfig", +] + +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) + + +# region Options & Settings + + +class ThinkingConfig(TypedDict, total=False): + """Extended thinking configuration for Gemini models. + + Use ``thinking_budget`` for Gemini 2.5 models (integer token count: 0 disables + thinking, -1 enables a dynamic budget). Use ``thinking_level`` for Gemini 3.x + models (one of ``'minimal'``, ``'low'``, ``'medium'``, ``'high'``). + """ + + thinking_budget: int + thinking_level: str + + +class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): + """Google Gemini API-specific chat options. + + Supported ChatOptions fields (mapped to GenerateContentConfig): + model_id -> model parameter + temperature -> temperature + max_tokens -> max_output_tokens + top_p -> top_p + stop -> stop_sequences + seed -> seed + frequency_penalty -> frequency_penalty + presence_penalty -> presence_penalty + tools -> tools[].function_declarations + tool_choice -> tool_config.function_calling_config.mode + response_format -> response_mime_type (signals JSON mode) + instructions -> merged into system_instruction + + Gemini-specific options: + thinking_config: Extended thinking. Maps to types.ThinkingConfig. + top_k: Top-K sampling. + google_search_grounding: Enable Google Search as a grounding tool. + google_maps_grounding: Enable Google Maps as a grounding tool. + code_execution: Enable the built-in code execution tool. + response_schema: JSON schema for structured output. + + Unsupported base options (passing these is a type error): + logit_bias, allow_multiple_tool_calls, store, user, metadata, conversation_id + """ + + thinking_config: ThinkingConfig + top_k: int + google_search_grounding: bool + google_maps_grounding: bool + code_execution: bool + response_schema: dict[str, Any] + + # Unsupported base options (override with None to indicate not supported) + logit_bias: None # type: ignore[misc] + allow_multiple_tool_calls: None # type: ignore[misc] + store: None # type: ignore[misc] + user: None # type: ignore[misc] + metadata: None # type: ignore[misc] + conversation_id: None # type: ignore[misc] + + +GeminiChatOptionsT = TypeVar( + "GeminiChatOptionsT", + bound=TypedDict, # type: ignore[misc] + default="GeminiChatOptions", + covariant=True, # type: ignore[valid-type] +) + + +class GeminiSettings(TypedDict, total=False): + """Gemini configuration settings loaded from environment or .env files.""" + + api_key: SecretString | None + chat_model_id: str | None + + +# endregion + + +_GEMINI_SERVICE_URL = "https://generativelanguage.googleapis.com" + +_FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = { + "STOP": "stop", + "MAX_TOKENS": "length", + "SAFETY": "content_filter", + "RECITATION": "content_filter", + "LANGUAGE": "content_filter", + "BLOCKLIST": "content_filter", + "PROHIBITED_CONTENT": "content_filter", + "SPII": "content_filter", + "IMAGE_SAFETY": "content_filter", + "IMAGE_PROHIBITED_CONTENT": "content_filter", + "IMAGE_RECITATION": "content_filter", + "MALFORMED_FUNCTION_CALL": "tool_calls", + "UNEXPECTED_TOOL_CALL": "tool_calls", +} + + +class GeminiChatClient( + FunctionInvocationLayer[GeminiChatOptionsT], + ChatMiddlewareLayer[GeminiChatOptionsT], + ChatTelemetryLayer[GeminiChatOptionsT], + BaseChatClient[GeminiChatOptionsT], + Generic[GeminiChatOptionsT], +): + """Async chat client for the Google Gemini API with middleware, telemetry, and function invocation.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "gcp.gemini" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + *, + api_key: str | None = None, + model_id: str | None = None, + client: genai.Client | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Create a Gemini chat client. + + Args: + api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` env var. + model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` env var. + client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required. + additional_properties: Extra properties stored on the client instance. + middleware: Optional middleware chain. + function_invocation_configuration: Optional function invocation configuration. + env_file_path: Path to a ``.env`` file for credential loading. + env_file_encoding: Encoding for the ``.env`` file. + """ + settings = load_settings( + GeminiSettings, + env_prefix="GEMINI_", + api_key=api_key, + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + if client: + self._genai_client = client + else: + resolved_key = settings.get("api_key") + if not resolved_key: + raise ValueError( + "Gemini API key is required. Set via api_key parameter or GEMINI_API_KEY environment variable." + ) + self._genai_client = genai.Client( + api_key=resolved_key.get_secret_value(), + http_options={"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}}, + ) + + self.model_id = settings.get("chat_model_id") + + super().__init__( + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) + + @override + def _inner_get_response( + self, + *, + messages: Sequence[Message], + options: Mapping[str, Any], + stream: bool = False, + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + model_id = options.get("model_id") or self.model_id + if not model_id: + raise ValueError( + "Gemini model_id is required. Set via model_id parameter or GEMINI_CHAT_MODEL_ID environment variable." + ) + + system_instruction, contents = self._prepare_gemini_messages(messages) + + if call_instructions := options.get("instructions"): + system_instruction = ( + f"{call_instructions}\n{system_instruction}" if system_instruction else call_instructions + ) + + config = self._prepare_config(options, system_instruction) + + if stream: + + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + async for chunk in await self._genai_client.aio.models.generate_content_stream( + model=model_id, + contents=contents, # type: ignore[arg-type] + config=config, + ): + yield self._process_chunk(chunk) + + return self._build_response_stream(_stream()) + + async def _get_response() -> ChatResponse: + raw = await self._genai_client.aio.models.generate_content(model=model_id, contents=contents, config=config) # type: ignore[arg-type] + return self._process_generate_response(raw) + + return _get_response() + + # region Message preparation + + def _prepare_gemini_messages(self, messages: Sequence[Message]) -> tuple[str | None, list[types.Content]]: + """Convert framework messages to Gemini contents and extract system instruction. + + Args: + messages: The full conversation history as framework Message objects. + + Returns: + A tuple of (system_instruction_text, contents_list). System messages are extracted + into the instruction string; tool results are grouped into user-role content blocks. + """ + system_parts: list[str] = [] + contents: list[types.Content] = [] + # Maps call_id to function name so function_result parts can include the required name field. + call_id_to_name: dict[str, str] = {} + # Accumulated functionResponse parts from consecutive tool messages. + pending_tool_parts: list[types.Part] = [] + + def flush_pending_tool_parts() -> None: + if pending_tool_parts: + contents.append(types.Content(role="user", parts=list(pending_tool_parts))) + pending_tool_parts.clear() + + for message in messages: + if message.role == "system": + if message.text: + system_parts.append(message.text) + continue + + if message.role == "tool": + for content in message.contents: + part = self._convert_function_result(content, call_id_to_name) + if part is not None: + pending_tool_parts.append(part) + continue + + # Non-tool message — flush any accumulated tool parts first. + flush_pending_tool_parts() + + parts = self._convert_message_contents(message.contents, call_id_to_name) + if not parts: + continue + + role = "model" if message.role == "assistant" else "user" + contents.append(types.Content(role=role, parts=parts)) + + flush_pending_tool_parts() + + system_instruction = "\n".join(system_parts) if system_parts else None + return system_instruction, contents + + def _convert_message_contents( + self, + message_contents: Sequence[Content], + call_id_to_name: dict[str, str], + ) -> list[types.Part]: + """Convert framework Content objects to Gemini Part objects, tracking function call IDs. + + Args: + message_contents: The content items of a single framework message. + call_id_to_name: Mutable mapping updated with any function call ID-to-name pairs found. + + Returns: + A list of Gemini Part objects representing the message contents. + """ + parts: list[types.Part] = [] + for content in message_contents: + match content.type: + case "text": + parts.append(types.Part(text=content.text or "")) + case "function_call": + call_id = content.call_id or self._generate_tool_call_id() + if content.call_id and content.name: + call_id_to_name[content.call_id] = content.name + parts.append( + types.Part( + function_call=types.FunctionCall( + id=call_id, + name=content.name or "", + args=content.parse_arguments() or {}, + ) + ) + ) + case _: + logger.debug("Skipping unsupported content type for Gemini: %s", content.type) + return parts + + def _convert_function_result( + self, + content: Content, + call_id_to_name: dict[str, str], + ) -> types.Part | None: + """Convert a function_result Content to a Gemini FunctionResponse Part. + + Args: + content: The framework Content object, expected to be of type ``function_result``. + call_id_to_name: Mapping of call IDs to function names, used to resolve the required name field. + + Returns: + A Gemini Part containing a FunctionResponse, or None if the content type is not + ``function_result`` or the call ID cannot be resolved. + """ + if content.type != "function_result": + return None + + name = call_id_to_name.get(content.call_id or "") + if not name: + logger.warning( + "Skipping function_result: no matching function_call found for call_id=%r", + content.call_id, + ) + return None + + response = self._coerce_to_dict(content.result) + return types.Part( + function_response=types.FunctionResponse( + id=content.call_id, + name=name, + response=response, + ) + ) + + @staticmethod + def _coerce_to_dict(value: Any) -> dict[str, Any]: + """Ensure a tool result value is a dict as required by Gemini's FunctionResponse. + + Args: + value: The raw tool result. May be a dict, JSON string, plain string, None, or any other value. + + Returns: + A dict representation of the value. JSON strings are parsed; all other non-dict values + are wrapped as ``{"result": }``. + """ + if isinstance(value, dict): + return cast(dict[str, Any], value) + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return cast(dict[str, Any], parsed) + except (json.JSONDecodeError, ValueError): + pass + return {"result": value} + if value is None: + return {"result": ""} + return {"result": str(value)} + + # endregion + + # region Config preparation + + def _prepare_config( + self, + options: Mapping[str, Any], + system_instruction: str | None, + ) -> types.GenerateContentConfig: + """Build a ``types.GenerateContentConfig`` from ``ChatOptions``. + + Args: + options: Resolved chat options mapping, typically a ``GeminiChatOptions`` dict. + system_instruction: Combined system instruction text, or None if absent. + + Returns: + A fully populated ``GenerateContentConfig`` ready to pass to the Gemini API. + """ + kwargs: dict[str, Any] = {} + + if system_instruction: + kwargs["system_instruction"] = system_instruction + if (v := options.get("temperature")) is not None: + kwargs["temperature"] = v + if (v := options.get("max_tokens")) is not None: + kwargs["max_output_tokens"] = v + if (v := options.get("top_p")) is not None: + kwargs["top_p"] = v + if (v := options.get("stop")) is not None: + kwargs["stop_sequences"] = v + if (v := options.get("seed")) is not None: + kwargs["seed"] = v + if (v := options.get("frequency_penalty")) is not None: + kwargs["frequency_penalty"] = v + if (v := options.get("presence_penalty")) is not None: + kwargs["presence_penalty"] = v + if (v := options.get("top_k")) is not None: + kwargs["top_k"] = v + if thinking_config := options.get("thinking_config"): + thinking_config_kwargs = {k: v for k, v in thinking_config.items() if v is not None} + if thinking_config_kwargs: + kwargs["thinking_config"] = types.ThinkingConfig(**thinking_config_kwargs) + if options.get("response_format") or options.get("response_schema"): + kwargs["response_mime_type"] = "application/json" + if schema := options.get("response_schema"): + kwargs["response_schema"] = schema + if tools := self._prepare_tools(options): + kwargs["tools"] = tools + if tool_config := self._prepare_tool_config(options.get("tool_choice")): + kwargs["tool_config"] = tool_config + + return types.GenerateContentConfig(**kwargs) + + def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None: + """Build the Gemini tool list from options, combining function declarations and built-in tools. + + Args: + options: Resolved chat options containing ``tools``, ``google_search_grounding``, + ``google_maps_grounding``, and ``code_execution`` flags. + + Returns: + A list of ``types.Tool`` objects, or None if no tools are configured. + """ + function_tools: list[Any] = options.get("tools") or [] + include_search = options.get("google_search_grounding", False) + include_maps = options.get("google_maps_grounding", False) + include_code_exec = options.get("code_execution", False) + + result: list[types.Tool] = [] + + declarations = [ + types.FunctionDeclaration( + name=tool.name, + description=tool.description or "", + parameters=tool.parameters(), # type: ignore[arg-type] + ) + for tool in function_tools + if isinstance(tool, FunctionTool) + ] + if declarations: + result.append(types.Tool(function_declarations=declarations)) + if include_search: + result.append(types.Tool(google_search=types.GoogleSearch())) + if include_maps: + result.append(types.Tool(google_maps=types.GoogleMaps())) + if include_code_exec: + result.append(types.Tool(code_execution=types.ToolCodeExecution())) + + return result or None + + def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None: + """Build a Gemini ``ToolConfig`` from the framework tool_choice value. + + Args: + tool_choice: Raw tool_choice value from options (string, dict, or None). + + Returns: + A ``types.ToolConfig`` with the appropriate ``FunctionCallingConfig``, or None + if no tool_choice is set or the mode is unsupported. + """ + tool_mode = validate_tool_mode(tool_choice) + if not tool_mode: + return None + + match tool_mode.get("mode"): + case "auto": + function_calling_mode, allowed_names = "AUTO", None + case "none": + function_calling_mode, allowed_names = "NONE", None + case "required": + function_calling_mode = "ANY" + name = tool_mode.get("required_function_name") + allowed_names = [name] if name else None + case unknown_mode: + logger.warning("Unsupported tool_choice mode for Gemini: %s", unknown_mode) + return None + + function_calling_kwargs: dict[str, Any] = {"mode": function_calling_mode} + if allowed_names: + function_calling_kwargs["allowed_function_names"] = allowed_names + + return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(**function_calling_kwargs)) + + # endregion + + # region Response parsing + + def _process_generate_response(self, response: types.GenerateContentResponse) -> ChatResponse: + """Convert a Gemini generate_content response to a framework ChatResponse. + + Args: + response: The raw ``GenerateContentResponse`` from the Gemini API. + + Returns: + A ``ChatResponse`` with parsed messages, usage details, finish reason, and model ID. + """ + candidate = response.candidates[0] if response.candidates else None + parts: list[types.Part] = (candidate.content.parts or []) if candidate and candidate.content else [] + contents = self._parse_parts(parts) + return ChatResponse( + response_id=None, + messages=[Message(role="assistant", contents=contents, raw_representation=candidate)], + usage_details=self._parse_usage(response.usage_metadata), + model_id=response.model_version or self.model_id, + finish_reason=self._map_finish_reason( + candidate.finish_reason.name if candidate and candidate.finish_reason else None + ), + raw_representation=response, + ) + + def _process_chunk(self, chunk: types.GenerateContentResponse) -> ChatResponseUpdate: + """Convert a single streaming chunk to a framework ChatResponseUpdate. + + Usage details are attached only to the final chunk, identified by a non-None finish reason. + + Args: + chunk: A streaming ``GenerateContentResponse`` chunk from the Gemini API. + + Returns: + A ``ChatResponseUpdate`` with parsed contents, finish reason, and model ID. + """ + candidate = chunk.candidates[0] if chunk.candidates else None + parts: list[types.Part] = (candidate.content.parts or []) if candidate and candidate.content else [] + contents = self._parse_parts(parts) + + finish_reason = self._map_finish_reason( + candidate.finish_reason.name if candidate and candidate.finish_reason else None + ) + + # Attach usage to the final chunk only (when finish_reason is set). + if finish_reason and (usage := self._parse_usage(chunk.usage_metadata)): + contents.append(Content.from_usage(usage_details=usage)) + + return ChatResponseUpdate( + contents=contents, + model_id=chunk.model_version, + finish_reason=finish_reason, + raw_representation=chunk, + ) + + def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: + """Convert Gemini response parts to framework Content objects, skipping thought/reasoning parts. + + Args: + parts: Sequence of ``types.Part`` objects from a Gemini response candidate. + + Returns: + A list of framework ``Content`` objects (text, function_call, or function_result). + """ + contents: list[Content] = [] + for part in parts: + if part.thought: + continue + if part.text is not None: + contents.append(Content.from_text(text=part.text, raw_representation=part)) + elif part.function_call is not None: + function_call = part.function_call + if function_call.id: + call_id = function_call.id + else: + call_id = self._generate_tool_call_id() + logger.debug("function_call missing id; generated fallback call_id=%r", call_id) + contents.append( + Content.from_function_call( + call_id=call_id, + name=function_call.name or "", + arguments=function_call.args or {}, + raw_representation=part, + ) + ) + elif part.function_response is not None: + function_response = part.function_response + contents.append( + Content.from_function_result( + call_id=function_response.id or self._generate_tool_call_id(), + result=function_response.response, + raw_representation=part, + ) + ) + return contents + + def _parse_usage(self, usage: types.GenerateContentResponseUsageMetadata | None) -> UsageDetails | None: + """Extract token usage counts from Gemini usage metadata. + + Args: + usage: The ``GenerateContentResponseUsageMetadata`` from the API response, or None. + + Returns: + A ``UsageDetails`` dict with available token counts, or None if no usage data is present. + """ + if not usage: + return None + details: UsageDetails = {} + if (v := usage.prompt_token_count) is not None: + details["input_token_count"] = v + if (v := usage.candidates_token_count) is not None: + details["output_token_count"] = v + if (v := usage.total_token_count) is not None: + details["total_token_count"] = v + return details or None + + def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None: + """Map a Gemini finish reason string to the framework's FinishReasonLiteral. + + Args: + reason: The finish reason name from the Gemini API (e.g. ``"STOP"``), or None. + + Returns: + The corresponding ``FinishReasonLiteral``, or None if the reason is absent or unmapped. + """ + if not reason: + return None + return _FINISH_REASON_MAP.get(reason) + + # endregion + + @override + def service_url(self) -> str: + """Return the base URL of the Gemini API service. + + Returns: + The Gemini API base URL. + """ + return _GEMINI_SERVICE_URL + + @staticmethod + def _generate_tool_call_id() -> str: + """Generate a unique fallback ID for tool calls that lack one. + + Returns: + A unique string in the format ``tool-call-``. + """ + return f"tool-call-{uuid4().hex}" diff --git a/python/packages/gemini/agent_framework_gemini/py.typed b/python/packages/gemini/agent_framework_gemini/py.typed new file mode 100644 index 0000000000..e69de29bb2 From 24fdbebb4493f86d42b8e8796ed32740fb95e45f Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:24:43 +0100 Subject: [PATCH 07/51] Add tests for GeminiChatClient --- python/packages/gemini/tests/__init__.py | 0 .../packages/gemini/tests/test_chat_client.py | 879 ++++++++++++++++++ .../tests/test_chat_client_integration.py | 177 ++++ 3 files changed, 1056 insertions(+) create mode 100644 python/packages/gemini/tests/__init__.py create mode 100644 python/packages/gemini/tests/test_chat_client.py create mode 100644 python/packages/gemini/tests/test_chat_client_integration.py diff --git a/python/packages/gemini/tests/__init__.py b/python/packages/gemini/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py new file mode 100644 index 0000000000..7a93b545e0 --- /dev/null +++ b/python/packages/gemini/tests/test_chat_client.py @@ -0,0 +1,879 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import Content, FunctionTool, Message +from google.genai import types + +from agent_framework_gemini import GeminiChatClient, ThinkingConfig + +# stub helpers + + +def _make_part( + *, + text: str | None = None, + thought: bool = False, + function_call: tuple[str, str, dict[str, Any]] | None = None, +) -> MagicMock: + """Build a mock types.Part. + + Args: + text: Text content of the part. + thought: Whether this is a thinking/reasoning part. + function_call: Tuple of (id, name, args) if this is a function call part. + """ + part = MagicMock() + part.text = text + part.thought = thought + part.function_response = None + + if function_call: + mock_function_call = MagicMock() + mock_function_call.id, mock_function_call.name, mock_function_call.args = function_call + part.function_call = mock_function_call + else: + part.function_call = None + + return part + + +def _make_response( + parts: list[MagicMock], + *, + finish_reason: str | None = "STOP", + model_version: str = "gemini-2.5-flash-001", + prompt_tokens: int | None = 10, + output_tokens: int | None = 5, + total_tokens: int | None = 15, +) -> MagicMock: + """Build a mock types.GenerateContentResponse.""" + response = MagicMock() + candidate = MagicMock() + candidate.content.parts = parts + + if finish_reason: + candidate.finish_reason.name = finish_reason + else: + candidate.finish_reason = None + + response.candidates = [candidate] + response.model_version = model_version + + if prompt_tokens is not None or output_tokens is not None: + usage = MagicMock() + usage.prompt_token_count = prompt_tokens + usage.candidates_token_count = output_tokens + usage.total_token_count = total_tokens + response.usage_metadata = usage + else: + response.usage_metadata = None + + return response + + +async def _async_iter(items: list[Any]): + """Async generator used to simulate generate_content_stream results.""" + for item in items: + yield item + + +def _make_gemini_client( + model_id: str = "gemini-2.5-flash", + mock_client: MagicMock | None = None, +) -> tuple[GeminiChatClient, MagicMock]: + """Return a (GeminiChatClient, mock_genai_client) pair.""" + mock = mock_client or MagicMock() + client = GeminiChatClient(client=mock, model_id=model_id) + return client, mock + + +# settings & initialisation + + +def test_model_id_stored_on_instance() -> None: + client, _ = _make_gemini_client(model_id="gemini-2.5-pro") + assert client.model_id == "gemini-2.5-pro" + + +def test_client_created_from_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GEMINI_API_KEY", "test-key-123") + client = GeminiChatClient(model_id="gemini-2.5-flash") + assert client.model_id == "gemini-2.5-flash" + + +def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_CHAT_MODEL_ID", raising=False) + + with pytest.raises(ValueError, match="GEMINI_API_KEY"): + GeminiChatClient(model_id="gemini-2.5-flash") + + +async def test_missing_model_id_raises_on_get_response() -> None: + client, mock = _make_gemini_client(model_id=None) # type: ignore[arg-type] + mock.aio.models.generate_content = AsyncMock() + + with pytest.raises(ValueError, match="model_id"): + await client.get_response(messages=[Message(role="user", contents=[Content.from_text("hi")])]) + + +# text response + + +async def test_get_response_returns_text() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hello!")])) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert response.messages[0].text == "Hello!" + + +async def test_get_response_model_id_from_response() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([_make_part(text="Hi")], model_version="gemini-2.5-pro-002") + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert response.model_id == "gemini-2.5-pro-002" + + +async def test_get_response_uses_model_id_from_options() -> None: + client, mock = _make_gemini_client(model_id="gemini-2.5-flash") + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"model_id": "gemini-2.5-pro"}, + ) + + call_kwargs = mock.aio.models.generate_content.call_args.kwargs + assert call_kwargs["model"] == "gemini-2.5-pro" + + +async def test_get_response_usage_details() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response( + [_make_part(text="Hi")], + prompt_tokens=20, + output_tokens=8, + total_tokens=28, + ) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert response.usage_details is not None + assert response.usage_details["input_token_count"] == 20 + assert response.usage_details["output_token_count"] == 8 + assert response.usage_details["total_token_count"] == 28 + + +async def test_get_response_no_usage_when_metadata_absent() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([_make_part(text="Hi")], prompt_tokens=None, output_tokens=None) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert not response.usage_details + + +# finish reasons + + +@pytest.mark.parametrize( + ("gemini_reason", "expected"), + [ + ("STOP", "stop"), + ("MAX_TOKENS", "length"), + ("SAFETY", "content_filter"), + ("RECITATION", "content_filter"), + ("BLOCKLIST", "content_filter"), + ("PROHIBITED_CONTENT", "content_filter"), + ("SPII", "content_filter"), + ("MALFORMED_FUNCTION_CALL", "tool_calls"), + ("OTHER", None), + ], +) +async def test_finish_reason_mapping(gemini_reason: str, expected: str | None) -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([_make_part(text="Hi")], finish_reason=gemini_reason) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert response.finish_reason == expected + + +# message conversion + + +async def test_system_message_extracted_to_system_instruction() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[ + Message(role="system", contents=[Content.from_text("You are concise.")]), + Message(role="user", contents=[Content.from_text("Hi")]), + ] + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.system_instruction == "You are concise." + + +async def test_multiple_system_messages_concatenated() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[ + Message(role="system", contents=[Content.from_text("Be concise.")]), + Message(role="system", contents=[Content.from_text("Use bullet points.")]), + Message(role="user", contents=[Content.from_text("Hi")]), + ] + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert "Be concise." in config.system_instruction + assert "Use bullet points." in config.system_instruction + + +async def test_instructions_option_merged_with_system_instruction() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[ + Message(role="system", contents=[Content.from_text("Be concise.")]), + Message(role="user", contents=[Content.from_text("Hi")]), + ], + options={"instructions": "Always respond in French."}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert "Always respond in French." in config.system_instruction + assert "Be concise." in config.system_instruction + + +async def test_instructions_option_without_system_message() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"instructions": "Be helpful."}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.system_instruction == "Be helpful." + + +async def test_assistant_role_mapped_to_model() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Sure")])) + + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Hello")]), + Message(role="assistant", contents=[Content.from_text("Hi there")]), + Message(role="user", contents=[Content.from_text("Follow up")]), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + roles = [c.role for c in contents] + assert roles == ["user", "model", "user"] + + +async def test_tool_messages_collapsed_into_single_user_message() -> None: + """Consecutive tool messages must be collapsed into one role='user' message + with multiple functionResponse parts (parallel tool call pattern).""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Run both")]), + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="c1", name="tool_a", arguments={}), + Content.from_function_call(call_id="c2", name="tool_b", arguments={}), + ], + ), + Message(role="tool", contents=[Content.from_function_result(call_id="c1", result="res_a")]), + Message(role="tool", contents=[Content.from_function_result(call_id="c2", result="res_b")]), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + # user, model (with 2 function calls), user (with 2 function responses) + assert contents[-1].role == "user" + assert len(contents[-1].parts) == 2 + + +async def test_function_result_name_resolved_from_call_history() -> None: + """function_result name must come from the matching function_call in history.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Go")]), + Message( + role="assistant", + contents=[Content.from_function_call(call_id="call-42", name="get_weather", arguments={})], + ), + Message(role="tool", contents=[Content.from_function_result(call_id="call-42", result="sunny")]), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + tool_user_msg = contents[-1] + assert tool_user_msg.role == "user" + function_response = tool_user_msg.parts[0].function_response + assert function_response.name == "get_weather" + assert function_response.id == "call-42" + + +async def test_function_result_without_matching_call_is_skipped(caplog: pytest.LogCaptureFixture) -> None: + """A function_result with no prior function_call in history should be skipped with a warning.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + with caplog.at_level(logging.WARNING, logger="agent_framework.gemini"): + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Go")]), + Message( + role="tool", + contents=[Content.from_function_result(call_id="unknown-id", result="oops")], + ), + Message(role="user", contents=[Content.from_text("What happened?")]), + ] + ) + + assert any("unknown-id" in r.message or "function_result" in r.message.lower() for r in caplog.records) + + +async def test_message_with_only_unsupported_content_type_is_skipped() -> None: + """A user message whose contents produce no convertible parts is dropped from the request.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_function_result(call_id="x", result="y")]), + Message(role="user", contents=[Content.from_text("Follow up")]), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + assert len(contents) == 1 + assert contents[0].parts[0].text == "Follow up" + + +async def test_non_function_result_content_in_tool_message_is_skipped() -> None: + """Unexpected content types inside a tool message are silently ignored.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Hi")]), + Message(role="tool", contents=[Content.from_text("unexpected")]), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + assert len(contents) == 1 + + +# thinking parts + + +async def test_thinking_parts_are_silently_skipped() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([ + _make_part(text="I should think first...", thought=True), + _make_part(text="The answer is 42."), + ]) + ) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the answer?")])] + ) + + assert len(response.messages[0].contents) == 1 + assert response.messages[0].text == "The answer is 42." + + +# generation config options + + +async def test_prepare_config_temperature() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"temperature": 0.3}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.temperature == 0.3 + + +async def test_prepare_config_max_tokens() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"max_tokens": 512}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.max_output_tokens == 512 + + +async def test_prepare_config_top_p_and_top_k() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"top_p": 0.9, "top_k": 40}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.top_p == 0.9 + assert config.top_k == 40 + + +async def test_prepare_config_stop_sequences() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"stop": ["END", "STOP"]}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.stop_sequences == ["END", "STOP"] + + +async def test_prepare_config_seed() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"seed": 42}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.seed == 42 + + +async def test_prepare_config_frequency_and_presence_penalty() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"frequency_penalty": 0.5, "presence_penalty": 0.2}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.frequency_penalty == 0.5 + assert config.presence_penalty == 0.2 + + +# thinking config + + +async def test_thinking_config_budget() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + tc: ThinkingConfig = {"thinking_budget": 1024} + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"thinking_config": tc}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert isinstance(config.thinking_config, types.ThinkingConfig) + assert config.thinking_config.thinking_budget == 1024 + + +async def test_thinking_config_level() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + tc: ThinkingConfig = {"thinking_level": "high"} + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"thinking_config": tc}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert isinstance(config.thinking_config, types.ThinkingConfig) + assert config.thinking_config.thinking_level == types.ThinkingLevel.HIGH + + +# structured output + + +async def test_response_format_sets_json_mime_type() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": "json"}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.response_mime_type == "application/json" + + +async def test_response_schema_added_to_config() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) + schema = {"type": "object", "properties": {"name": {"type": "string"}}} + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": "json", "response_schema": schema}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.response_mime_type == "application/json" + assert config.response_schema == schema + + +# tool calling + + +async def test_function_call_in_response_mapped_to_content() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([_make_part(function_call=("call-1", "get_weather", {"city": "Berlin"}))]) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Weather?")])]) + + fc = response.messages[0].contents[0] + assert fc.type == "function_call" + assert fc.name == "get_weather" + assert fc.call_id == "call-1" + + +async def test_function_call_missing_id_gets_fallback() -> None: + """Older Gemini models may omit function_call.id — a UUID fallback must be generated.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([ + _make_part(function_call=(None, "search", {"q": "test"})) # id is None + ]) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Search")])]) + + fc = response.messages[0].contents[0] + assert fc.call_id is not None + assert len(fc.call_id) > 0 + + +async def test_function_tool_converted_to_function_declaration() -> None: + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return "sunny" + + tool = FunctionTool(name="get_weather", func=get_weather) + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Weather?")])], + options={"tools": [tool]}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + assert len(config.tools) == 1 + function_declaration = config.tools[0].function_declarations[0] + assert function_declaration.name == "get_weather" + + +# _coerce_to_dict + + +def test_coerce_to_dict_with_dict_input() -> None: + assert GeminiChatClient._coerce_to_dict({"key": "value"}) == {"key": "value"} + + +def test_coerce_to_dict_with_json_string() -> None: + assert GeminiChatClient._coerce_to_dict('{"key": "value"}') == {"key": "value"} + + +def test_coerce_to_dict_with_plain_string() -> None: + assert GeminiChatClient._coerce_to_dict("some text") == {"result": "some text"} + + +def test_coerce_to_dict_with_none() -> None: + assert GeminiChatClient._coerce_to_dict(None) == {"result": ""} + + +def test_coerce_to_dict_with_numeric_value() -> None: + assert GeminiChatClient._coerce_to_dict(42) == {"result": "42"} + + +# tool choice + + +def _get_function_calling_mode(config: types.GenerateContentConfig) -> str: + return config.tool_config.function_calling_config.mode + + +def _make_dummy_tool() -> FunctionTool: + def dummy(x: int) -> int: + """Dummy.""" + return x + + return FunctionTool(name="dummy", func=dummy) + + +async def _get_config_for_tool_choice(tool_choice: str) -> types.GenerateContentConfig: + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"tools": [tool], "tool_choice": tool_choice}, + ) + + return mock.aio.models.generate_content.call_args.kwargs["config"] + + +async def test_tool_choice_auto_maps_to_AUTO() -> None: + config = await _get_config_for_tool_choice("auto") + assert _get_function_calling_mode(config) == "AUTO" + + +async def test_tool_choice_none_maps_to_NONE() -> None: + config = await _get_config_for_tool_choice("none") + assert _get_function_calling_mode(config) == "NONE" + + +async def test_tool_choice_required_maps_to_ANY() -> None: + config = await _get_config_for_tool_choice("required") + assert _get_function_calling_mode(config) == "ANY" + + +async def test_tool_choice_required_with_name_sets_allowed_function_names() -> None: + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={ + "tools": [tool], + "tool_choice": {"mode": "required", "required_function_name": "dummy"}, + }, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + function_calling_config = config.tool_config.function_calling_config + assert function_calling_config.mode == "ANY" + assert "dummy" in function_calling_config.allowed_function_names + + +async def test_unknown_tool_choice_mode_is_ignored() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + with patch("agent_framework_gemini._chat_client.validate_tool_mode", return_value={"mode": "unsupported"}): + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"tool_choice": "auto"}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert not hasattr(config, "tool_config") or config.tool_config is None + + +# built-in tools + + +async def test_google_search_grounding_injects_tool() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Search")])], + options={"google_search_grounding": True}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + assert any(t.google_search for t in config.tools) + + +async def test_google_maps_grounding_injects_tool() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Map")])], + options={"google_maps_grounding": True}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + assert any(t.google_maps for t in config.tools) + + +async def test_code_execution_injects_tool() -> None: + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Run code")])], + options={"code_execution": True}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + assert any(t.code_execution for t in config.tools) + + +async def test_function_response_part_in_response_mapped_to_content() -> None: + """A function_response part echoed back in a model response is mapped to a function_result Content.""" + client, mock = _make_gemini_client() + part = MagicMock() + part.text = None + part.thought = False + part.function_call = None + part.function_response = MagicMock() + part.function_response.id = "call-99" + part.function_response.response = {"result": "done"} + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([part])) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert response.messages[0].contents[0].type == "function_result" + + +# streaming + + +async def test_streaming_yields_text_chunks() -> None: + client, mock = _make_gemini_client() + chunks = [ + _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), + _make_response([_make_part(text="world!")], finish_reason="STOP"), + ] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + + updates = [update async for update in stream] + text = "".join(u.text or "" for u in updates) + assert "Hello" in text + assert "world" in text + + +async def test_streaming_function_call_emitted_immediately() -> None: + """Function calls in streaming chunks must be emitted as they arrive, not deferred.""" + client, mock = _make_gemini_client() + chunks = [ + _make_response( + [_make_part(function_call=("call-1", "search", {"q": "test"}))], + finish_reason=None, + prompt_tokens=None, + output_tokens=None, + ), + _make_response([_make_part(text="Done")], finish_reason="STOP"), + ] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Search")])], + stream=True, + ) + + all_contents = [] + async for update in stream: + all_contents.extend(update.contents) + + function_calls = [c for c in all_contents if c.type == "function_call"] + assert len(function_calls) == 1 + assert function_calls[0].name == "search" + + +async def test_streaming_finish_reason_only_on_last_chunk() -> None: + client, mock = _make_gemini_client() + chunks = [ + _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), + _make_response([_make_part(text="world!")], finish_reason="STOP"), + ] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + + updates = [update async for update in stream] + assert updates[0].finish_reason is None + assert updates[-1].finish_reason == "stop" + + +async def test_streaming_usage_only_on_final_chunk() -> None: + client, mock = _make_gemini_client() + chunks = [ + _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), + _make_response([_make_part(text="world!")], finish_reason="STOP", prompt_tokens=10, output_tokens=5), + ] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + + updates = [update async for update in stream] + assert not any(c.type == "usage" for c in updates[0].contents) + assert any(c.type == "usage" for c in updates[-1].contents) + + +# service_url + + +def test_service_url() -> None: + client, _ = _make_gemini_client() + assert client.service_url() == "https://generativelanguage.googleapis.com" diff --git a/python/packages/gemini/tests/test_chat_client_integration.py b/python/packages/gemini/tests/test_chat_client_integration.py new file mode 100644 index 0000000000..0e33a825ff --- /dev/null +++ b/python/packages/gemini/tests/test_chat_client_integration.py @@ -0,0 +1,177 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import json +import os + +import pytest +from agent_framework import Content, FunctionTool, Message + +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig + +skip_if_no_api_key = pytest.mark.skipif( + not os.getenv("GEMINI_API_KEY"), + reason="GEMINI_API_KEY not set; skipping integration tests.", +) + +_MODEL = "gemini-2.5-flash" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_basic_chat() -> None: + """Basic request/response round-trip returns a non-empty text reply.""" + client = GeminiChatClient(model_id=_MODEL) + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Reply with the single word: hello")])] + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_streaming() -> None: + """Streaming yields multiple chunks that together form a non-empty response.""" + client = GeminiChatClient(model_id=_MODEL) + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Count from 1 to 5.")])], + stream=True, + ) + + chunks = [update async for update in stream] + assert len(chunks) > 0 + full_text = "".join(u.text or "" for u in chunks) + assert full_text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_tool_calling() -> None: + """Model invokes the registered tool when asked a question that requires it.""" + + def get_temperature(city: str) -> str: + """Return the current temperature for a city.""" + return f"22°C in {city}" + + tool = FunctionTool(name="get_temperature", func=get_temperature) + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the temperature in Berlin?")])], + options={"tools": [tool], "tool_choice": "required"}, + ) + + function_calls = [c for c in response.messages[0].contents if c.type == "function_call"] + assert len(function_calls) >= 1 + assert function_calls[0].name == "get_temperature" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_thinking_config() -> None: + """Model accepts a thinking budget and returns a non-empty text reply.""" + options: GeminiChatOptions = {"thinking_config": ThinkingConfig(thinking_budget=512)} + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is 17 * 34?")])], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_google_search_grounding() -> None: + """Google Search grounding returns a non-empty response for a current-events question.""" + options: GeminiChatOptions = {"google_search_grounding": True} + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the latest stable version of Python?")])], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_code_execution() -> None: + """Code execution tool produces a non-empty response for a computation request.""" + options: GeminiChatOptions = {"code_execution": True} + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[ + Message( + role="user", + contents=[Content.from_text("Compute the sum of the first 100 natural numbers using code.")], + ) + ], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_structured_output() -> None: + """Structured output with a response schema returns valid JSON matching the schema.""" + options: GeminiChatOptions = { + "response_format": "json", + "response_schema": { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + }, + } + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the capital of Germany?")])], + options=options, + ) + + assert response.messages + text = response.messages[0].text + assert text + parsed = json.loads(text) + assert "answer" in parsed + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_google_maps_grounding() -> None: + """Google Maps grounding returns a non-empty response for a location-based question.""" + options: GeminiChatOptions = {"google_maps_grounding": True} + client = GeminiChatClient(model_id=_MODEL) + + response = await client.get_response( + messages=[ + Message( + role="user", + contents=[Content.from_text("What are some highly rated restaurants in Karlsruhe city center?")], + ) + ], + options=options, + ) + + assert response.messages + assert response.messages[0].text From dbc170990c74b961c145467c821861f305ac3e02 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 08:25:19 +0100 Subject: [PATCH 08/51] Add Google Gemini agent examples --- .../02-agents/providers/google/README.md | 18 ++++ .../providers/google/gemini_advanced.py | 49 +++++++++++ .../providers/google/gemini_basic.py | 82 +++++++++++++++++++ .../google/gemini_with_code_execution.py | 48 +++++++++++ .../google/gemini_with_google_maps.py | 48 +++++++++++ .../google/gemini_with_google_search.py | 48 +++++++++++ 6 files changed, 293 insertions(+) create mode 100644 python/samples/02-agents/providers/google/README.md create mode 100644 python/samples/02-agents/providers/google/gemini_advanced.py create mode 100644 python/samples/02-agents/providers/google/gemini_basic.py create mode 100644 python/samples/02-agents/providers/google/gemini_with_code_execution.py create mode 100644 python/samples/02-agents/providers/google/gemini_with_google_maps.py create mode 100644 python/samples/02-agents/providers/google/gemini_with_google_search.py diff --git a/python/samples/02-agents/providers/google/README.md b/python/samples/02-agents/providers/google/README.md new file mode 100644 index 0000000000..b0f243d5a7 --- /dev/null +++ b/python/samples/02-agents/providers/google/README.md @@ -0,0 +1,18 @@ +# Google Gemini Examples + +This folder contains examples demonstrating how to use Google Gemini models with the Agent Framework. + +## Examples + +| File | Description | +|------|-------------| +| [`gemini_basic.py`](gemini_basic.py) | Basic agent with a weather tool, demonstrating both streaming and non-streaming responses. | +| [`gemini_advanced.py`](gemini_advanced.py) | Extended thinking via `ThinkingConfig` for reasoning-heavy questions (Gemini 2.5+). | +| [`gemini_with_google_search.py`](gemini_with_google_search.py) | Google Search grounding for up-to-date answers. | +| [`gemini_with_google_maps.py`](gemini_with_google_maps.py) | Google Maps grounding for location and mapping information. | +| [`gemini_with_code_execution.py`](gemini_with_code_execution.py) | Built-in code execution tool for computing precise answers in a sandboxed environment. | + +## Environment Variables + +- `GEMINI_API_KEY`: Your Google AI Studio API key (get one from [Google AI Studio](https://aistudio.google.com/apikey)) +- `GEMINI_CHAT_MODEL_ID`: The Gemini model to use (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`) diff --git a/python/samples/02-agents/providers/google/gemini_advanced.py b/python/samples/02-agents/providers/google/gemini_advanced.py new file mode 100644 index 0000000000..2f812efe63 --- /dev/null +++ b/python/samples/02-agents/providers/google/gemini_advanced.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Gemini Advanced Example + +This sample demonstrates extended thinking via ThinkingConfig (Gemini 2.5+), +which lets the model reason through complex problems before responding. + +Environment variables used: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) +""" + + +async def main() -> None: + """Example of extended thinking with a Python version comparison question.""" + print("=== Extended Thinking Example ===") + + options: GeminiChatOptions = { + "thinking_config": ThinkingConfig(thinking_budget=2048), + } + + agent = Agent( + client=GeminiChatClient(), + name="PythonAgent", + instructions="You are a helpful Python expert.", + default_options=options, + ) + + query = "What new language features were introduced in Python between 3.10 and 3.14?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/google/gemini_basic.py b/python/samples/02-agents/providers/google/gemini_basic.py new file mode 100644 index 0000000000..f8c3bab03e --- /dev/null +++ b/python/samples/02-agents/providers/google/gemini_basic.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from random import randint +from typing import Annotated + +from agent_framework import Agent, tool +from agent_framework_gemini import GeminiChatClient +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Gemini Chat Agent Example + +This sample demonstrates using GeminiChatClient with an agent and a single custom tool. + +Environment variables used: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, "The location to get the weather for."], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def non_streaming_example() -> None: + """Example of non-streaming response (get the complete result at once).""" + print("=== Non-streaming Response Example ===") + + agent = Agent( + client=GeminiChatClient(), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) + + query = "What's the weather like in Karlsruhe?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Result: {result}\n") + + +async def streaming_example() -> None: + """Example of streaming response (get results as they are generated).""" + print("=== Streaming Response Example ===") + + agent = Agent( + client=GeminiChatClient(), + name="WeatherAgent", + instructions="You are a helpful weather agent.", + tools=[get_weather], + ) + + query = "What's the weather like in Portland and in Paris?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +async def main() -> None: + print("=== Gemini Example ===\n") + + await non_streaming_example() + await streaming_example() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/google/gemini_with_code_execution.py b/python/samples/02-agents/providers/google/gemini_with_code_execution.py new file mode 100644 index 0000000000..9ddf04b923 --- /dev/null +++ b/python/samples/02-agents/providers/google/gemini_with_code_execution.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Gemini Code Execution Example + +This sample demonstrates Gemini's built-in code execution tool, which lets the +model write and run Python code in a sandboxed environment to answer questions. + +Environment variables used: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) +""" + + +async def main() -> None: + print("=== Code Execution Example ===") + + options: GeminiChatOptions = { + "code_execution": True, + } + + agent = Agent( + client=GeminiChatClient(), + name="CodeAgent", + instructions="You are a helpful assistant. Use code execution to compute precise answers.", + default_options=options, + ) + + query = "What are the first 20 prime numbers? Compute them in code." + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/google/gemini_with_google_maps.py b/python/samples/02-agents/providers/google/gemini_with_google_maps.py new file mode 100644 index 0000000000..9ececa4654 --- /dev/null +++ b/python/samples/02-agents/providers/google/gemini_with_google_maps.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Gemini Google Maps Grounding Example + +This sample demonstrates Google Maps grounding, which lets Gemini retrieve +location and mapping information from Google Maps before responding. + +Environment variables used: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) +""" + + +async def main() -> None: + print("=== Google Maps Grounding Example ===") + + options: GeminiChatOptions = { + "google_maps_grounding": True, + } + + agent = Agent( + client=GeminiChatClient(), + name="MapsAgent", + instructions="You are a helpful travel assistant. Use Google Maps to provide accurate location information.", + default_options=options, + ) + + query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/google/gemini_with_google_search.py b/python/samples/02-agents/providers/google/gemini_with_google_search.py new file mode 100644 index 0000000000..d1063169ae --- /dev/null +++ b/python/samples/02-agents/providers/google/gemini_with_google_search.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Gemini Google Search Grounding Example + +This sample demonstrates Google Search grounding, which lets Gemini retrieve +up-to-date information from the web before responding. + +Environment variables used: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) +""" + + +async def main() -> None: + print("=== Google Search Grounding Example ===") + + options: GeminiChatOptions = { + "google_search_grounding": True, + } + + agent = Agent( + client=GeminiChatClient(), + name="SearchAgent", + instructions="You are a helpful assistant. Use Google Search to provide accurate, up-to-date answers.", + default_options=options, + ) + + query = "What is the latest stable release of the .NET SDK?" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + print("\n") + + +if __name__ == "__main__": + asyncio.run(main()) From 85a86a15414e5343dbc6a9beb3a75bea47194eeb Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 11:06:04 +0100 Subject: [PATCH 09/51] Fix client inheritence order --- python/packages/gemini/agent_framework_gemini/_chat_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 7ca34b22ee..e1c15d657e 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -158,8 +158,8 @@ class GeminiSettings(TypedDict, total=False): class GeminiChatClient( - FunctionInvocationLayer[GeminiChatOptionsT], ChatMiddlewareLayer[GeminiChatOptionsT], + FunctionInvocationLayer[GeminiChatOptionsT], ChatTelemetryLayer[GeminiChatOptionsT], BaseChatClient[GeminiChatOptionsT], Generic[GeminiChatOptionsT], From db6521b4790bdade4bb99cedfab5fafb72e3db1b Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 11:29:33 +0100 Subject: [PATCH 10/51] Update Gemini agent examples --- .../providers/google/gemini_advanced.py | 23 ++++++------ .../providers/google/gemini_basic.py | 36 ++++++++----------- .../google/gemini_with_code_execution.py | 23 ++++++------ .../google/gemini_with_google_maps.py | 23 ++++++------ .../google/gemini_with_google_search.py | 23 ++++++------ 5 files changed, 55 insertions(+), 73 deletions(-) diff --git a/python/samples/02-agents/providers/google/gemini_advanced.py b/python/samples/02-agents/providers/google/gemini_advanced.py index 2f812efe63..9f0c80074f 100644 --- a/python/samples/02-agents/providers/google/gemini_advanced.py +++ b/python/samples/02-agents/providers/google/gemini_advanced.py @@ -1,29 +1,26 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Shows how to enable extended thinking with ThinkingConfig so the model can +reason through complex problems before responding. + +Requires the following environment variables to be set: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID +""" + import asyncio from agent_framework import Agent from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -""" -Gemini Advanced Example - -This sample demonstrates extended thinking via ThinkingConfig (Gemini 2.5+), -which lets the model reason through complex problems before responding. - -Environment variables used: -- GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) -""" - async def main() -> None: """Example of extended thinking with a Python version comparison question.""" - print("=== Extended Thinking Example ===") + print("=== Extended thinking ===") options: GeminiChatOptions = { "thinking_config": ThinkingConfig(thinking_budget=2048), diff --git a/python/samples/02-agents/providers/google/gemini_basic.py b/python/samples/02-agents/providers/google/gemini_basic.py index f8c3bab03e..a596e2e704 100644 --- a/python/samples/02-agents/providers/google/gemini_basic.py +++ b/python/samples/02-agents/providers/google/gemini_basic.py @@ -1,5 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Shows how to use GeminiChatClient with an agent and a custom tool, covering both +non-streaming and streaming responses. + +Requires the following environment variables to be set: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID +""" + import asyncio from random import randint from typing import Annotated @@ -8,23 +17,10 @@ from agent_framework_gemini import GeminiChatClient from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -""" -Gemini Chat Agent Example - -This sample demonstrates using GeminiChatClient with an agent and a single custom tool. - -Environment variables used: -- GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) -""" - -# NOTE: approval_mode="never_require" is for sample brevity. -# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py -# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production @tool(approval_mode="never_require") def get_weather( location: Annotated[str, "The location to get the weather for."], @@ -35,8 +31,8 @@ def get_weather( async def non_streaming_example() -> None: - """Example of non-streaming response (get the complete result at once).""" - print("=== Non-streaming Response Example ===") + """Runs the agent and waits for the complete response before printing it.""" + print("=== Non-streaming ===") agent = Agent( client=GeminiChatClient(), @@ -45,15 +41,15 @@ async def non_streaming_example() -> None: tools=[get_weather], ) - query = "What's the weather like in Karlsruhe?" + query = "What's the weather like in Karlsruhe, Germany?" print(f"User: {query}") result = await agent.run(query) print(f"Result: {result}\n") async def streaming_example() -> None: - """Example of streaming response (get results as they are generated).""" - print("=== Streaming Response Example ===") + """Runs the agent and prints each chunk as it is received.""" + print("=== Streaming ===") agent = Agent( client=GeminiChatClient(), @@ -72,8 +68,6 @@ async def streaming_example() -> None: async def main() -> None: - print("=== Gemini Example ===\n") - await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/google/gemini_with_code_execution.py b/python/samples/02-agents/providers/google/gemini_with_code_execution.py index 9ddf04b923..f6e38e670a 100644 --- a/python/samples/02-agents/providers/google/gemini_with_code_execution.py +++ b/python/samples/02-agents/providers/google/gemini_with_code_execution.py @@ -1,28 +1,25 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Shows how to enable Gemini's built-in code execution tool so the model can write +and run code in a sandboxed environment to answer questions. + +Requires the following environment variables to be set: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID +""" + import asyncio from agent_framework import Agent from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -""" -Gemini Code Execution Example - -This sample demonstrates Gemini's built-in code execution tool, which lets the -model write and run Python code in a sandboxed environment to answer questions. - -Environment variables used: -- GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) -""" - async def main() -> None: - print("=== Code Execution Example ===") + print("=== Code execution ===") options: GeminiChatOptions = { "code_execution": True, diff --git a/python/samples/02-agents/providers/google/gemini_with_google_maps.py b/python/samples/02-agents/providers/google/gemini_with_google_maps.py index 9ececa4654..b242c05703 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_maps.py +++ b/python/samples/02-agents/providers/google/gemini_with_google_maps.py @@ -1,28 +1,25 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Shows how to enable Google Maps grounding so Gemini can retrieve location and +mapping information before responding. + +Requires the following environment variables to be set: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID +""" + import asyncio from agent_framework import Agent from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -""" -Gemini Google Maps Grounding Example - -This sample demonstrates Google Maps grounding, which lets Gemini retrieve -location and mapping information from Google Maps before responding. - -Environment variables used: -- GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) -""" - async def main() -> None: - print("=== Google Maps Grounding Example ===") + print("=== Google Maps grounding ===") options: GeminiChatOptions = { "google_maps_grounding": True, diff --git a/python/samples/02-agents/providers/google/gemini_with_google_search.py b/python/samples/02-agents/providers/google/gemini_with_google_search.py index d1063169ae..5531c38775 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_search.py +++ b/python/samples/02-agents/providers/google/gemini_with_google_search.py @@ -1,28 +1,25 @@ # Copyright (c) Microsoft. All rights reserved. +""" +Shows how to enable Google Search grounding so Gemini can retrieve up-to-date +information from the web before responding. + +Requires the following environment variables to be set: +- GEMINI_API_KEY +- GEMINI_CHAT_MODEL_ID +""" + import asyncio from agent_framework import Agent from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -""" -Gemini Google Search Grounding Example - -This sample demonstrates Google Search grounding, which lets Gemini retrieve -up-to-date information from the web before responding. - -Environment variables used: -- GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID (defaults to gemini-2.5-flash if unset) -""" - async def main() -> None: - print("=== Google Search Grounding Example ===") + print("=== Google Search grounding ===") options: GeminiChatOptions = { "google_search_grounding": True, From 54af10bba6b14a3e39e3ceb8aab93931daf7a0dd Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 15:00:51 +0100 Subject: [PATCH 11/51] Update documentation --- .../gemini/agent_framework_gemini/_chat_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index e1c15d657e..17c6fccf56 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -66,9 +66,11 @@ class ThinkingConfig(TypedDict, total=False): """Extended thinking configuration for Gemini models. - Use ``thinking_budget`` for Gemini 2.5 models (integer token count: 0 disables - thinking, -1 enables a dynamic budget). Use ``thinking_level`` for Gemini 3.x - models (one of ``'minimal'``, ``'low'``, ``'medium'``, ``'high'``). + Attributes: + thinking_budget: Token budget for Gemini 2.5 models. Set to 0 to disable + thinking or -1 to enable a dynamic budget. + thinking_level: Thinking level for Gemini 3.x models. One of + ``'minimal'``, ``'low'``, ``'medium'``, or ``'high'``. """ thinking_budget: int @@ -164,7 +166,7 @@ class GeminiChatClient( BaseChatClient[GeminiChatOptionsT], Generic[GeminiChatOptionsT], ): - """Async chat client for the Google Gemini API with middleware, telemetry, and function invocation.""" + """Async chat client for the Google Gemini API with middleware, function invocation and telemetry.""" OTEL_PROVIDER_NAME: ClassVar[str] = "gcp.gemini" # type: ignore[reportIncompatibleVariableOverride, misc] From cf4a6fb53d0bafc4aee8073b405b05ba1e150ac3 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 15:01:37 +0100 Subject: [PATCH 12/51] Update AGENTS.md --- python/packages/gemini/AGENTS.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/python/packages/gemini/AGENTS.md b/python/packages/gemini/AGENTS.md index 6e8dbe8647..30a3c2ebb3 100644 --- a/python/packages/gemini/AGENTS.md +++ b/python/packages/gemini/AGENTS.md @@ -2,23 +2,26 @@ Integration with Google's Gemini API via the `google-genai` SDK. -## Main Classes +## Core Classes - **`GeminiChatClient`** - Chat client for Google Gemini models - **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters -- **`ThinkingConfig`** - Configuration for extended thinking (Gemini 2.5+) +- **`GeminiSettings`** - Settings loaded from environment variables +- **`ThinkingConfig`** - Configuration for extended thinking + +## Gemini Options + +- **`thinking_config`** - Enable extended thinking via `ThinkingConfig` +- **`google_search_grounding`** - Responses with live Google Search results +- **`google_maps_grounding`** - Responses with Google Maps data +- **`code_execution`** - Let the model write and run code in a sandboxed environment ## Usage ```python +from agent_framework import Content, Message from agent_framework_gemini import GeminiChatClient client = GeminiChatClient(model_id="gemini-2.5-flash") -response = await client.get_response("Hello") -``` - -## Import Path - -```python -from agent_framework_gemini import GeminiChatClient +response = await client.get_response([Message(role="user", contents=[Content.from_text("Hello")])]) ``` From 0b0afd1ff7b38e3012daa91ba560bd96f0dda482 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 15:08:41 +0100 Subject: [PATCH 13/51] Add tests for JSON string handling in GeminiChatClient --- python/packages/gemini/tests/test_chat_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index 7a93b545e0..ac8dfa9eeb 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -647,6 +647,14 @@ def test_coerce_to_dict_with_numeric_value() -> None: assert GeminiChatClient._coerce_to_dict(42) == {"result": "42"} +def test_coerce_to_dict_with_json_array_string() -> None: + assert GeminiChatClient._coerce_to_dict("[1, 2, 3]") == {"result": "[1, 2, 3]"} + + +def test_coerce_to_dict_with_json_string_literal() -> None: + assert GeminiChatClient._coerce_to_dict('"hello"') == {"result": '"hello"'} + + # tool choice From 8964929228ab2ffc783f51ce11551d448203e1d1 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 15:11:15 +0100 Subject: [PATCH 14/51] Add final response assembly test in GeminiChatClient --- .../packages/gemini/tests/test_chat_client.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index ac8dfa9eeb..6545a3aa34 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -879,6 +879,32 @@ async def test_streaming_usage_only_on_final_chunk() -> None: assert any(c.type == "usage" for c in updates[-1].contents) +async def test_streaming_get_final_response() -> None: + """get_final_response() must return a fully assembled ChatResponse after the stream is exhausted.""" + client, mock = _make_gemini_client() + chunks = [ + _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), + _make_response([_make_part(text="world!")], finish_reason="STOP", prompt_tokens=10, output_tokens=5), + ] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + + async for _ in stream: + pass + + final = await stream.get_final_response() + + assert final.messages[0].text == "Hello world!" + assert final.finish_reason == "stop" + assert final.usage_details is not None + assert final.usage_details["input_token_count"] == 10 + assert final.usage_details["output_token_count"] == 5 + + # service_url From 533aa7c7136f61b18a9e88a210f1711c47c9e587 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 15:16:13 +0100 Subject: [PATCH 15/51] Add tests for handling empty candidates in GeminiChatClient --- .../packages/gemini/tests/test_chat_client.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index 6545a3aa34..dec69e93ca 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -905,6 +905,47 @@ async def test_streaming_get_final_response() -> None: assert final.usage_details["output_token_count"] == 5 +# The Gemini API returns a list of candidates, each representing a possible response from the model. +# In practice only one candidate is returned, but the list can be empty or None if the request +# was blocked by safety filters or the API returned an unexpected response. + + +@pytest.mark.parametrize("candidates", [None, []]) +async def test_empty_candidates_returns_empty_message(candidates: list | None) -> None: + """An API response with no candidates must not raise and must return an empty assistant message.""" + client, mock = _make_gemini_client() + response = _make_response([]) + response.candidates = candidates + mock.aio.models.generate_content = AsyncMock(return_value=response) + + result = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert result.messages[0].role == "assistant" + assert result.messages[0].contents == [] + assert result.finish_reason is None + + +@pytest.mark.parametrize("candidates", [None, []]) +async def test_empty_candidates_in_stream_does_not_raise(candidates: list | None) -> None: + """A streaming chunk with no candidates must not raise and must yield an empty update.""" + client, mock = _make_gemini_client() + chunk = _make_response([], finish_reason=None, prompt_tokens=None, output_tokens=None) + chunk.candidates = candidates + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter([chunk])) + + updates = [ + update + async for update in client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + stream=True, + ) + ] + + assert len(updates) == 1 + assert updates[0].contents == [] + assert updates[0].finish_reason is None + + # service_url From 308c4748f56fe31788bd82d135e2c58e07d2893f Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 16:17:19 +0100 Subject: [PATCH 16/51] Improve Pydantic response handling in GeminiChatClient --- .../agent_framework_gemini/_chat_client.py | 68 ++++++++++++++----- .../packages/gemini/tests/test_chat_client.py | 54 ++++++++++++++- .../tests/test_chat_client_integration.py | 26 +++---- 3 files changed, 112 insertions(+), 36 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 17c6fccf56..9de1f544e7 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -233,24 +233,11 @@ def _inner_get_response( stream: bool = False, **kwargs: Any, ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: - model_id = options.get("model_id") or self.model_id - if not model_id: - raise ValueError( - "Gemini model_id is required. Set via model_id parameter or GEMINI_CHAT_MODEL_ID environment variable." - ) - - system_instruction, contents = self._prepare_gemini_messages(messages) - - if call_instructions := options.get("instructions"): - system_instruction = ( - f"{call_instructions}\n{system_instruction}" if system_instruction else call_instructions - ) - - config = self._prepare_config(options, system_instruction) - if stream: async def _stream() -> AsyncIterable[ChatResponseUpdate]: + validated = await self._validate_options(options) + model_id, contents, config = self._prepare_request(messages, validated) async for chunk in await self._genai_client.aio.models.generate_content_stream( model=model_id, contents=contents, # type: ignore[arg-type] @@ -258,14 +245,50 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: ): yield self._process_chunk(chunk) - return self._build_response_stream(_stream()) + return self._build_response_stream(_stream(), response_format=options.get("response_format")) async def _get_response() -> ChatResponse: + validated = await self._validate_options(options) + model_id, contents, config = self._prepare_request(messages, validated) raw = await self._genai_client.aio.models.generate_content(model=model_id, contents=contents, config=config) # type: ignore[arg-type] - return self._process_generate_response(raw) + return self._process_generate_response(raw, response_format=validated.get("response_format")) return _get_response() + def _prepare_request( + self, + messages: Sequence[Message], + options: Mapping[str, Any], + ) -> tuple[str, list[types.Content], types.GenerateContentConfig]: + """Resolve the model ID, convert messages to Gemini contents, and build the generation config. + + Call this after awaiting ``_validate_options`` so that tools and other options are + fully normalized before the request is assembled. + + Args: + messages: The conversation history as framework Message objects. + options: Validated and normalized chat options. + + Returns: + A tuple of the resolved model ID, the Gemini contents list, and the generation config. + + Raises: + ValueError: If no model ID is set on the options or the client instance. + """ + model_id = options.get("model_id") or self.model_id + if not model_id: + raise ValueError( + "Gemini model_id is required. Set via model_id parameter or GEMINI_CHAT_MODEL_ID environment variable." + ) + + system_instruction, contents = self._prepare_gemini_messages(messages) + if call_instructions := options.get("instructions"): + system_instruction = ( + f"{call_instructions}\n{system_instruction}" if system_instruction else call_instructions + ) + + return model_id, contents, self._prepare_config(options, system_instruction) + # region Message preparation def _prepare_gemini_messages(self, messages: Sequence[Message]) -> tuple[str | None, list[types.Content]]: @@ -541,11 +564,19 @@ def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None: # region Response parsing - def _process_generate_response(self, response: types.GenerateContentResponse) -> ChatResponse: + def _process_generate_response( + self, + response: types.GenerateContentResponse, + *, + response_format: type[BaseModel] | None = None, + ) -> ChatResponse: """Convert a Gemini generate_content response to a framework ChatResponse. Args: response: The raw ``GenerateContentResponse`` from the Gemini API. + response_format: Optional Pydantic model type for structured output parsing. + When provided, the response text is parsed into the given model and + made available via ``ChatResponse.value``. Returns: A ``ChatResponse`` with parsed messages, usage details, finish reason, and model ID. @@ -561,6 +592,7 @@ def _process_generate_response(self, response: types.GenerateContentResponse) -> finish_reason=self._map_finish_reason( candidate.finish_reason.name if candidate and candidate.finish_reason else None ), + response_format=response_format, raw_representation=response, ) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index dec69e93ca..ee1cc75c12 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -543,18 +543,41 @@ async def test_thinking_config_level() -> None: async def test_response_format_sets_json_mime_type() -> None: + from pydantic import BaseModel + + class Reply(BaseModel): + text: str + client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Hi")])], - options={"response_format": "json"}, + options={"response_format": Reply}, ) config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] assert config.response_mime_type == "application/json" +async def test_response_format_populates_value_on_chat_response() -> None: + """When response_format is a Pydantic model, ChatResponse.value must be parsed from the response text.""" + from pydantic import BaseModel + + class Reply(BaseModel): + text: str + + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text='{"text": "hello"}')])) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": Reply}, + ) + + assert response.value == Reply(text="hello") + + async def test_response_schema_added_to_config() -> None: client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) @@ -562,7 +585,7 @@ async def test_response_schema_added_to_config() -> None: await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Hi")])], - options={"response_format": "json", "response_schema": schema}, + options={"response_schema": schema}, ) config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] @@ -570,6 +593,33 @@ async def test_response_schema_added_to_config() -> None: assert config.response_schema == schema +async def test_streaming_response_format_passed_to_build_response_stream() -> None: + """Verifies that response_format is forwarded to _build_response_stream when streaming + so that structured output parsing works correctly on the final assembled response.""" + from unittest.mock import patch + + from pydantic import BaseModel + + class Reply(BaseModel): + text: str + + client, mock = _make_gemini_client() + chunks = [_make_response([_make_part(text='{"text": "hello"}')], finish_reason="STOP")] + mock.aio.models.generate_content_stream = AsyncMock(return_value=_async_iter(chunks)) + + with patch.object(client, "_build_response_stream", wraps=client._build_response_stream) as spy: + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"response_format": Reply}, + stream=True, + ) + async for _ in stream: + pass + + _, kwargs = spy.call_args + assert kwargs.get("response_format") is Reply + + # tool calling diff --git a/python/packages/gemini/tests/test_chat_client_integration.py b/python/packages/gemini/tests/test_chat_client_integration.py index 0e33a825ff..6843ac9b9b 100644 --- a/python/packages/gemini/tests/test_chat_client_integration.py +++ b/python/packages/gemini/tests/test_chat_client_integration.py @@ -2,11 +2,11 @@ from __future__ import annotations -import json import os import pytest from agent_framework import Content, FunctionTool, Message +from pydantic import BaseModel from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig @@ -132,27 +132,21 @@ async def test_integration_code_execution() -> None: @pytest.mark.integration @skip_if_no_api_key async def test_integration_structured_output() -> None: - """Structured output with a response schema returns valid JSON matching the schema.""" - options: GeminiChatOptions = { - "response_format": "json", - "response_schema": { - "type": "object", - "properties": {"answer": {"type": "string"}}, - "required": ["answer"], - }, - } + """Structured output with a Pydantic response_format returns a parsed value via response.value.""" + + class Answer(BaseModel): + answer: str + client = GeminiChatClient(model_id=_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is the capital of Germany?")])], - options=options, + options={"response_format": Answer}, ) - assert response.messages - text = response.messages[0].text - assert text - parsed = json.loads(text) - assert "answer" in parsed + assert response.value is not None + assert isinstance(response.value, Answer) + assert response.value.answer @pytest.mark.flaky From 80580fce6856d9e3d3b9638298d52a2f573b0434 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 16:18:32 +0100 Subject: [PATCH 17/51] Add tests for function result resolution and callable tool normalization --- .../packages/gemini/tests/test_chat_client.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index ee1cc75c12..1681b52d51 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -674,6 +674,28 @@ def get_weather(city: str) -> str: assert function_declaration.name == "get_weather" +async def test_callable_tool_resolved_via_validate_options() -> None: + """Raw callables passed as tools must be normalized by _validate_options into FunctionTools + and reach the Gemini config as function declarations.""" + + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return "sunny" + + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Weather?")])], + options={"tools": [get_weather]}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + function_declaration = config.tools[0].function_declarations[0] + assert function_declaration.name == "get_weather" + + # _coerce_to_dict From aeda903c9f144af9aa06cd2692c3e977fce71e86 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 16:19:13 +0100 Subject: [PATCH 18/51] Add test for function result resolution when call_id is generated --- .../agent_framework_gemini/_chat_client.py | 4 +-- .../packages/gemini/tests/test_chat_client.py | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 9de1f544e7..738bc65434 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -362,8 +362,8 @@ def _convert_message_contents( parts.append(types.Part(text=content.text or "")) case "function_call": call_id = content.call_id or self._generate_tool_call_id() - if content.call_id and content.name: - call_id_to_name[content.call_id] = content.name + if content.name: + call_id_to_name[call_id] = content.name parts.append( types.Part( function_call=types.FunctionCall( diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index 1681b52d51..c32aef7ce4 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -350,6 +350,34 @@ async def test_function_result_name_resolved_from_call_history() -> None: assert function_response.id == "call-42" +async def test_function_result_resolved_when_call_id_was_generated() -> None: + """When a function_call has no call_id and a fallback is generated, the subsequent + function_result referencing that generated ID must still resolve the function name.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) + + generated_id = "tool-call-generated-123" + with patch.object(client, "_generate_tool_call_id", return_value=generated_id): + await client.get_response( + messages=[ + Message(role="user", contents=[Content.from_text("Go")]), + Message( + role="assistant", + contents=[Content.from_function_call(call_id=None, name="get_weather", arguments={})], # type: ignore[arg-type] + ), + Message( + role="tool", + contents=[Content.from_function_result(call_id=generated_id, result="sunny")], + ), + ] + ) + + contents: list[types.Content] = mock.aio.models.generate_content.call_args.kwargs["contents"] + tool_turn = next(c for c in contents if c.role == "user" and any(p.function_response for p in c.parts)) + assert tool_turn.parts[0].function_response.name == "get_weather" + assert tool_turn.parts[0].function_response.id == generated_id + + async def test_function_result_without_matching_call_is_skipped(caplog: pytest.LogCaptureFixture) -> None: """A function_result with no prior function_call in history should be skipped with a warning.""" client, mock = _make_gemini_client() From ff52460b516c0811b1f3077bfa33d53ff3e968fe Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 16:29:49 +0100 Subject: [PATCH 19/51] Refactor GeminiChatClient to correct inheritance order Also updates constructor parameter order for environment file handling --- .../gemini/agent_framework_gemini/_chat_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 738bc65434..eb88d4b7fa 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -160,8 +160,8 @@ class GeminiSettings(TypedDict, total=False): class GeminiChatClient( - ChatMiddlewareLayer[GeminiChatOptionsT], FunctionInvocationLayer[GeminiChatOptionsT], + ChatMiddlewareLayer[GeminiChatOptionsT], ChatTelemetryLayer[GeminiChatOptionsT], BaseChatClient[GeminiChatOptionsT], Generic[GeminiChatOptionsT], @@ -175,24 +175,24 @@ def __init__( *, api_key: str | None = None, model_id: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, client: genai.Client | None = None, additional_properties: dict[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, ) -> None: """Create a Gemini chat client. Args: api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` env var. model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` env var. + 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, ``api_key`` is not required. additional_properties: Extra properties stored on the client instance. middleware: Optional middleware chain. function_invocation_configuration: Optional function invocation configuration. - env_file_path: Path to a ``.env`` file for credential loading. - env_file_encoding: Encoding for the ``.env`` file. """ settings = load_settings( GeminiSettings, From c7529f9cf9e521ff9b25d0b66942bdaa98e79ea5 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 17:40:24 +0100 Subject: [PATCH 20/51] Enhance documentation and clarify Gemini-specific fields --- .../agent_framework_gemini/_chat_client.py | 114 ++++++++++++------ 1 file changed, 75 insertions(+), 39 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index eb88d4b7fa..afa8b8b48e 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -80,46 +80,79 @@ class ThinkingConfig(TypedDict, total=False): class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): """Google Gemini API-specific chat options. - Supported ChatOptions fields (mapped to GenerateContentConfig): - model_id -> model parameter - temperature -> temperature - max_tokens -> max_output_tokens - top_p -> top_p - stop -> stop_sequences - seed -> seed - frequency_penalty -> frequency_penalty - presence_penalty -> presence_penalty - tools -> tools[].function_declarations - tool_choice -> tool_config.function_calling_config.mode - response_format -> response_mime_type (signals JSON mode) - instructions -> merged into system_instruction - - Gemini-specific options: - thinking_config: Extended thinking. Maps to types.ThinkingConfig. - top_k: Top-K sampling. - google_search_grounding: Enable Google Search as a grounding tool. - google_maps_grounding: Enable Google Maps as a grounding tool. - code_execution: Enable the built-in code execution tool. - response_schema: JSON schema for structured output. - - Unsupported base options (passing these is a type error): - logit_bias, allow_multiple_tool_calls, store, user, metadata, conversation_id + Extends ``ChatOptions`` with Gemini-specific fields. Standard options are mapped to their + ``GenerateContentConfig`` equivalents; Gemini-specific fields are declared below. + + Only text output is supported for now. Other modalities may be added later. + + See: https://ai.google.dev/api/generate-content#generationconfig + + Inherited fields from ``ChatOptions``: + model_id: Model to use for this call (e.g. ``"gemini-2.5-flash"``). + temperature: Controls randomness. Higher values produce more varied output. + max_tokens: Maximum number of tokens to generate (``maxOutputTokens``). + top_p: Nucleus sampling cutoff. Only tokens within the top-p probability mass are considered. + stop: One or more sequences that stop generation when encountered (``stopSequences``). + seed: Fixed seed for reproducible outputs. + frequency_penalty: Reduces repetition by penalising tokens that appear frequently. + presence_penalty: Reduces repetition by penalising tokens that have already appeared. + tools: Function tools the model may call. Accepts ``FunctionTool`` instances or plain callables. + tool_choice: How the model picks a tool. One of ``'auto'``, ``'none'``, or ``'required'``. + response_format: Pydantic model type for structured JSON output. The response text is + parsed into the model and exposed via ``ChatResponse.value``. + instructions: Extra system-level instructions prepended to the system message. + + Not supported, and passing these raises a type error: + - ``logit_bias`` + - ``allow_multiple_tool_calls`` + - ``store`` + - ``user`` + - ``metadata`` + - ``conversation_id`` """ - thinking_config: ThinkingConfig + # Gemini's GenerationConfig options + response_schema: dict[str, Any] + """Raw JSON schema dict for structured output (alternative to ``response_format``). + Sets ``response_mime_type`` to ``'application/json'`` and passes the schema directly.""" + top_k: int - google_search_grounding: bool - google_maps_grounding: bool + """Top-K sampling: limits token selection to the K most probable tokens.""" + + thinking_config: ThinkingConfig + """Extended thinking configuration. See ``ThinkingConfig`` for available fields.""" + + # Tool options code_execution: bool - response_schema: dict[str, Any] + """Allow the model to write and run Python code in a sandboxed environment.""" - # Unsupported base options (override with None to indicate not supported) + google_search_grounding: bool | types.GoogleSearch + """Ground responses with live Google Search results. Pass ``True`` to use default settings, + or a ``types.GoogleSearch`` instance for full control (e.g. ``time_range_filter``, + ``search_types``, ``exclude_domains``).""" + + google_maps_grounding: bool | types.GoogleMaps + """Ground responses with Google Maps data. Pass ``True`` to use default settings, + or a ``types.GoogleMaps`` instance for full control (e.g. ``enable_widget``).""" + + # Unsupported base options. Override with None to indicate not supported logit_bias: None # type: ignore[misc] + """Not supported in the Gemini API.""" + allow_multiple_tool_calls: None # type: ignore[misc] + """Not supported. Gemini handles parallel tool calls automatically.""" + store: None # type: ignore[misc] + """Not supported in the Gemini API.""" + user: None # type: ignore[misc] + """Not supported in the Gemini API.""" + metadata: None # type: ignore[misc] + """Not supported in the Gemini API.""" + conversation_id: None # type: ignore[misc] + """Not supported in the Gemini API.""" GeminiChatOptionsT = TypeVar( @@ -185,8 +218,8 @@ def __init__( """Create a Gemini chat client. Args: - api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` env var. - model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` env var. + api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable. + model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` environment variable. 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, ``api_key`` is not required. @@ -494,15 +527,16 @@ def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None: """Build the Gemini tool list from options, combining function declarations and built-in tools. Args: - options: Resolved chat options containing ``tools``, ``google_search_grounding``, - ``google_maps_grounding``, and ``code_execution`` flags. + options: Resolved chat options containing ``tools``, ``google_search_grounding`` + (``bool`` or ``types.GoogleSearch``), ``google_maps_grounding`` + (``bool`` or ``types.GoogleMaps``), and ``code_execution`` flag. Returns: A list of ``types.Tool`` objects, or None if no tools are configured. """ function_tools: list[Any] = options.get("tools") or [] - include_search = options.get("google_search_grounding", False) - include_maps = options.get("google_maps_grounding", False) + search_option = options.get("google_search_grounding", False) + maps_option = options.get("google_maps_grounding", False) include_code_exec = options.get("code_execution", False) result: list[types.Tool] = [] @@ -518,10 +552,12 @@ def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None: ] if declarations: result.append(types.Tool(function_declarations=declarations)) - if include_search: - result.append(types.Tool(google_search=types.GoogleSearch())) - if include_maps: - result.append(types.Tool(google_maps=types.GoogleMaps())) + if search_option: + google_search = search_option if isinstance(search_option, types.GoogleSearch) else types.GoogleSearch() + result.append(types.Tool(google_search=google_search)) + if maps_option: + google_maps = maps_option if isinstance(maps_option, types.GoogleMaps) else types.GoogleMaps() + result.append(types.Tool(google_maps=google_maps)) if include_code_exec: result.append(types.Tool(code_execution=types.ToolCodeExecution())) From 5a839e642374d61e912a3aa38a88fc8c6a742fa6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 17:41:24 +0100 Subject: [PATCH 21/51] Update ThinkingConfig with new attributes and type --- .../gemini/agent_framework_gemini/_chat_client.py | 11 +++++++---- python/packages/gemini/tests/test_chat_client.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index afa8b8b48e..5983015f7c 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -67,14 +67,17 @@ class ThinkingConfig(TypedDict, total=False): """Extended thinking configuration for Gemini models. Attributes: - thinking_budget: Token budget for Gemini 2.5 models. Set to 0 to disable - thinking or -1 to enable a dynamic budget. + include_thoughts: Whether to include the model's reasoning thoughts in the response. + thinking_budget: Token budget for Gemini 2.5 models. Set to ``0`` to disable + thinking or ``-1`` to enable a dynamic budget. thinking_level: Thinking level for Gemini 3.x models. One of - ``'minimal'``, ``'low'``, ``'medium'``, or ``'high'``. + ``ThinkingLevel.THINKING_LEVEL_UNSPECIFIED`` (default), ``ThinkingLevel.MINIMAL``, + ``ThinkingLevel.LOW``, ``ThinkingLevel.MEDIUM``, or ``ThinkingLevel.HIGH``. """ + include_thoughts: bool thinking_budget: int - thinking_level: str + thinking_level: types.ThinkingLevel class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index c32aef7ce4..148891a981 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -555,7 +555,7 @@ async def test_thinking_config_budget() -> None: async def test_thinking_config_level() -> None: client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) - tc: ThinkingConfig = {"thinking_level": "high"} + tc: ThinkingConfig = {"thinking_level": types.ThinkingLevel.HIGH} await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Hi")])], From 6a868503550ccfd11bf02b0ee5cb340cf4a3a1e0 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 17:41:44 +0100 Subject: [PATCH 22/51] Add tests for GoogleSearch and GoogleMaps configs --- .../packages/gemini/tests/test_chat_client.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_chat_client.py index 148891a981..5ecbd03cff 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_chat_client.py @@ -862,6 +862,40 @@ async def test_google_maps_grounding_injects_tool() -> None: assert any(t.google_maps for t in config.tools) +async def test_google_search_grounding_with_config_uses_provided_instance() -> None: + """Passing a types.GoogleSearch instance forwards it directly rather than constructing a default.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + search_config = types.GoogleSearch(exclude_domains=["example.com"]) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Search")])], + options={"google_search_grounding": search_config}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + injected = next((t.google_search for t in config.tools if t.google_search is not None), None) # type: ignore[union-attr] + assert injected is search_config + + +async def test_google_maps_grounding_with_config_uses_provided_instance() -> None: + """Passing a types.GoogleMaps instance forwards it directly rather than constructing a default.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + maps_config = types.GoogleMaps(enable_widget=True) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Map")])], + options={"google_maps_grounding": maps_config}, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + assert config.tools is not None + injected = next((t.google_maps for t in config.tools if t.google_maps is not None), None) # type: ignore[union-attr] + assert injected is maps_config + + async def test_code_execution_injects_tool() -> None: client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) From 093e21138131d2159108e04a8fac9d4ed138d518 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 21:09:14 +0100 Subject: [PATCH 23/51] Suppress valid-type mypy error on GeminiChatOptionsT --- .../packages/gemini/agent_framework_gemini/_chat_client.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 5983015f7c..783635d2a1 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -158,12 +158,7 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to """Not supported in the Gemini API.""" -GeminiChatOptionsT = TypeVar( - "GeminiChatOptionsT", - bound=TypedDict, # type: ignore[misc] - default="GeminiChatOptions", - covariant=True, # type: ignore[valid-type] -) +GeminiChatOptionsT = TypeVar("GeminiChatOptionsT", bound=TypedDict, default="GeminiChatOptions", covariant=True) # type: ignore[valid-type] class GeminiSettings(TypedDict, total=False): From 49d99ea5c8db1c32292884799fa8325816ff79dd Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 23:15:05 +0100 Subject: [PATCH 24/51] Move service_url method near overrides --- .../agent_framework_gemini/_chat_client.py | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 783635d2a1..70bc66eac1 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -286,6 +286,17 @@ async def _get_response() -> ChatResponse: return _get_response() + @override + def service_url(self) -> str: + """Return the base URL of the Gemini API service. + + Returns: + The Gemini API base URL. + """ + return _GEMINI_SERVICE_URL + + # region Request preparation + def _prepare_request( self, messages: Sequence[Message], @@ -320,8 +331,6 @@ def _prepare_request( return model_id, contents, self._prepare_config(options, system_instruction) - # region Message preparation - def _prepare_gemini_messages(self, messages: Sequence[Message]) -> tuple[str | None, list[types.Content]]: """Convert framework messages to Gemini contents and extract system instruction. @@ -468,10 +477,6 @@ def _coerce_to_dict(value: Any) -> dict[str, Any]: return {"result": ""} return {"result": str(value)} - # endregion - - # region Config preparation - def _prepare_config( self, options: Mapping[str, Any], @@ -736,15 +741,6 @@ def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None: # endregion - @override - def service_url(self) -> str: - """Return the base URL of the Gemini API service. - - Returns: - The Gemini API base URL. - """ - return _GEMINI_SERVICE_URL - @staticmethod def _generate_tool_call_id() -> str: """Generate a unique fallback ID for tool calls that lack one. From 2add366712126bf3becaabfee4c9fc79288225d6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 23:29:08 +0100 Subject: [PATCH 25/51] Order _prepare_config kwargs by base then Gemini-specific --- .../agent_framework_gemini/_chat_client.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 70bc66eac1..320bbe5b43 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -493,14 +493,15 @@ def _prepare_config( """ kwargs: dict[str, Any] = {} + # Base ChatOptions fields if system_instruction: kwargs["system_instruction"] = system_instruction if (v := options.get("temperature")) is not None: kwargs["temperature"] = v - if (v := options.get("max_tokens")) is not None: - kwargs["max_output_tokens"] = v if (v := options.get("top_p")) is not None: kwargs["top_p"] = v + if (v := options.get("max_tokens")) is not None: + kwargs["max_output_tokens"] = v if (v := options.get("stop")) is not None: kwargs["stop_sequences"] = v if (v := options.get("seed")) is not None: @@ -509,20 +510,22 @@ def _prepare_config( kwargs["frequency_penalty"] = v if (v := options.get("presence_penalty")) is not None: kwargs["presence_penalty"] = v + if options.get("response_format"): + kwargs["response_mime_type"] = "application/json" + if tools := self._prepare_tools(options): + kwargs["tools"] = tools + if tool_config := self._prepare_tool_config(options.get("tool_choice")): + kwargs["tool_config"] = tool_config + # Gemini-specific fields + if schema := options.get("response_schema"): + kwargs["response_mime_type"] = "application/json" + kwargs["response_schema"] = schema if (v := options.get("top_k")) is not None: kwargs["top_k"] = v if thinking_config := options.get("thinking_config"): thinking_config_kwargs = {k: v for k, v in thinking_config.items() if v is not None} if thinking_config_kwargs: kwargs["thinking_config"] = types.ThinkingConfig(**thinking_config_kwargs) - if options.get("response_format") or options.get("response_schema"): - kwargs["response_mime_type"] = "application/json" - if schema := options.get("response_schema"): - kwargs["response_schema"] = schema - if tools := self._prepare_tools(options): - kwargs["tools"] = tools - if tool_config := self._prepare_tool_config(options.get("tool_choice")): - kwargs["tool_config"] = tool_config return types.GenerateContentConfig(**kwargs) From 03ee02274f5cb51bb710616dab84258bb2f01dd8 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 23:35:06 +0100 Subject: [PATCH 26/51] Use FunctionCallingConfigMode for clarity and type safety --- .../packages/gemini/agent_framework_gemini/_chat_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 320bbe5b43..a07871c6a2 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -585,11 +585,11 @@ def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None: match tool_mode.get("mode"): case "auto": - function_calling_mode, allowed_names = "AUTO", None + function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None case "none": - function_calling_mode, allowed_names = "NONE", None + function_calling_mode, allowed_names = types.FunctionCallingConfigMode.NONE, None case "required": - function_calling_mode = "ANY" + function_calling_mode = types.FunctionCallingConfigMode.ANY name = tool_mode.get("required_function_name") allowed_names = [name] if name else None case unknown_mode: From b9fa07d193e2497790ce8fd519addd9a3685aa74 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Mon, 23 Mar 2026 23:49:02 +0100 Subject: [PATCH 27/51] Fix code_execution doc --- python/packages/gemini/agent_framework_gemini/_chat_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index a07871c6a2..b1a9eb7d2d 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -127,7 +127,7 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to # Tool options code_execution: bool - """Allow the model to write and run Python code in a sandboxed environment.""" + """Allow the model to write and run code in a sandboxed environment.""" google_search_grounding: bool | types.GoogleSearch """Ground responses with live Google Search results. Pass ``True`` to use default settings, From cb8d1e7ac52b9387574f429df32d70a189777024 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 13:35:23 +0100 Subject: [PATCH 28/51] Add agent-framework-gemini to project dependencies --- python/packages/core/pyproject.toml | 3 ++- python/pyproject.toml | 7 ++++--- python/uv.lock | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index a94d5c6f1a..78c08e0734 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -45,16 +45,17 @@ all = [ "agent-framework-ag-ui", "agent-framework-azure-ai-search", "agent-framework-anthropic", - "agent-framework-claude", "agent-framework-azure-ai", "agent-framework-azurefunctions", "agent-framework-bedrock", "agent-framework-chatkit", + "agent-framework-claude", "agent-framework-copilotstudio", "agent-framework-declarative", "agent-framework-devui", "agent-framework-durabletask", "agent-framework-foundry-local", + "agent-framework-gemini", "agent-framework-github-copilot; python_version >= '3.11'", "agent-framework-lab", "agent-framework-mem0", diff --git a/python/pyproject.toml b/python/pyproject.toml index f955062de1..408284e7cc 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -70,19 +70,20 @@ agent-framework-azure-ai = { workspace = true } agent-framework-azurefunctions = { workspace = true } agent-framework-bedrock = { workspace = true } agent-framework-chatkit = { workspace = true } +agent-framework-claude = { workspace = true } agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-durabletask = { workspace = true } agent-framework-foundry-local = { workspace = true } +agent-framework-gemini = { workspace = true } +agent-framework-github-copilot = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } agent-framework-ollama = { workspace = true } +agent-framework-orchestrations = { workspace = true } agent-framework-purview = { workspace = true } agent-framework-redis = { workspace = true } -agent-framework-github-copilot = { workspace = true } -agent-framework-claude = { workspace = true } -agent-framework-orchestrations = { workspace = true } [tool.ruff] line-length = 120 diff --git a/python/uv.lock b/python/uv.lock index b6af46120d..47ae87fe41 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -364,6 +364,7 @@ all = [ { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-gemini", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-github-copilot", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -389,6 +390,7 @@ requires-dist = [ { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-durabletask", marker = "extra == 'all'", editable = "packages/durabletask" }, { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, + { name = "agent-framework-gemini", marker = "extra == 'all'", editable = "packages/gemini" }, { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/github_copilot" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, From 3bddddff41ccaee74f3b9572a38db424a221c678 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 14:52:01 +0100 Subject: [PATCH 29/51] Remove package from core dependencies Initial release will be done without agent-framework-gemini in core[all]. --- python/packages/core/pyproject.toml | 1 - python/uv.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 78c08e0734..20b696fef7 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -55,7 +55,6 @@ all = [ "agent-framework-devui", "agent-framework-durabletask", "agent-framework-foundry-local", - "agent-framework-gemini", "agent-framework-github-copilot; python_version >= '3.11'", "agent-framework-lab", "agent-framework-mem0", diff --git a/python/uv.lock b/python/uv.lock index 47ae87fe41..b6af46120d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -364,7 +364,6 @@ all = [ { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-durabletask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-foundry-local", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agent-framework-gemini", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-github-copilot", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -390,7 +389,6 @@ requires-dist = [ { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-durabletask", marker = "extra == 'all'", editable = "packages/durabletask" }, { name = "agent-framework-foundry-local", marker = "extra == 'all'", editable = "packages/foundry_local" }, - { name = "agent-framework-gemini", marker = "extra == 'all'", editable = "packages/gemini" }, { name = "agent-framework-github-copilot", marker = "python_full_version >= '3.11' and extra == 'all'", editable = "packages/github_copilot" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, From eba6ded80e2c0446ed84caaa1e38869bcaf0f43a Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 15:13:25 +0100 Subject: [PATCH 30/51] Move integration tests into one file --- .../tests/test_chat_client_integration.py | 171 ------------------ ...t_chat_client.py => test_gemini_client.py} | 167 ++++++++++++++++- 2 files changed, 166 insertions(+), 172 deletions(-) delete mode 100644 python/packages/gemini/tests/test_chat_client_integration.py rename python/packages/gemini/tests/{test_chat_client.py => test_gemini_client.py} (88%) diff --git a/python/packages/gemini/tests/test_chat_client_integration.py b/python/packages/gemini/tests/test_chat_client_integration.py deleted file mode 100644 index 6843ac9b9b..0000000000 --- a/python/packages/gemini/tests/test_chat_client_integration.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from __future__ import annotations - -import os - -import pytest -from agent_framework import Content, FunctionTool, Message -from pydantic import BaseModel - -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig - -skip_if_no_api_key = pytest.mark.skipif( - not os.getenv("GEMINI_API_KEY"), - reason="GEMINI_API_KEY not set; skipping integration tests.", -) - -_MODEL = "gemini-2.5-flash" - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_basic_chat() -> None: - """Basic request/response round-trip returns a non-empty text reply.""" - client = GeminiChatClient(model_id=_MODEL) - response = await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Reply with the single word: hello")])] - ) - - assert response.messages - assert response.messages[0].text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_streaming() -> None: - """Streaming yields multiple chunks that together form a non-empty response.""" - client = GeminiChatClient(model_id=_MODEL) - stream = client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Count from 1 to 5.")])], - stream=True, - ) - - chunks = [update async for update in stream] - assert len(chunks) > 0 - full_text = "".join(u.text or "" for u in chunks) - assert full_text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_tool_calling() -> None: - """Model invokes the registered tool when asked a question that requires it.""" - - def get_temperature(city: str) -> str: - """Return the current temperature for a city.""" - return f"22°C in {city}" - - tool = FunctionTool(name="get_temperature", func=get_temperature) - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("What is the temperature in Berlin?")])], - options={"tools": [tool], "tool_choice": "required"}, - ) - - function_calls = [c for c in response.messages[0].contents if c.type == "function_call"] - assert len(function_calls) >= 1 - assert function_calls[0].name == "get_temperature" - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_thinking_config() -> None: - """Model accepts a thinking budget and returns a non-empty text reply.""" - options: GeminiChatOptions = {"thinking_config": ThinkingConfig(thinking_budget=512)} - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("What is 17 * 34?")])], - options=options, - ) - - assert response.messages - assert response.messages[0].text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_google_search_grounding() -> None: - """Google Search grounding returns a non-empty response for a current-events question.""" - options: GeminiChatOptions = {"google_search_grounding": True} - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("What is the latest stable version of Python?")])], - options=options, - ) - - assert response.messages - assert response.messages[0].text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_code_execution() -> None: - """Code execution tool produces a non-empty response for a computation request.""" - options: GeminiChatOptions = {"code_execution": True} - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[ - Message( - role="user", - contents=[Content.from_text("Compute the sum of the first 100 natural numbers using code.")], - ) - ], - options=options, - ) - - assert response.messages - assert response.messages[0].text - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_structured_output() -> None: - """Structured output with a Pydantic response_format returns a parsed value via response.value.""" - - class Answer(BaseModel): - answer: str - - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("What is the capital of Germany?")])], - options={"response_format": Answer}, - ) - - assert response.value is not None - assert isinstance(response.value, Answer) - assert response.value.answer - - -@pytest.mark.flaky -@pytest.mark.integration -@skip_if_no_api_key -async def test_integration_google_maps_grounding() -> None: - """Google Maps grounding returns a non-empty response for a location-based question.""" - options: GeminiChatOptions = {"google_maps_grounding": True} - client = GeminiChatClient(model_id=_MODEL) - - response = await client.get_response( - messages=[ - Message( - role="user", - contents=[Content.from_text("What are some highly rated restaurants in Karlsruhe city center?")], - ) - ], - options=options, - ) - - assert response.messages - assert response.messages[0].text diff --git a/python/packages/gemini/tests/test_chat_client.py b/python/packages/gemini/tests/test_gemini_client.py similarity index 88% rename from python/packages/gemini/tests/test_chat_client.py rename to python/packages/gemini/tests/test_gemini_client.py index 5ecbd03cff..dea07d7763 100644 --- a/python/packages/gemini/tests/test_chat_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -3,14 +3,23 @@ from __future__ import annotations import logging +import os from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import Content, FunctionTool, Message from google.genai import types +from pydantic import BaseModel -from agent_framework_gemini import GeminiChatClient, ThinkingConfig +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig + +skip_if_no_api_key = pytest.mark.skipif( + not os.getenv("GEMINI_API_KEY"), + reason="GEMINI_API_KEY not set; skipping integration tests.", +) + +_TEST_MODEL = "gemini-2.5-flash" # stub helpers @@ -1086,3 +1095,159 @@ async def test_empty_candidates_in_stream_does_not_raise(candidates: list | None def test_service_url() -> None: client, _ = _make_gemini_client() assert client.service_url() == "https://generativelanguage.googleapis.com" + + +# integration tests + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_basic_chat() -> None: + """Basic request/response round-trip returns a non-empty text reply.""" + client = GeminiChatClient(model_id=_TEST_MODEL) + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Reply with the single word: hello")])] + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_streaming() -> None: + """Streaming yields multiple chunks that together form a non-empty response.""" + client = GeminiChatClient(model_id=_TEST_MODEL) + stream = client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Count from 1 to 5.")])], + stream=True, + ) + + chunks = [update async for update in stream] + assert len(chunks) > 0 + full_text = "".join(u.text or "" for u in chunks) + assert full_text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_structured_output() -> None: + """Structured output with a Pydantic response_format returns a parsed value via response.value.""" + + class Answer(BaseModel): + answer: str + + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the capital of Germany?")])], + options={"response_format": Answer}, + ) + + assert response.value is not None + assert isinstance(response.value, Answer) + assert response.value.answer + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_tool_calling() -> None: + """Model invokes the registered tool when asked a question that requires it.""" + + def get_temperature(city: str) -> str: + """Return the current temperature for a city.""" + return f"22°C in {city}" + + tool = FunctionTool(name="get_temperature", func=get_temperature) + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the temperature in Berlin?")])], + options={"tools": [tool], "tool_choice": "required"}, + ) + + function_calls = [c for c in response.messages[0].contents if c.type == "function_call"] + assert len(function_calls) >= 1 + assert function_calls[0].name == "get_temperature" + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_thinking_config() -> None: + """Model accepts a thinking budget and returns a non-empty text reply.""" + options: GeminiChatOptions = {"thinking_config": ThinkingConfig(thinking_budget=512)} + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is 17 * 34?")])], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_google_search_grounding() -> None: + """Google Search grounding returns a non-empty response for a current-events question.""" + options: GeminiChatOptions = {"google_search_grounding": True} + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("What is the latest stable version of Python?")])], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_google_maps_grounding() -> None: + """Google Maps grounding returns a non-empty response for a location-based question.""" + options: GeminiChatOptions = {"google_maps_grounding": True} + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[ + Message( + role="user", + contents=[Content.from_text("What are some highly rated restaurants in Karlsruhe city center?")], + ) + ], + options=options, + ) + + assert response.messages + assert response.messages[0].text + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_no_api_key +async def test_integration_code_execution() -> None: + """Code execution tool produces a non-empty response for a computation request.""" + options: GeminiChatOptions = {"code_execution": True} + client = GeminiChatClient(model_id=_TEST_MODEL) + + response = await client.get_response( + messages=[ + Message( + role="user", + contents=[Content.from_text("Compute the sum of the first 100 natural numbers using code.")], + ) + ], + options=options, + ) + + assert response.messages + assert response.messages[0].text From 901fc44e5b00f29371cc78a1e8a65db8d3164b8a Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 15:13:41 +0100 Subject: [PATCH 31/51] Remove __init__.py file from gemini tests directory --- python/packages/gemini/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 python/packages/gemini/tests/__init__.py diff --git a/python/packages/gemini/tests/__init__.py b/python/packages/gemini/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From e81e6f417ff702418819d9001b2d85115aeb4897 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 15:30:31 +0100 Subject: [PATCH 32/51] Introduce RawGeminiChatClient as lightweight chat client Updated GeminiChatClient to inherit from RawGeminiChatClient, maintaining full functionality with added features. --- .../gemini/agent_framework_gemini/__init__.py | 3 +- .../agent_framework_gemini/_chat_client.py | 70 +++++++++++++++---- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/__init__.py b/python/packages/gemini/agent_framework_gemini/__init__.py index acf8a70103..42099ae0b1 100644 --- a/python/packages/gemini/agent_framework_gemini/__init__.py +++ b/python/packages/gemini/agent_framework_gemini/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._chat_client import GeminiChatClient, GeminiChatOptions, GeminiSettings, ThinkingConfig +from ._chat_client import GeminiChatClient, GeminiChatOptions, GeminiSettings, RawGeminiChatClient, ThinkingConfig try: __version__ = importlib.metadata.version(__name__) @@ -13,6 +13,7 @@ "GeminiChatClient", "GeminiChatOptions", "GeminiSettings", + "RawGeminiChatClient", "ThinkingConfig", "__version__", ] diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index b1a9eb7d2d..480a881eab 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -54,6 +54,7 @@ "GeminiChatClient", "GeminiChatOptions", "GeminiSettings", + "RawGeminiChatClient", "ThinkingConfig", ] @@ -190,14 +191,16 @@ class GeminiSettings(TypedDict, total=False): } -class GeminiChatClient( - FunctionInvocationLayer[GeminiChatOptionsT], - ChatMiddlewareLayer[GeminiChatOptionsT], - ChatTelemetryLayer[GeminiChatOptionsT], +class RawGeminiChatClient( BaseChatClient[GeminiChatOptionsT], Generic[GeminiChatOptionsT], ): - """Async chat client for the Google Gemini API with middleware, function invocation and telemetry.""" + """A raw Gemini chat client for the Google Gemini API without function invocation, middleware or telemetry. + + Use this when you want full control over the request pipeline. For instance, to opt out of + telemetry, use custom middleware, or compose your own layers. If you want the full-featured + client with batteries included, use `GeminiChatClient` instead. + """ OTEL_PROVIDER_NAME: ClassVar[str] = "gcp.gemini" # type: ignore[reportIncompatibleVariableOverride, misc] @@ -210,10 +213,8 @@ def __init__( env_file_encoding: str | None = None, client: genai.Client | None = None, additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, ) -> None: - """Create a Gemini chat client. + """Create a raw Gemini chat client. Args: api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable. @@ -222,8 +223,6 @@ def __init__( env_file_encoding: Encoding for the ``.env`` file. client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required. additional_properties: Extra properties stored on the client instance. - middleware: Optional middleware chain. - function_invocation_configuration: Optional function invocation configuration. """ settings = load_settings( GeminiSettings, @@ -249,11 +248,7 @@ def __init__( self.model_id = settings.get("chat_model_id") - super().__init__( - additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, - ) + super().__init__(additional_properties=additional_properties) @override def _inner_get_response( @@ -752,3 +747,48 @@ def _generate_tool_call_id() -> str: A unique string in the format ``tool-call-``. """ return f"tool-call-{uuid4().hex}" + + +class GeminiChatClient( + FunctionInvocationLayer[GeminiChatOptionsT], + ChatMiddlewareLayer[GeminiChatOptionsT], + ChatTelemetryLayer[GeminiChatOptionsT], + RawGeminiChatClient[GeminiChatOptionsT], + Generic[GeminiChatOptionsT], +): + """Gemini chat client for the Google Gemini API with function invocation, middleware and telemetry.""" + + def __init__( + self, + *, + api_key: str | None = None, + model_id: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + client: genai.Client | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + ) -> None: + """Create a Gemini chat client. + + Args: + api_key: The Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable. + model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` environment variable. + 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, ``api_key`` is not required. + 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. + """ + super().__init__( + api_key=api_key, + model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + client=client, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) From 6f29d452f2d223da6493d239c913ff53d5f60058 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 15:59:07 +0100 Subject: [PATCH 33/51] Updated variable names from `model_id` to `model` Across the codebase, including environment variables and client initialization. Adjusted related tests and sample scripts to reflect this change, ensuring consistency in the usage of the Gemini model identifier. --- python/.env.example | 2 +- .../agent_framework_gemini/_chat_client.py | 42 ++++++++--------- .../gemini/tests/test_gemini_client.py | 46 +++++++++---------- .../02-agents/providers/google/README.md | 2 +- .../providers/google/gemini_advanced.py | 2 +- .../providers/google/gemini_basic.py | 2 +- .../google/gemini_with_code_execution.py | 2 +- .../google/gemini_with_google_maps.py | 2 +- .../google/gemini_with_google_search.py | 2 +- 9 files changed, 50 insertions(+), 52 deletions(-) diff --git a/python/.env.example b/python/.env.example index ad6f9b2423..ad173125e2 100644 --- a/python/.env.example +++ b/python/.env.example @@ -31,7 +31,7 @@ ANTHROPIC_API_KEY="" ANTHROPIC_MODEL="" # Google Gemini GEMINI_API_KEY="" -GEMINI_CHAT_MODEL_ID="" +GEMINI_MODEL="" # Ollama OLLAMA_ENDPOINT="" OLLAMA_MODEL="" diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 480a881eab..d09f7556b5 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -92,7 +92,7 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to See: https://ai.google.dev/api/generate-content#generationconfig Inherited fields from ``ChatOptions``: - model_id: Model to use for this call (e.g. ``"gemini-2.5-flash"``). + model: Model to use for this call (e.g. ``"gemini-2.5-flash"``). temperature: Controls randomness. Higher values produce more varied output. max_tokens: Maximum number of tokens to generate (``maxOutputTokens``). top_p: Nucleus sampling cutoff. Only tokens within the top-p probability mass are considered. @@ -166,7 +166,7 @@ class GeminiSettings(TypedDict, total=False): """Gemini configuration settings loaded from environment or .env files.""" api_key: SecretString | None - chat_model_id: str | None + model: str | None # endregion @@ -208,7 +208,7 @@ def __init__( self, *, api_key: str | None = None, - model_id: str | None = None, + model: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, client: genai.Client | None = None, @@ -218,7 +218,7 @@ def __init__( Args: api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable. - model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` environment variable. + model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable. 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, ``api_key`` is not required. @@ -228,7 +228,7 @@ def __init__( GeminiSettings, env_prefix="GEMINI_", api_key=api_key, - chat_model_id=model_id, + model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) @@ -246,7 +246,7 @@ def __init__( http_options={"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}}, ) - self.model_id = settings.get("chat_model_id") + self.model = settings.get("model") super().__init__(additional_properties=additional_properties) @@ -263,9 +263,9 @@ def _inner_get_response( async def _stream() -> AsyncIterable[ChatResponseUpdate]: validated = await self._validate_options(options) - model_id, contents, config = self._prepare_request(messages, validated) + model, contents, config = self._prepare_request(messages, validated) async for chunk in await self._genai_client.aio.models.generate_content_stream( - model=model_id, + model=model, contents=contents, # type: ignore[arg-type] config=config, ): @@ -275,8 +275,8 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: async def _get_response() -> ChatResponse: validated = await self._validate_options(options) - model_id, contents, config = self._prepare_request(messages, validated) - raw = await self._genai_client.aio.models.generate_content(model=model_id, contents=contents, config=config) # type: ignore[arg-type] + model, contents, config = self._prepare_request(messages, validated) + raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type] return self._process_generate_response(raw, response_format=validated.get("response_format")) return _get_response() @@ -307,16 +307,14 @@ def _prepare_request( options: Validated and normalized chat options. Returns: - A tuple of the resolved model ID, the Gemini contents list, and the generation config. + A tuple of the resolved model, the Gemini contents list, and the generation config. Raises: - ValueError: If no model ID is set on the options or the client instance. + ValueError: If no model is set on the options or the client instance. """ - model_id = options.get("model_id") or self.model_id - if not model_id: - raise ValueError( - "Gemini model_id is required. Set via model_id parameter or GEMINI_CHAT_MODEL_ID environment variable." - ) + model = options.get("model") or self.model + if not model: + raise ValueError("Gemini model is required. Set via model parameter or GEMINI_MODEL environment variable.") system_instruction, contents = self._prepare_gemini_messages(messages) if call_instructions := options.get("instructions"): @@ -324,7 +322,7 @@ def _prepare_request( f"{call_instructions}\n{system_instruction}" if system_instruction else call_instructions ) - return model_id, contents, self._prepare_config(options, system_instruction) + return model, contents, self._prepare_config(options, system_instruction) def _prepare_gemini_messages(self, messages: Sequence[Message]) -> tuple[str | None, list[types.Content]]: """Convert framework messages to Gemini contents and extract system instruction. @@ -625,7 +623,7 @@ def _process_generate_response( response_id=None, messages=[Message(role="assistant", contents=contents, raw_representation=candidate)], usage_details=self._parse_usage(response.usage_metadata), - model_id=response.model_version or self.model_id, + model_id=response.model_version or self.model, finish_reason=self._map_finish_reason( candidate.finish_reason.name if candidate and candidate.finish_reason else None ), @@ -762,7 +760,7 @@ def __init__( self, *, api_key: str | None = None, - model_id: str | None = None, + model: str | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, client: genai.Client | None = None, @@ -774,7 +772,7 @@ def __init__( Args: api_key: The Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable. - model_id: Default model identifier. Falls back to ``GEMINI_CHAT_MODEL_ID`` environment variable. + model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable. 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, ``api_key`` is not required. @@ -784,7 +782,7 @@ def __init__( """ super().__init__( api_key=api_key, - model_id=model_id, + model=model, env_file_path=env_file_path, env_file_encoding=env_file_encoding, client=client, diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index dea07d7763..07c511232e 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -93,42 +93,42 @@ async def _async_iter(items: list[Any]): def _make_gemini_client( - model_id: str = "gemini-2.5-flash", + model: str = "gemini-2.5-flash", mock_client: MagicMock | None = None, ) -> tuple[GeminiChatClient, MagicMock]: """Return a (GeminiChatClient, mock_genai_client) pair.""" mock = mock_client or MagicMock() - client = GeminiChatClient(client=mock, model_id=model_id) + client = GeminiChatClient(client=mock, model=model) return client, mock # settings & initialisation -def test_model_id_stored_on_instance() -> None: - client, _ = _make_gemini_client(model_id="gemini-2.5-pro") - assert client.model_id == "gemini-2.5-pro" +def test_model_stored_on_instance() -> None: + client, _ = _make_gemini_client(model="gemini-2.5-pro") + assert client.model == "gemini-2.5-pro" def test_client_created_from_api_key(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GEMINI_API_KEY", "test-key-123") - client = GeminiChatClient(model_id="gemini-2.5-flash") - assert client.model_id == "gemini-2.5-flash" + client = GeminiChatClient(model="gemini-2.5-flash") + assert client.model == "gemini-2.5-flash" def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GEMINI_API_KEY", raising=False) - monkeypatch.delenv("GEMINI_CHAT_MODEL_ID", raising=False) + monkeypatch.delenv("GEMINI_MODEL", raising=False) with pytest.raises(ValueError, match="GEMINI_API_KEY"): - GeminiChatClient(model_id="gemini-2.5-flash") + GeminiChatClient(model="gemini-2.5-flash") -async def test_missing_model_id_raises_on_get_response() -> None: - client, mock = _make_gemini_client(model_id=None) # type: ignore[arg-type] +async def test_missing_model_raises_on_get_response() -> None: + client, mock = _make_gemini_client(model=None) # type: ignore[arg-type] mock.aio.models.generate_content = AsyncMock() - with pytest.raises(ValueError, match="model_id"): + with pytest.raises(ValueError, match="model"): await client.get_response(messages=[Message(role="user", contents=[Content.from_text("hi")])]) @@ -155,13 +155,13 @@ async def test_get_response_model_id_from_response() -> None: assert response.model_id == "gemini-2.5-pro-002" -async def test_get_response_uses_model_id_from_options() -> None: - client, mock = _make_gemini_client(model_id="gemini-2.5-flash") +async def test_get_response_uses_model_from_options() -> None: + client, mock = _make_gemini_client(model="gemini-2.5-flash") mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Hi")])], - options={"model_id": "gemini-2.5-pro"}, + options={"model": "gemini-2.5-pro"}, ) call_kwargs = mock.aio.models.generate_content.call_args.kwargs @@ -1105,7 +1105,7 @@ def test_service_url() -> None: @skip_if_no_api_key async def test_integration_basic_chat() -> None: """Basic request/response round-trip returns a non-empty text reply.""" - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("Reply with the single word: hello")])] ) @@ -1119,7 +1119,7 @@ async def test_integration_basic_chat() -> None: @skip_if_no_api_key async def test_integration_streaming() -> None: """Streaming yields multiple chunks that together form a non-empty response.""" - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) stream = client.get_response( messages=[Message(role="user", contents=[Content.from_text("Count from 1 to 5.")])], stream=True, @@ -1140,7 +1140,7 @@ async def test_integration_structured_output() -> None: class Answer(BaseModel): answer: str - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is the capital of Germany?")])], @@ -1163,7 +1163,7 @@ def get_temperature(city: str) -> str: return f"22°C in {city}" tool = FunctionTool(name="get_temperature", func=get_temperature) - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is the temperature in Berlin?")])], @@ -1181,7 +1181,7 @@ def get_temperature(city: str) -> str: async def test_integration_thinking_config() -> None: """Model accepts a thinking budget and returns a non-empty text reply.""" options: GeminiChatOptions = {"thinking_config": ThinkingConfig(thinking_budget=512)} - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is 17 * 34?")])], @@ -1198,7 +1198,7 @@ async def test_integration_thinking_config() -> None: async def test_integration_google_search_grounding() -> None: """Google Search grounding returns a non-empty response for a current-events question.""" options: GeminiChatOptions = {"google_search_grounding": True} - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is the latest stable version of Python?")])], @@ -1215,7 +1215,7 @@ async def test_integration_google_search_grounding() -> None: async def test_integration_google_maps_grounding() -> None: """Google Maps grounding returns a non-empty response for a location-based question.""" options: GeminiChatOptions = {"google_maps_grounding": True} - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[ @@ -1237,7 +1237,7 @@ async def test_integration_google_maps_grounding() -> None: async def test_integration_code_execution() -> None: """Code execution tool produces a non-empty response for a computation request.""" options: GeminiChatOptions = {"code_execution": True} - client = GeminiChatClient(model_id=_TEST_MODEL) + client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[ diff --git a/python/samples/02-agents/providers/google/README.md b/python/samples/02-agents/providers/google/README.md index b0f243d5a7..28fb05abeb 100644 --- a/python/samples/02-agents/providers/google/README.md +++ b/python/samples/02-agents/providers/google/README.md @@ -15,4 +15,4 @@ This folder contains examples demonstrating how to use Google Gemini models with ## Environment Variables - `GEMINI_API_KEY`: Your Google AI Studio API key (get one from [Google AI Studio](https://aistudio.google.com/apikey)) -- `GEMINI_CHAT_MODEL_ID`: The Gemini model to use (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`) +- `GEMINI_MODEL`: The Gemini model to use (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`) diff --git a/python/samples/02-agents/providers/google/gemini_advanced.py b/python/samples/02-agents/providers/google/gemini_advanced.py index 9f0c80074f..03ab9ad80d 100644 --- a/python/samples/02-agents/providers/google/gemini_advanced.py +++ b/python/samples/02-agents/providers/google/gemini_advanced.py @@ -6,7 +6,7 @@ Requires the following environment variables to be set: - GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID +- GEMINI_MODEL """ import asyncio diff --git a/python/samples/02-agents/providers/google/gemini_basic.py b/python/samples/02-agents/providers/google/gemini_basic.py index a596e2e704..d3dc253c14 100644 --- a/python/samples/02-agents/providers/google/gemini_basic.py +++ b/python/samples/02-agents/providers/google/gemini_basic.py @@ -6,7 +6,7 @@ Requires the following environment variables to be set: - GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID +- GEMINI_MODEL """ import asyncio diff --git a/python/samples/02-agents/providers/google/gemini_with_code_execution.py b/python/samples/02-agents/providers/google/gemini_with_code_execution.py index f6e38e670a..dd73ad6c75 100644 --- a/python/samples/02-agents/providers/google/gemini_with_code_execution.py +++ b/python/samples/02-agents/providers/google/gemini_with_code_execution.py @@ -6,7 +6,7 @@ Requires the following environment variables to be set: - GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID +- GEMINI_MODEL """ import asyncio diff --git a/python/samples/02-agents/providers/google/gemini_with_google_maps.py b/python/samples/02-agents/providers/google/gemini_with_google_maps.py index b242c05703..375bd23732 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_maps.py +++ b/python/samples/02-agents/providers/google/gemini_with_google_maps.py @@ -6,7 +6,7 @@ Requires the following environment variables to be set: - GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID +- GEMINI_MODEL """ import asyncio diff --git a/python/samples/02-agents/providers/google/gemini_with_google_search.py b/python/samples/02-agents/providers/google/gemini_with_google_search.py index 5531c38775..aed53fc8fd 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_search.py +++ b/python/samples/02-agents/providers/google/gemini_with_google_search.py @@ -6,7 +6,7 @@ Requires the following environment variables to be set: - GEMINI_API_KEY -- GEMINI_CHAT_MODEL_ID +- GEMINI_MODEL """ import asyncio From 60a4ade5456f139fe397de6aff60e25640e3b0cd Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 24 Mar 2026 16:14:53 +0100 Subject: [PATCH 34/51] Update AGENTS.md --- python/packages/gemini/AGENTS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/python/packages/gemini/AGENTS.md b/python/packages/gemini/AGENTS.md index 30a3c2ebb3..e50dc06d66 100644 --- a/python/packages/gemini/AGENTS.md +++ b/python/packages/gemini/AGENTS.md @@ -4,7 +4,8 @@ Integration with Google's Gemini API via the `google-genai` SDK. ## Core Classes -- **`GeminiChatClient`** - Chat client for Google Gemini models +- **`RawGeminiChatClient`** - Lightweight chat client without any layers, for custom pipeline composition +- **`GeminiChatClient`** - Full-featured chat client with function invocation, middleware, and telemetry - **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters - **`GeminiSettings`** - Settings loaded from environment variables - **`ThinkingConfig`** - Configuration for extended thinking @@ -12,9 +13,9 @@ Integration with Google's Gemini API via the `google-genai` SDK. ## Gemini Options - **`thinking_config`** - Enable extended thinking via `ThinkingConfig` +- **`code_execution`** - Let the model write and run code in a sandboxed environment - **`google_search_grounding`** - Responses with live Google Search results - **`google_maps_grounding`** - Responses with Google Maps data -- **`code_execution`** - Let the model write and run code in a sandboxed environment ## Usage @@ -22,6 +23,6 @@ Integration with Google's Gemini API via the `google-genai` SDK. from agent_framework import Content, Message from agent_framework_gemini import GeminiChatClient -client = GeminiChatClient(model_id="gemini-2.5-flash") +client = GeminiChatClient(model="gemini-2.5-flash") response = await client.get_response([Message(role="user", contents=[Content.from_text("Hello")])]) ``` From fb931c0009fe38ccc9c508f7ea3facd8b5ceb4f6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 14:55:20 +0200 Subject: [PATCH 35/51] Update Gemini package to alpha status --- python/PACKAGE_STATUS.md | 1 + python/packages/gemini/pyproject.toml | 7 +++++-- .../google => packages/gemini/samples}/README.md | 0 python/packages/gemini/samples/__init__.py | 0 .../gemini/samples}/gemini_advanced.py | 9 +++++---- .../google => packages/gemini/samples}/gemini_basic.py | 10 ++++++---- .../gemini/samples}/gemini_with_code_execution.py | 10 ++++++---- .../gemini/samples}/gemini_with_google_maps.py | 10 ++++++---- .../gemini/samples}/gemini_with_google_search.py | 10 ++++++---- python/uv.lock | 2 +- 10 files changed, 36 insertions(+), 23 deletions(-) rename python/{samples/02-agents/providers/google => packages/gemini/samples}/README.md (100%) create mode 100644 python/packages/gemini/samples/__init__.py rename python/{samples/02-agents/providers/google => packages/gemini/samples}/gemini_advanced.py (89%) rename python/{samples/02-agents/providers/google => packages/gemini/samples}/gemini_basic.py (92%) rename python/{samples/02-agents/providers/google => packages/gemini/samples}/gemini_with_code_execution.py (83%) rename python/{samples/02-agents/providers/google => packages/gemini/samples}/gemini_with_google_maps.py (85%) rename python/{samples/02-agents/providers/google => packages/gemini/samples}/gemini_with_google_search.py (84%) diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 7a726812ff..e6b5f403ce 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -31,6 +31,7 @@ Status is grouped into these buckets: | `agent-framework-durabletask` | `python/packages/durabletask` | `beta` | | `agent-framework-foundry` | `python/packages/foundry` | `released` | | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | +| `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index b8fb764c48..2185378a62 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260319" +version = "1.0.0a260410" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Beta", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", @@ -53,6 +53,9 @@ timeout = 120 [tool.ruff] extend = "../../pyproject.toml" +[tool.ruff.lint.extend-per-file-ignores] +"samples/**" = ["S", "T201"] + [tool.coverage.run] omit = [ "**/__init__.py" diff --git a/python/samples/02-agents/providers/google/README.md b/python/packages/gemini/samples/README.md similarity index 100% rename from python/samples/02-agents/providers/google/README.md rename to python/packages/gemini/samples/README.md diff --git a/python/packages/gemini/samples/__init__.py b/python/packages/gemini/samples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/samples/02-agents/providers/google/gemini_advanced.py b/python/packages/gemini/samples/gemini_advanced.py similarity index 89% rename from python/samples/02-agents/providers/google/gemini_advanced.py rename to python/packages/gemini/samples/gemini_advanced.py index 03ab9ad80d..a8afbecbd2 100644 --- a/python/samples/02-agents/providers/google/gemini_advanced.py +++ b/python/packages/gemini/samples/gemini_advanced.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Shows how to enable extended thinking with ThinkingConfig so the model can -reason through complex problems before responding. +"""Shows how to enable extended thinking with ThinkingConfig. + +Allows the model to reason through complex problems before responding. Requires the following environment variables to be set: - GEMINI_API_KEY @@ -12,9 +12,10 @@ import asyncio from agent_framework import Agent -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig from dotenv import load_dotenv +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig + load_dotenv() diff --git a/python/samples/02-agents/providers/google/gemini_basic.py b/python/packages/gemini/samples/gemini_basic.py similarity index 92% rename from python/samples/02-agents/providers/google/gemini_basic.py rename to python/packages/gemini/samples/gemini_basic.py index d3dc253c14..af1b5f1076 100644 --- a/python/samples/02-agents/providers/google/gemini_basic.py +++ b/python/packages/gemini/samples/gemini_basic.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Shows how to use GeminiChatClient with an agent and a custom tool, covering both -non-streaming and streaming responses. +"""Shows how to use GeminiChatClient with an agent and a custom tool. + +Covers both non-streaming and streaming responses. Requires the following environment variables to be set: - GEMINI_API_KEY @@ -14,9 +14,10 @@ from typing import Annotated from agent_framework import Agent, tool -from agent_framework_gemini import GeminiChatClient from dotenv import load_dotenv +from agent_framework_gemini import GeminiChatClient + load_dotenv() @@ -68,6 +69,7 @@ async def streaming_example() -> None: async def main() -> None: + """Run non-streaming and streaming examples.""" await non_streaming_example() await streaming_example() diff --git a/python/samples/02-agents/providers/google/gemini_with_code_execution.py b/python/packages/gemini/samples/gemini_with_code_execution.py similarity index 83% rename from python/samples/02-agents/providers/google/gemini_with_code_execution.py rename to python/packages/gemini/samples/gemini_with_code_execution.py index dd73ad6c75..3d95982ac2 100644 --- a/python/samples/02-agents/providers/google/gemini_with_code_execution.py +++ b/python/packages/gemini/samples/gemini_with_code_execution.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Shows how to enable Gemini's built-in code execution tool so the model can write -and run code in a sandboxed environment to answer questions. +"""Shows how to enable Gemini's built-in code execution tool. + +Allows the model to write and run code in a sandboxed environment to answer questions. Requires the following environment variables to be set: - GEMINI_API_KEY @@ -12,13 +12,15 @@ import asyncio from agent_framework import Agent -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions + load_dotenv() async def main() -> None: + """Run the code execution example.""" print("=== Code execution ===") options: GeminiChatOptions = { diff --git a/python/samples/02-agents/providers/google/gemini_with_google_maps.py b/python/packages/gemini/samples/gemini_with_google_maps.py similarity index 85% rename from python/samples/02-agents/providers/google/gemini_with_google_maps.py rename to python/packages/gemini/samples/gemini_with_google_maps.py index 375bd23732..1c862a3c5e 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_maps.py +++ b/python/packages/gemini/samples/gemini_with_google_maps.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Shows how to enable Google Maps grounding so Gemini can retrieve location and -mapping information before responding. +"""Shows how to enable Google Maps grounding. + +Allows Gemini to retrieve location and mapping information before responding. Requires the following environment variables to be set: - GEMINI_API_KEY @@ -12,13 +12,15 @@ import asyncio from agent_framework import Agent -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions + load_dotenv() async def main() -> None: + """Run the Google Maps grounding example.""" print("=== Google Maps grounding ===") options: GeminiChatOptions = { diff --git a/python/samples/02-agents/providers/google/gemini_with_google_search.py b/python/packages/gemini/samples/gemini_with_google_search.py similarity index 84% rename from python/samples/02-agents/providers/google/gemini_with_google_search.py rename to python/packages/gemini/samples/gemini_with_google_search.py index aed53fc8fd..652e698856 100644 --- a/python/samples/02-agents/providers/google/gemini_with_google_search.py +++ b/python/packages/gemini/samples/gemini_with_google_search.py @@ -1,8 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. -""" -Shows how to enable Google Search grounding so Gemini can retrieve up-to-date -information from the web before responding. +"""Shows how to enable Google Search grounding. + +Allows Gemini to retrieve up-to-date information from the web before responding. Requires the following environment variables to be set: - GEMINI_API_KEY @@ -12,13 +12,15 @@ import asyncio from agent_framework import Agent -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions from dotenv import load_dotenv +from agent_framework_gemini import GeminiChatClient, GeminiChatOptions + load_dotenv() async def main() -> None: + """Run the Google Search grounding example.""" print("=== Google Search grounding ===") options: GeminiChatOptions = { diff --git a/python/uv.lock b/python/uv.lock index 393b498969..138c6b49fb 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -517,7 +517,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0b260319" +version = "1.0.0a260410" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From fdc816deb778d1e12a46015d08db7b5671330d52 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 14:56:35 +0200 Subject: [PATCH 36/51] Fix docstrings in Gemini tests --- python/packages/gemini/tests/test_gemini_client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 07c511232e..b2325b7e94 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -310,7 +310,8 @@ async def test_assistant_role_mapped_to_model() -> None: async def test_tool_messages_collapsed_into_single_user_message() -> None: """Consecutive tool messages must be collapsed into one role='user' message - with multiple functionResponse parts (parallel tool call pattern).""" + with multiple functionResponse parts (parallel tool call pattern). + """ client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) @@ -361,7 +362,8 @@ async def test_function_result_name_resolved_from_call_history() -> None: async def test_function_result_resolved_when_call_id_was_generated() -> None: """When a function_call has no call_id and a fallback is generated, the subsequent - function_result referencing that generated ID must still resolve the function name.""" + function_result referencing that generated ID must still resolve the function name. + """ client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Done")])) @@ -632,7 +634,8 @@ async def test_response_schema_added_to_config() -> None: async def test_streaming_response_format_passed_to_build_response_stream() -> None: """Verifies that response_format is forwarded to _build_response_stream when streaming - so that structured output parsing works correctly on the final assembled response.""" + so that structured output parsing works correctly on the final assembled response. + """ from unittest.mock import patch from pydantic import BaseModel @@ -713,7 +716,8 @@ def get_weather(city: str) -> str: async def test_callable_tool_resolved_via_validate_options() -> None: """Raw callables passed as tools must be normalized by _validate_options into FunctionTools - and reach the Gemini config as function declarations.""" + and reach the Gemini config as function declarations. + """ def get_weather(city: str) -> str: """Get the weather for a city.""" From 374bf602804cca9822a75a898d505ea7b6a548e6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 14:57:21 +0200 Subject: [PATCH 37/51] Change 'model_id' to 'model' in response handling --- python/packages/gemini/agent_framework_gemini/_chat_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index d09f7556b5..782c4f131d 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -623,7 +623,7 @@ def _process_generate_response( response_id=None, messages=[Message(role="assistant", contents=contents, raw_representation=candidate)], usage_details=self._parse_usage(response.usage_metadata), - model_id=response.model_version or self.model, + model=response.model_version or self.model, finish_reason=self._map_finish_reason( candidate.finish_reason.name if candidate and candidate.finish_reason else None ), @@ -656,7 +656,7 @@ def _process_chunk(self, chunk: types.GenerateContentResponse) -> ChatResponseUp return ChatResponseUpdate( contents=contents, - model_id=chunk.model_version, + model=chunk.model_version, finish_reason=finish_reason, raw_representation=chunk, ) From 718f561028d035d676a1c96c4b963718510c7768 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 14:58:40 +0200 Subject: [PATCH 38/51] Fix model property change in response handling --- python/packages/gemini/tests/test_gemini_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index b2325b7e94..5d69a4fc57 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -144,7 +144,7 @@ async def test_get_response_returns_text() -> None: assert response.messages[0].text == "Hello!" -async def test_get_response_model_id_from_response() -> None: +async def test_get_response_model_from_response() -> None: client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([_make_part(text="Hi")], model_version="gemini-2.5-pro-002") @@ -152,7 +152,7 @@ async def test_get_response_model_id_from_response() -> None: response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) - assert response.model_id == "gemini-2.5-pro-002" + assert response.model == "gemini-2.5-pro-002" async def test_get_response_uses_model_from_options() -> None: From 9f5c508728ce4092999abb5efffa228045c08d9b Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 16:57:31 +0200 Subject: [PATCH 39/51] Add built-in tool factory methods to Gemini client Replaces boolean tool options (code_execution, google_search_grounding, google_maps_grounding) with static factory methods that return types.Tool objects: get_code_interpreter_tool, get_web_search_tool, get_mcp_tool, get_file_search_tool, and get_maps_grounding_tool. Simplifies _prepare_tools to a single translation boundary between FunctionTool (framework) and FunctionDeclaration (Gemini API), with types.Tool objects passed through unchanged. --- .../agent_framework_gemini/_chat_client.py | 126 ++++++++++++++--- .../samples/gemini_with_code_execution.py | 8 +- .../gemini/samples/gemini_with_google_maps.py | 8 +- .../samples/gemini_with_google_search.py | 8 +- .../gemini/tests/test_gemini_client.py | 131 ++++++++++-------- 5 files changed, 187 insertions(+), 94 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 782c4f131d..23c5528818 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -250,6 +250,91 @@ def __init__( super().__init__(additional_properties=additional_properties) + @staticmethod + def get_code_interpreter_tool(**kwargs: Any) -> types.Tool: + """Create a code execution tool. + + Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. + + Keyword Args: + **kwargs: Reserved for future use; currently ignored. + + Returns: + A ``types.Tool`` configured for sandboxed code execution. + """ + return types.Tool(code_execution=types.ToolCodeExecution()) + + @staticmethod + def get_web_search_tool(**kwargs: Any) -> types.Tool: + """Create a Google Search grounding tool. + + Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. + + Keyword Args: + **kwargs: Passed directly to ``types.GoogleSearch``. Supported fields include + ``time_range_filter``, ``search_types``, and ``exclude_domains``. + + Returns: + A ``types.Tool`` configured for Google Search grounding. + """ + return types.Tool(google_search=types.GoogleSearch(**kwargs)) + + @staticmethod + def get_mcp_tool(url: str, *, name: str | None = None, **kwargs: Any) -> types.Tool: + """Create an MCP (Model Context Protocol) server tool. + + Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. + + Args: + url: The URL of the MCP server's streamable HTTP endpoint. + name: Optional display name for the MCP server. + **kwargs: Additional kwargs passed to ``StreamableHttpTransport``. Supported fields + include ``headers``, ``timeout``, ``sse_read_timeout``, and ``terminate_on_close``. + + Returns: + A ``types.Tool`` configured for the given MCP server. + """ + return types.Tool( + mcp_servers=[ + types.McpServer( + name=name, + streamable_http_transport=types.StreamableHttpTransport(url=url, **kwargs), + ) + ] + ) + + @staticmethod + def get_file_search_tool(**kwargs: Any) -> types.Tool: + """Create a file search tool backed by a Gemini file search store. + + Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. + + Keyword Args: + **kwargs: Passed directly to ``types.FileSearch``. Supported fields include + ``file_search_store_names`` (list of store resource names to query), + ``top_k`` (maximum results per query), and ``metadata_filter`` + (CEL expression to filter by metadata). + + Returns: + A ``types.Tool`` configured for file search retrieval. + """ + return types.Tool(file_search=types.FileSearch(**kwargs)) + + @staticmethod + def get_maps_grounding_tool(**kwargs: Any) -> types.Tool: + """Create a Google Maps grounding tool. + + Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. + + Keyword Args: + **kwargs: Passed directly to ``types.GoogleMaps``. Supported fields include + ``enable_widget``. + + Returns: + A ``types.Tool`` configured for Google Maps grounding. + """ + return types.Tool(google_maps=types.GoogleMaps(**kwargs)) + @override def _inner_get_response( self, @@ -523,42 +608,47 @@ def _prepare_config( return types.GenerateContentConfig(**kwargs) def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None: - """Build the Gemini tool list from options, combining function declarations and built-in tools. + """Translate the framework tool list into Gemini API tool objects. + + The Gemini API does not accept framework ``FunctionTool`` objects directly. + This method acts as the translation boundary between the two type systems. + It handles two kinds of entries in ``options["tools"]``: + + - ``FunctionTool``: a framework abstraction for a callable with a name, + description, and JSON schema. Translated to ``types.FunctionDeclaration`` + (Gemini's equivalent) and grouped into a single ``types.Tool``, which is + how the Gemini API expects function declarations to be passed. + - ``types.Tool``: already in Gemini's native format (e.g. built-in tools + such as search or code execution). Passed through unchanged. Use the + ``get_*_tool`` factory methods on this class to produce these. Args: - options: Resolved chat options containing ``tools``, ``google_search_grounding`` - (``bool`` or ``types.GoogleSearch``), ``google_maps_grounding`` - (``bool`` or ``types.GoogleMaps``), and ``code_execution`` flag. + options: Resolved chat options whose ``tools`` entry may contain + ``FunctionTool`` instances, plain callables, or ``types.Tool`` objects. Returns: - A list of ``types.Tool`` objects, or None if no tools are configured. + A non-empty list of ``types.Tool`` objects ready for the Gemini API, + or ``None`` if no tools are configured. """ - function_tools: list[Any] = options.get("tools") or [] - search_option = options.get("google_search_grounding", False) - maps_option = options.get("google_maps_grounding", False) - include_code_exec = options.get("code_execution", False) + tools_option: list[Any] = options.get("tools") or [] result: list[types.Tool] = [] + # Translate framework FunctionTool objects to Gemini API FunctionDeclaration objects declarations = [ types.FunctionDeclaration( name=tool.name, description=tool.description or "", parameters=tool.parameters(), # type: ignore[arg-type] ) - for tool in function_tools + for tool in tools_option if isinstance(tool, FunctionTool) ] if declarations: result.append(types.Tool(function_declarations=declarations)) - if search_option: - google_search = search_option if isinstance(search_option, types.GoogleSearch) else types.GoogleSearch() - result.append(types.Tool(google_search=google_search)) - if maps_option: - google_maps = maps_option if isinstance(maps_option, types.GoogleMaps) else types.GoogleMaps() - result.append(types.Tool(google_maps=google_maps)) - if include_code_exec: - result.append(types.Tool(code_execution=types.ToolCodeExecution())) + + # Objects of type types.Tool are already in Gemini's native format + result.extend(tool for tool in tools_option if isinstance(tool, types.Tool)) return result or None diff --git a/python/packages/gemini/samples/gemini_with_code_execution.py b/python/packages/gemini/samples/gemini_with_code_execution.py index 3d95982ac2..e41c63637c 100644 --- a/python/packages/gemini/samples/gemini_with_code_execution.py +++ b/python/packages/gemini/samples/gemini_with_code_execution.py @@ -14,7 +14,7 @@ from agent_framework import Agent from dotenv import load_dotenv -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from agent_framework_gemini import GeminiChatClient load_dotenv() @@ -23,15 +23,11 @@ async def main() -> None: """Run the code execution example.""" print("=== Code execution ===") - options: GeminiChatOptions = { - "code_execution": True, - } - agent = Agent( client=GeminiChatClient(), name="CodeAgent", instructions="You are a helpful assistant. Use code execution to compute precise answers.", - default_options=options, + tools=[GeminiChatClient.get_code_interpreter_tool()], ) query = "What are the first 20 prime numbers? Compute them in code." diff --git a/python/packages/gemini/samples/gemini_with_google_maps.py b/python/packages/gemini/samples/gemini_with_google_maps.py index 1c862a3c5e..8083655b7d 100644 --- a/python/packages/gemini/samples/gemini_with_google_maps.py +++ b/python/packages/gemini/samples/gemini_with_google_maps.py @@ -14,7 +14,7 @@ from agent_framework import Agent from dotenv import load_dotenv -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from agent_framework_gemini import GeminiChatClient load_dotenv() @@ -23,15 +23,11 @@ async def main() -> None: """Run the Google Maps grounding example.""" print("=== Google Maps grounding ===") - options: GeminiChatOptions = { - "google_maps_grounding": True, - } - agent = Agent( client=GeminiChatClient(), name="MapsAgent", instructions="You are a helpful travel assistant. Use Google Maps to provide accurate location information.", - default_options=options, + tools=[GeminiChatClient.get_maps_grounding_tool()], ) query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?" diff --git a/python/packages/gemini/samples/gemini_with_google_search.py b/python/packages/gemini/samples/gemini_with_google_search.py index 652e698856..741f4d4d27 100644 --- a/python/packages/gemini/samples/gemini_with_google_search.py +++ b/python/packages/gemini/samples/gemini_with_google_search.py @@ -14,7 +14,7 @@ from agent_framework import Agent from dotenv import load_dotenv -from agent_framework_gemini import GeminiChatClient, GeminiChatOptions +from agent_framework_gemini import GeminiChatClient load_dotenv() @@ -23,15 +23,11 @@ async def main() -> None: """Run the Google Search grounding example.""" print("=== Google Search grounding ===") - options: GeminiChatOptions = { - "google_search_grounding": True, - } - agent = Agent( client=GeminiChatClient(), name="SearchAgent", instructions="You are a helpful assistant. Use Google Search to provide accurate, up-to-date answers.", - default_options=options, + tools=[GeminiChatClient.get_web_search_tool()], ) query = "What is the latest stable release of the .NET SDK?" diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 5d69a4fc57..5a1ea2f793 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -844,83 +844,101 @@ async def test_unknown_tool_choice_mode_is_ignored() -> None: assert not hasattr(config, "tool_config") or config.tool_config is None -# built-in tools +# built-in tool factories -async def test_google_search_grounding_injects_tool() -> None: - client, mock = _make_gemini_client() - mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) - - await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Search")])], - options={"google_search_grounding": True}, - ) +def test_get_web_search_tool_returns_google_search_tool() -> None: + """get_web_search_tool returns a types.Tool with google_search set.""" + tool = GeminiChatClient.get_web_search_tool() + assert isinstance(tool, types.Tool) + assert tool.google_search is not None - config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] - assert config.tools is not None - assert any(t.google_search for t in config.tools) +def test_get_web_search_tool_forwards_kwargs() -> None: + """Keyword arguments are passed through to types.GoogleSearch.""" + tool = GeminiChatClient.get_web_search_tool(exclude_domains=["example.com"]) + assert tool.google_search is not None + assert tool.google_search.exclude_domains == ["example.com"] -async def test_google_maps_grounding_injects_tool() -> None: - client, mock = _make_gemini_client() - mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) - await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Map")])], - options={"google_maps_grounding": True}, - ) +def test_get_code_interpreter_tool_returns_code_execution_tool() -> None: + """get_code_interpreter_tool returns a types.Tool with code_execution set.""" + tool = GeminiChatClient.get_code_interpreter_tool() + assert isinstance(tool, types.Tool) + assert tool.code_execution is not None - config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] - assert config.tools is not None - assert any(t.google_maps for t in config.tools) +def test_get_maps_grounding_tool_returns_google_maps_tool() -> None: + """get_maps_grounding_tool returns a types.Tool with google_maps set.""" + tool = GeminiChatClient.get_maps_grounding_tool() + assert isinstance(tool, types.Tool) + assert tool.google_maps is not None -async def test_google_search_grounding_with_config_uses_provided_instance() -> None: - """Passing a types.GoogleSearch instance forwards it directly rather than constructing a default.""" - client, mock = _make_gemini_client() - mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) - search_config = types.GoogleSearch(exclude_domains=["example.com"]) - await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Search")])], - options={"google_search_grounding": search_config}, - ) +def test_get_maps_grounding_tool_forwards_kwargs() -> None: + """Keyword arguments are passed through to types.GoogleMaps.""" + tool = GeminiChatClient.get_maps_grounding_tool(enable_widget=True) + assert tool.google_maps is not None + assert tool.google_maps.enable_widget is True - config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] - assert config.tools is not None - injected = next((t.google_search for t in config.tools if t.google_search is not None), None) # type: ignore[union-attr] - assert injected is search_config +def test_get_file_search_tool_returns_file_search_tool() -> None: + """get_file_search_tool returns a types.Tool with file_search set.""" + tool = GeminiChatClient.get_file_search_tool(file_search_store_names=["stores/my-store"]) + assert isinstance(tool, types.Tool) + assert tool.file_search is not None + assert tool.file_search.file_search_store_names == ["stores/my-store"] -async def test_google_maps_grounding_with_config_uses_provided_instance() -> None: - """Passing a types.GoogleMaps instance forwards it directly rather than constructing a default.""" - client, mock = _make_gemini_client() - mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) - maps_config = types.GoogleMaps(enable_widget=True) - await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Map")])], - options={"google_maps_grounding": maps_config}, +def test_get_file_search_tool_forwards_kwargs() -> None: + """Keyword arguments are passed through to types.FileSearch.""" + tool = GeminiChatClient.get_file_search_tool( + file_search_store_names=["stores/my-store"], + top_k=5, + metadata_filter="type='pdf'", ) - - config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] - assert config.tools is not None - injected = next((t.google_maps for t in config.tools if t.google_maps is not None), None) # type: ignore[union-attr] - assert injected is maps_config + assert tool.file_search is not None + assert tool.file_search.top_k == 5 + assert tool.file_search.metadata_filter == "type='pdf'" + + +def test_get_mcp_tool_returns_mcp_server_tool() -> None: + """get_mcp_tool returns a types.Tool with a single McpServer entry.""" + tool = GeminiChatClient.get_mcp_tool(name="my-mcp", url="https://mcp.example.com/sse") + assert isinstance(tool, types.Tool) + assert tool.mcp_servers is not None + assert len(tool.mcp_servers) == 1 + server = tool.mcp_servers[0] + assert server.name == "my-mcp" + assert server.streamable_http_transport is not None + assert server.streamable_http_transport.url == "https://mcp.example.com/sse" + + +def test_get_mcp_tool_forwards_transport_kwargs() -> None: + """Transport keyword arguments are passed through to StreamableHttpTransport.""" + tool = GeminiChatClient.get_mcp_tool( + name="secure-mcp", + url="https://mcp.example.com/sse", + headers={"Authorization": "Bearer token"}, + ) + server = tool.mcp_servers[0] # type: ignore[index] + assert server.streamable_http_transport.headers == {"Authorization": "Bearer token"} -async def test_code_execution_injects_tool() -> None: +async def test_types_tool_passed_in_tools_list_is_forwarded() -> None: + """A types.Tool in the tools list is passed through directly to the Gemini config.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Result")])) + search_tool = GeminiChatClient.get_web_search_tool() await client.get_response( - messages=[Message(role="user", contents=[Content.from_text("Run code")])], - options={"code_execution": True}, + messages=[Message(role="user", contents=[Content.from_text("Search")])], + options={"tools": [search_tool]}, ) config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] assert config.tools is not None - assert any(t.code_execution for t in config.tools) + assert any(tool.google_search for tool in config.tools) async def test_function_response_part_in_response_mapped_to_content() -> None: @@ -1201,12 +1219,11 @@ async def test_integration_thinking_config() -> None: @skip_if_no_api_key async def test_integration_google_search_grounding() -> None: """Google Search grounding returns a non-empty response for a current-events question.""" - options: GeminiChatOptions = {"google_search_grounding": True} client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( messages=[Message(role="user", contents=[Content.from_text("What is the latest stable version of Python?")])], - options=options, + options={"tools": [GeminiChatClient.get_web_search_tool()]}, ) assert response.messages @@ -1218,7 +1235,6 @@ async def test_integration_google_search_grounding() -> None: @skip_if_no_api_key async def test_integration_google_maps_grounding() -> None: """Google Maps grounding returns a non-empty response for a location-based question.""" - options: GeminiChatOptions = {"google_maps_grounding": True} client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( @@ -1228,7 +1244,7 @@ async def test_integration_google_maps_grounding() -> None: contents=[Content.from_text("What are some highly rated restaurants in Karlsruhe city center?")], ) ], - options=options, + options={"tools": [GeminiChatClient.get_maps_grounding_tool()]}, ) assert response.messages @@ -1240,7 +1256,6 @@ async def test_integration_google_maps_grounding() -> None: @skip_if_no_api_key async def test_integration_code_execution() -> None: """Code execution tool produces a non-empty response for a computation request.""" - options: GeminiChatOptions = {"code_execution": True} client = GeminiChatClient(model=_TEST_MODEL) response = await client.get_response( @@ -1250,7 +1265,7 @@ async def test_integration_code_execution() -> None: contents=[Content.from_text("Compute the sum of the first 100 natural numbers using code.")], ) ], - options=options, + options={"tools": [GeminiChatClient.get_code_interpreter_tool()]}, ) assert response.messages From b4056d87fbba80d9582b3529ea5163811cdd9866 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 17:06:39 +0200 Subject: [PATCH 40/51] Surface code execution parts _parse_parts now maps executable_code and code_execution_result parts to text Content objects so callers can see the code run and its output. Unknown part types log at debug level rather than being silently dropped. --- .../agent_framework_gemini/_chat_client.py | 8 ++ .../gemini/tests/test_gemini_client.py | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 23c5528818..c8827c8783 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -790,6 +790,14 @@ def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]: raw_representation=part, ) ) + elif part.executable_code is not None: + if part.executable_code.code: + contents.append(Content.from_text(text=part.executable_code.code, raw_representation=part)) + elif part.code_execution_result is not None: + if part.code_execution_result.output: + contents.append(Content.from_text(text=part.code_execution_result.output, raw_representation=part)) + else: + logger.debug("Skipping unsupported response part from Gemini") return contents def _parse_usage(self, usage: types.GenerateContentResponseUsageMetadata | None) -> UsageDetails | None: diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 5a1ea2f793..abea3bb1e1 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -29,6 +29,8 @@ def _make_part( text: str | None = None, thought: bool = False, function_call: tuple[str, str, dict[str, Any]] | None = None, + executable_code: str | None = None, + code_execution_result: str | None = None, ) -> MagicMock: """Build a mock types.Part. @@ -36,11 +38,15 @@ def _make_part( text: Text content of the part. thought: Whether this is a thinking/reasoning part. function_call: Tuple of (id, name, args) if this is a function call part. + executable_code: Source code string for a code execution part. + code_execution_result: Output string for a code execution result part. """ part = MagicMock() part.text = text part.thought = thought part.function_response = None + part.executable_code = None + part.code_execution_result = None if function_call: mock_function_call = MagicMock() @@ -49,6 +55,16 @@ def _make_part( else: part.function_call = None + if executable_code is not None: + mock_exec = MagicMock() + mock_exec.code = executable_code + part.executable_code = mock_exec + + if code_execution_result is not None: + mock_result = MagicMock() + mock_result.output = code_execution_result + part.code_execution_result = mock_result + return part @@ -462,6 +478,65 @@ async def test_thinking_parts_are_silently_skipped() -> None: assert response.messages[0].text == "The answer is 42." +# code execution parts + + +async def test_executable_code_part_is_included_as_text() -> None: + """executable_code parts are surfaced as text content so callers can see what code was run.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock( + return_value=_make_response([ + _make_part(executable_code="print(sum(range(10)))"), + _make_part(code_execution_result="45"), + _make_part(text="The sum of 0 through 9 is 45."), + ]) + ) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Sum 0 to 9")])]) + + texts = [c.text for c in response.messages[0].contents if c.text] + assert "print(sum(range(10)))" in texts + assert "45" in texts + assert "The sum of 0 through 9 is 45." in texts + + +async def test_unknown_part_type_is_skipped() -> None: + """Parts with no recognised field set are silently skipped.""" + client, mock = _make_gemini_client() + unknown_part = MagicMock() + unknown_part.thought = False + unknown_part.text = None + unknown_part.function_call = None + unknown_part.function_response = None + unknown_part.executable_code = None + unknown_part.code_execution_result = None + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([unknown_part, _make_part(text="Hi")])) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert len(response.messages[0].contents) == 1 + assert response.messages[0].text == "Hi" + + +async def test_empty_executable_code_part_is_skipped() -> None: + """executable_code parts with no code string produce no Content entry.""" + client, mock = _make_gemini_client() + mock_part = MagicMock() + mock_part.text = None + mock_part.thought = False + mock_part.function_call = None + mock_part.function_response = None + mock_part.code_execution_result = None + mock_part.executable_code = MagicMock() + mock_part.executable_code.code = "" + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([mock_part, _make_part(text="Done.")])) + + response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])]) + + assert len(response.messages[0].contents) == 1 + assert response.messages[0].text == "Done." + + # generation config options From b3a0a3042eaac1265b90a9b3c40c15dd5656a784 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 17:07:48 +0200 Subject: [PATCH 41/51] Update Gemini client documentation --- .../agent_framework_gemini/_chat_client.py | 50 +++++++++++-------- .../gemini/tests/test_gemini_client.py | 44 ++++++++++++++++ 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index c8827c8783..1a413cfa5f 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -68,10 +68,13 @@ class ThinkingConfig(TypedDict, total=False): """Extended thinking configuration for Gemini models. Attributes: - include_thoughts: Whether to include the model's reasoning thoughts in the response. + include_thoughts: Whether to include thought summaries in the response. Thought summaries + are condensed representations of the model's internal reasoning and appear as response + parts where ``part.thought`` is ``True``. Note: the framework currently excludes + thought parts from ``ChatResponse.contents`` and does not surface them as output. thinking_budget: Token budget for Gemini 2.5 models. Set to ``0`` to disable thinking or ``-1`` to enable a dynamic budget. - thinking_level: Thinking level for Gemini 3.x models. One of + thinking_level: Thinking level for Gemini 2.5 models and later. One of ``ThinkingLevel.THINKING_LEVEL_UNSPECIFIED`` (default), ``ThinkingLevel.MINIMAL``, ``ThinkingLevel.LOW``, ``ThinkingLevel.MEDIUM``, or ``ThinkingLevel.HIGH``. """ @@ -100,7 +103,9 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to seed: Fixed seed for reproducible outputs. frequency_penalty: Reduces repetition by penalising tokens that appear frequently. presence_penalty: Reduces repetition by penalising tokens that have already appeared. - tools: Function tools the model may call. Accepts ``FunctionTool`` instances or plain callables. + tools: Function tools the model may call. Accepts ``FunctionTool`` instances, plain callables, + or ``types.Tool`` objects returned by ``get_code_interpreter_tool``, ``get_web_search_tool``, + ``get_mcp_tool``, ``get_file_search_tool``, or ``get_maps_grounding_tool``. tool_choice: How the model picks a tool. One of ``'auto'``, ``'none'``, or ``'required'``. response_format: Pydantic model type for structured JSON output. The response text is parsed into the model and exposed via ``ChatResponse.value``. @@ -126,19 +131,6 @@ class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], to thinking_config: ThinkingConfig """Extended thinking configuration. See ``ThinkingConfig`` for available fields.""" - # Tool options - code_execution: bool - """Allow the model to write and run code in a sandboxed environment.""" - - google_search_grounding: bool | types.GoogleSearch - """Ground responses with live Google Search results. Pass ``True`` to use default settings, - or a ``types.GoogleSearch`` instance for full control (e.g. ``time_range_filter``, - ``search_types``, ``exclude_domains``).""" - - google_maps_grounding: bool | types.GoogleMaps - """Ground responses with Google Maps data. Pass ``True`` to use default settings, - or a ``types.GoogleMaps`` instance for full control (e.g. ``enable_widget``).""" - # Unsupported base options. Override with None to indicate not supported logit_bias: None # type: ignore[misc] """Not supported in the Gemini API.""" @@ -560,7 +552,11 @@ def _prepare_config( options: Mapping[str, Any], system_instruction: str | None, ) -> types.GenerateContentConfig: - """Build a ``types.GenerateContentConfig`` from ``ChatOptions``. + """Build a ``types.GenerateContentConfig`` from the resolved chat options. + + Maps both standard ``ChatOptions`` fields (temperature, top_p, tools, etc.) and + ``GeminiChatOptions``-specific fields (``response_schema``, ``top_k``, ``thinking_config``) + to their ``GenerateContentConfig`` equivalents. Args: options: Resolved chat options mapping, typically a ``GeminiChatOptions`` dict. @@ -653,14 +649,14 @@ def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None: return result or None def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None: - """Build a Gemini ``ToolConfig`` from the framework tool_choice value. + """Build a Gemini ``ToolConfig`` from the framework ``tool_choice`` value. Args: - tool_choice: Raw tool_choice value from options (string, dict, or None). + tool_choice: Raw ``tool_choice`` value from options (string, dict, or None). Returns: A ``types.ToolConfig`` with the appropriate ``FunctionCallingConfig``, or None - if no tool_choice is set or the mode is unsupported. + if no ``tool_choice`` is set or the mode is unsupported. """ tool_mode = validate_tool_mode(tool_choice) if not tool_mode: @@ -852,7 +848,19 @@ class GeminiChatClient( RawGeminiChatClient[GeminiChatOptionsT], Generic[GeminiChatOptionsT], ): - """Gemini chat client for the Google Gemini API with function invocation, middleware and telemetry.""" + """Gemini chat client for the Google Gemini API with function invocation, middleware, and telemetry. + + This is the recommended client for most use cases. It builds on ``RawGeminiChatClient`` + and adds: + + - **Function invocation**: automatically calls ``FunctionTool`` implementations and feeds + results back to the model until it produces a final text response. + - **Middleware**: a composable chain for cross-cutting concerns (logging, retries, etc.). + - **Telemetry**: OpenTelemetry traces and metrics emitted for every request. + + Use ``RawGeminiChatClient`` instead when you need full control over the request pipeline + and want to opt out of one or more of these layers. + """ def __init__( self, diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index abea3bb1e1..1eb45239a7 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -122,17 +122,20 @@ def _make_gemini_client( def test_model_stored_on_instance() -> None: + """Stores the model identifier on the instance so it can be read back.""" client, _ = _make_gemini_client(model="gemini-2.5-pro") assert client.model == "gemini-2.5-pro" def test_client_created_from_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Initialises successfully when the API key is supplied via environment variable.""" monkeypatch.setenv("GEMINI_API_KEY", "test-key-123") client = GeminiChatClient(model="gemini-2.5-flash") assert client.model == "gemini-2.5-flash" def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.MonkeyPatch) -> None: + """Raises ValueError at construction when neither an API key nor a pre-built client is available.""" monkeypatch.delenv("GEMINI_API_KEY", raising=False) monkeypatch.delenv("GEMINI_MODEL", raising=False) @@ -141,6 +144,7 @@ def test_missing_api_key_raises_when_no_client_injected(monkeypatch: pytest.Monk async def test_missing_model_raises_on_get_response() -> None: + """Raises ValueError at call time when no model is set on the client or in options.""" client, mock = _make_gemini_client(model=None) # type: ignore[arg-type] mock.aio.models.generate_content = AsyncMock() @@ -152,6 +156,7 @@ async def test_missing_model_raises_on_get_response() -> None: async def test_get_response_returns_text() -> None: + """Returns the model's text reply in the first message of the response.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hello!")])) @@ -161,6 +166,7 @@ async def test_get_response_returns_text() -> None: async def test_get_response_model_from_response() -> None: + """Populates ChatResponse.model from the model_version field in the API response.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([_make_part(text="Hi")], model_version="gemini-2.5-pro-002") @@ -172,6 +178,7 @@ async def test_get_response_model_from_response() -> None: async def test_get_response_uses_model_from_options() -> None: + """Uses the model specified in options, overriding the client's default.""" client, mock = _make_gemini_client(model="gemini-2.5-flash") mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -185,6 +192,7 @@ async def test_get_response_uses_model_from_options() -> None: async def test_get_response_usage_details() -> None: + """Surfaces input, output, and total token counts from the API usage metadata.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response( @@ -204,6 +212,7 @@ async def test_get_response_usage_details() -> None: async def test_get_response_no_usage_when_metadata_absent() -> None: + """Returns None for usage_details when the API response includes no usage metadata.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([_make_part(text="Hi")], prompt_tokens=None, output_tokens=None) @@ -232,6 +241,7 @@ async def test_get_response_no_usage_when_metadata_absent() -> None: ], ) async def test_finish_reason_mapping(gemini_reason: str, expected: str | None) -> None: + """Maps Gemini finish reason strings to the correct FinishReasonLiteral values.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([_make_part(text="Hi")], finish_reason=gemini_reason) @@ -246,6 +256,7 @@ async def test_finish_reason_mapping(gemini_reason: str, expected: str | None) - async def test_system_message_extracted_to_system_instruction() -> None: + """Extracts a system role message from the conversation and sends it as the system instruction.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -261,6 +272,7 @@ async def test_system_message_extracted_to_system_instruction() -> None: async def test_multiple_system_messages_concatenated() -> None: + """Joins multiple system messages into a single system instruction string.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -278,6 +290,7 @@ async def test_multiple_system_messages_concatenated() -> None: async def test_instructions_option_merged_with_system_instruction() -> None: + """Prepends the instructions option to the system message when both are present.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -295,6 +308,7 @@ async def test_instructions_option_merged_with_system_instruction() -> None: async def test_instructions_option_without_system_message() -> None: + """Uses the instructions option as the sole system instruction when no system message is present.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -308,6 +322,7 @@ async def test_instructions_option_without_system_message() -> None: async def test_assistant_role_mapped_to_model() -> None: + """Maps the framework 'assistant' role to the 'model' role expected by the Gemini API.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Sure")])) @@ -462,6 +477,7 @@ async def test_non_function_result_content_in_tool_message_is_skipped() -> None: async def test_thinking_parts_are_silently_skipped() -> None: + """Excludes thought-summary parts from ChatResponse.contents, returning only the final answer.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([ @@ -541,6 +557,7 @@ async def test_empty_executable_code_part_is_skipped() -> None: async def test_prepare_config_temperature() -> None: + """Forwards the temperature option to GenerateContentConfig.temperature.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -554,6 +571,7 @@ async def test_prepare_config_temperature() -> None: async def test_prepare_config_max_tokens() -> None: + """Forwards max_tokens to GenerateContentConfig.max_output_tokens.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -567,6 +585,7 @@ async def test_prepare_config_max_tokens() -> None: async def test_prepare_config_top_p_and_top_k() -> None: + """Forwards top_p and top_k to their respective GenerateContentConfig fields.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -581,6 +600,7 @@ async def test_prepare_config_top_p_and_top_k() -> None: async def test_prepare_config_stop_sequences() -> None: + """Forwards the stop option to GenerateContentConfig.stop_sequences.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -594,6 +614,7 @@ async def test_prepare_config_stop_sequences() -> None: async def test_prepare_config_seed() -> None: + """Forwards the seed option to GenerateContentConfig.seed for reproducible outputs.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -607,6 +628,7 @@ async def test_prepare_config_seed() -> None: async def test_prepare_config_frequency_and_presence_penalty() -> None: + """Forwards frequency_penalty and presence_penalty to their GenerateContentConfig equivalents.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -624,6 +646,7 @@ async def test_prepare_config_frequency_and_presence_penalty() -> None: async def test_thinking_config_budget() -> None: + """Passes thinking_budget through to GenerateContentConfig.thinking_config.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) tc: ThinkingConfig = {"thinking_budget": 1024} @@ -639,6 +662,7 @@ async def test_thinking_config_budget() -> None: async def test_thinking_config_level() -> None: + """Passes thinking_level through to GenerateContentConfig.thinking_config.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) tc: ThinkingConfig = {"thinking_level": types.ThinkingLevel.HIGH} @@ -657,6 +681,7 @@ async def test_thinking_config_level() -> None: async def test_response_format_sets_json_mime_type() -> None: + """Sets response_mime_type to application/json when response_format is given.""" from pydantic import BaseModel class Reply(BaseModel): @@ -693,6 +718,7 @@ class Reply(BaseModel): async def test_response_schema_added_to_config() -> None: + """Sets both response_mime_type and the raw schema on the config when response_schema is given.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="{}")])) schema = {"type": "object", "properties": {"name": {"type": "string"}}} @@ -739,6 +765,7 @@ class Reply(BaseModel): async def test_function_call_in_response_mapped_to_content() -> None: + """Maps a function_call part in the response to a function_call Content with the correct name and call ID.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock( return_value=_make_response([_make_part(function_call=("call-1", "get_weather", {"city": "Berlin"}))]) @@ -769,6 +796,8 @@ async def test_function_call_missing_id_gets_fallback() -> None: async def test_function_tool_converted_to_function_declaration() -> None: + """Translates a FunctionTool in the tools list into a FunctionDeclaration in the generation config.""" + def get_weather(city: str) -> str: """Get the weather for a city.""" return "sunny" @@ -816,30 +845,37 @@ def get_weather(city: str) -> str: def test_coerce_to_dict_with_dict_input() -> None: + """Returns a dict value unchanged.""" assert GeminiChatClient._coerce_to_dict({"key": "value"}) == {"key": "value"} def test_coerce_to_dict_with_json_string() -> None: + """Parses a JSON object string into a dict.""" assert GeminiChatClient._coerce_to_dict('{"key": "value"}') == {"key": "value"} def test_coerce_to_dict_with_plain_string() -> None: + """Wraps a plain non-JSON string as {'result': value}.""" assert GeminiChatClient._coerce_to_dict("some text") == {"result": "some text"} def test_coerce_to_dict_with_none() -> None: + """Coerces None to {'result': ''}.""" assert GeminiChatClient._coerce_to_dict(None) == {"result": ""} def test_coerce_to_dict_with_numeric_value() -> None: + """Wraps a numeric value as {'result': str(value)}.""" assert GeminiChatClient._coerce_to_dict(42) == {"result": "42"} def test_coerce_to_dict_with_json_array_string() -> None: + """Wraps a JSON array string as {'result': value} because it is not a dict.""" assert GeminiChatClient._coerce_to_dict("[1, 2, 3]") == {"result": "[1, 2, 3]"} def test_coerce_to_dict_with_json_string_literal() -> None: + """Wraps a JSON string literal as {'result': value} because it is not a dict.""" assert GeminiChatClient._coerce_to_dict('"hello"') == {"result": '"hello"'} @@ -872,16 +908,19 @@ async def _get_config_for_tool_choice(tool_choice: str) -> types.GenerateContent async def test_tool_choice_auto_maps_to_AUTO() -> None: + """Maps 'auto' tool_choice to FunctionCallingConfigMode.AUTO.""" config = await _get_config_for_tool_choice("auto") assert _get_function_calling_mode(config) == "AUTO" async def test_tool_choice_none_maps_to_NONE() -> None: + """Maps 'none' tool_choice to FunctionCallingConfigMode.NONE.""" config = await _get_config_for_tool_choice("none") assert _get_function_calling_mode(config) == "NONE" async def test_tool_choice_required_maps_to_ANY() -> None: + """Maps 'required' tool_choice to FunctionCallingConfigMode.ANY.""" config = await _get_config_for_tool_choice("required") assert _get_function_calling_mode(config) == "ANY" @@ -906,6 +945,7 @@ async def test_tool_choice_required_with_name_sets_allowed_function_names() -> N async def test_unknown_tool_choice_mode_is_ignored() -> None: + """Produces no tool_config in the generation config when the tool_choice mode is unrecognised.""" client, mock = _make_gemini_client() mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) @@ -1037,6 +1077,7 @@ async def test_function_response_part_in_response_mapped_to_content() -> None: async def test_streaming_yields_text_chunks() -> None: + """Yields incremental text updates that together form the complete response.""" client, mock = _make_gemini_client() chunks = [ _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), @@ -1084,6 +1125,7 @@ async def test_streaming_function_call_emitted_immediately() -> None: async def test_streaming_finish_reason_only_on_last_chunk() -> None: + """Sets finish_reason only on the final chunk; intermediate chunks have it as None.""" client, mock = _make_gemini_client() chunks = [ _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), @@ -1102,6 +1144,7 @@ async def test_streaming_finish_reason_only_on_last_chunk() -> None: async def test_streaming_usage_only_on_final_chunk() -> None: + """Attaches usage content only to the final chunk, not to intermediate ones.""" client, mock = _make_gemini_client() chunks = [ _make_response([_make_part(text="Hello ")], finish_reason=None, prompt_tokens=None, output_tokens=None), @@ -1190,6 +1233,7 @@ async def test_empty_candidates_in_stream_does_not_raise(candidates: list | None def test_service_url() -> None: + """Returns the Gemini API base URL.""" client, _ = _make_gemini_client() assert client.service_url() == "https://generativelanguage.googleapis.com" From 313a001ecd0073cefc727eb46a6fab2257569520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Holtvogt?= Date: Fri, 10 Apr 2026 17:25:46 +0200 Subject: [PATCH 42/51] Unify Gemini model name Co-authored-by: Eduard van Valkenburg --- python/packages/gemini/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/gemini/README.md b/python/packages/gemini/README.md index 807dd470f4..f0a10097e8 100644 --- a/python/packages/gemini/README.md +++ b/python/packages/gemini/README.md @@ -16,7 +16,7 @@ Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) an ```bash export GEMINI_API_KEY="your-api-key" -export GEMINI_CHAT_MODEL_ID="gemini-2.5-flash" +export GEMINI_CHAT_MODEL="gemini-2.5-flash" ``` ## Examples From 29855dd32b349d1af7c9441df6dc3a86dcb2fde1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Holtvogt?= Date: Fri, 10 Apr 2026 17:26:19 +0200 Subject: [PATCH 43/51] Update Agent Framework core version Co-authored-by: Eduard van Valkenburg --- python/packages/gemini/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 2185378a62..aa3f10fea8 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc5", + "agent-framework-core>=1.0.0,<2.0", "google-genai>=1.0.0,<2.0.0", ] From 149b463e42cd98c43c110c4b3dc8ab324148c7bb Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 17:27:20 +0200 Subject: [PATCH 44/51] Add Python 3.14 in classifiers --- python/packages/gemini/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index aa3f10fea8..8a650eb984 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Framework :: Pydantic :: 2", "Typing :: Typed", ] From e7e4183c6a49dbe393dceb84523370eab9e9846c Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 18:17:57 +0200 Subject: [PATCH 45/51] Replace kwargs with parameters in tool factories --- .../agent_framework_gemini/_chat_client.py | 73 +++++++++++++------ .../gemini/tests/test_gemini_client.py | 31 ++++++-- 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 1a413cfa5f..ccca9750a8 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -243,33 +243,46 @@ def __init__( super().__init__(additional_properties=additional_properties) @staticmethod - def get_code_interpreter_tool(**kwargs: Any) -> types.Tool: + def get_code_interpreter_tool() -> types.Tool: """Create a code execution tool. Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. - Keyword Args: - **kwargs: Reserved for future use; currently ignored. - Returns: A ``types.Tool`` configured for sandboxed code execution. """ return types.Tool(code_execution=types.ToolCodeExecution()) @staticmethod - def get_web_search_tool(**kwargs: Any) -> types.Tool: + def get_web_search_tool( + *, + search_types: types.SearchTypes | None = None, + blocking_confidence: types.PhishBlockThreshold | None = None, + exclude_domains: list[str] | None = None, + time_range_filter: types.Interval | None = None, + ) -> types.Tool: """Create a Google Search grounding tool. Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. - Keyword Args: - **kwargs: Passed directly to ``types.GoogleSearch``. Supported fields include - ``time_range_filter``, ``search_types``, and ``exclude_domains``. + Args: + search_types: Controls which search types are enabled (web search, image search). + blocking_confidence: Block sites at or above this phishing confidence level. + Not supported in Gemini API. + exclude_domains: List of domains to exclude from search results. Not supported in Gemini API. + time_range_filter: Restrict results to a specific time range. Not supported in Vertex AI. Returns: A ``types.Tool`` configured for Google Search grounding. """ - return types.Tool(google_search=types.GoogleSearch(**kwargs)) + return types.Tool( + google_search=types.GoogleSearch( + search_types=search_types, + blocking_confidence=blocking_confidence, + exclude_domains=exclude_domains, + time_range_filter=time_range_filter, + ) + ) @staticmethod def get_mcp_tool(url: str, *, name: str | None = None, **kwargs: Any) -> types.Tool: @@ -296,36 +309,54 @@ def get_mcp_tool(url: str, *, name: str | None = None, **kwargs: Any) -> types.T ) @staticmethod - def get_file_search_tool(**kwargs: Any) -> types.Tool: + def get_file_search_tool( + *, + file_search_store_names: list[str] | None = None, + top_k: int | None = None, + metadata_filter: str | None = None, + ) -> types.Tool: """Create a file search tool backed by a Gemini file search store. Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. - Keyword Args: - **kwargs: Passed directly to ``types.FileSearch``. Supported fields include - ``file_search_store_names`` (list of store resource names to query), - ``top_k`` (maximum results per query), and ``metadata_filter`` - (CEL expression to filter by metadata). + Args: + file_search_store_names: Resource names of the file search stores to query. + Example: ``["fileSearchStores/my-file-search-store-123"]``. + top_k: Maximum number of retrieval chunks to return. + metadata_filter: CEL expression to filter retrieval results by metadata. + See https://google.aip.dev/160 for syntax. Returns: A ``types.Tool`` configured for file search retrieval. """ - return types.Tool(file_search=types.FileSearch(**kwargs)) + return types.Tool( + file_search=types.FileSearch( + file_search_store_names=file_search_store_names, + top_k=top_k, + metadata_filter=metadata_filter, + ) + ) @staticmethod - def get_maps_grounding_tool(**kwargs: Any) -> types.Tool: + def get_maps_grounding_tool( + *, + enable_widget: bool | None = None, + auth_config: types.AuthConfig | None = None, + ) -> types.Tool: """Create a Google Maps grounding tool. Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``. - Keyword Args: - **kwargs: Passed directly to ``types.GoogleMaps``. Supported fields include - ``enable_widget``. + Args: + enable_widget: Return a widget context token in ``GroundingMetadata`` so callers + can render a Google Maps widget with geospatial context. + auth_config: Authentication config to access the Maps API. Only API key is + supported. Not supported in Gemini API. Returns: A ``types.Tool`` configured for Google Maps grounding. """ - return types.Tool(google_maps=types.GoogleMaps(**kwargs)) + return types.Tool(google_maps=types.GoogleMaps(enable_widget=enable_widget, auth_config=auth_config)) @override def _inner_get_response( diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 1eb45239a7..13daadbb14 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime import logging import os from typing import Any @@ -969,11 +970,23 @@ def test_get_web_search_tool_returns_google_search_tool() -> None: assert tool.google_search is not None -def test_get_web_search_tool_forwards_kwargs() -> None: - """Keyword arguments are passed through to types.GoogleSearch.""" - tool = GeminiChatClient.get_web_search_tool(exclude_domains=["example.com"]) +def test_get_web_search_tool_with_params() -> None: + """Parameters are forwarded to types.GoogleSearch.""" + time_range = types.Interval( + start_time=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), + end_time=datetime.datetime(2024, 12, 31, tzinfo=datetime.timezone.utc), + ) + tool = GeminiChatClient.get_web_search_tool( + search_types=types.SearchTypes(web_search=types.WebSearch()), + blocking_confidence=types.PhishBlockThreshold.BLOCK_LOW_AND_ABOVE, + exclude_domains=["example.com"], + time_range_filter=time_range, + ) assert tool.google_search is not None + assert tool.google_search.search_types is not None + assert tool.google_search.blocking_confidence == types.PhishBlockThreshold.BLOCK_LOW_AND_ABOVE assert tool.google_search.exclude_domains == ["example.com"] + assert tool.google_search.time_range_filter == time_range def test_get_code_interpreter_tool_returns_code_execution_tool() -> None: @@ -990,11 +1003,13 @@ def test_get_maps_grounding_tool_returns_google_maps_tool() -> None: assert tool.google_maps is not None -def test_get_maps_grounding_tool_forwards_kwargs() -> None: - """Keyword arguments are passed through to types.GoogleMaps.""" - tool = GeminiChatClient.get_maps_grounding_tool(enable_widget=True) +def test_get_maps_grounding_tool_with_params() -> None: + """Parameters are forwarded to types.GoogleMaps.""" + auth = types.AuthConfig(api_key="test-key") + tool = GeminiChatClient.get_maps_grounding_tool(enable_widget=True, auth_config=auth) assert tool.google_maps is not None assert tool.google_maps.enable_widget is True + assert tool.google_maps.auth_config == auth def test_get_file_search_tool_returns_file_search_tool() -> None: @@ -1005,8 +1020,8 @@ def test_get_file_search_tool_returns_file_search_tool() -> None: assert tool.file_search.file_search_store_names == ["stores/my-store"] -def test_get_file_search_tool_forwards_kwargs() -> None: - """Keyword arguments are passed through to types.FileSearch.""" +def test_get_file_search_tool_with_params() -> None: + """Parameters are forwarded to types.FileSearch.""" tool = GeminiChatClient.get_file_search_tool( file_search_store_names=["stores/my-store"], top_k=5, From 889a8462c751efa8876ce2807f9e7aebe5c1edb8 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 18:18:59 +0200 Subject: [PATCH 46/51] Refactor chat options handling in Gemini client --- .../agent_framework_gemini/_chat_client.py | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index ccca9750a8..0ae334625b 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -166,6 +166,29 @@ class GeminiSettings(TypedDict, total=False): _GEMINI_SERVICE_URL = "https://generativelanguage.googleapis.com" +# Keys mapping to a different GenerateContentConfig field name +_OPTION_TRANSLATIONS: dict[str, str] = { + "max_tokens": "max_output_tokens", + "stop": "stop_sequences", +} + +# Keys handled with dedicated logic, not via the generic passthrough +_OPTION_EXPLICIT_KEYS: frozenset[str] = frozenset({ + "tools", + "tool_choice", + "response_format", + "response_schema", + "thinking_config", +}) + +# Keys consumed upstream and not forwarded to GenerateContentConfig +_OPTION_CONSUMED_KEYS: frozenset[str] = frozenset({ + "model", + "instructions", +}) + +_OPTION_EXCLUDE_KEYS: frozenset[str] = _OPTION_EXPLICIT_KEYS | _OPTION_CONSUMED_KEYS + _FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = { "STOP": "stop", "MAX_TOKENS": "length", @@ -585,9 +608,9 @@ def _prepare_config( ) -> types.GenerateContentConfig: """Build a ``types.GenerateContentConfig`` from the resolved chat options. - Maps both standard ``ChatOptions`` fields (temperature, top_p, tools, etc.) and - ``GeminiChatOptions``-specific fields (``response_schema``, ``top_k``, ``thinking_config``) - to their ``GenerateContentConfig`` equivalents. + Note: ``_OPTION_TRANSLATIONS`` keys are renamed, ``_OPTION_EXCLUDE_KEYS`` are skipped, and all + remaining keys are forwarded as-is, allowing new Gemini parameters to be adopted without + framework changes. Args: options: Resolved chat options mapping, typically a ``GeminiChatOptions`` dict. @@ -598,35 +621,22 @@ def _prepare_config( """ kwargs: dict[str, Any] = {} - # Base ChatOptions fields if system_instruction: kwargs["system_instruction"] = system_instruction - if (v := options.get("temperature")) is not None: - kwargs["temperature"] = v - if (v := options.get("top_p")) is not None: - kwargs["top_p"] = v - if (v := options.get("max_tokens")) is not None: - kwargs["max_output_tokens"] = v - if (v := options.get("stop")) is not None: - kwargs["stop_sequences"] = v - if (v := options.get("seed")) is not None: - kwargs["seed"] = v - if (v := options.get("frequency_penalty")) is not None: - kwargs["frequency_penalty"] = v - if (v := options.get("presence_penalty")) is not None: - kwargs["presence_penalty"] = v - if options.get("response_format"): + + for key, value in options.items(): + if key in _OPTION_EXCLUDE_KEYS or value is None: + continue + kwargs[_OPTION_TRANSLATIONS.get(key, key)] = value + + if options.get("response_format") or options.get("response_schema"): kwargs["response_mime_type"] = "application/json" + if schema := options.get("response_schema"): + kwargs["response_schema"] = schema if tools := self._prepare_tools(options): kwargs["tools"] = tools if tool_config := self._prepare_tool_config(options.get("tool_choice")): kwargs["tool_config"] = tool_config - # Gemini-specific fields - if schema := options.get("response_schema"): - kwargs["response_mime_type"] = "application/json" - kwargs["response_schema"] = schema - if (v := options.get("top_k")) is not None: - kwargs["top_k"] = v if thinking_config := options.get("thinking_config"): thinking_config_kwargs = {k: v for k, v in thinking_config.items() if v is not None} if thinking_config_kwargs: From fa18e0e41dd8699ef3886c8d14ddcc09036885b7 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 18:22:09 +0200 Subject: [PATCH 47/51] Add tests for handling unknown and consumed keys --- .../gemini/tests/test_gemini_client.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index 13daadbb14..07248cb7e5 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -643,6 +643,36 @@ async def test_prepare_config_frequency_and_presence_penalty() -> None: assert config.presence_penalty == 0.2 +async def test_prepare_config_unknown_key_is_forwarded() -> None: + """Keys absent from _OPTION_EXCLUDE_KEYS and _OPTION_TRANSLATIONS are forwarded as-is.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + with patch("agent_framework_gemini._chat_client.types.GenerateContentConfig") as mock_config: + mock_config.return_value = MagicMock() + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"some_future_param": "value"}, + ) + assert mock_config.call_args.kwargs.get("some_future_param") == "value" + + +async def test_prepare_config_consumed_keys_are_excluded() -> None: + """Keys consumed upstream (model, instructions) are not forwarded to GenerateContentConfig.""" + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + with patch("agent_framework_gemini._chat_client.types.GenerateContentConfig") as mock_config: + mock_config.return_value = MagicMock() + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={"model": "gemini-2.5-pro", "instructions": "Be helpful."}, + ) + kwargs = mock_config.call_args.kwargs + assert "model" not in kwargs + assert "instructions" not in kwargs + + # thinking config From d29c077e6f7022d010bfdf967d43aee9eaa6db62 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Fri, 10 Apr 2026 18:36:09 +0200 Subject: [PATCH 48/51] Update Gemini documentation Now reflects new options and built-in tool factory methods --- python/packages/gemini/AGENTS.md | 15 +++++++++++---- python/packages/gemini/README.md | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/python/packages/gemini/AGENTS.md b/python/packages/gemini/AGENTS.md index e50dc06d66..aa12fddf0a 100644 --- a/python/packages/gemini/AGENTS.md +++ b/python/packages/gemini/AGENTS.md @@ -10,12 +10,19 @@ Integration with Google's Gemini API via the `google-genai` SDK. - **`GeminiSettings`** - Settings loaded from environment variables - **`ThinkingConfig`** - Configuration for extended thinking -## Gemini Options +## Gemini-specific Options - **`thinking_config`** - Enable extended thinking via `ThinkingConfig` -- **`code_execution`** - Let the model write and run code in a sandboxed environment -- **`google_search_grounding`** - Responses with live Google Search results -- **`google_maps_grounding`** - Responses with Google Maps data +- **`response_schema`** - Raw JSON schema dict for structured output (alternative to `response_format`) +- **`top_k`** - Top-K sampling parameter + +## Built-in Tool Factory Methods + +- **`get_web_search_tool()`** - Google Search grounding for up-to-date web answers +- **`get_code_interpreter_tool()`** - Sandboxed code execution +- **`get_maps_grounding_tool()`** - Google Maps grounding for location and mapping +- **`get_file_search_tool()`** - Retrieval from Gemini file search stores +- **`get_mcp_tool()`** - Model Context Protocol server integration ## Usage diff --git a/python/packages/gemini/README.md b/python/packages/gemini/README.md index f0a10097e8..e72e2f8126 100644 --- a/python/packages/gemini/README.md +++ b/python/packages/gemini/README.md @@ -16,14 +16,15 @@ Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) an ```bash export GEMINI_API_KEY="your-api-key" -export GEMINI_CHAT_MODEL="gemini-2.5-flash" +export GEMINI_MODEL="gemini-2.5-flash" ``` ## Examples -See the [Google Gemini samples](../../samples/02-agents/providers/google/) for runnable end-to-end scripts covering: +See the [Google Gemini samples](samples/) for runnable end-to-end scripts covering: - Basic agent with tool calling and streaming - Extended thinking with `ThinkingConfig` - Google Search grounding +- Google Maps grounding - Built-in code execution From 2c44e2967ea4f3ab6796e4d73382882008b550f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Holtvogt?= Date: Tue, 14 Apr 2026 11:04:37 +0200 Subject: [PATCH 49/51] Change build system to flit Co-authored-by: Eduard van Valkenburg --- python/packages/gemini/pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 8a650eb984..797388e6e0 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -97,7 +97,12 @@ cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_gemini" help = "Run the default unit test suite for this package." cmd = 'pytest -m "not integration" --cov=agent_framework_gemini --cov-report=term-missing:skip-covered tests' -[tool.uv.build-backend] +[tool.flit.module] +name = "agent_framework_gemini" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" module-name = "agent_framework_gemini" module-root = "" From b05b6274cf99e70b4f9c22127657568b2b663075 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 14 Apr 2026 11:37:37 +0200 Subject: [PATCH 50/51] Fix build system in pyproject.toml --- python/packages/gemini/pyproject.toml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 797388e6e0..bae8b4ac1a 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -103,9 +103,3 @@ name = "agent_framework_gemini" [build-system] requires = ["flit-core >= 3.11,<4.0"] build-backend = "flit_core.buildapi" -module-name = "agent_framework_gemini" -module-root = "" - -[build-system] -requires = ["uv_build>=0.8.2,<0.9.0"] -build-backend = "uv_build" From 8fac9e4f4e7c37c08dab015fd5a4b858870d41e6 Mon Sep 17 00:00:00 2001 From: Bjoern Holtvogt Date: Tue, 14 Apr 2026 11:38:00 +0200 Subject: [PATCH 51/51] Fix type checking for generate_content_stream --- python/packages/gemini/agent_framework_gemini/_chat_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index 0ae334625b..2f56b3f9a4 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -395,7 +395,7 @@ def _inner_get_response( async def _stream() -> AsyncIterable[ChatResponseUpdate]: validated = await self._validate_options(options) model, contents, config = self._prepare_request(messages, validated) - async for chunk in await self._genai_client.aio.models.generate_content_stream( + async for chunk in await self._genai_client.aio.models.generate_content_stream( # pyright: ignore[reportUnknownMemberType] model=model, contents=contents, # type: ignore[arg-type] config=config,