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..7feaf0f8d --- /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 + and owns the real ``genai.Client`` on the worker side. Pass the same args + you would pass to ``genai.Client()``. +- :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) ---- + plugin = GeminiPlugin(api_key=os.environ["GOOGLE_API_KEY"]) + + @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..9823c28d0 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/_gemini_activity.py @@ -0,0 +1,103 @@ +"""Temporal activity that executes Gemini SDK API calls with real credentials. + +The ``TemporalApiClient`` in the workflow dispatches calls here. This activity +creates (or reuses) a real ``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, Optional, Union + +import google.auth.credentials +from google.genai import Client as GeminiClient +from google.genai.client import DebugConfig +from google.genai.types import HttpOptions, HttpOptionsDict +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: + """Holds a cached ``genai.Client`` and exposes it as a Temporal activity. + + The client is created lazily on first invocation and reused for all + subsequent calls, avoiding repeated SSL context creation, connection + pool setup, and credential loading. + + Args match ``genai.Client()`` constructor parameters. + """ + + def __init__( + self, + *, + vertexai: Optional[bool] = None, + api_key: Optional[str] = None, + credentials: Optional[google.auth.credentials.Credentials] = None, + project: Optional[str] = None, + location: Optional[str] = None, + debug_config: Optional[DebugConfig] = None, + http_options: Optional[Union[HttpOptions, HttpOptionsDict]] = None, + ) -> None: + # Build kwargs for genai.Client, omitting None values so the SDK + # can apply its own defaults. + self._gemini_client_kwargs: dict[str, Any] = {} + if vertexai is not None: + self._gemini_client_kwargs["vertexai"] = vertexai + if api_key is not None: + self._gemini_client_kwargs["api_key"] = api_key + if credentials is not None: + self._gemini_client_kwargs["credentials"] = credentials + if project is not None: + self._gemini_client_kwargs["project"] = project + if location is not None: + self._gemini_client_kwargs["location"] = location + if debug_config is not None: + self._gemini_client_kwargs["debug_config"] = debug_config + if http_options is not None: + self._gemini_client_kwargs["http_options"] = http_options + + self._client: GeminiClient | None = None + + def _get_client(self) -> GeminiClient: + if self._client is None: + self._client = GeminiClient(**self._gemini_client_kwargs) + return self._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. + """ + client = self._get_client() + 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 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..bd3356217 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/_gemini_plugin.py @@ -0,0 +1,110 @@ +"""Temporal plugin for Google Gemini SDK integration.""" + +from __future__ import annotations + +import dataclasses +from typing import Any, Optional, Union + +import google.auth.credentials +from google.genai.client import DebugConfig +from google.genai.types import HttpOptions, HttpOptionsDict + +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 owns the + real ``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. + + All ``genai.Client`` constructor arguments (``api_key``, ``vertexai``, + ``project``, ``credentials``, etc.) are forwarded via ``**kwargs``. + + Example (Gemini Developer API):: + + plugin = GeminiPlugin(api_key=os.environ["GOOGLE_API_KEY"]) + + Example (Vertex AI):: + + plugin = GeminiPlugin( + vertexai=True, project="my-project", location="us-central1", + ) + """ + + def __init__( + self, + *, + vertexai: Optional[bool] = None, + api_key: Optional[str] = None, + credentials: Optional[google.auth.credentials.Credentials] = None, + project: Optional[str] = None, + location: Optional[str] = None, + debug_config: Optional[DebugConfig] = None, + http_options: Optional[Union[HttpOptions, HttpOptionsDict]] = None, + ) -> None: + """Initialize the Gemini plugin. + + Args: + vertexai: Whether to use Vertex AI API endpoints. + api_key: API key for the Gemini Developer API. + credentials: Google Cloud credentials for Vertex AI. + project: Google Cloud project ID for Vertex AI. + location: Google Cloud location for Vertex AI. + debug_config: Debug/testing configuration for the ``genai.Client``. + http_options: HTTP options (timeout, base_url, api_version, etc.) + forwarded to the ``genai.Client`` inside the activity. + """ + self._api_caller = GeminiApiCaller( + vertexai=vertexai, + api_key=api_key, + credentials=credentials, + project=project, + location=location, + debug_config=debug_config, + http_options=http_options, + ) + + 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..0599b5453 --- /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 pydantic import BaseModel + +from google.genai._api_client import BaseApiClient +from google.genai.types import HttpOptions, HttpOptionsOrDict +from google.genai.types import HttpResponse as SdkHttpResponse + +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..7c4f32e78 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/first_class_example/worker.py @@ -0,0 +1,191 @@ +# 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.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: + # GeminiPlugin creates the genai.Client lazily inside the activity, + # so credentials are never fetched in the workflow and never appear + # in Temporal's event history. + plugin = GeminiPlugin(api_key=os.environ["GOOGLE_API_KEY"]) + + 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..025cd61c3 --- /dev/null +++ b/temporalio/contrib/google_gemini_sdk/workflow.py @@ -0,0 +1,179 @@ +"""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/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 = [