From ed1f0e8ec6022d50a1a9710907b3c01d943a78d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:06:31 +0000 Subject: [PATCH 1/8] feat: add Google Nano Banana 2 support to create_image tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates Google Nano Banana (Gemini image generation models) into the existing create_image tool via a new `provider` parameter, using ADC-based auth auto-detection that covers Vertex AI, service account keys, and gcloud. Changes: - pyproject.toml: add `google` optional extra (google-genai>=0.8.0) - config.py: add get_google_client() with ADC auto-detect — Vertex AI (GOOGLE_GENAI_USE_VERTEXAI + GOOGLE_CLOUD_PROJECT) or Gemini Developer API (GOOGLE_API_KEY), no explicit credential loading required - features.py: add check_google_available() + include in get_available_features() - tools/image.py: add _create_image_google() helper (sync client wrapped in anyio thread pool) and dispatch from create_image() on provider="google"; Google path returns ImageDownloadResult immediately (no polling required) - descriptions.py: rewrite CREATE_IMAGE to document both providers, model IDs (Nano Banana 2/Pro/v1), and the synchronous vs async return shapes - server.py: add provider, aspect_ratio, filename params to create_image tool Google Nano Banana models supported: - gemini-3.1-flash-image-preview (Nano Banana 2, default) - gemini-3-pro-image-preview (Nano Banana Pro) - gemini-2.5-flash-image (Nano Banana) https://claude.ai/code/session_01CVTKuW7q1AVoN4PQfhgF9K --- pyproject.toml | 5 +- src/sanzaru/config.py | 50 ++++++++++++++ src/sanzaru/descriptions.py | 122 ++++++++++++++-------------------- src/sanzaru/features.py | 33 ++++++++++ src/sanzaru/server.py | 17 ++++- src/sanzaru/tools/image.py | 126 ++++++++++++++++++++++++++++++++---- 6 files changed, 264 insertions(+), 89 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dbd0991..b1af456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,9 +72,12 @@ audio = [ image = [ "pillow>=12.0.0", ] +google = [ + "google-genai>=0.8.0", +] databricks = [] # httpx already a core dep; extra exists for signaling intent all = [ - "sanzaru[video,audio,image]", # databricks intentionally excluded from "all" + "sanzaru[video,audio,image,google]", # databricks intentionally excluded from "all" ] [dependency-groups] diff --git a/src/sanzaru/config.py b/src/sanzaru/config.py index 43afeef..1c28d69 100644 --- a/src/sanzaru/config.py +++ b/src/sanzaru/config.py @@ -43,6 +43,56 @@ def get_client() -> AsyncOpenAI: return AsyncOpenAI(api_key=api_key) +# ---------- Google Gen AI client (stateless) ---------- +def get_google_client(): + """Get a Google Gen AI client instance. + + Supports both Vertex AI and Gemini Developer API via environment variable auto-detection. + + Credential resolution order (ADC): + 1. GOOGLE_APPLICATION_CREDENTIALS → JSON file (service account key or WIF config) + 2. gcloud auth application-default login (local dev) + 3. Attached service account on GCP compute (GKE, Cloud Run, Compute Engine) + + For Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=True): + - Requires: GOOGLE_CLOUD_PROJECT + - Optional: GOOGLE_CLOUD_LOCATION (default: us-central1) + - Credentials: resolved automatically by ADC + + For Gemini Developer API: + - Requires: GOOGLE_API_KEY + + Returns: + Configured Google Gen AI Client + + Raises: + ImportError: If google-genai package is not installed + RuntimeError: If required environment variables are not set + """ + try: + from google import genai + except ImportError as e: + raise ImportError("google-genai package is required. Install with: uv add 'sanzaru[google]'") from e + + use_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1") + + if use_vertex: + project = os.getenv("GOOGLE_CLOUD_PROJECT") + if not project: + raise RuntimeError("GOOGLE_CLOUD_PROJECT is required when GOOGLE_GENAI_USE_VERTEXAI=True") + location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1") + return genai.Client(vertexai=True, project=project, location=location) + + api_key = os.getenv("GOOGLE_API_KEY") + if not api_key: + raise RuntimeError( + "Google credentials not configured. " + "Set GOOGLE_GENAI_USE_VERTEXAI=True + GOOGLE_CLOUD_PROJECT (Vertex AI) " + "or GOOGLE_API_KEY (Gemini Developer API)" + ) + return genai.Client(api_key=api_key) + + # ---------- Path configuration (runtime) ---------- # Mapping from path_type to (individual env var, subdirectory under SANZARU_MEDIA_PATH) diff --git a/src/sanzaru/descriptions.py b/src/sanzaru/descriptions.py index a93f2b9..c49fd29 100644 --- a/src/sanzaru/descriptions.py +++ b/src/sanzaru/descriptions.py @@ -185,98 +185,74 @@ # ==================== IMAGE GENERATION TOOL DESCRIPTIONS ==================== -CREATE_IMAGE = """Non-blocking async image generation with gpt-image-1.5 support. +CREATE_IMAGE = """Image generation supporting OpenAI Responses API and Google Nano Banana (Gemini image models). -Creates images from text prompts OR edits existing images by providing reference images. -Returns immediately with a response_id - use get_image_status() to poll for completion. -Supports iterative refinement via previous_response_id. +Switch between providers via the `provider` parameter. Each provider has different strengths: -**Best for:** parallel generation (multiple images at once), iterative refinement chains, -and workflows where you need to do other work while images generate. -For simple one-shot generation, generate_image is simpler (no polling needed). +**Google Nano Banana (provider="google") — synchronous, image ready immediately:** +- Fastest path: no polling required, result returned directly with filename +- Powered by Gemini image models (Nano Banana 2 is the default) +- Best for: speed-first workflows, high-volume generation, character/object consistency +- Up to 4K resolution, SynthID watermarking, C2PA credentials -**Text-only generation (no input_images):** -- Generates image from scratch based on prompt - -**Image editing (with input_images):** -- Modifies existing images based on prompt -- Combines multiple images into new composition -- First image receives highest detail preservation -- Prompt describes desired changes, not what's already in images +**OpenAI Responses API (provider="openai") — async with polling:** +- Returns immediately with a response_id; poll with get_image_status(), then download_image() +- Best for: parallel generation, iterative refinement chains (previous_response_id) Parameters: - prompt: Text description (required) - * Without input_images: Describe what to generate - * With input_images: Describe what changes to make -- model: Mainline model - "gpt-5.2" (default), "gpt-5.1", "gpt-5", etc. -- tool_config: Optional ImageGeneration configuration object (optional) - * Supports all fields: model, size, quality, moderation, input_fidelity, etc. - * MCP library handles serialization automatically - * See examples below for common configurations -- previous_response_id: Refine previous image iteratively (optional) -- input_images: List of filenames from IMAGE_PATH (optional) - * Example: ["cat.png"] or ["lotion.jpg", "soap.png", "bomb.jpg"] - * Use list_reference_images() to discover available images - * Supported formats: JPEG, PNG, WEBP -- mask_filename: PNG with alpha channel for inpainting (optional) - * Defines which region of first input image to edit - * Transparent = edit this area, black = keep original - * Requires input_images parameter - -**Image generation models (tool_config.model):** -- gpt-image-1.5: STATE-OF-THE-ART (RECOMMENDED) - Best quality, better instruction following, improved text rendering -- gpt-image-1: High quality image generation -- gpt-image-1-mini: Fast, cost-effective generation - -Common tool_config examples: - -Best quality with GPT Image 1.5: - tool_config={"type": "image_generation", "model": "gpt-image-1.5"} - -Fast generation with mini model: - tool_config={"type": "image_generation", "model": "gpt-image-1-mini"} - -Lower content moderation: - tool_config={"type": "image_generation", "moderation": "low"} - -High-fidelity with custom settings: - tool_config={ - "type": "image_generation", - "model": "gpt-image-1.5", - "quality": "high", - "input_fidelity": "high", - "size": "1536x1024" - } +- provider: "openai" (default) or "google" +- model: Model ID. Defaults per provider: + * openai: "gpt-5.2" (mainline model that calls image generation tool) + * google: "gemini-3.1-flash-image-preview" (Nano Banana 2, RECOMMENDED) + - "gemini-3-pro-image-preview" → Nano Banana Pro (max quality, complex instructions) + - "gemini-2.5-flash-image" → Nano Banana (fastest, high-volume) +- aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4" +- filename: Google only — custom output filename (auto-generated if omitted) +- tool_config: OpenAI only — ImageGeneration config object (model, size, quality, etc.) + * gpt-image-1.5: STATE-OF-THE-ART OpenAI image model + * gpt-image-1: High quality + * gpt-image-1-mini: Fast, cost-effective +- previous_response_id: OpenAI only — refine a previous generation iteratively +- input_images: OpenAI only — list of reference image filenames from IMAGE_PATH +- mask_filename: OpenAI only — PNG with alpha channel for inpainting + +Returns: +- provider="google": ImageDownloadResult with {filename, size, format} — ready immediately +- provider="openai": ImageResponse with {id, status, created_at} — poll then download Workflows: -1. Text-only generation (recommended): +1. Google Nano Banana 2 (fast, synchronous): + create_image("a futuristic cityscape at dusk", provider="google") + +2. Google landscape: + create_image("mountain vista at golden hour", provider="google", aspect_ratio="16:9") + +3. Google Nano Banana Pro (max quality): + create_image("detailed product render", provider="google", model="gemini-3-pro-image-preview") + +4. OpenAI text-only generation: create_image("sunset over mountains", tool_config={"type": "image_generation", "model": "gpt-image-1.5"}) -2. Single image editing: +5. OpenAI image editing: create_image("add a flamingo to the pool", input_images=["lounge.png"]) -3. Multi-image composition: - create_image("gift basket with all these items", input_images=["lotion.png", "soap.png", "bomb.jpg"]) - -4. High-fidelity logo placement: - create_image( - "add logo to woman's shirt", - input_images=["woman.jpg", "logo.png"], - tool_config={"type": "image_generation", "input_fidelity": "high"} - ) +6. OpenAI multi-image composition: + create_image("gift basket with all items", input_images=["lotion.png", "soap.png"]) -5. Masked inpainting: +7. OpenAI masked inpainting: create_image("add flamingo", input_images=["pool.png"], mask_filename="pool_mask.png") -6. Fast generation with mini model: - create_image("quick sketch of a cat", tool_config={"type": "image_generation", "model": "gpt-image-1-mini"}) - -7. Iterative refinement: +8. OpenAI iterative refinement: resp1 = create_image("a cyberpunk character") resp2 = create_image("add neon details", previous_response_id=resp1.id) -Returns ImageResponse with: id, status, created_at""" +OpenAI tool_config examples: + +Best quality: tool_config={"type": "image_generation", "model": "gpt-image-1.5"} +Fast: tool_config={"type": "image_generation", "model": "gpt-image-1-mini"} +High-fidelity: tool_config={"type": "image_generation", "model": "gpt-image-1.5", "quality": "high", "size": "1536x1024"}""" GET_IMAGE_STATUS = """Check status and progress of image generation. diff --git a/src/sanzaru/features.py b/src/sanzaru/features.py index ae3f85e..5be7548 100644 --- a/src/sanzaru/features.py +++ b/src/sanzaru/features.py @@ -120,6 +120,38 @@ def check_databricks_storage() -> bool: return True +def check_google_available() -> bool: + """Check if Google Nano Banana image generation is available. + + Requires: + 1. google-genai package installed + 2. Either: + - GOOGLE_GENAI_USE_VERTEXAI=True + GOOGLE_CLOUD_PROJECT (Vertex AI, recommended for teams) + - GOOGLE_API_KEY (Gemini Developer API) + + Returns: + True if google-genai is installed and credentials are configured, False otherwise + """ + try: + import google.genai # noqa: F401 + except ImportError: + return False + + use_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1") + if use_vertex: + if os.getenv("GOOGLE_CLOUD_PROJECT"): + logger.info("Google Nano Banana available via Vertex AI (project=%s)", os.getenv("GOOGLE_CLOUD_PROJECT")) + return True + logger.info("GOOGLE_GENAI_USE_VERTEXAI=True but GOOGLE_CLOUD_PROJECT not set - Google image gen disabled") + return False + + if os.getenv("GOOGLE_API_KEY"): + logger.info("Google Nano Banana available via Gemini Developer API") + return True + + return False + + def get_available_features() -> dict[str, bool]: """Get a dictionary of available features. @@ -130,4 +162,5 @@ def get_available_features() -> dict[str, bool]: "video": check_video_available(), "audio": check_audio_available(), "image": check_image_available(), + "google": check_google_available(), } diff --git a/src/sanzaru/server.py b/src/sanzaru/server.py index 4eb69a9..9dcd91f 100644 --- a/src/sanzaru/server.py +++ b/src/sanzaru/server.py @@ -132,13 +132,26 @@ async def prepare_reference_image( @mcp.tool(description=CREATE_IMAGE) async def create_image( prompt: str, - model: str = "gpt-5.2", + provider: Literal["openai", "google"] = "openai", + model: str | None = None, + aspect_ratio: str = "1:1", + filename: str | None = None, tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, mask_filename: str | None = None, ): - return await image.create_image(prompt, model, tool_config, previous_response_id, input_images, mask_filename) + return await image.create_image( + prompt, + provider, + model, + aspect_ratio, + filename, + tool_config, + previous_response_id, + input_images, + mask_filename, + ) @mcp.tool(description=GET_IMAGE_STATUS) async def get_image_status(response_id: str): diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index b96bf52..0c2d4e0 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -9,6 +9,7 @@ import base64 import io +from typing import Literal import anyio from openai._types import Omit, omit @@ -23,7 +24,7 @@ from openai.types.responses.tool_param import ImageGeneration from PIL import Image -from ..config import get_client, logger +from ..config import get_client, get_google_client, logger from ..storage import get_storage from ..types import ImageDownloadResult, ImageResponse from ..utils import generate_filename @@ -84,35 +85,125 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: raise ValueError(f"Failed to upload mask file: {e}") from e +# ==================== GOOGLE NANO BANANA ==================== + +_GOOGLE_DEFAULT_MODEL = "gemini-3.1-flash-image-preview" # Nano Banana 2 + + +async def _create_image_google( + prompt: str, + model: str, + aspect_ratio: str, + filename: str | None, +) -> ImageDownloadResult: + """Generate an image using Google Nano Banana (Gemini image generation models). + + Wraps the synchronous Google Gen AI client in a thread pool for async compatibility. + The Google API is synchronous and returns results immediately (no polling required). + + Args: + prompt: Text description of the image to generate + model: Google model ID (e.g., "gemini-3.1-flash-image-preview" for Nano Banana 2) + aspect_ratio: Image aspect ratio ("1:1", "16:9", "9:16", "4:3", "3:4") + filename: Optional custom filename for the saved image (auto-generated if None) + + Returns: + ImageDownloadResult with filename, pixel dimensions, and format + + Raises: + ImportError: If google-genai package is not installed + RuntimeError: If Google credentials are not configured + ValueError: If generation returns no images (e.g., blocked by safety filters) + """ + try: + from google.genai.types import GenerateImagesConfig + except ImportError as e: + raise ImportError("google-genai package is required. Install with: uv add 'sanzaru[google]'") from e + + storage = get_storage() + google_client = get_google_client() + + config = GenerateImagesConfig( + number_of_images=1, + aspect_ratio=aspect_ratio, + output_mime_type="image/png", + ) + + logger.info("Generating Nano Banana image: model=%s aspect_ratio=%s", model, aspect_ratio) + + # Wrap synchronous Google API in thread pool (network I/O — avoids blocking the event loop) + def _call_google() -> object: + return google_client.models.generate_images(model=model, prompt=prompt, config=config) + + response = await anyio.to_thread.run_sync(_call_google) + + if not response.generated_images: + raise ValueError("Google Nano Banana returned no images — prompt may have been blocked by safety filters") + + image_bytes: bytes = response.generated_images[0].image.image_bytes + + if filename is None: + filename = generate_filename("nb", "png", use_timestamp=True) + + await storage.write("reference", filename, image_bytes) + + def _get_dimensions() -> tuple[tuple[int, int], str]: + img = Image.open(io.BytesIO(image_bytes)) + return img.size, img.format.lower() if img.format else "png" + + size, fmt = await anyio.to_thread.run_sync(_get_dimensions) + + logger.info("Nano Banana image saved: %s (%dx%d, %s)", filename, size[0], size[1], fmt) + + return {"filename": filename, "size": size, "format": fmt} + + # ==================== PUBLIC API ==================== async def create_image( prompt: str, - model: str = "gpt-5.2", + provider: Literal["openai", "google"] = "openai", + model: str | None = None, + aspect_ratio: str = "1:1", + filename: str | None = None, tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, mask_filename: str | None = None, -) -> ImageResponse: - """Create a new image generation job using Responses API. +) -> ImageResponse | ImageDownloadResult: + """Create a new image generation job using OpenAI Responses API or Google Nano Banana. Args: prompt: Text description of image to generate (or edits to make if input_images provided) - model: Mainline model to use (gpt-5.2, gpt-5.1, gpt-5, etc.) - calls the image generation tool - tool_config: Optional ImageGeneration tool configuration (size, quality, model, moderation, etc.) - previous_response_id: Optional ID to refine previous generation - input_images: Optional list of reference image filenames from IMAGE_PATH - mask_filename: Optional PNG mask with alpha channel for inpainting + provider: Image provider — "openai" (default) or "google" (Nano Banana / Gemini image models) + model: Model ID. Defaults by provider: + - openai: "gpt-5.2" (or any mainline model) + - google: "gemini-3.1-flash-image-preview" (Nano Banana 2, recommended) + Other Google models: "gemini-3-pro-image-preview" (Nano Banana Pro), + "gemini-2.5-flash-image" (Nano Banana) + aspect_ratio: Image aspect ratio for Google provider ("1:1", "16:9", "9:16", "4:3", "3:4"). + Ignored for OpenAI (use tool_config.size instead). + filename: Output filename for Google provider (auto-generated if None). + Ignored for OpenAI (use download_image after polling). + tool_config: OpenAI ImageGeneration tool configuration (size, quality, model, moderation, etc.). + Only used for OpenAI provider. + previous_response_id: Refine a previous OpenAI generation iteratively. Only used for OpenAI. + input_images: List of reference image filenames from IMAGE_PATH. Only used for OpenAI. + mask_filename: PNG mask with alpha channel for inpainting. Only used for OpenAI. Returns: - ImageResponse with response ID, status, and creation timestamp + - OpenAI provider: ImageResponse with {id, status, created_at} — poll with get_image_status, + then download with download_image. + - Google provider: ImageDownloadResult with {filename, size, format} — image is ready + immediately, no polling required. Raises: - RuntimeError: If OPENAI_API_KEY not set or IMAGE_PATH not configured + RuntimeError: If required API credentials are not set ValueError: If invalid filename, path traversal, or mask without input_images + ImportError: If google-genai is not installed and provider="google" - Example tool_config: + Example tool_config (OpenAI): { "type": "image_generation", "model": "gpt-image-1.5", # recommended (or "gpt-image-1", "gpt-image-1-mini") @@ -124,6 +215,15 @@ async def create_image( "background": "transparent" } """ + if provider == "google": + return await _create_image_google( + prompt=prompt, + model=model or _GOOGLE_DEFAULT_MODEL, + aspect_ratio=aspect_ratio, + filename=filename, + ) + # OpenAI provider path + openai_model = model or "gpt-5.2" client = get_client() storage = get_storage() @@ -195,7 +295,7 @@ async def create_image( # Create response with image generation tool prev_resp_param: str | Omit = omit if previous_response_id is None else previous_response_id response = await client.responses.create( - model=model, + model=openai_model, input=input_param, tools=[config], previous_response_id=prev_resp_param, From 285bb61c2dc2f7672e6db065a90a5018a6426389 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:14:55 +0000 Subject: [PATCH 2/8] feat: support Vertex AI Express mode with GOOGLE_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relaxes the GOOGLE_CLOUD_PROJECT requirement when GOOGLE_GENAI_USE_VERTEXAI=True to also allow Vertex AI Express mode (paid tier), where a Google Cloud API key can be used instead of or alongside ADC credentials. Auth resolution for GOOGLE_GENAI_USE_VERTEXAI=True: - GOOGLE_CLOUD_PROJECT only → ADC path (service account, gcloud, attached SA) - GOOGLE_API_KEY only → Vertex Express mode - Both set → Express mode with explicit project context - Neither set → RuntimeError with clear guidance check_google_available() updated to mirror the same three-way logic. https://claude.ai/code/session_01CVTKuW7q1AVoN4PQfhgF9K --- src/sanzaru/config.py | 43 +++++++++++++++++++++++++++-------------- src/sanzaru/features.py | 14 +++++++++++--- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/sanzaru/config.py b/src/sanzaru/config.py index 1c28d69..8582573 100644 --- a/src/sanzaru/config.py +++ b/src/sanzaru/config.py @@ -47,20 +47,23 @@ def get_client() -> AsyncOpenAI: def get_google_client(): """Get a Google Gen AI client instance. - Supports both Vertex AI and Gemini Developer API via environment variable auto-detection. + Supports Vertex AI (ADC or Express mode) and Gemini Developer API via env var auto-detection. - Credential resolution order (ADC): - 1. GOOGLE_APPLICATION_CREDENTIALS → JSON file (service account key or WIF config) - 2. gcloud auth application-default login (local dev) - 3. Attached service account on GCP compute (GKE, Cloud Run, Compute Engine) + Auth is fully driven by environment variables — no explicit credential loading required. - For Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=True): - - Requires: GOOGLE_CLOUD_PROJECT - - Optional: GOOGLE_CLOUD_LOCATION (default: us-central1) - - Credentials: resolved automatically by ADC + Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=True): + Standard mode (ADC) — for teams using service accounts, gcloud, or attached SA: + GOOGLE_CLOUD_PROJECT= (required) + GOOGLE_CLOUD_LOCATION= (optional, default: us-central1) + GOOGLE_APPLICATION_CREDENTIALS=... (optional — SA key file, WIF config, etc.) - For Gemini Developer API: - - Requires: GOOGLE_API_KEY + Express mode — simplified access for paid-tier projects via API key: + GOOGLE_API_KEY= + GOOGLE_CLOUD_PROJECT= (optional, but recommended) + GOOGLE_CLOUD_LOCATION= (optional, default: us-central1) + + Gemini Developer API (no GOOGLE_GENAI_USE_VERTEXAI): + GOOGLE_API_KEY= Returns: Configured Google Gen AI Client @@ -78,10 +81,22 @@ def get_google_client(): if use_vertex: project = os.getenv("GOOGLE_CLOUD_PROJECT") - if not project: - raise RuntimeError("GOOGLE_CLOUD_PROJECT is required when GOOGLE_GENAI_USE_VERTEXAI=True") location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1") - return genai.Client(vertexai=True, project=project, location=location) + api_key = os.getenv("GOOGLE_API_KEY") + + if not project and not api_key: + raise RuntimeError( + "Vertex AI requires GOOGLE_CLOUD_PROJECT (ADC/service-account auth) " + "or GOOGLE_API_KEY (Express mode) when GOOGLE_GENAI_USE_VERTEXAI=True" + ) + + # Build kwargs: pass project + location when available; add api_key for Express mode + kwargs: dict[str, object] = {"vertexai": True, "location": location} + if project: + kwargs["project"] = project + if api_key: + kwargs["api_key"] = api_key + return genai.Client(**kwargs) api_key = os.getenv("GOOGLE_API_KEY") if not api_key: diff --git a/src/sanzaru/features.py b/src/sanzaru/features.py index 5be7548..dab1210 100644 --- a/src/sanzaru/features.py +++ b/src/sanzaru/features.py @@ -139,10 +139,18 @@ def check_google_available() -> bool: use_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1") if use_vertex: - if os.getenv("GOOGLE_CLOUD_PROJECT"): - logger.info("Google Nano Banana available via Vertex AI (project=%s)", os.getenv("GOOGLE_CLOUD_PROJECT")) + project = os.getenv("GOOGLE_CLOUD_PROJECT") + api_key = os.getenv("GOOGLE_API_KEY") + if project and api_key: + logger.info("Google Nano Banana available via Vertex AI Express (project=%s, api_key)", project) return True - logger.info("GOOGLE_GENAI_USE_VERTEXAI=True but GOOGLE_CLOUD_PROJECT not set - Google image gen disabled") + if project: + logger.info("Google Nano Banana available via Vertex AI ADC (project=%s)", project) + return True + if api_key: + logger.info("Google Nano Banana available via Vertex AI Express (api_key only)") + return True + logger.info("GOOGLE_GENAI_USE_VERTEXAI=True but neither GOOGLE_CLOUD_PROJECT nor GOOGLE_API_KEY set") return False if os.getenv("GOOGLE_API_KEY"): From ce6cc6397227e48fdc6345eddbcf9d4481200ebf Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Sun, 8 Mar 2026 15:14:23 -0400 Subject: [PATCH 3/8] fix: use generate_content API for Nano Banana + add Literal types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nano Banana models are Gemini models, not Imagen — they use generate_content() with IMAGE response modality, not generate_images(). Changes: - tools/image.py: rewrite _create_image_google to use generate_content() with GenerateContentConfig, ImageConfig, ThinkingConfig, and SafetySetting; extract image from response.candidates[].content.parts[].inline_data; add thinking_config (HIGH) for Nano Banana 2 (Flash-based); default all safety settings to OFF - Add Literal types: GoogleImageModel, GoogleImageSize, GoogleAspectRatio for type-safe parameter validation at the MCP tool boundary - Use proper google.genai enum types (HarmCategory, HarmBlockThreshold, ThinkingLevel) for mypy compliance - config.py: fix Vertex AI Express — api_key and project/location are mutually exclusive in the SDK Client initializer - descriptions.py: document image_size, safety_settings params - .mcp.json: add Google env vars (GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION) - uv.lock: lock google-genai dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- .mcp.json | 6 +- src/sanzaru/config.py | 10 +- src/sanzaru/descriptions.py | 7 +- src/sanzaru/server.py | 10 +- src/sanzaru/tools/image.py | 125 ++++++++++++----- uv.lock | 272 +++++++++++++++++++++++++++++++++++- 6 files changed, 385 insertions(+), 45 deletions(-) diff --git a/.mcp.json b/.mcp.json index 1a3ef99..49b6c6f 100644 --- a/.mcp.json +++ b/.mcp.json @@ -5,7 +5,11 @@ "args": ["run", "sanzaru"], "env": { "OPENAI_API_KEY": "${OPENAI_API_KEY}", - "SANZARU_MEDIA_PATH": "${SANZARU_MEDIA_PATH}" + "SANZARU_MEDIA_PATH": "${SANZARU_MEDIA_PATH}", + "GOOGLE_API_KEY": "${GOOGLE_API_KEY}", + "GOOGLE_GENAI_USE_VERTEXAI": "${GOOGLE_GENAI_USE_VERTEXAI}", + "GOOGLE_CLOUD_PROJECT": "${GOOGLE_CLOUD_PROJECT}", + "GOOGLE_CLOUD_LOCATION": "${GOOGLE_CLOUD_LOCATION}" } } } diff --git a/src/sanzaru/config.py b/src/sanzaru/config.py index 8582573..3e01257 100644 --- a/src/sanzaru/config.py +++ b/src/sanzaru/config.py @@ -90,13 +90,11 @@ def get_google_client(): "or GOOGLE_API_KEY (Express mode) when GOOGLE_GENAI_USE_VERTEXAI=True" ) - # Build kwargs: pass project + location when available; add api_key for Express mode - kwargs: dict[str, object] = {"vertexai": True, "location": location} - if project: - kwargs["project"] = project + # API key and project/location are mutually exclusive in the SDK. + # Express mode: api_key only. Standard mode: project + location + ADC. if api_key: - kwargs["api_key"] = api_key - return genai.Client(**kwargs) + return genai.Client(vertexai=True, api_key=api_key) + return genai.Client(vertexai=True, project=project, location=location) api_key = os.getenv("GOOGLE_API_KEY") if not api_key: diff --git a/src/sanzaru/descriptions.py b/src/sanzaru/descriptions.py index c49fd29..a7458c0 100644 --- a/src/sanzaru/descriptions.py +++ b/src/sanzaru/descriptions.py @@ -207,8 +207,13 @@ * google: "gemini-3.1-flash-image-preview" (Nano Banana 2, RECOMMENDED) - "gemini-3-pro-image-preview" → Nano Banana Pro (max quality, complex instructions) - "gemini-2.5-flash-image" → Nano Banana (fastest, high-volume) -- aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4" +- aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "5:4", "4:5", "auto" +- image_size: Google only — output resolution: "1K" (default), "2K", "4K" - filename: Google only — custom output filename (auto-generated if omitted) +- safety_settings: Google only — list of {"category", "threshold"} dicts. All OFF by default. + Categories: HARM_CATEGORY_HATE_SPEECH, HARM_CATEGORY_DANGEROUS_CONTENT, + HARM_CATEGORY_SEXUALLY_EXPLICIT, HARM_CATEGORY_HARASSMENT + Thresholds: "OFF" (default), "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_HIGH_AND_ABOVE" - tool_config: OpenAI only — ImageGeneration config object (model, size, quality, etc.) * gpt-image-1.5: STATE-OF-THE-ART OpenAI image model * gpt-image-1: High quality diff --git a/src/sanzaru/server.py b/src/sanzaru/server.py index c66136a..4f990fa 100644 --- a/src/sanzaru/server.py +++ b/src/sanzaru/server.py @@ -141,13 +141,17 @@ async def prepare_reference_image( ): return await reference.prepare_reference_image(input_filename, target_size, output_filename, resize_mode) + from .tools.image import GoogleAspectRatio, GoogleImageModel, GoogleImageSize + @mcp.tool(description=CREATE_IMAGE, annotations=WRITE_OPEN) async def create_image( prompt: str, provider: Literal["openai", "google"] = "openai", - model: str | None = None, - aspect_ratio: str = "1:1", + model: str | GoogleImageModel | None = None, + aspect_ratio: GoogleAspectRatio = "1:1", + image_size: GoogleImageSize = "1K", filename: str | None = None, + safety_settings: list[dict[str, str]] | None = None, tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, @@ -158,7 +162,9 @@ async def create_image( provider, model, aspect_ratio, + image_size, filename, + safety_settings, tool_config, previous_response_id, input_images, diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index 0c2d4e0..5b8f226 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -89,23 +89,47 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: _GOOGLE_DEFAULT_MODEL = "gemini-3.1-flash-image-preview" # Nano Banana 2 +# Type aliases for Google image generation parameters +GoogleImageModel = Literal[ + "gemini-3.1-flash-image-preview", # Nano Banana 2 (default — Flash speed + Pro quality) + "gemini-3-pro-image-preview", # Nano Banana Pro (max quality, complex instructions) + "gemini-2.5-flash-image", # Nano Banana (fastest, high-volume) +] +GoogleImageSize = Literal["1K", "2K", "4K"] +GoogleAspectRatio = Literal["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9", "5:4", "4:5", "auto"] + +# Models that support thinking_config (Nano Banana 2 / Flash-based) +_THINKING_MODELS: set[str] = {"gemini-3.1-flash-image-preview"} + +# Default safety settings — all OFF for maximum creative freedom +_DEFAULT_SAFETY_OFF = [ + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, +] + async def _create_image_google( prompt: str, - model: str, - aspect_ratio: str, + model: GoogleImageModel, + aspect_ratio: GoogleAspectRatio, + image_size: GoogleImageSize, filename: str | None, + safety_settings: list[dict[str, str]] | None = None, ) -> ImageDownloadResult: """Generate an image using Google Nano Banana (Gemini image generation models). - Wraps the synchronous Google Gen AI client in a thread pool for async compatibility. - The Google API is synchronous and returns results immediately (no polling required). + Uses generate_content() with IMAGE response modality — Nano Banana models are Gemini + models, not Imagen, so they use the content generation API with image output. Args: prompt: Text description of the image to generate model: Google model ID (e.g., "gemini-3.1-flash-image-preview" for Nano Banana 2) - aspect_ratio: Image aspect ratio ("1:1", "16:9", "9:16", "4:3", "3:4") + aspect_ratio: Image aspect ratio ("1:1", "16:9", "9:16", "4:3", "3:4", "auto") + image_size: Output resolution ("1K", "2K", "4K") filename: Optional custom filename for the saved image (auto-generated if None) + safety_settings: Optional list of safety settings dicts. Defaults to all OFF. Returns: ImageDownloadResult with filename, pixel dimensions, and format @@ -116,31 +140,61 @@ async def _create_image_google( ValueError: If generation returns no images (e.g., blocked by safety filters) """ try: - from google.genai.types import GenerateImagesConfig + from google.genai import types as genai_types except ImportError as e: raise ImportError("google-genai package is required. Install with: uv add 'sanzaru[google]'") from e storage = get_storage() google_client = get_google_client() - config = GenerateImagesConfig( - number_of_images=1, - aspect_ratio=aspect_ratio, - output_mime_type="image/png", + # Build safety settings from dicts → typed SafetySetting objects + raw_safety = safety_settings if safety_settings is not None else _DEFAULT_SAFETY_OFF + typed_safety = [ + genai_types.SafetySetting( + category=genai_types.HarmCategory(s["category"]), + threshold=genai_types.HarmBlockThreshold(s["threshold"]), + ) + for s in raw_safety + ] + + config = genai_types.GenerateContentConfig( + response_modalities=["IMAGE", "TEXT"], + safety_settings=typed_safety, + image_config=genai_types.ImageConfig( + aspect_ratio=aspect_ratio, + image_size=image_size, + output_mime_type="image/png", + ), ) - logger.info("Generating Nano Banana image: model=%s aspect_ratio=%s", model, aspect_ratio) + # Nano Banana 2 (Flash-based) supports thinking for better quality + if model in _THINKING_MODELS: + config.thinking_config = genai_types.ThinkingConfig(thinking_level=genai_types.ThinkingLevel.HIGH) + + logger.info( + "Generating Nano Banana image: model=%s aspect_ratio=%s image_size=%s thinking=%s", + model, + aspect_ratio, + image_size, + model in _THINKING_MODELS, + ) # Wrap synchronous Google API in thread pool (network I/O — avoids blocking the event loop) - def _call_google() -> object: - return google_client.models.generate_images(model=model, prompt=prompt, config=config) + def _call_google() -> genai_types.GenerateContentResponse: + return google_client.models.generate_content(model=model, contents=prompt, config=config) - response = await anyio.to_thread.run_sync(_call_google) + response: genai_types.GenerateContentResponse = await anyio.to_thread.run_sync(_call_google) - if not response.generated_images: - raise ValueError("Google Nano Banana returned no images — prompt may have been blocked by safety filters") + # Extract image from response parts (Gemini returns mixed text + image parts) + image_bytes: bytes | None = None + if response.candidates and response.candidates[0].content and response.candidates[0].content.parts: + for part in response.candidates[0].content.parts: + if part.inline_data and part.inline_data.data: + image_bytes = part.inline_data.data + break - image_bytes: bytes = response.generated_images[0].image.image_bytes + if image_bytes is None: + raise ValueError("Google Nano Banana returned no image — prompt may have been blocked by safety filters") if filename is None: filename = generate_filename("nb", "png", use_timestamp=True) @@ -164,9 +218,11 @@ def _get_dimensions() -> tuple[tuple[int, int], str]: async def create_image( prompt: str, provider: Literal["openai", "google"] = "openai", - model: str | None = None, - aspect_ratio: str = "1:1", + model: str | GoogleImageModel | None = None, + aspect_ratio: GoogleAspectRatio = "1:1", + image_size: GoogleImageSize = "1K", filename: str | None = None, + safety_settings: list[dict[str, str]] | None = None, tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, @@ -182,21 +238,20 @@ async def create_image( - google: "gemini-3.1-flash-image-preview" (Nano Banana 2, recommended) Other Google models: "gemini-3-pro-image-preview" (Nano Banana Pro), "gemini-2.5-flash-image" (Nano Banana) - aspect_ratio: Image aspect ratio for Google provider ("1:1", "16:9", "9:16", "4:3", "3:4"). - Ignored for OpenAI (use tool_config.size instead). - filename: Output filename for Google provider (auto-generated if None). - Ignored for OpenAI (use download_image after polling). - tool_config: OpenAI ImageGeneration tool configuration (size, quality, model, moderation, etc.). - Only used for OpenAI provider. - previous_response_id: Refine a previous OpenAI generation iteratively. Only used for OpenAI. - input_images: List of reference image filenames from IMAGE_PATH. Only used for OpenAI. - mask_filename: PNG mask with alpha channel for inpainting. Only used for OpenAI. + aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4", "auto". + image_size: Google only — output resolution "1K" (default), "2K", or "4K". + filename: Google only — custom output filename (auto-generated if None). + safety_settings: Google only — list of safety setting dicts with "category" and "threshold". + Defaults to all categories OFF. Example to enable filtering: + [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}] + tool_config: OpenAI only — ImageGeneration tool configuration (size, quality, model, etc.). + previous_response_id: OpenAI only — refine a previous generation iteratively. + input_images: OpenAI only — list of reference image filenames from IMAGE_PATH. + mask_filename: OpenAI only — PNG mask with alpha channel for inpainting. Returns: - - OpenAI provider: ImageResponse with {id, status, created_at} — poll with get_image_status, - then download with download_image. - - Google provider: ImageDownloadResult with {filename, size, format} — image is ready - immediately, no polling required. + - OpenAI: ImageResponse with {id, status, created_at} — poll then download. + - Google: ImageDownloadResult with {filename, size, format} — ready immediately. Raises: RuntimeError: If required API credentials are not set @@ -216,11 +271,15 @@ async def create_image( } """ if provider == "google": + # Default to Nano Banana 2; cast is safe — MCP Literal types enforce valid values at the boundary + google_model: GoogleImageModel = model or _GOOGLE_DEFAULT_MODEL # type: ignore[assignment] return await _create_image_google( prompt=prompt, - model=model or _GOOGLE_DEFAULT_MODEL, + model=google_model, aspect_ratio=aspect_ratio, + image_size=image_size, filename=filename, + safety_settings=safety_settings, ) # OpenAI provider path openai_model = model or "gpt-5.2" diff --git a/uv.lock b/uv.lock index cbd8a8f..8a0c990 100644 --- a/uv.lock +++ b/uv.lock @@ -238,6 +238,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -488,6 +577,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "google-auth" +version = "2.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/59/7371175bfd949abfb1170aa076352131d7281bd9449c0f978604fc4431c3/google_auth-2.49.0.tar.gz", hash = "sha256:9cc2d9259d3700d7a257681f81052db6737495a1a46b610597f4b8bafe5286ae", size = 333444, upload-time = "2026-03-06T21:53:06.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/45/de64b823b639103de4b63dd193480dce99526bd36be6530c2dba85bf7817/google_auth-2.49.0-py3-none-any.whl", hash = "sha256:f893ef7307f19cf53700b7e2f61b5a6affe3aa0edf9943b13788920ab92d8d87", size = 240676, upload-time = "2026-03-06T21:52:38.304Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/0b343b0770d4710ad2979fd9301d7caa56c940174d5361ed4a7cc4979241/google_genai-1.66.0.tar.gz", hash = "sha256:ffc01647b65046bca6387320057aa51db0ad64bcc72c8e3e914062acfa5f7c49", size = 504386, upload-time = "2026-03-04T22:15:28.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/dd/403949d922d4e261b08b64aaa132af4e456c3b15c8e2a2d9e6ef693f66e2/google_genai-1.66.0-py3-none-any.whl", hash = "sha256:7f127a39cf695277104ce4091bb26e417c59bb46e952ff3699c3a982d9c474ee", size = 732174, upload-time = "2026-03-04T22:15:26.63Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -942,6 +1071,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1302,6 +1452,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "rpds-py" version = "0.28.0" @@ -1424,6 +1589,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199, upload-time = "2025-10-22T22:24:26.54Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.3" @@ -1471,6 +1648,7 @@ all = [ { name = "async-lru" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "ffmpeg-python" }, + { name = "google-genai" }, { name = "pillow" }, { name = "pydub" }, ] @@ -1481,6 +1659,9 @@ audio = [ { name = "ffmpeg-python" }, { name = "pydub" }, ] +google = [ + { name = "google-genai" }, +] image = [ { name = "pillow" }, ] @@ -1507,6 +1688,7 @@ requires-dist = [ { name = "async-lru", marker = "extra == 'audio'", specifier = ">=2.0.5" }, { name = "audioop-lts", marker = "python_full_version >= '3.13' and extra == 'audio'" }, { name = "ffmpeg-python", marker = "extra == 'audio'" }, + { name = "google-genai", marker = "extra == 'google'", specifier = ">=0.8.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "openai", specifier = ">=2.17.0" }, @@ -1514,9 +1696,9 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pydub", marker = "extra == 'audio'" }, - { name = "sanzaru", extras = ["video", "audio", "image"], marker = "extra == 'all'" }, + { name = "sanzaru", extras = ["video", "audio", "image", "google"], marker = "extra == 'all'" }, ] -provides-extras = ["video", "audio", "image", "databricks", "all"] +provides-extras = ["video", "audio", "image", "google", "databricks", "all"] [package.metadata.requires-dev] dev = [ @@ -1565,6 +1747,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tomli" version = "2.3.0" @@ -1656,6 +1847,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + [[package]] name = "uvicorn" version = "0.38.0" @@ -1684,3 +1884,71 @@ sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615 wheels = [ { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From 9ad854d78937e0d9f0972cb7a676abcb2e786bc7 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Sun, 8 Mar 2026 15:32:29 -0400 Subject: [PATCH 4/8] feat: split create_image_google into dedicated tool with reference image support Separate Google Nano Banana into its own `create_image_google` tool, restoring `create_image` to a clean OpenAI-only signature. The new tool accepts up to 14 reference images via `input_images` parameter for editing, style transfer, and multi-image composition. Conditionally registered only when Google is configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/sanzaru/descriptions.py | 123 +++++++++++++++++------------- src/sanzaru/server.py | 45 +++++++---- src/sanzaru/tools/image.py | 145 ++++++++++++++++++++---------------- 3 files changed, 183 insertions(+), 130 deletions(-) diff --git a/src/sanzaru/descriptions.py b/src/sanzaru/descriptions.py index a7458c0..9464ee2 100644 --- a/src/sanzaru/descriptions.py +++ b/src/sanzaru/descriptions.py @@ -185,79 +185,100 @@ # ==================== IMAGE GENERATION TOOL DESCRIPTIONS ==================== -CREATE_IMAGE = """Image generation supporting OpenAI Responses API and Google Nano Banana (Gemini image models). +CREATE_IMAGE = """Create an async image generation job via OpenAI Responses API. -Switch between providers via the `provider` parameter. Each provider has different strengths: +Returns immediately with a response_id. Poll with get_image_status() until completed, then download_image(). +Best for: parallel generation (multiple images at once) and iterative refinement chains (previous_response_id). -**Google Nano Banana (provider="google") — synchronous, image ready immediately:** -- Fastest path: no polling required, result returned directly with filename -- Powered by Gemini image models (Nano Banana 2 is the default) -- Best for: speed-first workflows, high-volume generation, character/object consistency -- Up to 4K resolution, SynthID watermarking, C2PA credentials - -**OpenAI Responses API (provider="openai") — async with polling:** -- Returns immediately with a response_id; poll with get_image_status(), then download_image() -- Best for: parallel generation, iterative refinement chains (previous_response_id) +For synchronous one-shot generation (no polling), use generate_image instead. +For Google Nano Banana generation, use create_image_google. Parameters: -- prompt: Text description (required) -- provider: "openai" (default) or "google" -- model: Model ID. Defaults per provider: - * openai: "gpt-5.2" (mainline model that calls image generation tool) - * google: "gemini-3.1-flash-image-preview" (Nano Banana 2, RECOMMENDED) - - "gemini-3-pro-image-preview" → Nano Banana Pro (max quality, complex instructions) - - "gemini-2.5-flash-image" → Nano Banana (fastest, high-volume) -- aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "5:4", "4:5", "auto" -- image_size: Google only — output resolution: "1K" (default), "2K", "4K" -- filename: Google only — custom output filename (auto-generated if omitted) -- safety_settings: Google only — list of {"category", "threshold"} dicts. All OFF by default. - Categories: HARM_CATEGORY_HATE_SPEECH, HARM_CATEGORY_DANGEROUS_CONTENT, - HARM_CATEGORY_SEXUALLY_EXPLICIT, HARM_CATEGORY_HARASSMENT - Thresholds: "OFF" (default), "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_HIGH_AND_ABOVE" -- tool_config: OpenAI only — ImageGeneration config object (model, size, quality, etc.) - * gpt-image-1.5: STATE-OF-THE-ART OpenAI image model +- prompt: Text description of image to generate (required) +- model: OpenAI model ID (default: "gpt-5.2") +- tool_config: ImageGeneration config object to control the image generation tool: + * gpt-image-1.5: STATE-OF-THE-ART (recommended) * gpt-image-1: High quality * gpt-image-1-mini: Fast, cost-effective -- previous_response_id: OpenAI only — refine a previous generation iteratively -- input_images: OpenAI only — list of reference image filenames from IMAGE_PATH -- mask_filename: OpenAI only — PNG with alpha channel for inpainting +- previous_response_id: Refine a previous generation iteratively (optional) +- input_images: List of reference image filenames from IMAGE_PATH (optional) +- mask_filename: PNG with alpha channel for inpainting (optional, requires input_images) -Returns: -- provider="google": ImageDownloadResult with {filename, size, format} — ready immediately -- provider="openai": ImageResponse with {id, status, created_at} — poll then download +Returns ImageResponse with {id, status, created_at} — poll then download. Workflows: -1. Google Nano Banana 2 (fast, synchronous): - create_image("a futuristic cityscape at dusk", provider="google") - -2. Google landscape: - create_image("mountain vista at golden hour", provider="google", aspect_ratio="16:9") - -3. Google Nano Banana Pro (max quality): - create_image("detailed product render", provider="google", model="gemini-3-pro-image-preview") - -4. OpenAI text-only generation: +1. Text-only generation: create_image("sunset over mountains", tool_config={"type": "image_generation", "model": "gpt-image-1.5"}) -5. OpenAI image editing: +2. Image editing: create_image("add a flamingo to the pool", input_images=["lounge.png"]) -6. OpenAI multi-image composition: +3. Multi-image composition: create_image("gift basket with all items", input_images=["lotion.png", "soap.png"]) -7. OpenAI masked inpainting: +4. Masked inpainting: create_image("add flamingo", input_images=["pool.png"], mask_filename="pool_mask.png") -8. OpenAI iterative refinement: +5. Iterative refinement: resp1 = create_image("a cyberpunk character") resp2 = create_image("add neon details", previous_response_id=resp1.id) -OpenAI tool_config examples: +tool_config examples: +Best quality: {"type": "image_generation", "model": "gpt-image-1.5"} +Fast: {"type": "image_generation", "model": "gpt-image-1-mini"} +High-fidelity: {"type": "image_generation", "model": "gpt-image-1.5", "quality": "high", "size": "1536x1024"}""" + +CREATE_IMAGE_GOOGLE = """Generate an image using Google Nano Banana (Gemini image models). Synchronous — image ready immediately. + +No polling required. Returns the saved filename, dimensions, and format directly. +Supports reference images for editing, style transfer, and multi-image composition (up to 14 images). + +Models: +- "gemini-3.1-flash-image-preview": Nano Banana 2 (DEFAULT, RECOMMENDED) — Flash speed + Pro quality, thinking-enhanced +- "gemini-3-pro-image-preview": Nano Banana Pro — max quality, complex instructions, slowest +- "gemini-2.5-flash-image": Nano Banana — fastest, high-volume generation + +Parameters: +- prompt: Text description (required). When using input_images, describe only the desired edits/transformation. +- model: Google model ID (default: "gemini-3.1-flash-image-preview") +- aspect_ratio: "1:1" (default), "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "5:4", "4:5", "auto" +- image_size: Output resolution: "1K" (default), "2K", "4K" +- filename: Custom output filename (auto-generated if omitted) +- input_images: List of reference image filenames from IMAGE_PATH (optional, max 14). + Supported formats: JPEG, PNG, WEBP. Use list_reference_images to find available images. +- safety_settings: List of {"category", "threshold"} dicts. All OFF by default. + Categories: HARM_CATEGORY_HATE_SPEECH, HARM_CATEGORY_DANGEROUS_CONTENT, + HARM_CATEGORY_SEXUALLY_EXPLICIT, HARM_CATEGORY_HARASSMENT + Thresholds: "OFF" (default), "BLOCK_LOW_AND_ABOVE", "BLOCK_MEDIUM_AND_ABOVE", "BLOCK_HIGH_AND_ABOVE" + +Returns ImageDownloadResult with {filename, size, format} — ready immediately. + +Workflows: + +1. Text-only generation: + create_image_google("a futuristic cityscape at dusk") + +2. Landscape with high resolution: + create_image_google("mountain vista at golden hour", aspect_ratio="16:9", image_size="4K") + +3. Max quality (Nano Banana Pro): + create_image_google("detailed product render", model="gemini-3-pro-image-preview") + +4. Image editing with reference: + create_image_google("make this watercolor style", input_images=["photo.png"]) + +5. Multi-image composition: + create_image_google("combine these into a collage", input_images=["img1.png", "img2.png", "img3.png"]) + +6. Character consistency (same character, new scene): + create_image_google("place this character in a forest", input_images=["character.png"]) + +7. Style transfer from reference: + create_image_google("apply this art style to a cityscape", input_images=["style_ref.png"]) -Best quality: tool_config={"type": "image_generation", "model": "gpt-image-1.5"} -Fast: tool_config={"type": "image_generation", "model": "gpt-image-1-mini"} -High-fidelity: tool_config={"type": "image_generation", "model": "gpt-image-1.5", "quality": "high", "size": "1536x1024"}""" +8. Custom filename: + create_image_google("a cute robot", filename="robot_concept.png")""" GET_IMAGE_STATUS = """Check status and progress of image generation. diff --git a/src/sanzaru/server.py b/src/sanzaru/server.py index 4f990fa..721dc32 100644 --- a/src/sanzaru/server.py +++ b/src/sanzaru/server.py @@ -20,7 +20,7 @@ from starlette.responses import Response from .config import logger -from .features import check_audio_available, check_image_available, check_video_available +from .features import check_audio_available, check_google_available, check_image_available, check_video_available from .storage.factory import get_storage from .tools.media_viewer import MEDIA_TYPE_TO_PATH_TYPE @@ -141,17 +141,10 @@ async def prepare_reference_image( ): return await reference.prepare_reference_image(input_filename, target_size, output_filename, resize_mode) - from .tools.image import GoogleAspectRatio, GoogleImageModel, GoogleImageSize - @mcp.tool(description=CREATE_IMAGE, annotations=WRITE_OPEN) async def create_image( prompt: str, - provider: Literal["openai", "google"] = "openai", - model: str | GoogleImageModel | None = None, - aspect_ratio: GoogleAspectRatio = "1:1", - image_size: GoogleImageSize = "1K", - filename: str | None = None, - safety_settings: list[dict[str, str]] | None = None, + model: str = "gpt-5.2", tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, @@ -159,12 +152,7 @@ async def create_image( ): return await image.create_image( prompt, - provider, model, - aspect_ratio, - image_size, - filename, - safety_settings, tool_config, previous_response_id, input_images, @@ -224,6 +212,35 @@ async def edit_image( logger.info("Image tools registered (7 tools)") +# ==================== GOOGLE IMAGE TOOLS (CONDITIONAL) ==================== +if check_google_available() and check_image_available(): + from .descriptions import CREATE_IMAGE_GOOGLE + from .tools import image as _image_google + from .tools.image import GoogleAspectRatio, GoogleImageModel, GoogleImageSize + + @mcp.tool(description=CREATE_IMAGE_GOOGLE, annotations=WRITE_OPEN) + async def create_image_google( + prompt: str, + model: GoogleImageModel = "gemini-3.1-flash-image-preview", + aspect_ratio: GoogleAspectRatio = "1:1", + image_size: GoogleImageSize = "1K", + filename: str | None = None, + input_images: list[str] | None = None, + safety_settings: list[dict[str, str]] | None = None, + ): + return await _image_google.create_image_google( + prompt, + model, + aspect_ratio, + image_size, + filename, + input_images, + safety_settings, + ) + + logger.info("Google image tools registered (1 tool)") + + # ==================== AUDIO TOOLS (CONDITIONAL) ==================== if check_audio_available(): from openai.types import AudioModel, AudioResponseFormat diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index 5b8f226..52c3b4b 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -29,6 +29,9 @@ from ..types import ImageDownloadResult, ImageResponse from ..utils import generate_filename +# Allowed image extensions for reference image validation +_ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + # ==================== HELPER FUNCTIONS ==================== @@ -110,12 +113,13 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: ] -async def _create_image_google( +async def create_image_google( prompt: str, - model: GoogleImageModel, - aspect_ratio: GoogleAspectRatio, - image_size: GoogleImageSize, - filename: str | None, + model: GoogleImageModel = "gemini-3.1-flash-image-preview", + aspect_ratio: GoogleAspectRatio = "1:1", + image_size: GoogleImageSize = "1K", + filename: str | None = None, + input_images: list[str] | None = None, safety_settings: list[dict[str, str]] | None = None, ) -> ImageDownloadResult: """Generate an image using Google Nano Banana (Gemini image generation models). @@ -123,12 +127,18 @@ async def _create_image_google( Uses generate_content() with IMAGE response modality — Nano Banana models are Gemini models, not Imagen, so they use the content generation API with image output. + Supports multimodal input: when input_images are provided, they are loaded as PIL.Image + objects and passed alongside the text prompt for image editing, style transfer, or + multi-image composition (up to 14 reference images). + Args: - prompt: Text description of the image to generate - model: Google model ID (e.g., "gemini-3.1-flash-image-preview" for Nano Banana 2) + prompt: Text description of the image to generate (or edits to apply to input images) + model: Google model ID (default: "gemini-3.1-flash-image-preview" for Nano Banana 2) aspect_ratio: Image aspect ratio ("1:1", "16:9", "9:16", "4:3", "3:4", "auto") image_size: Output resolution ("1K", "2K", "4K") filename: Optional custom filename for the saved image (auto-generated if None) + input_images: Optional list of reference image filenames from IMAGE_PATH (max 14). + Supported formats: JPEG, PNG, WEBP. safety_settings: Optional list of safety settings dicts. Defaults to all OFF. Returns: @@ -137,7 +147,7 @@ async def _create_image_google( Raises: ImportError: If google-genai package is not installed RuntimeError: If Google credentials are not configured - ValueError: If generation returns no images (e.g., blocked by safety filters) + ValueError: If generation returns no images, invalid image format, or too many images """ try: from google.genai import types as genai_types @@ -171,17 +181,53 @@ async def _create_image_google( if model in _THINKING_MODELS: config.thinking_config = genai_types.ThinkingConfig(thinking_level=genai_types.ThinkingLevel.HIGH) - logger.info( - "Generating Nano Banana image: model=%s aspect_ratio=%s image_size=%s thinking=%s", - model, - aspect_ratio, - image_size, - model in _THINKING_MODELS, - ) + # Build contents: text-only or multimodal with reference images + contents: str | list[str | Image.Image] + if input_images: + if len(input_images) > 14: + raise ValueError(f"Too many input images ({len(input_images)}). Maximum is 14.") + + pil_images: list[Image.Image] = [] + for img_filename in input_images: + # Validate extension + ext = ("." + img_filename.rsplit(".", 1)[-1].lower()) if "." in img_filename else "" + if ext not in _ALLOWED_IMAGE_EXTENSIONS: + raise ValueError(f"Unsupported image format: {img_filename} (use JPEG, PNG, WEBP)") + + # Read via storage backend (handles path validation + security) + img_bytes = await storage.read("reference", img_filename) + + # Open as PIL.Image in thread pool (blocking I/O) + def _open_image(data: bytes = img_bytes) -> Image.Image: + return Image.open(io.BytesIO(data)) + + pil_img = await anyio.to_thread.run_sync(_open_image) + pil_images.append(pil_img) + + contents = [prompt, *pil_images] + + logger.info( + "Generating Nano Banana image with %d reference image(s): model=%s aspect_ratio=%s image_size=%s thinking=%s", + len(input_images), + model, + aspect_ratio, + image_size, + model in _THINKING_MODELS, + ) + else: + contents = prompt + + logger.info( + "Generating Nano Banana image: model=%s aspect_ratio=%s image_size=%s thinking=%s", + model, + aspect_ratio, + image_size, + model in _THINKING_MODELS, + ) # Wrap synchronous Google API in thread pool (network I/O — avoids blocking the event loop) def _call_google() -> genai_types.GenerateContentResponse: - return google_client.models.generate_content(model=model, contents=prompt, config=config) + return google_client.models.generate_content(model=model, contents=contents, config=config) response: genai_types.GenerateContentResponse = await anyio.to_thread.run_sync(_call_google) @@ -217,48 +263,30 @@ def _get_dimensions() -> tuple[tuple[int, int], str]: async def create_image( prompt: str, - provider: Literal["openai", "google"] = "openai", - model: str | GoogleImageModel | None = None, - aspect_ratio: GoogleAspectRatio = "1:1", - image_size: GoogleImageSize = "1K", - filename: str | None = None, - safety_settings: list[dict[str, str]] | None = None, + model: str = "gpt-5.2", tool_config: ImageGeneration | None = None, previous_response_id: str | None = None, input_images: list[str] | None = None, mask_filename: str | None = None, -) -> ImageResponse | ImageDownloadResult: - """Create a new image generation job using OpenAI Responses API or Google Nano Banana. +) -> ImageResponse: + """Create a new image generation job using OpenAI Responses API. Args: prompt: Text description of image to generate (or edits to make if input_images provided) - provider: Image provider — "openai" (default) or "google" (Nano Banana / Gemini image models) - model: Model ID. Defaults by provider: - - openai: "gpt-5.2" (or any mainline model) - - google: "gemini-3.1-flash-image-preview" (Nano Banana 2, recommended) - Other Google models: "gemini-3-pro-image-preview" (Nano Banana Pro), - "gemini-2.5-flash-image" (Nano Banana) - aspect_ratio: Google only — "1:1" (default), "16:9", "9:16", "4:3", "3:4", "auto". - image_size: Google only — output resolution "1K" (default), "2K", or "4K". - filename: Google only — custom output filename (auto-generated if None). - safety_settings: Google only — list of safety setting dicts with "category" and "threshold". - Defaults to all categories OFF. Example to enable filtering: - [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}] - tool_config: OpenAI only — ImageGeneration tool configuration (size, quality, model, etc.). - previous_response_id: OpenAI only — refine a previous generation iteratively. - input_images: OpenAI only — list of reference image filenames from IMAGE_PATH. - mask_filename: OpenAI only — PNG mask with alpha channel for inpainting. + model: OpenAI model ID (default: "gpt-5.2") + tool_config: ImageGeneration tool configuration (size, quality, model, etc.). + previous_response_id: Refine a previous generation iteratively. + input_images: List of reference image filenames from IMAGE_PATH. + mask_filename: PNG mask with alpha channel for inpainting. Returns: - - OpenAI: ImageResponse with {id, status, created_at} — poll then download. - - Google: ImageDownloadResult with {filename, size, format} — ready immediately. + ImageResponse with {id, status, created_at} — poll with get_image_status, then download_image. Raises: - RuntimeError: If required API credentials are not set + RuntimeError: If OPENAI_API_KEY not set ValueError: If invalid filename, path traversal, or mask without input_images - ImportError: If google-genai is not installed and provider="google" - Example tool_config (OpenAI): + Example tool_config: { "type": "image_generation", "model": "gpt-image-1.5", # recommended (or "gpt-image-1", "gpt-image-1-mini") @@ -270,19 +298,6 @@ async def create_image( "background": "transparent" } """ - if provider == "google": - # Default to Nano Banana 2; cast is safe — MCP Literal types enforce valid values at the boundary - google_model: GoogleImageModel = model or _GOOGLE_DEFAULT_MODEL # type: ignore[assignment] - return await _create_image_google( - prompt=prompt, - model=google_model, - aspect_ratio=aspect_ratio, - image_size=image_size, - filename=filename, - safety_settings=safety_settings, - ) - # OpenAI provider path - openai_model = model or "gpt-5.2" client = get_client() storage = get_storage() @@ -316,18 +331,18 @@ async def create_image( # Structured input with images content_items: ResponseInputMessageContentListParam = [ResponseInputTextParam(type="input_text", text=prompt)] - for filename in input_images: + for img_filename in input_images: # Validate file extension - ext = ("." + filename.rsplit(".", 1)[-1].lower()) if "." in filename else "" - if ext not in {".jpg", ".jpeg", ".png", ".webp"}: - raise ValueError(f"Unsupported image format: {filename} (use JPEG, PNG, WEBP)") + ext = ("." + img_filename.rsplit(".", 1)[-1].lower()) if "." in img_filename else "" + if ext not in _ALLOWED_IMAGE_EXTENSIONS: + raise ValueError(f"Unsupported image format: {img_filename} (use JPEG, PNG, WEBP)") # Read image via storage backend (handles path validation + security) - img_bytes = await storage.read("reference", filename) + img_bytes = await storage.read("reference", img_filename) # Encode to base64 (in thread pool to avoid blocking event loop) base64_data = await anyio.to_thread.run_sync(_encode_image_base64, img_bytes) - mime_type = _get_mime_type(filename) + mime_type = _get_mime_type(img_filename) # Add to content items image_item: ResponseInputImageParam = { @@ -354,7 +369,7 @@ async def create_image( # Create response with image generation tool prev_resp_param: str | Omit = omit if previous_response_id is None else previous_response_id response = await client.responses.create( - model=openai_model, + model=model, input=input_param, tools=[config], previous_response_id=prev_resp_param, From 8efee308c0759bc861523961d953165e2f5b199d Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Sun, 8 Mar 2026 15:57:40 -0400 Subject: [PATCH 5/8] fix: skip output_mime_type on Gemini Developer API (Vertex AI only) The ImageConfig.output_mime_type parameter is only supported on Vertex AI, not the Gemini Developer API. Conditionally set it based on the GOOGLE_GENAI_USE_VERTEXAI environment variable. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/sanzaru/tools/image.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index 52c3b4b..3013304 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -9,6 +9,7 @@ import base64 import io +import os from typing import Literal import anyio @@ -167,14 +168,16 @@ async def create_image_google( for s in raw_safety ] + # Build image config — output_mime_type only supported on Vertex AI, not Gemini Developer API + use_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in ("true", "1") + image_cfg = genai_types.ImageConfig(aspect_ratio=aspect_ratio, image_size=image_size) + if use_vertex: + image_cfg.output_mime_type = "image/png" + config = genai_types.GenerateContentConfig( response_modalities=["IMAGE", "TEXT"], safety_settings=typed_safety, - image_config=genai_types.ImageConfig( - aspect_ratio=aspect_ratio, - image_size=image_size, - output_mime_type="image/png", - ), + image_config=image_cfg, ) # Nano Banana 2 (Flash-based) supports thinking for better quality From 152b461c18ba2d789962be4f95fd4e0f0b493ea5 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 9 Mar 2026 11:13:53 -0400 Subject: [PATCH 6/8] fix: add pillow to google extra, remove unsupported auto aspect ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pillow>=12.0.0 to the google optional dependency group since create_image_google uses PIL for reference images and dimensions - Remove "auto" from GoogleAspectRatio — not documented in the SDK (keep 4:5/5:4 which appear in some Google docs) Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 + src/sanzaru/descriptions.py | 2 +- src/sanzaru/tools/image.py | 2 +- uv.lock | 2 ++ 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b1af456..27305f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ image = [ ] google = [ "google-genai>=0.8.0", + "pillow>=12.0.0", ] databricks = [] # httpx already a core dep; extra exists for signaling intent all = [ diff --git a/src/sanzaru/descriptions.py b/src/sanzaru/descriptions.py index 9464ee2..52b9982 100644 --- a/src/sanzaru/descriptions.py +++ b/src/sanzaru/descriptions.py @@ -242,7 +242,7 @@ Parameters: - prompt: Text description (required). When using input_images, describe only the desired edits/transformation. - model: Google model ID (default: "gemini-3.1-flash-image-preview") -- aspect_ratio: "1:1" (default), "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "5:4", "4:5", "auto" +- aspect_ratio: "1:1" (default), "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9", "5:4", "4:5" - image_size: Output resolution: "1K" (default), "2K", "4K" - filename: Custom output filename (auto-generated if omitted) - input_images: List of reference image filenames from IMAGE_PATH (optional, max 14). diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index 3013304..d788b94 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -100,7 +100,7 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: "gemini-2.5-flash-image", # Nano Banana (fastest, high-volume) ] GoogleImageSize = Literal["1K", "2K", "4K"] -GoogleAspectRatio = Literal["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9", "5:4", "4:5", "auto"] +GoogleAspectRatio = Literal["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "21:9", "5:4", "4:5"] # Models that support thinking_config (Nano Banana 2 / Flash-based) _THINKING_MODELS: set[str] = {"gemini-3.1-flash-image-preview"} diff --git a/uv.lock b/uv.lock index 8a0c990..a504a2a 100644 --- a/uv.lock +++ b/uv.lock @@ -1661,6 +1661,7 @@ audio = [ ] google = [ { name = "google-genai" }, + { name = "pillow" }, ] image = [ { name = "pillow" }, @@ -1689,6 +1690,7 @@ requires-dist = [ { name = "audioop-lts", marker = "python_full_version >= '3.13' and extra == 'audio'" }, { name = "ffmpeg-python", marker = "extra == 'audio'" }, { name = "google-genai", marker = "extra == 'google'", specifier = ">=0.8.0" }, + { name = "pillow", marker = "extra == 'google'", specifier = ">=12.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "openai", specifier = ">=2.17.0" }, From b20503487957675d33a18d22ac507ad8c7039fce Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 9 Mar 2026 12:05:23 -0400 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?type=20safety,=20dead=20code,=20version=20constraint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix _get_dimensions closure type narrowing: rebind image_bytes to safe_bytes after None guard so closures see `bytes` not `bytes | None` - Add SafetySettingDict TypedDict replacing raw dict[str, str] - Add return type annotation to get_google_client() with TYPE_CHECKING - Remove unused _GOOGLE_DEFAULT_MODEL constant - Fix misleading log when both project+api_key set (api_key wins) - Tighten google-genai lower bound to >=1.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- src/sanzaru/config.py | 9 +++++++-- src/sanzaru/features.py | 5 ++++- src/sanzaru/server.py | 3 ++- src/sanzaru/tools/image.py | 15 ++++++++------- src/sanzaru/types.py | 7 +++++++ uv.lock | 4 ++-- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27305f2..305b6ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ image = [ "pillow>=12.0.0", ] google = [ - "google-genai>=0.8.0", + "google-genai>=1.0.0", "pillow>=12.0.0", ] databricks = [] # httpx already a core dep; extra exists for signaling intent diff --git a/src/sanzaru/config.py b/src/sanzaru/config.py index 3e01257..379300d 100644 --- a/src/sanzaru/config.py +++ b/src/sanzaru/config.py @@ -8,15 +8,20 @@ - Logging setup """ +from __future__ import annotations + import logging import os import pathlib import sys from functools import lru_cache -from typing import Literal +from typing import TYPE_CHECKING, Literal from openai import AsyncOpenAI +if TYPE_CHECKING: + from google import genai + # ---------- Logging configuration ---------- LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -44,7 +49,7 @@ def get_client() -> AsyncOpenAI: # ---------- Google Gen AI client (stateless) ---------- -def get_google_client(): +def get_google_client() -> genai.Client: """Get a Google Gen AI client instance. Supports Vertex AI (ADC or Express mode) and Gemini Developer API via env var auto-detection. diff --git a/src/sanzaru/features.py b/src/sanzaru/features.py index dab1210..2d15261 100644 --- a/src/sanzaru/features.py +++ b/src/sanzaru/features.py @@ -142,7 +142,10 @@ def check_google_available() -> bool: project = os.getenv("GOOGLE_CLOUD_PROJECT") api_key = os.getenv("GOOGLE_API_KEY") if project and api_key: - logger.info("Google Nano Banana available via Vertex AI Express (project=%s, api_key)", project) + logger.info( + "Google Nano Banana available via Vertex AI Express (api_key takes precedence, project=%s ignored)", + project, + ) return True if project: logger.info("Google Nano Banana available via Vertex AI ADC (project=%s)", project) diff --git a/src/sanzaru/server.py b/src/sanzaru/server.py index 721dc32..f12f2d4 100644 --- a/src/sanzaru/server.py +++ b/src/sanzaru/server.py @@ -217,6 +217,7 @@ async def edit_image( from .descriptions import CREATE_IMAGE_GOOGLE from .tools import image as _image_google from .tools.image import GoogleAspectRatio, GoogleImageModel, GoogleImageSize + from .types import SafetySettingDict @mcp.tool(description=CREATE_IMAGE_GOOGLE, annotations=WRITE_OPEN) async def create_image_google( @@ -226,7 +227,7 @@ async def create_image_google( image_size: GoogleImageSize = "1K", filename: str | None = None, input_images: list[str] | None = None, - safety_settings: list[dict[str, str]] | None = None, + safety_settings: list[SafetySettingDict] | None = None, ): return await _image_google.create_image_google( prompt, diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index d788b94..53ba3d2 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -27,7 +27,7 @@ from ..config import get_client, get_google_client, logger from ..storage import get_storage -from ..types import ImageDownloadResult, ImageResponse +from ..types import ImageDownloadResult, ImageResponse, SafetySettingDict from ..utils import generate_filename # Allowed image extensions for reference image validation @@ -91,8 +91,6 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: # ==================== GOOGLE NANO BANANA ==================== -_GOOGLE_DEFAULT_MODEL = "gemini-3.1-flash-image-preview" # Nano Banana 2 - # Type aliases for Google image generation parameters GoogleImageModel = Literal[ "gemini-3.1-flash-image-preview", # Nano Banana 2 (default — Flash speed + Pro quality) @@ -106,7 +104,7 @@ async def _upload_mask_file(data: bytes, filename: str) -> str: _THINKING_MODELS: set[str] = {"gemini-3.1-flash-image-preview"} # Default safety settings — all OFF for maximum creative freedom -_DEFAULT_SAFETY_OFF = [ +_DEFAULT_SAFETY_OFF: list[SafetySettingDict] = [ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, @@ -121,7 +119,7 @@ async def create_image_google( image_size: GoogleImageSize = "1K", filename: str | None = None, input_images: list[str] | None = None, - safety_settings: list[dict[str, str]] | None = None, + safety_settings: list[SafetySettingDict] | None = None, ) -> ImageDownloadResult: """Generate an image using Google Nano Banana (Gemini image generation models). @@ -245,13 +243,16 @@ def _call_google() -> genai_types.GenerateContentResponse: if image_bytes is None: raise ValueError("Google Nano Banana returned no image — prompt may have been blocked by safety filters") + # Rebind so closures below see `bytes` instead of `bytes | None` + safe_bytes: bytes = image_bytes + if filename is None: filename = generate_filename("nb", "png", use_timestamp=True) - await storage.write("reference", filename, image_bytes) + await storage.write("reference", filename, safe_bytes) def _get_dimensions() -> tuple[tuple[int, int], str]: - img = Image.open(io.BytesIO(image_bytes)) + img = Image.open(io.BytesIO(safe_bytes)) return img.size, img.format.lower() if img.format else "png" size, fmt = await anyio.to_thread.run_sync(_get_dimensions) diff --git a/src/sanzaru/types.py b/src/sanzaru/types.py index 4f3f13a..7f5967a 100644 --- a/src/sanzaru/types.py +++ b/src/sanzaru/types.py @@ -81,6 +81,13 @@ class ImageDownloadResult(TypedDict): format: str +class SafetySettingDict(TypedDict): + """Google safety setting with category and threshold.""" + + category: str + threshold: str + + class ImageGenerateResult(BaseModel): """Result from generating an image via Images API.""" diff --git a/uv.lock b/uv.lock index a504a2a..6df28f4 100644 --- a/uv.lock +++ b/uv.lock @@ -1689,11 +1689,11 @@ requires-dist = [ { name = "async-lru", marker = "extra == 'audio'", specifier = ">=2.0.5" }, { name = "audioop-lts", marker = "python_full_version >= '3.13' and extra == 'audio'" }, { name = "ffmpeg-python", marker = "extra == 'audio'" }, - { name = "google-genai", marker = "extra == 'google'", specifier = ">=0.8.0" }, - { name = "pillow", marker = "extra == 'google'", specifier = ">=12.0.0" }, + { name = "google-genai", marker = "extra == 'google'", specifier = ">=1.0.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "mcp", specifier = ">=1.26.0" }, { name = "openai", specifier = ">=2.17.0" }, + { name = "pillow", marker = "extra == 'google'", specifier = ">=12.0.0" }, { name = "pillow", marker = "extra == 'image'", specifier = ">=12.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, From fa6fc65a6212aa7ebeecdedc5141f0e0c49774d0 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Mon, 9 Mar 2026 13:25:49 -0400 Subject: [PATCH 8/8] fix: suppress mypy arg-type for google-genai SDK union mismatch The SDK's generate_content parameter type includes Dict variants that don't align with its own ContentListUnion type. Add targeted type: ignore since PIL.Image is accepted at runtime via PartUnion. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/sanzaru/tools/image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sanzaru/tools/image.py b/src/sanzaru/tools/image.py index 53ba3d2..17cf39f 100644 --- a/src/sanzaru/tools/image.py +++ b/src/sanzaru/tools/image.py @@ -183,7 +183,7 @@ async def create_image_google( config.thinking_config = genai_types.ThinkingConfig(thinking_level=genai_types.ThinkingLevel.HIGH) # Build contents: text-only or multimodal with reference images - contents: str | list[str | Image.Image] + contents: str | list[str | Image.Image] # SDK accepts PIL.Image via PartUnion if input_images: if len(input_images) > 14: raise ValueError(f"Too many input images ({len(input_images)}). Maximum is 14.") @@ -228,7 +228,7 @@ def _open_image(data: bytes = img_bytes) -> Image.Image: # Wrap synchronous Google API in thread pool (network I/O — avoids blocking the event loop) def _call_google() -> genai_types.GenerateContentResponse: - return google_client.models.generate_content(model=model, contents=contents, config=config) + return google_client.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type] response: genai_types.GenerateContentResponse = await anyio.to_thread.run_sync(_call_google)