diff --git a/pyproject.toml b/pyproject.toml index 7a2df7ea8..909153304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ opentelemetry = ["opentelemetry-api>=1.11.1,<2", "opentelemetry-sdk>=1.11.1,<2"] pydantic = ["pydantic>=2.0.0,<3"] openai-agents = ["openai-agents>=0.3,<0.7", "mcp>=1.9.4, <2"] google-adk = ["google-adk>=1.27.0,<2"] +google-gemini = [ + "google-genai>=1.66.0", +] [project.urls] Homepage = "https://github.com/temporalio/sdk-python" diff --git a/temporalio/contrib/google_gemini_sdk/__init__.py b/temporalio/contrib/google_gemini_sdk/__init__.py new file mode 100644 index 000000000..451bd5308 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/__init__.py @@ -0,0 +1,63 @@ +"""First-class Temporal integration for the Google Gemini SDK. + +.. warning:: + This module is experimental and may change in future versions. + Use with caution in production environments. + +This integration lets you use the Gemini SDK's async client with full +automatic function calling (AFC) support, where every API call and every +tool invocation is a **durable Temporal activity**. + +No credentials are fetched in the workflow, and no auth material appears in +Temporal's event history. + +- :class:`GeminiPlugin` — registers the ``gemini_api_call`` activity + using a caller-provided ``genai.Client`` on the worker side. +- :func:`gemini_client` — call from a workflow to get an ``AsyncClient`` + that routes API calls through activities. +- :func:`activity_as_tool` — convert any ``@activity.defn`` function into a + Gemini tool callable; Gemini's AFC invokes it as a Temporal activity. + +Quickstart:: + + # ---- worker setup (outside sandbox) ---- + client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) + plugin = GeminiPlugin(client) + + @activity.defn + async def get_weather(state: str) -> str: ... + + # ---- workflow (sandbox-safe) ---- + @workflow.defn + class AgentWorkflow: + @workflow.run + async def run(self, query: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=query, + config=types.GenerateContentConfig( + tools=[ + activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(seconds=30), + ), + ], + ), + ) + return response.text +""" + +from __future__ import annotations + +from temporalio.contrib.google_gemini_sdk._gemini_plugin import GeminiPlugin +from temporalio.contrib.google_gemini_sdk.workflow import ( + activity_as_tool, + gemini_client, +) + +__all__ = [ + "GeminiPlugin", + "activity_as_tool", + "gemini_client", +] diff --git a/temporalio/contrib/google_gemini_sdk/_gemini_activity.py b/temporalio/contrib/google_gemini_sdk/_gemini_activity.py new file mode 100644 index 000000000..401fa8caf --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/_gemini_activity.py @@ -0,0 +1,66 @@ +"""Temporal activity that executes Gemini SDK API calls with real credentials. + +The ``TemporalApiClient`` in the workflow dispatches calls here. This activity +holds a user-provided ``genai.Client`` and forwards the structured request. +Credentials are fetched/refreshed only within the activity — they never appear +in workflow event history. +""" + +from __future__ import annotations + +from typing import Any + +from google.genai import Client as GeminiClient +from google.genai.types import HttpOptions +from google.genai.types import HttpResponse as SdkHttpResponse + +from temporalio import activity +from temporalio.contrib.google_gemini_sdk._temporal_api_client import ( + _GeminiApiRequest, + _GeminiApiResponse, +) + + +class GeminiApiCaller: + """Wraps a ``genai.Client`` and exposes it as a Temporal activity. + + The caller owns a reference to the user-provided ``genai.Client``. + All credential management, HTTP client configuration, etc. is the + responsibility of whoever constructs the client. + """ + + def __init__(self, client: GeminiClient) -> None: + self._client = client + + def activity(self) -> Any: + """Return a ``gemini_api_call`` activity that closes over this instance.""" + + @activity.defn(name="gemini_api_call") + async def gemini_api_call(req: _GeminiApiRequest) -> _GeminiApiResponse: + """Execute a Gemini SDK API call with real credentials. + + This activity is registered automatically by + :class:`~temporalio.contrib.google_gemini_sdk.GeminiPlugin`. + Do not call it directly. + """ + per_request_options: HttpOptions | None = ( + HttpOptions.model_validate( + req.http_options_overrides.model_dump(exclude_none=True) + ) + if req.http_options_overrides + else None + ) + response: SdkHttpResponse = ( + await self._client.aio._api_client.async_request( + http_method=req.http_method, + path=req.path, + request_dict=req.request_dict, + http_options=per_request_options, + ) + ) + return _GeminiApiResponse( + headers=response.headers or {}, + body=response.body or "", + ) + + return gemini_api_call diff --git a/temporalio/contrib/google_gemini_sdk/_gemini_plugin.py b/temporalio/contrib/google_gemini_sdk/_gemini_plugin.py new file mode 100644 index 000000000..cf84a6da2 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/_gemini_plugin.py @@ -0,0 +1,83 @@ +"""Temporal plugin for Google Gemini SDK integration.""" + +from __future__ import annotations + +import dataclasses + +from google.genai import Client as GeminiClient + +from temporalio.contrib.google_gemini_sdk._gemini_activity import GeminiApiCaller +from temporalio.contrib.pydantic import PydanticPayloadConverter +from temporalio.converter import DataConverter, DefaultPayloadConverter +from temporalio.plugin import SimplePlugin +from temporalio.worker import WorkflowRunner +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner + + +def _data_converter(converter: DataConverter | None) -> DataConverter: + if converter is None: + return DataConverter(payload_converter_class=PydanticPayloadConverter) + elif converter.payload_converter_class is DefaultPayloadConverter: + return dataclasses.replace( + converter, payload_converter_class=PydanticPayloadConverter + ) + return converter + + +class GeminiPlugin(SimplePlugin): + """A Temporal Worker Plugin configured for the Google Gemini SDK. + + .. warning:: + This class is experimental and may change in future versions. + Use with caution in production environments. + + This plugin registers the ``gemini_api_call`` activity, which uses the + provided ``genai.Client`` with real credentials. Workflows use + :func:`~temporalio.contrib.google_gemini_sdk.workflow.gemini_client` to + get an ``AsyncClient`` backed by a ``TemporalApiClient`` that routes all + API calls through this activity. + + No credentials are passed to or from the workflow. Auth material never + appears in Temporal's event history. + + Example (Gemini Developer API):: + + client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) + plugin = GeminiPlugin(client) + + Example (Vertex AI):: + + client = genai.Client( + vertexai=True, project="my-project", location="us-central1", + ) + plugin = GeminiPlugin(client) + """ + + def __init__(self, client: GeminiClient) -> None: + """Initialize the Gemini plugin. + + Args: + client: A fully configured ``genai.Client`` instance. + All credential management, HTTP client configuration, etc. + is the responsibility of the caller. + """ + self._api_caller = GeminiApiCaller(client) + + def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner: + if not runner: + raise ValueError("No WorkflowRunner provided to GeminiPlugin.") + if isinstance(runner, SandboxedWorkflowRunner): + return dataclasses.replace( + runner, + restrictions=runner.restrictions.with_passthrough_modules( + "google.genai" + ), + ) + return runner + + super().__init__( + name="GeminiPlugin", + data_converter=_data_converter, + activities=[self._api_caller.activity()], + workflow_runner=workflow_runner, + ) diff --git a/temporalio/contrib/google_gemini_sdk/_temporal_api_client.py b/temporalio/contrib/google_gemini_sdk/_temporal_api_client.py new file mode 100644 index 000000000..1f4f64885 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/_temporal_api_client.py @@ -0,0 +1,244 @@ +"""Temporal-aware BaseApiClient that routes SDK calls through activities. + +This module provides ``TemporalApiClient``, a minimal ``BaseApiClient`` +subclass whose ``async_request()`` dispatches through a Temporal activity +instead of making direct HTTP calls. The real ``genai.Client`` with real +credentials only exists on the worker side inside the activity. + +This ensures: +- No credential fetching or refreshing happens in the workflow. +- No auth material (tokens, API keys) appears in Temporal event history. +- The SDK's AFC (automatic function calling) loop runs in the workflow, + so ``activity_as_tool()`` wrappers work naturally. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, Optional + +from google.genai._api_client import BaseApiClient +from google.genai.types import HttpOptions, HttpOptionsOrDict +from google.genai.types import HttpResponse as SdkHttpResponse +from pydantic import BaseModel + +from temporalio import workflow as temporal_workflow +from temporalio.workflow import ActivityConfig + + +class _SerializableHttpOptions(BaseModel): + """Per-request HTTP options that can be serialized across the activity boundary. + + Non-serializable fields (httpx_client, httpx_async_client, aiohttp_client, + client_args, async_client_args) must be configured at GeminiPlugin init. + + ``timeout`` is excluded because Temporal owns timeouts/retries — configure + via ``ActivityConfig`` instead. + """ + + base_url: str | None = None + base_url_resource_scope: str | None = None + api_version: str | None = None + headers: dict[str, str] | None = None + extra_body: dict[str, Any] | None = None + + +# Fields on HttpOptions that cannot be serialized or should not be forwarded. +_REJECTED_HTTP_OPTION_FIELDS = frozenset( + { + "httpx_client", + "httpx_async_client", + "aiohttp_client", + "client_args", + "async_client_args", + } +) + + +class _GeminiApiRequest(BaseModel): + """Serializable activity input for a Gemini SDK API call.""" + + http_method: str + path: str + request_dict: dict[str, object] + http_options_overrides: _SerializableHttpOptions | None = None + + +class _GeminiApiResponse(BaseModel): + """Serializable activity output for a Gemini SDK API call.""" + + headers: dict[str, str] + body: str + + +class TemporalApiClient(BaseApiClient): + """A ``BaseApiClient`` that routes all API calls through Temporal activities. + + This client is used on the workflow side. It does NOT initialize HTTP + clients, load credentials, or make any network calls. It only holds the + minimal configuration needed for the SDK's request formatting logic + (e.g., choosing between Vertex AI and ML Dev parameter transformations). + + All actual HTTP calls are dispatched via ``workflow.execute_activity``. + """ + + def __init__( + self, + *, + vertexai: bool = False, + project: str | None = None, + location: str | None = None, + activity_config: ActivityConfig | None = None, + ) -> None: + # Do NOT call super().__init__() — it creates HTTP clients, loads + # credentials, etc. We only set the properties the SDK's request + # formatting code accesses. + self.vertexai = vertexai + self.project = project + self.location = location + self.api_key: str | None = None + self.custom_base_url: str | None = None + + self._activity_config = activity_config or ActivityConfig( + start_to_close_timeout=__import__("datetime").timedelta(seconds=60), + ) + + def _verify_response(self, response_model: Any) -> None: + """No-op — matches the base implementation.""" + pass + + def close(self) -> None: + """No-op — no HTTP resources to close.""" + pass + + async def aclose(self) -> None: + """No-op — no HTTP resources to close.""" + pass + + def __del__(self) -> None: + """No-op — no HTTP resources to clean up.""" + pass + + @staticmethod + def _process_http_options( + http_options: Optional[HttpOptionsOrDict], + config: ActivityConfig, + ) -> _SerializableHttpOptions | None: + """Validate and extract serializable per-request HTTP options. + + Rejects non-serializable fields (custom HTTP clients), maps timeout + to the Temporal activity config, and returns the remaining options + for forwarding to the activity. + + Args: + http_options: Per-request options from the SDK call. + config: Mutable activity config dict — timeout is applied here. + + Returns: + Serializable options to forward, or None if nothing to forward. + """ + if http_options is None: + return None + + if isinstance(http_options, HttpOptions): + opts = http_options + else: + opts = HttpOptions.model_validate(http_options) + + bad_fields = [ + f + for f in _REJECTED_HTTP_OPTION_FIELDS + if getattr(opts, f, None) is not None + ] + if bad_fields: + raise ValueError( + f"Per-request http_options cannot include {bad_fields}. " + f"Configure custom HTTP clients at GeminiPlugin init instead." + ) + + # timeout is owned by Temporal — apply it to the activity config + # rather than forwarding to the underlying HTTP client. + if opts.timeout is not None: + config["start_to_close_timeout"] = timedelta(milliseconds=opts.timeout) + + result = _SerializableHttpOptions( + base_url=opts.base_url, + base_url_resource_scope=( + opts.base_url_resource_scope.value + if opts.base_url_resource_scope + else None + ), + api_version=opts.api_version, + headers=opts.headers, + extra_body=opts.extra_body, + ) + # Only return if there are actual values set + if not result.model_dump(exclude_none=True): + return None + return result + + # ── Async (primary path for workflows) ────────────────────────────── + + async def async_request( + self, + http_method: str, + path: str, + request_dict: dict[str, object], + http_options: Optional[HttpOptionsOrDict] = None, + ) -> SdkHttpResponse: + config: ActivityConfig = {**self._activity_config} + if "summary" not in config: + # Default summary is the API path (e.g. "models/gemini-2.5-flash:generateContent"). + config["summary"] = path + overrides = self._process_http_options(http_options, config) + + resp = await temporal_workflow.execute_activity( + "gemini_api_call", + _GeminiApiRequest( + http_method=http_method, + path=path, + request_dict=request_dict, + http_options_overrides=overrides, + ), + result_type=_GeminiApiResponse, + **config, + ) + return SdkHttpResponse(headers=resp.headers, body=resp.body) + + # ── Sync (not expected in async workflows, but raise clearly) ─────── + + def request( + self, + http_method: str, + path: str, + request_dict: dict[str, object], + http_options: Optional[HttpOptionsOrDict] = None, + ) -> SdkHttpResponse: + raise RuntimeError( + "Synchronous requests are not supported in Temporal workflows. " + "Use the AsyncClient returned by gemini_client() instead." + ) + + def request_streamed( + self, + http_method: str, + path: str, + request_dict: dict[str, object], + http_options: Optional[HttpOptionsOrDict] = None, + ) -> Any: + raise RuntimeError( + "Synchronous streaming is not supported in Temporal workflows. " + "Use the AsyncClient returned by gemini_client() instead." + ) + + async def async_request_streamed( + self, + http_method: str, + path: str, + request_dict: dict[str, object], + http_options: Optional[HttpOptionsOrDict] = None, + ) -> Any: + raise NotImplementedError( + "Streaming is not yet supported in the Temporal Gemini integration. " + "Use generate_content() instead of generate_content_stream()." + ) diff --git a/temporalio/contrib/google_gemini_sdk/first_class_example/start_workflow.py b/temporalio/contrib/google_gemini_sdk/first_class_example/start_workflow.py new file mode 100644 index 000000000..1a773a87e --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/first_class_example/start_workflow.py @@ -0,0 +1,32 @@ +# ABOUTME: Client script to start the first-class Gemini agent workflow. +# No GOOGLE_API_KEY needed here — only the worker requires it. + +import asyncio +import sys +import uuid + +from temporalio.client import Client +from temporalio.contrib.pydantic import pydantic_data_converter + +TASK_QUEUE = "gemini-first-class" + + +async def main() -> None: + client = await Client.connect( + "localhost:7233", + data_converter=pydantic_data_converter, + ) + + query = sys.argv[1] if len(sys.argv) > 1 else "What's the weather like right now?" + + result = await client.execute_workflow( + "WeatherAgentWorkflow", + query, + id=f"gemini-first-class-{uuid.uuid4()}", + task_queue=TASK_QUEUE, + ) + print(f"\nResult:\n{result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/temporalio/contrib/google_gemini_sdk/first_class_example/worker.py b/temporalio/contrib/google_gemini_sdk/first_class_example/worker.py new file mode 100644 index 000000000..64bf8c895 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/first_class_example/worker.py @@ -0,0 +1,192 @@ +# ABOUTME: First-class Temporal + Gemini SDK integration demo. +# +# Key differences from example/durable_agent_worker.py: +# - AFC is ENABLED: Gemini's SDK owns the agentic loop, no manual while-True +# - Tools are plain @activity.defn functions; no registry, no dynamic activities +# - activity_as_tool() makes each tool call a durable Temporal activity +# - gemini_client() routes each model API call through a durable Temporal activity +# - GeminiPlugin owns the real genai.Client on the worker side +# - No credentials in workflow event history +# - No run_agent(), no inspect hackery, no print() logging + +import asyncio +import json +import os +from datetime import timedelta + +from google import genai +from google.genai import types +from pydantic import BaseModel, Field + +import temporalio.contrib.google_gemini_sdk.workflow +from temporalio import activity, workflow +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from temporalio.contrib.google_gemini_sdk import GeminiPlugin, activity_as_tool +from temporalio.workflow import ActivityConfig + +# ============================================================================= +# System Instructions +# ============================================================================= + +SYSTEM_INSTRUCTIONS = """ +You are a helpful agent that can use tools to help the user. +You will be given an input from the user and a list of tools to use. +You may or may not need to use the tools to satisfy the user ask. +If no tools are needed, respond in haikus. +""" + +# ============================================================================= +# Tool Definitions — plain @activity.defn functions, no registry required +# ============================================================================= + +NWS_API_BASE = "https://api.weather.gov" +USER_AGENT = "weather-app/1.0" + + +class GetWeatherAlertsRequest(BaseModel): + """Request model for getting weather alerts.""" + + state: str = Field(description="Two-letter US state code (e.g. CA, NY)") + + +@activity.defn +async def get_weather_alerts(request: GetWeatherAlertsRequest) -> str: + """Get weather alerts for a US state. + + Args: + request: The request object containing: + - state: Two-letter US state code (e.g. CA, NY) + """ + import httpx + + headers = {"User-Agent": USER_AGENT, "Accept": "application/geo+json"} + url = f"{NWS_API_BASE}/alerts/active/area/{request.state}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=5.0) + response.raise_for_status() + return json.dumps(response.json()) + + +@activity.defn +async def get_ip_address() -> str: + """Get the public IP address of the current machine.""" + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get("https://icanhazip.com") + response.raise_for_status() + return response.text.strip() + + +class GetLocationRequest(BaseModel): + """Request model for getting location info from an IP address.""" + + ipaddress: str = Field(description="An IP address") + + +@activity.defn +async def get_location_info(request: GetLocationRequest) -> str: + """Get the location information for an IP address including city, state, and country. + + Args: + request: The request object containing: + - ipaddress: An IP address to look up + """ + import httpx + + async with httpx.AsyncClient() as client: + response = await client.get(f"http://ip-api.com/json/{request.ipaddress}") + response.raise_for_status() + result = response.json() + return f"{result['city']}, {result['regionName']}, {result['country']}" + + +# ============================================================================= +# Workflow — natural Gemini SDK usage; AFC drives the loop; all calls are durable +# ============================================================================= + +TASK_QUEUE = "gemini-first-class" + + +@workflow.defn +class WeatherAgentWorkflow: + """Durable agentic workflow powered by Gemini SDK and Temporal. + + The Gemini SDK's automatic function calling (AFC) drives the multi-turn + agentic loop. gemini_client() routes every model API call through a + durable Temporal activity. activity_as_tool() ensures every tool + invocation is also a durable Temporal activity. Together, every step + of the agentic loop is visible in the workflow event history and + recoverable after a crash. + """ + + @workflow.run + async def run(self, query: str) -> str | None: + client = temporalio.contrib.google_gemini_sdk.workflow.gemini_client( + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(minutes=5), + ), + ) + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=query, + config=types.GenerateContentConfig( + system_instruction=SYSTEM_INSTRUCTIONS, + http_options=types.HttpOptions( + timeout=120_000, # 120s — applied as activity timeout + headers={"X-Custom-Header": "temporal-gemini-demo"}, + ), + tools=[ + activity_as_tool( + get_weather_alerts, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=30), + ), + ), + activity_as_tool( + get_ip_address, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + activity_as_tool( + get_location_info, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + ], + ), + ) + return response.text + + +# ============================================================================= +# Worker — plugin owns client creation, start worker +# ============================================================================= + + +async def main() -> None: + # The genai.Client holds credentials; GeminiPlugin ensures they stay + # on the worker side and never appear in Temporal's event history. + gemini_client = genai.Client(api_key=os.environ["GOOGLE_API_KEY"]) + plugin = GeminiPlugin(gemini_client) + + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config, plugins=[plugin]) + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[WeatherAgentWorkflow], + activities=[get_weather_alerts, get_ip_address, get_location_info], + ): + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/temporalio/contrib/google_gemini_sdk/justfile b/temporalio/contrib/google_gemini_sdk/justfile new file mode 100644 index 000000000..b883c0379 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/justfile @@ -0,0 +1,11 @@ +set dotenv-filename := ".env.local" +set dotenv-load + +run: + uv run python test_gemini.py + +worker: + uv run python first_class_example/worker.py + +query q="What's the weather right now?": + uv run python first_class_example/start_workflow.py "{{q}}" diff --git a/temporalio/contrib/google_gemini_sdk/plans/temporal_api_client.md b/temporalio/contrib/google_gemini_sdk/plans/temporal_api_client.md new file mode 100644 index 000000000..3b24153af --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/plans/temporal_api_client.md @@ -0,0 +1,221 @@ +# Plan: TemporalApiClient — Activity-level interception of Gemini SDK + +## Problem + +The current approach intercepts the Gemini SDK at the HTTP transport layer (custom +`httpx.AsyncClient`). This has two issues: + +1. **Auth in event history** — `BaseApiClient` fetches/refreshes credentials *before* the + request reaches the transport. The `Authorization` header (bearer token) and `x-goog-api-key` + end up serialized in activity inputs and stored in Temporal event history. + +2. **Non-deterministic network calls in the workflow** — credential refresh + (`google.auth.default()`, `credentials.refresh()`) makes network calls from within + the workflow context, violating Temporal's determinism requirements. + +Both stem from the same root cause: the HTTP transport sits *below* the SDK's credential +management. There is no `HttpOptions` configuration that can prevent the SDK from fetching +credentials before handing the request to the transport. + +## Design + +### Core idea + +Replace the current HTTP transport interception with a custom `BaseApiClient` subclass +(`TemporalApiClient`) that overrides `request()` / `async_request()` to route calls +through Temporal activities. The real `genai.Client` (with real credentials) only exists +inside the activity on the worker side. + +This means the SDK's own code — including the AFC (automatic function calling) loop — +runs in the workflow. Only the actual API call crosses into an activity. Tools wrapped +with `activity_as_tool()` are called by the SDK's AFC loop from workflow context, so +`workflow.execute_activity` works naturally. + +### Interception point + +`BaseApiClient.request()` and `async_request()` receive `(http_method, path, +request_dict, http_options)` — structured data with no auth attached. Auth is added +later in `_request_once()` / `_async_request_once()`, which we never call. + +``` +SDK call chain: + + AsyncModels.generate_content() ← AFC loop lives here (workflow) + → _generate_content() ← request formatting (workflow) + → _api_client.async_request() ← INTERCEPT HERE → activity + → _build_request() ← adds auth headers (skipped in workflow) + → _async_request_once() ← HTTP call (skipped in workflow) +``` + +### What flows through event history + +| Layer | Activity input contains | +|----------------------|------------------------------------------------------| +| HTTP transport (old) | Full HTTP request + `Authorization: Bearer ` | +| `request()` (new) | `http_method` + `path` + `request_dict` — no auth | + +### AFC loop (unchanged) + +The SDK's built-in AFC loop in `AsyncModels.generate_content` (models.py ~line 7574): + +```python +while remaining_remote_calls_afc > 0: + response = await self._generate_content(...) # → activity (via TemporalApiClient) + func_response_parts = await get_function_response_parts_async( + response, function_map # → calls activity_as_tool wrappers + ) + contents.append(func_call_content) + contents.append(func_response_content) +``` + +Each iteration produces two durable events in workflow history: +1. The Gemini API call (activity via `TemporalApiClient.async_request`) +2. The tool invocation (activity via `activity_as_tool`) + +## Components + +### 1. `TemporalApiClient(BaseApiClient)` + +Workflow-side. Minimal `__init__` that sets only the properties needed for request +formatting — no HTTP clients, no credential loading. + +**Required properties:** +- `vertexai: bool` — needed by `_generate_content` to choose between + `_GenerateContentParameters_to_vertex` vs `_GenerateContentParameters_to_mldev` +- `project: str | None` — needed by `_transformers.t_caches_model()` when + `vertexai=True`. Can default to `None` for non-Vertex usage. +- `location: str | None` — same as `project` + +**Overridden methods:** +- `request()` → `workflow.execute_activity(gemini_api_request, ...)` +- `async_request()` → `await workflow.execute_activity(gemini_api_request, ...)` +- Streaming variants TBD (may collect chunks in activity and return as list) + +**Not needed:** +- `_build_request()`, `_request_once()`, `_async_request_once()` — never called +- `_credentials`, `_httpx_client`, `_async_httpx_client` — no HTTP in workflow +- `_access_token()`, `_async_access_token()` — no auth in workflow + +### 2. `gemini_api_request` activity + +Worker-side. Registered by `GeminiPlugin`. + +``` +Input: (http_method: str, path: str, request_dict: dict) +Output: SdkHttpResponse (headers + body) +``` + +The activity creates (or reuses) a real `genai.Client` with real credentials from +the worker environment and calls `_api_client.request()`. + +### 3. `gemini_client(vertexai=False)` factory + +Workflow-side. Returns an `AsyncClient` directly — not a `genai.Client`. This is the +right choice because: + +- `AsyncClient.__init__` already accepts a `BaseApiClient` parameter (`api_client`), + so we can pass `TemporalApiClient` directly with no subclassing of `Client` needed. +- Temporal workflows are async, so callers must use the async interface anyway. + Returning `genai.Client` would only add a `.aio` indirection that serves no purpose. +- Avoids triggering `Client.__init__`, which calls `_get_api_client()` and sets up + sync resources (`Models`, `Tunings`, etc.) that would never be used. + +```python +from google.genai.client import AsyncClient + +def gemini_client( + *, + vertexai: bool = False, + project: str | None = None, + location: str | None = None, +) -> AsyncClient: + temporal_api_client = TemporalApiClient( + vertexai=vertexai, project=project, location=location, + ) + return AsyncClient(temporal_api_client) +``` + +Callers use it directly in their workflow `run` method: + +```python +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self) -> str: + client = gemini_client(vertexai=True) + response = await client.models.generate_content( + model="gemini-2.0-flash", + contents="Hello", + config=GenerateContentConfig(tools=[activity_as_tool(my_tool)]), + ) + return response.text +``` + +### 4. `GeminiPlugin` updates + +Worker-side. Holds the real client configuration and registers `gemini_api_request`. + +```python +plugin = GeminiPlugin( + api_key="...", # or vertexai + project + location + credentials +) +worker = Worker(..., plugins=[plugin]) +``` + +### 5. `activity_as_tool(fn)` (unchanged) + +Already works. The SDK's AFC loop calls these from workflow context, which triggers +`workflow.execute_activity`. No changes needed. + +## Configuration + +| Setting | Where | Purpose | +|----------------------|------------------------------|----------------------------------| +| `vertexai` | Both | Request formatting + routing | +| `project`, `location`| Both (when `vertexai=True`) | Path construction + routing | +| `api_key` | `GeminiPlugin` / worker env | Auth (activity only) | +| `credentials` | `GeminiPlugin` / ADC on worker| Auth (activity only) | + +`vertexai`, `project`, and `location` are duplicated between workflow and worker +configuration. These are non-sensitive routing/formatting values. Auth material +(`api_key`, `credentials`) only exists on the worker side. + +## Migration from current approach + +The current HTTP transport interception (`TemporalHttpxClient`, `_http_activity.py`, +`_sensitive_fields_codec.py`) can be removed once this approach is implemented. The +field-level encryption codec is no longer needed since auth material never enters +event history. + +## Resolved questions + +- **Client construction:** Use `AsyncClient(temporal_api_client)` directly. + `AsyncClient.__init__` already accepts a `BaseApiClient`, no subclassing needed. + +- **`_api_client` property access:** `TemporalApiClient` must expose: + - `vertexai: bool` — accessed in 88+ locations in `models.py` to choose between + `_GenerateContentParameters_to_vertex` vs `_to_mldev`. + - `project: str | None` — accessed in `_transformers.py` `t_caches_model()` for + Vertex AI cache model path construction. Only needed when `vertexai=True`. + - `location: str | None` — same as `project`, used in `t_caches_model()`. + - `_verify_response()` — called after every API response. The base implementation + is a no-op, so `TemporalApiClient` inherits it or provides its own no-op. + The AFC loop helpers (`get_function_map`, `get_function_response_parts_async`, + `should_disable_afc`) do **not** access `_api_client` properties. + +- **Client reuse in activity:** The activity **must** cache the `genai.Client` at the + plugin/worker level, not create one per call. `BaseApiClient.__init__` creates SSL + contexts, httpx sync+async connection pools, and potentially triggers credential + loading via `google.auth.default()` (100ms-2s+ for Vertex AI). Creating per-call + wastes connection pool reuse and risks resource exhaustion under concurrency. + `GeminiPlugin` should create the client once and pass it to the activity. + +- **Streaming:** Both `generate_content_stream` and non-streaming `generate_content` + have their own AFC loops. The non-streaming AFC loop (line 7574) calls + `_generate_content` (non-streaming). The streaming AFC loop (line 7750) calls + `_generate_content_stream` (streaming), but must consume the entire stream each + iteration to detect function calls before looping. + For initial implementation: support non-streaming only. The activity returns a + single `SdkHttpResponse`. Streaming support can be added later by collecting all + chunks in the activity and returning them as a list, since the AFC loop consumes + the full stream anyway. diff --git a/temporalio/contrib/google_gemini_sdk/workflow.py b/temporalio/contrib/google_gemini_sdk/workflow.py new file mode 100644 index 000000000..fb739079a --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/workflow.py @@ -0,0 +1,184 @@ +"""Workflow utilities for Google Gemini SDK integration with Temporal. + +This module provides utilities for using the Google Gemini SDK within Temporal +workflows. The key entry points are: + +- :func:`gemini_client` — returns an ``AsyncClient`` backed by a + ``TemporalApiClient`` that routes all API calls through Temporal activities. +- :func:`activity_as_tool` — converts a Temporal activity into a Gemini tool + callable for use with automatic function calling (AFC). +""" + +from __future__ import annotations + +import functools +import inspect +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from google.genai.client import AsyncClient + +from temporalio import activity +from temporalio import workflow as temporal_workflow +from temporalio.contrib.google_gemini_sdk._temporal_api_client import ( + TemporalApiClient, +) +from temporalio.exceptions import ApplicationError +from temporalio.workflow import ActivityConfig + + +def activity_as_tool( + fn: Callable, + *, + activity_config: ActivityConfig | None = None, +) -> Callable: + """Convert a Temporal activity into a Gemini-compatible async tool callable. + + .. warning:: + This API is experimental and may change in future versions. + Use with caution in production environments. + + Returns an async callable with the same name, docstring, and type signature as + ``fn``. When Gemini's automatic function calling (AFC) invokes the returned + callable from within a Temporal workflow, the call is executed as a Temporal + activity via :func:`workflow.execute_activity`. Each tool invocation therefore + appears as a separate, durable entry in the workflow event history. + + Because AFC is left **enabled**, the Gemini SDK owns the agentic loop — no + manual ``while`` loop or ``run_agent()`` helper is required. Pass the returned + callable directly to ``GenerateContentConfig(tools=[...])``. + + Args: + fn: A Temporal activity function decorated with ``@activity.defn``. + activity_config: Configuration for the activity execution (timeouts, + retry policy, etc.). Defaults to a 30-second + ``start_to_close_timeout``. + + Returns: + An async callable suitable for use as a Gemini tool. + + Raises: + ApplicationError: If ``fn`` is not decorated with ``@activity.defn`` or + has no activity name. + """ + ret = activity._Definition.from_callable(fn) + if not ret: + raise ApplicationError( + "Bare function without @activity.defn decorator is not supported", + "invalid_tool", + ) + if ret.name is None: + raise ApplicationError( + "Activity must have a name to be used as a Gemini tool", + "invalid_tool", + ) + + config: ActivityConfig = { + **( + activity_config + or ActivityConfig( + start_to_close_timeout=timedelta(seconds=30), + ) + ) + } + if "summary" not in config: + config["summary"] = "tool_call" + + # For class-based activities the first parameter is 'self'. Partially apply + # it so that Gemini inspects only the user-facing parameters when building + # the function-call schema, while the worker resolves the real instance at + # execution time. + params = list(inspect.signature(fn).parameters.keys()) + schema_fn: Callable = fn + if params and params[0] == "self": + partial = functools.partial(fn, None) + setattr(partial, "__name__", fn.__name__) + partial.__annotations__ = getattr(fn, "__annotations__", {}) + setattr( + partial, + "__temporal_activity_definition", + getattr(fn, "__temporal_activity_definition", None), + ) + partial.__doc__ = fn.__doc__ + schema_fn = partial + + activity_name: str = ret.name + + async def wrapper(*args: Any, **kwargs: Any) -> Any: + sig = inspect.signature(schema_fn) + bound = sig.bind(*args, **kwargs) + bound.apply_defaults() + activity_args = list(bound.arguments.values()) + return await temporal_workflow.execute_activity( + activity_name, + args=activity_args, + **config, + ) + + wrapper.__name__ = schema_fn.__name__ # type: ignore + wrapper.__doc__ = schema_fn.__doc__ + setattr(wrapper, "__signature__", inspect.signature(schema_fn)) + wrapper.__annotations__ = getattr(schema_fn, "__annotations__", {}) + + return wrapper + + +def gemini_client( + *, + vertexai: bool = False, + project: str | None = None, + location: str | None = None, + activity_config: ActivityConfig | None = None, +) -> AsyncClient: + """Create a Gemini ``AsyncClient`` that routes API calls through Temporal activities. + + .. warning:: + This API is experimental and may change in future versions. + Use with caution in production environments. + + Returns an ``AsyncClient`` backed by a :class:`TemporalApiClient`. The + SDK's code (including the AFC loop) runs in the workflow; only the actual + HTTP API calls cross into activities. Credentials are never fetched or + stored in the workflow — the activity worker handles authentication + independently. + + Call this from within a workflow ``run`` method: + + .. code-block:: python + + @workflow.defn + class MyWorkflow: + @workflow.run + async def run(self, query: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.0-flash", + contents=query, + config=GenerateContentConfig( + tools=[activity_as_tool(my_tool)], + ), + ) + return response.text + + Args: + vertexai: Whether to use Vertex AI API endpoints. Must match the + ``GeminiPlugin`` configuration on the worker side. Defaults to + ``False`` (Gemini Developer API). + project: Google Cloud project ID. Only needed when ``vertexai=True`` + and the SDK's request formatting requires it (e.g., cache + operations). + location: Google Cloud location. Same conditions as ``project``. + activity_config: Override the default activity configuration + (timeouts, retry policy, etc.) for Gemini API call activities. + + Returns: + A ``google.genai.client.AsyncClient`` instance. + """ + temporal_api_client = TemporalApiClient( + vertexai=vertexai, + project=project, + location=location, + activity_config=activity_config, + ) + return AsyncClient(temporal_api_client) diff --git a/tests/contrib/google_gemini_sdk/__init__.py b/tests/contrib/google_gemini_sdk/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/contrib/google_gemini_sdk/test_gemini.py b/tests/contrib/google_gemini_sdk/test_gemini.py new file mode 100644 index 000000000..af9cf180d --- /dev/null +++ b/tests/contrib/google_gemini_sdk/test_gemini.py @@ -0,0 +1,729 @@ +"""Integration tests for the Google Gemini SDK Temporal integration. + +Tests cover: +- Basic generate_content through workflow +- Tool calling via activity_as_tool (single arg, multi arg, class method) +- Workflow method as a plain tool (runs in-workflow, not as an activity) +- Tool failure propagation +- Multiple sequential tool calls with arg verification +- TemporalApiClient edge cases (sync, streaming, http_options validation) +- Serialization round-trip for request/response models +- activity_as_tool validation and metadata preservation +- gemini_client configuration +""" + +import inspect +import json +import uuid +from datetime import timedelta +from typing import Any + +import pytest +from google.genai import Client as GeminiClient +from google.genai import types + +from temporalio import activity, workflow +from temporalio.client import Client, WorkflowFailureError +from temporalio.common import RetryPolicy +from temporalio.contrib.google_gemini_sdk import ( + GeminiPlugin, + activity_as_tool, + gemini_client, +) +from temporalio.contrib.google_gemini_sdk._temporal_api_client import ( + TemporalApiClient, + _GeminiApiRequest, + _GeminiApiResponse, +) +from temporalio.exceptions import ApplicationError +from temporalio.workflow import ActivityConfig +from tests.helpers import new_worker + +# --------------------------------------------------------------------------- +# Mock response helpers +# --------------------------------------------------------------------------- + + +def make_text_response(text: str) -> str: + """Build a JSON body string for a simple text response.""" + return json.dumps( + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": text}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 10, + }, + } + ) + + +def make_function_call_response(fn_name: str, args: dict) -> str: + """Build a JSON body string for a function-call response.""" + return json.dumps( + { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"functionCall": {"name": fn_name, "args": args}}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 15, + }, + } + ) + + +# --------------------------------------------------------------------------- +# Tool call tracker — records every tool invocation for assertion +# --------------------------------------------------------------------------- + + +class ToolCallTracker: + """Tracks tool invocations across activities and workflow methods. + + Each tool appends (name, args_dict) to ``calls`` so tests can assert + exactly which tools were called, in what order, with what arguments. + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, dict]] = [] + + @activity.defn + async def get_weather(self, city: str) -> str: + """Get the weather for a given city.""" + self.calls.append(("get_weather", {"city": city})) + return f"Weather in {city}: Sunny, 20C" + + @activity.defn + async def get_weather_country(self, city: str, country: str) -> str: + """Get the weather for a given city in a country.""" + self.calls.append(("get_weather_country", {"city": city, "country": country})) + return f"Weather in {city}, {country}: Rainy, 15C" + + @activity.defn + async def get_weather_failure(self, city: str) -> str: + """Activity that always fails.""" + self.calls.append(("get_weather_failure", {"city": city})) + raise ApplicationError("Weather service unavailable", non_retryable=True) + + +# --------------------------------------------------------------------------- +# Test helper: tracking gemini_api_call activity +# --------------------------------------------------------------------------- + + +class GeminiApiCallTracker: + """A test replacement for the ``gemini_api_call`` activity. + + Records every ``_GeminiApiRequest`` received and returns canned + ``_GeminiApiResponse`` bodies in order. After the workflow completes, + inspect ``requests`` to verify exactly what the integration sent. + + The real ``GeminiPlugin`` is still used for its data converter, sandbox + passthrough, and workflow runner configuration — only its activity + registration is suppressed so this tracker can take its place. + """ + + def __init__(self, mock_responses: list[str]) -> None: + self._mock_responses = mock_responses + self.requests: list[_GeminiApiRequest] = [] + self._call_index = 0 + + @activity.defn(name="gemini_api_call") + async def gemini_api_call(self, req: _GeminiApiRequest) -> _GeminiApiResponse: + self.requests.append(req) + idx = self._call_index + self._call_index += 1 + if idx >= len(self._mock_responses): + raise ApplicationError( + f"No more mock responses (called {idx + 1} times, " + f"have {len(self._mock_responses)})", + non_retryable=True, + ) + return _GeminiApiResponse( + headers={"content-type": "application/json"}, + body=self._mock_responses[idx], + ) + + +def apply_plugin( + client: Client, mock_responses: list[str] +) -> tuple[Client, GeminiApiCallTracker]: + """Create a real GeminiPlugin whose activity is a tracking fake. + + Monkey-patches ``GeminiApiCaller.activity`` so that when the plugin + constructs itself, it registers our tracking activity instead of + the real one. Everything else — data converter, sandbox passthrough, + workflow runner — is the real plugin code. + + Returns the configured Temporal client and the tracker. + """ + from temporalio.contrib.google_gemini_sdk._gemini_activity import GeminiApiCaller + + tracker = GeminiApiCallTracker(mock_responses) + original_activity = GeminiApiCaller.activity + GeminiApiCaller.activity = lambda self: tracker.gemini_api_call + try: + gemini = GeminiClient(api_key="fake-test-key") + plugin = GeminiPlugin(gemini) + finally: + GeminiApiCaller.activity = original_activity + + config = client.config() + config["plugins"] = [plugin] + return Client(**config), tracker + + +# --------------------------------------------------------------------------- +# Workflows +# --------------------------------------------------------------------------- + + +@workflow.defn +class SimpleGenerateWorkflow: + """Workflow that does a simple generate_content call.""" + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig( + disable=True, + ), + ), + ) + return response.text or "" + + +@workflow.defn +class SingleArgToolWorkflow: + """Workflow that uses activity_as_tool for a single-arg tool.""" + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + tools=[ + activity_as_tool( + ToolCallTracker.get_weather, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + ], + ), + ) + return response.text or "" + + +@workflow.defn +class MultiArgToolWorkflow: + """Workflow with multi-arg tool.""" + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + tools=[ + activity_as_tool( + ToolCallTracker.get_weather_country, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + ], + ), + ) + return response.text or "" + + +@workflow.defn +class ToolFailureWorkflow: + """Workflow with a tool that always fails.""" + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + tools=[ + activity_as_tool( + ToolCallTracker.get_weather_failure, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + retry_policy=RetryPolicy(maximum_attempts=1), + ), + ), + ], + ), + ) + return response.text or "" + + +@workflow.defn +class MultipleToolsWorkflow: + """Workflow with multiple tools that are called in sequence.""" + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + tools=[ + activity_as_tool( + ToolCallTracker.get_weather, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + activity_as_tool( + ToolCallTracker.get_weather_country, + activity_config=ActivityConfig( + start_to_close_timeout=timedelta(seconds=10), + ), + ), + ], + ), + ) + return response.text or "" + + +@workflow.defn +class WorkflowMethodToolWorkflow: + """Workflow that passes a plain method as a tool (runs in-workflow, not as an activity).""" + + def __init__(self) -> None: + self.tool_calls: list[tuple[str, dict]] = [] + + @workflow.run + async def run(self, prompt: str) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + tools=[self.lookup_city], + ), + ) + return response.text or "" + + async def lookup_city(self, city: str) -> str: + """Look up info about a city.""" + self.tool_calls.append(("lookup_city", {"city": city})) + return f"{city} is a great place to visit" + + @workflow.query + def get_tool_calls(self) -> list[tuple[str, dict]]: + return self.tool_calls + + +@workflow.defn +class HttpOptionsWorkflow: + """Workflow that passes per-request http_options through generate_content.""" + + @workflow.run + async def run(self, prompt: str, http_options: types.HttpOptionsDict) -> str: + client = gemini_client() + response = await client.models.generate_content( + model="gemini-2.5-flash", + contents=prompt, + config=types.GenerateContentConfig( + automatic_function_calling=types.AutomaticFunctionCallingConfig( + disable=True, + ), + http_options=types.HttpOptions.model_validate(http_options), + ), + ) + return response.text or "" + + +# =========================================================================== +# Integration tests — run workflows against a real Temporal test server +# =========================================================================== + + +async def test_simple_generate_content(client: Client): + """Basic generate_content returns text through a workflow.""" + new_client, _ = apply_plugin(client, [make_text_response("Hello from Gemini!")]) + + async with new_worker(new_client, SimpleGenerateWorkflow) as worker: + result = await new_client.execute_workflow( + SimpleGenerateWorkflow.run, + "Say hello", + id=f"gemini-simple-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert result == "Hello from Gemini!" + + +async def test_tool_call_single_arg(client: Client): + """Tool calling with a single-argument activity via AFC.""" + tool_tracker = ToolCallTracker() + new_client, _ = apply_plugin( + client, + [ + make_function_call_response("get_weather", {"city": "Tokyo"}), + make_text_response("The weather in Tokyo is sunny and 20C."), + ], + ) + + async with new_worker( + new_client, + SingleArgToolWorkflow, + activities=[tool_tracker.get_weather], + ) as worker: + result = await new_client.execute_workflow( + SingleArgToolWorkflow.run, + "What's the weather in Tokyo?", + id=f"gemini-tool-single-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert tool_tracker.calls == [("get_weather", {"city": "Tokyo"})] + assert result == "The weather in Tokyo is sunny and 20C." + + +async def test_tool_call_multi_arg(client: Client): + """Tool calling with a multi-argument activity.""" + tool_tracker = ToolCallTracker() + new_client, _ = apply_plugin( + client, + [ + make_function_call_response( + "get_weather_country", {"city": "Paris", "country": "France"} + ), + make_text_response("Paris, France: Rainy, 15C."), + ], + ) + + async with new_worker( + new_client, + MultiArgToolWorkflow, + activities=[tool_tracker.get_weather_country], + ) as worker: + result = await new_client.execute_workflow( + MultiArgToolWorkflow.run, + "What's the weather in Paris, France?", + id=f"gemini-tool-multi-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert tool_tracker.calls == [ + ("get_weather_country", {"city": "Paris", "country": "France"}) + ] + assert result == "Paris, France: Rainy, 15C." + + +async def test_tool_failure_propagation(client: Client): + """Tool activity failure causes the workflow to fail.""" + tool_tracker = ToolCallTracker() + new_client, _ = apply_plugin( + client, + [ + make_function_call_response("get_weather_failure", {"city": "Nowhere"}), + ], + ) + + async with new_worker( + new_client, + ToolFailureWorkflow, + activities=[tool_tracker.get_weather_failure], + ) as worker: + with pytest.raises(WorkflowFailureError): + await new_client.execute_workflow( + ToolFailureWorkflow.run, + "Weather in Nowhere?", + id=f"gemini-tool-fail-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert tool_tracker.calls == [("get_weather_failure", {"city": "Nowhere"})] + + +async def test_multiple_tools_sequential(client: Client): + """Multiple tools called in sequence within one generate_content call.""" + tool_tracker = ToolCallTracker() + new_client, _ = apply_plugin( + client, + [ + make_function_call_response("get_weather", {"city": "Tokyo"}), + make_function_call_response( + "get_weather_country", {"city": "Paris", "country": "France"} + ), + make_text_response("Tokyo is sunny; Paris is rainy."), + ], + ) + + async with new_worker( + new_client, + MultipleToolsWorkflow, + activities=[ + tool_tracker.get_weather, + tool_tracker.get_weather_country, + ], + ) as worker: + result = await new_client.execute_workflow( + MultipleToolsWorkflow.run, + "Compare Tokyo and Paris weather", + id=f"gemini-multi-tools-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=15), + ) + + assert tool_tracker.calls == [ + ("get_weather", {"city": "Tokyo"}), + ("get_weather_country", {"city": "Paris", "country": "France"}), + ] + assert result == "Tokyo is sunny; Paris is rainy." + + +async def test_workflow_method_as_tool(client: Client): + """A plain workflow method (not an activity) used as a tool runs in-workflow.""" + new_client, _ = apply_plugin( + client, + [ + make_function_call_response("lookup_city", {"city": "Berlin"}), + make_text_response("Berlin is wonderful."), + ], + ) + + async with new_worker(new_client, WorkflowMethodToolWorkflow) as worker: + handle = await new_client.start_workflow( + WorkflowMethodToolWorkflow.run, + "Tell me about Berlin", + id=f"gemini-wf-method-tool-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + result = await handle.result() + # Query must happen while worker is alive + tool_calls = await handle.query(WorkflowMethodToolWorkflow.get_tool_calls) + + assert tool_calls == [("lookup_city", {"city": "Berlin"})] + assert result == "Berlin is wonderful." + + +# =========================================================================== +# http_options propagation tests - per request overrides +# =========================================================================== + + +async def test_http_options_headers_propagate(client: Client): + """Custom headers passed via http_options arrive at the activity.""" + new_client, api_tracker = apply_plugin(client, [make_text_response("ok")]) + + async with new_worker(new_client, HttpOptionsWorkflow) as worker: + await new_client.execute_workflow( + HttpOptionsWorkflow.run, + args=["hi", {"headers": {"X-Custom": "test-value"}}], + id=f"gemini-http-headers-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert len(api_tracker.requests) == 1 + opts = api_tracker.requests[0].http_options_overrides + assert opts is not None + assert opts.headers == {"X-Custom": "test-value"} + + +async def test_http_options_api_version_propagates(client: Client): + """api_version passed via http_options arrives at the activity.""" + new_client, api_tracker = apply_plugin(client, [make_text_response("ok")]) + + async with new_worker(new_client, HttpOptionsWorkflow) as worker: + await new_client.execute_workflow( + HttpOptionsWorkflow.run, + args=["hi", {"api_version": "v1"}], + id=f"gemini-http-version-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert len(api_tracker.requests) == 1 + opts = api_tracker.requests[0].http_options_overrides + assert opts is not None + assert opts.api_version == "v1" + + +async def test_http_options_base_url_propagates(client: Client): + """base_url passed via http_options arrives at the activity.""" + new_client, api_tracker = apply_plugin(client, [make_text_response("ok")]) + + async with new_worker(new_client, HttpOptionsWorkflow) as worker: + await new_client.execute_workflow( + HttpOptionsWorkflow.run, + args=["hi", {"base_url": "https://custom.example.com"}], + id=f"gemini-http-base-url-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert len(api_tracker.requests) == 1 + opts = api_tracker.requests[0].http_options_overrides + assert opts is not None + assert opts.base_url == "https://custom.example.com" + + +async def test_http_options_multiple_fields_propagate(client: Client): + """Multiple http_options fields propagate together to the activity.""" + new_client, api_tracker = apply_plugin(client, [make_text_response("ok")]) + + async with new_worker(new_client, HttpOptionsWorkflow) as worker: + await new_client.execute_workflow( + HttpOptionsWorkflow.run, + args=[ + "hi", + { + "api_version": "v1beta", + "headers": {"X-Foo": "bar"}, + "base_url": "https://other.example.com", + }, + ], + id=f"gemini-http-multi-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert len(api_tracker.requests) == 1 + opts = api_tracker.requests[0].http_options_overrides + assert opts is not None + assert opts.api_version == "v1beta" + assert opts.headers == {"X-Foo": "bar"} + assert opts.base_url == "https://other.example.com" + + +async def test_no_http_options_passes_none(client: Client): + """When no per-request http_options are set, None reaches the activity.""" + new_client, api_tracker = apply_plugin(client, [make_text_response("ok")]) + + async with new_worker(new_client, SimpleGenerateWorkflow) as worker: + await new_client.execute_workflow( + SimpleGenerateWorkflow.run, + "hi", + id=f"gemini-http-none-{uuid.uuid4()}", + task_queue=worker.task_queue, + execution_timeout=timedelta(seconds=10), + ) + + assert len(api_tracker.requests) == 1 + assert api_tracker.requests[0].http_options_overrides is None + + +# =========================================================================== +# Unit tests for TemporalApiClient +# =========================================================================== + + +def test_sync_request_raises(): + """Synchronous request() raises RuntimeError.""" + api_client = TemporalApiClient() + with pytest.raises(RuntimeError, match="Synchronous requests are not supported"): + api_client.request("GET", "/test", {}) + + +def test_sync_request_streamed_raises(): + """Synchronous request_streamed() raises RuntimeError.""" + api_client = TemporalApiClient() + with pytest.raises(RuntimeError, match="Synchronous streaming is not supported"): + api_client.request_streamed("GET", "/test", {}) + + +async def test_async_request_streamed_raises(): + """Async streaming raises NotImplementedError.""" + api_client = TemporalApiClient() + with pytest.raises(NotImplementedError, match="Streaming is not yet supported"): + await api_client.async_request_streamed("GET", "/test", {}) + + +# =========================================================================== +# Unit tests for activity_as_tool +# =========================================================================== + + +def test_activity_as_tool_bare_function_raises(): + """activity_as_tool rejects a function without @activity.defn.""" + + async def not_an_activity(x: str) -> str: + return x + + with pytest.raises(ApplicationError, match="@activity.defn"): + activity_as_tool(not_an_activity) + + +def test_activity_as_tool_preserves_name(): + """Returned wrapper keeps the original function name.""" + wrapper = activity_as_tool(ToolCallTracker.get_weather) + assert wrapper.__name__ == "get_weather" + + +def test_activity_as_tool_preserves_doc(): + """Returned wrapper keeps the original docstring.""" + wrapper = activity_as_tool(ToolCallTracker.get_weather) + assert wrapper.__doc__ == "Get the weather for a given city." + + +def test_activity_as_tool_preserves_signature(): + """Returned wrapper has the correct parameter signature (self hidden).""" + wrapper = activity_as_tool(ToolCallTracker.get_weather) + sig = inspect.signature(wrapper) + params = list(sig.parameters.keys()) + assert params == ["city"] + + +def test_activity_as_tool_multi_arg_signature(): + """Multi-arg activity preserves all parameter names (self hidden).""" + wrapper = activity_as_tool(ToolCallTracker.get_weather_country) + sig = inspect.signature(wrapper) + params = list(sig.parameters.keys()) + assert params == ["city", "country"] + + +def test_activity_as_tool_is_async_callable(): + """Returned wrapper is an async callable.""" + wrapper = activity_as_tool(ToolCallTracker.get_weather) + assert inspect.iscoroutinefunction(wrapper) + + +# =========================================================================== +# Unit tests for gemini_client +# =========================================================================== + + +def test_gemini_client_vertexai_config(): + """gemini_client() forwards Vertex AI configuration to the TemporalApiClient.""" + result = gemini_client(vertexai=True, project="proj", location="us-central1") + assert result._api_client.vertexai is True + assert result._api_client.project == "proj" + assert result._api_client.location == "us-central1" diff --git a/uv.lock b/uv.lock index 9921726a0..c75bfe154 100644 --- a/uv.lock +++ b/uv.lock @@ -1556,7 +1556,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -1564,7 +1563,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -1573,7 +1571,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -1582,7 +1579,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -1591,7 +1587,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -1600,7 +1595,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -4282,6 +4276,9 @@ dependencies = [ google-adk = [ { name = "google-adk" }, ] +google-gemini = [ + { name = "google-genai" }, +] grpc = [ { name = "grpcio" }, ] @@ -4329,6 +4326,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "google-adk", marker = "extra == 'google-adk'", specifier = ">=1.27.0,<2" }, + { name = "google-genai", marker = "extra == 'google-gemini'", specifier = ">=1.66.0" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = ">=1.48.2,<2" }, { name = "mcp", marker = "extra == 'openai-agents'", specifier = ">=1.9.4,<2" }, { name = "nexus-rpc", specifier = "==1.4.0" }, @@ -4341,7 +4339,7 @@ requires-dist = [ { name = "types-protobuf", specifier = ">=3.20" }, { name = "typing-extensions", specifier = ">=4.2.0,<5" }, ] -provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk"] +provides-extras = ["grpc", "opentelemetry", "pydantic", "openai-agents", "google-adk", "google-gemini"] [package.metadata.requires-dev] dev = [