diff --git a/packages/gemini-image/README.md b/packages/gemini-image/README.md
new file mode 100644
index 0000000..3a87faf
--- /dev/null
+++ b/packages/gemini-image/README.md
@@ -0,0 +1,189 @@
+# Gemini Image Generation
+
+A comprehensive image generation library built on Google's Gemini models (Nano Banana / Nano Banana Pro).
+
+## Features
+
+- **Text-to-image generation** with configurable resolution and aspect ratio
+- **Reference-based editing** - modify existing images with prompts
+- **Multi-part story generation** - sequential images with visual continuity
+- **Draft-then-finalize workflow** - 75% cost reduction during iteration
+- **Thinking mode** - visualize model reasoning with intermediate images
+
+## Installation
+
+```bash
+# Using uv (recommended)
+uv add byronwilliamscpa-gemini-image
+
+# Using pip
+pip install byronwilliamscpa-gemini-image
+```
+
+## Quick Start
+
+### Set API Key
+
+```bash
+export GEMINI_API_KEY='your-api-key'
+```
+
+### Python API
+
+```python
+from gemini_image import generate_image, generate_story_sequence
+
+# Basic text-to-image
+result = generate_image("A futuristic city at sunset")
+print(f"Image saved to: {result}")
+
+# With resolution and aspect ratio
+result = generate_image(
+    "A technical blueprint",
+    aspect_ratio="16:9",
+    image_size="2K",
+    verbose=True,
+)
+
+# Draft mode for iteration (1K resolution)
+draft = generate_image(
+    "A data governance diagram",
+    is_draft=True,
+)
+
+# Reference-based editing
+from pathlib import Path
+edited = generate_image(
+    "Make the title larger",
+    reference_images=[Path("original.png")],
+)
+
+# Multi-part story sequence
+from gemini_image import generate_story_sequence
+
+images = generate_story_sequence(
+    "A journey through data governance evolution",
+    num_parts=3,
+    aspect_ratio="16:9",
+)
+```
+
+### Command Line
+
+```bash
+# Basic generation
+gemini-image "A serene mountain landscape at dawn"
+
+# With output path
+gemini-image "A data governance diagram" -o governance.png
+
+# Draft mode (faster, lower cost)
+gemini-image "A technical blueprint" --draft-mode -o draft.png
+
+# Finalize draft at higher resolution
+gemini-image --finalize draft.png --size 2K -o final.png
+
+# Reference-based editing
+gemini-image "Make the building taller" -r blueprint.png
+
+# Multi-part story
+gemini-image "Evolution of a data platform" --story-parts 4 -o evolution
+
+# Show thinking process
+gemini-image "Complex blueprint design" --save-thoughts --verbose
+
+# List available models
+gemini-image --list-models
+```
+
+## Models
+
+| Key | Model | Features |
+|-----|-------|----------|
+| `flash` | Gemini 2.5 Flash | Fast generation |
+| `pro` | Gemini 3 Pro (default) | 4K, better text rendering, thinking mode |
+
+## Resolution Options (Pro Model)
+
+| Size | Dimensions (16:9) | Use Case |
+|------|-------------------|----------|
+| 1K | ~1408 x 768 | Draft mode, fast iteration |
+| 2K | 2752 x 1536 | Standard documents |
+| 4K | 5504 x 3072 | High-detail, large prints |
+
+## Aspect Ratios
+
+- `1:1` - Square
+- `3:4` - Portrait
+- `4:3` - Standard landscape
+- `9:16` - Vertical/mobile
+- `16:9` - Widescreen (default)
+
+## Draft-Then-Finalize Workflow
+
+Reduce costs by ~75% during iteration:
+
+```bash
+# 1. Generate draft at 1K
+gemini-image "A technical blueprint" --draft-mode -o draft.png
+
+# 2. Iterate on draft
+gemini-image "Add more detail to the header" -r draft.png --draft-mode -o draft_v2.png
+
+# 3. Finalize at 2K when satisfied
+gemini-image --finalize draft_v2.png --size 2K -o final.png
+```
+
+## API Reference
+
+### `generate_image()`
+
+```python
+def generate_image(
+    prompt: str,
+    model_key: ModelKey = "pro",
+    reference_images: list[Path] | None = None,
+    output_path: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    use_search: bool = False,
+    save_thoughts: bool = False,
+    verbose: bool = False,
+    is_draft: bool = False,
+) -> Path | None:
+```
+
+### `generate_story_sequence()`
+
+```python
+def generate_story_sequence(
+    base_prompt: str,
+    num_parts: int,
+    model_key: ModelKey = "pro",
+    output_prefix: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    verbose: bool = False,
+) -> list[Path]:
+```
+
+### `finalize_draft()`
+
+```python
+def finalize_draft(
+    draft_path: Path,
+    prompt: str | None = None,
+    model_key: ModelKey = "pro",
+    output_path: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    verbose: bool = False,
+) -> Path | None:
+```
+
+## License
+
+MIT
diff --git a/packages/gemini-image/pyproject.toml b/packages/gemini-image/pyproject.toml
new file mode 100644
index 0000000..6493126
--- /dev/null
+++ b/packages/gemini-image/pyproject.toml
@@ -0,0 +1,66 @@
+[project]
+name = "byronwilliamscpa-gemini-image"
+version = "0.1.0"
+description = "Image generation using Google Gemini models (Nano Banana / Nano Banana Pro)"
+readme = "README.md"
+requires-python = ">=3.10,<3.15"
+license = {text = "MIT"}
+authors = [
+    {name = "Byron Williams", email = "byronawilliams@gmail.com"}
+]
+keywords = ["gemini", "image-generation", "ai", "google", "genai", "text-to-image"]
+classifiers = [
+    "Development Status :: 4 - Beta",
+    "Intended Audience :: Developers",
+    "License :: OSI Approved :: MIT License",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Programming Language :: Python :: 3.13",
+    "Topic :: Multimedia :: Graphics",
+    "Topic :: Scientific/Engineering :: Artificial Intelligence",
+    "Typing :: Typed",
+]
+
+dependencies = [
+    "google-genai>=1.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=7.4.0",
+    "pytest-cov>=4.1.0",
+    "pytest-asyncio>=0.21.0",
+]
+
+[project.scripts]
+gemini-image = "gemini_image.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/ByronWilliamsCPA/python-libs"
+Repository = "https://github.com/ByronWilliamsCPA/python-libs"
+Documentation = "https://github.com/ByronWilliamsCPA/python-libs#readme"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/gemini_image"]
+
+# Per-package semantic release configuration
+[tool.semantic_release]
+version_toml = ["pyproject.toml:project.version"]
+tag_format = "gemini-image-v{version}"
+
+[tool.semantic_release.commit_parser_options]
+allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore"]
+minor_tags = ["feat"]
+patch_tags = ["fix", "perf"]
+
+[tool.semantic_release.changelog]
+changelog_file = "CHANGELOG.md"
+
+[tool.semantic_release.branches.main]
+match = "(main|master)"
+prerelease = false
diff --git a/packages/gemini-image/src/gemini_image/__init__.py b/packages/gemini-image/src/gemini_image/__init__.py
new file mode 100644
index 0000000..b1e82b6
--- /dev/null
+++ b/packages/gemini-image/src/gemini_image/__init__.py
@@ -0,0 +1,48 @@
+"""Gemini Image Generation Library.
+
+A comprehensive image generation system built on Google's Gemini models.
+
+Features:
+    - Text-to-image generation with configurable resolution and aspect ratio
+    - Reference-based image editing and refinement
+    - Multi-part story sequence generation with visual continuity
+    - Draft-then-finalize workflow for cost optimization
+    - Thinking mode with intermediate image visualization
+
+Models:
+    - flash: Gemini 2.5 Flash (fast generation)
+    - pro: Gemini 3 Pro (4K, better text rendering, thinking mode)
+
+Example:
+    >>> from gemini_image import generate_image, MODELS
+    >>> result = generate_image("A futuristic city at sunset")
+    >>> print(f"Image saved to: {result}")
+
+"""
+
+from gemini_image.generator import generate_image, generate_story_sequence
+from gemini_image.models import (
+    ASPECT_RATIOS,
+    DEFAULT_MODEL,
+    IMAGE_SIZES,
+    MODELS,
+    AspectRatio,
+    ImageSize,
+    ModelConfig,
+    ModelKey,
+)
+
+__all__ = [
+    "ASPECT_RATIOS",
+    "DEFAULT_MODEL",
+    "IMAGE_SIZES",
+    "MODELS",
+    "AspectRatio",
+    "ImageSize",
+    "ModelConfig",
+    "ModelKey",
+    "generate_image",
+    "generate_story_sequence",
+]
+
+__version__ = "0.1.0"
diff --git a/packages/gemini-image/src/gemini_image/cli.py b/packages/gemini-image/src/gemini_image/cli.py
new file mode 100644
index 0000000..74ee5c2
--- /dev/null
+++ b/packages/gemini-image/src/gemini_image/cli.py
@@ -0,0 +1,230 @@
+"""Command-line interface for Gemini image generation."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+from gemini_image.generator import (
+    finalize_draft,
+    generate_image,
+    generate_story_sequence,
+)
+from gemini_image.models import ASPECT_RATIOS, DEFAULT_MODEL, IMAGE_SIZES, MODELS
+
+
+def list_models() -> None:
+    """Print available models."""
+    print("Available models:\n")  # noqa: T201
+    for key, config in MODELS.items():
+        print(f"  {key}:")  # noqa: T201
+        print(f"    Name: {config['name']}")  # noqa: T201
+        print(f"    ID: {config['id']}")  # noqa: T201
+        print(f"    Description: {config['description']}")  # noqa: T201
+        print()  # noqa: T201
+
+
+def main() -> None:
+    """Main entry point for CLI."""
+    parser = argparse.ArgumentParser(
+        description="Generate images using Google Gemini (Nano Banana / Nano Banana Pro)",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  # Single image generation
+  %(prog)s "A serene mountain landscape at dawn"
+  %(prog)s "A data governance diagram" -o governance.png
+
+  # Draft-then-finalize workflow (cost-effective iteration)
+  %(prog)s "A technical blueprint" --draft-mode -o draft.png
+  %(prog)s "Adjust colors" -r draft.png --draft-mode -o draft_v2.png
+  %(prog)s --finalize draft_v2.png --size 2K -o final.png
+
+  # Image editing with reference
+  %(prog)s "Make the building taller" -r blueprint.png
+  %(prog)s "Refine this architectural drawing" -r img1.png -r img2.png
+
+  # Advanced options
+  %(prog)s "A landscape" --aspect 16:9 --size 4K
+  %(prog)s "Current weather in Tokyo" --search
+  %(prog)s "Complex blueprint design" --save-thoughts --verbose
+
+  # Multi-part story generation (automatic continuity)
+  %(prog)s "A 3-part journey through data governance" --story-parts 3 -o journey
+  %(prog)s "Evolution of a data platform" --story-parts 4 --aspect 16:9 --size 2K -o evolution
+        """,
+    )
+
+    parser.add_argument(
+        "prompt",
+        nargs="?",
+        help="Text prompt describing the image to generate",
+    )
+
+    parser.add_argument(
+        "-o",
+        "--output",
+        type=Path,
+        help="Output file path (default: generated_TIMESTAMP.png)",
+    )
+
+    parser.add_argument(
+        "-d",
+        "--output-dir",
+        type=Path,
+        help="Output directory (default: current directory)",
+    )
+
+    parser.add_argument(
+        "-m",
+        "--model",
+        choices=list(MODELS.keys()),
+        default=DEFAULT_MODEL,
+        help=f"Model to use (default: {DEFAULT_MODEL})",
+    )
+
+    parser.add_argument(
+        "-r",
+        "--reference",
+        type=Path,
+        action="append",
+        dest="references",
+        help="Reference image(s) for editing or style (can be used multiple times)",
+    )
+
+    parser.add_argument(
+        "--aspect",
+        choices=ASPECT_RATIOS,
+        help="Aspect ratio (pro model only): 1:1, 3:4, 4:3, 9:16, 16:9",
+    )
+
+    parser.add_argument(
+        "--size",
+        choices=IMAGE_SIZES,
+        help="Image size (pro model only): 1K, 2K, 4K",
+    )
+
+    parser.add_argument(
+        "--search",
+        action="store_true",
+        help="Enable Google Search grounding for real-time data (pro model only)",
+    )
+
+    parser.add_argument(
+        "--save-thoughts",
+        action="store_true",
+        help="Save intermediate thought images (pro model only)",
+    )
+
+    parser.add_argument(
+        "--verbose",
+        "-v",
+        action="store_true",
+        help="Show detailed thinking process and save thought signatures",
+    )
+
+    parser.add_argument(
+        "--story-parts",
+        type=int,
+        metavar="N",
+        help="Generate a multi-part story with N parts (uses previous image as reference)",
+    )
+
+    parser.add_argument(
+        "--draft-mode",
+        action="store_true",
+        help="Generate at 1K resolution for faster, lower-cost iteration",
+    )
+
+    parser.add_argument(
+        "--finalize",
+        type=Path,
+        metavar="DRAFT_IMAGE",
+        help="Finalize a draft image by regenerating at higher resolution (2K default)",
+    )
+
+    parser.add_argument(
+        "--list-models",
+        action="store_true",
+        help="List available models and exit",
+    )
+
+    args = parser.parse_args()
+
+    if args.list_models:
+        list_models()
+        return
+
+    # Finalize mode
+    if args.finalize:
+        try:
+            result = finalize_draft(
+                draft_path=args.finalize,
+                prompt=args.prompt,
+                model_key=args.model,
+                output_path=args.output,
+                output_dir=args.output_dir,
+                aspect_ratio=args.aspect,
+                image_size=args.size,
+                verbose=args.verbose,
+            )
+            sys.exit(0 if result else 1)
+        except FileNotFoundError as e:
+            print(f"Error: {e}")  # noqa: T201
+            sys.exit(1)
+
+    if not args.prompt:
+        parser.print_help()
+        sys.exit(1)
+
+    # Story sequence mode
+    if args.story_parts:
+        if args.story_parts < 2:
+            print("Error: Story must have at least 2 parts")  # noqa: T201
+            sys.exit(1)
+
+        results = generate_story_sequence(
+            base_prompt=args.prompt,
+            num_parts=args.story_parts,
+            model_key=args.model,
+            output_prefix=args.output,
+            output_dir=args.output_dir,
+            aspect_ratio=args.aspect,
+            image_size=args.size,
+            verbose=args.verbose,
+        )
+
+        sys.exit(0 if len(results) == args.story_parts else 1)
+
+    # Single image mode
+    try:
+        result = generate_image(
+            prompt=args.prompt,
+            model_key=args.model,
+            reference_images=args.references,
+            output_path=args.output,
+            output_dir=args.output_dir,
+            aspect_ratio=args.aspect,
+            image_size=args.size,
+            use_search=args.search,
+            save_thoughts=args.save_thoughts,
+            verbose=args.verbose,
+            is_draft=args.draft_mode,
+        )
+
+        if result and args.draft_mode:
+            print(f"\n{'=' * 60}")  # noqa: T201
+            print("Draft complete! To finalize at higher resolution:")  # noqa: T201
+            print(f"  gemini-image --finalize {result} --size 2K")  # noqa: T201
+            print(f"{'=' * 60}")  # noqa: T201
+
+        sys.exit(0 if result else 1)
+
+    except (ValueError, ImportError) as e:
+        print(f"Error: {e}")  # noqa: T201
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/packages/gemini-image/src/gemini_image/generator.py b/packages/gemini-image/src/gemini_image/generator.py
new file mode 100644
index 0000000..6b06e17
--- /dev/null
+++ b/packages/gemini-image/src/gemini_image/generator.py
@@ -0,0 +1,501 @@
+"""Core image generation functions using Google Gemini.
+
+Note: This module has complexity warnings (C901, PLR0912, PLR0915) due to the
+comprehensive response handling logic inherited from the source script.
+The google-genai types are dynamically loaded, causing reportUnknown* warnings.
+"""
+# ruff: noqa: C901, PLR0912, PLR0915, PLC0415
+
+from __future__ import annotations
+
+import base64
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from gemini_image.models import (
+    ASPECT_RATIOS,
+    DEFAULT_MODEL,
+    IMAGE_SIZES,
+    MODELS,
+    AspectRatio,
+    ImageSize,
+    ModelKey,
+)
+from gemini_image.utils import (
+    get_api_key,
+    get_file_extension,
+    load_image_as_base64,
+)
+
+# Lazy import for google.genai
+_genai = None
+_types = None
+
+
+def _get_genai() -> tuple[Any, Any]:
+    """Lazy import google.genai to avoid import errors when not installed."""
+    global _genai, _types  # noqa: PLW0603
+    if _genai is None:
+        try:
+            from google import genai
+            from google.genai import types
+
+            _genai = genai
+            _types = types
+        except ImportError as e:
+            msg = (
+                "google-genai package not installed. "
+                "Install with: pip install google-genai"
+            )
+            raise ImportError(msg) from e
+    return _genai, _types
+
+
+def generate_image(
+    prompt: str,
+    model_key: ModelKey = DEFAULT_MODEL,
+    reference_images: list[Path] | None = None,
+    output_path: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    use_search: bool = False,
+    save_thoughts: bool = False,
+    verbose: bool = False,
+    is_draft: bool = False,
+) -> Path | None:
+    """Generate an image using Gemini.
+
+    Args:
+        prompt: Text description of the image to generate.
+        model_key: Model to use ('flash' or 'pro').
+        reference_images: Optional list of reference images for editing/style.
+        output_path: Optional output file path. If not provided, generates
+            a timestamped filename.
+        output_dir: Optional output directory. Defaults to current directory.
+        aspect_ratio: Aspect ratio for pro model (e.g., "16:9", "1:1").
+        image_size: Image size for pro model ("1K", "2K", "4K").
+        use_search: Enable Google Search grounding (pro model only).
+        save_thoughts: Save intermediate thought images (pro model only).
+        verbose: Show detailed thinking process and thought signatures.
+        is_draft: Generate at 1K resolution for fast iteration.
+
+    Returns:
+        Path to the generated image, or None on failure.
+
+    Raises:
+        ValueError: If model_key is invalid or API key is missing.
+        ImportError: If google-genai is not installed.
+
+    """
+    genai, types = _get_genai()
+    api_key = get_api_key()
+
+    if model_key not in MODELS:
+        msg = f"Unknown model '{model_key}'. Valid options: {list(MODELS.keys())}"
+        raise ValueError(msg)
+
+    model_config = MODELS[model_key]
+    model_id = model_config["id"]
+
+    if verbose:
+        print(f"Using model: {model_config['name']}")  # noqa: T201
+        print(f"Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}")  # noqa: T201
+
+    # Initialize client
+    client = genai.Client(api_key=api_key)
+
+    # Build the content parts
+    contents: list = []
+
+    # Add reference images if provided
+    if reference_images:
+        for img_path in reference_images:
+            if not img_path.exists():
+                if verbose:
+                    print(f"Warning: Reference image not found: {img_path}")  # noqa: T201
+                continue
+
+            if verbose:
+                print(f"Including reference image: {img_path}")  # noqa: T201
+            img_data, mime_type = load_image_as_base64(img_path)
+            contents.append(
+                types.Part.from_bytes(
+                    data=base64.standard_b64decode(img_data),
+                    mime_type=mime_type,
+                )
+            )
+
+    # Add the text prompt
+    contents.append(prompt)
+
+    # Build config kwargs
+    config_kwargs = {
+        "response_modalities": ["IMAGE", "TEXT"],
+    }
+
+    # Override size to 1K if draft mode
+    effective_size = "1K" if is_draft else image_size
+
+    # Add image config for pro model
+    if model_config.get("supports_image_config"):
+        image_config_kwargs = {}
+        if aspect_ratio:
+            if aspect_ratio not in ASPECT_RATIOS:
+                if verbose:
+                    print(  # noqa: T201
+                        f"Warning: Invalid aspect ratio '{aspect_ratio}'. "
+                        f"Valid: {ASPECT_RATIOS}"
+                    )
+            else:
+                image_config_kwargs["aspect_ratio"] = aspect_ratio
+                if verbose:
+                    print(f"Aspect ratio: {aspect_ratio}")  # noqa: T201
+        if effective_size:
+            if effective_size not in IMAGE_SIZES:
+                if verbose:
+                    print(  # noqa: T201
+                        f"Warning: Invalid image size '{effective_size}'. "
+                        f"Valid: {IMAGE_SIZES}"
+                    )
+            else:
+                image_config_kwargs["image_size"] = effective_size
+                if verbose:
+                    print(f"Image size: {effective_size}")  # noqa: T201
+
+        if image_config_kwargs:
+            config_kwargs["image_config"] = types.ImageConfig(**image_config_kwargs)
+
+        # Add Google Search grounding if requested
+        if use_search:
+            config_kwargs["tools"] = [{"google_search": {}}]
+            if verbose:
+                print("Google Search grounding: enabled")  # noqa: T201
+
+    # Configure generation
+    generate_config = types.GenerateContentConfig(**config_kwargs)
+
+    if verbose:
+        print("Generating image...")  # noqa: T201
+
+    response = client.models.generate_content(
+        model=model_id,
+        contents=contents,
+        config=generate_config,
+    )
+
+    # Process response
+    if not response.candidates:
+        if verbose:
+            print("Error: No response candidates returned.")  # noqa: T201
+            if hasattr(response, "prompt_feedback"):
+                print(f"Feedback: {response.prompt_feedback}")  # noqa: T201
+        return None
+
+    # Track thoughts and final images
+    thought_count = 0
+    final_image_data = None
+    final_mime_type = None
+    final_signature = None
+
+    # Determine output directory
+    if output_dir is None:
+        output_dir = Path.cwd()
+
+    # Process all parts in response
+    for part in response.candidates[0].content.parts:
+        # Check if this is a thought (intermediate reasoning step)
+        is_thought = hasattr(part, "thought") and part.thought
+
+        if is_thought:
+            thought_count += 1
+            if verbose:
+                print(f"\n[Thought {thought_count}]")  # noqa: T201
+
+            # Handle thought text
+            if part.text is not None and verbose:
+                print(f"Reasoning: {part.text}")  # noqa: T201
+
+            # Handle thought image
+            if part.inline_data is not None and save_thoughts:
+                thought_data = part.inline_data.data
+                thought_mime = part.inline_data.mime_type
+                thought_ext = get_file_extension(thought_mime)
+
+                # Save thought image
+                if output_path:
+                    thought_path = (
+                        output_dir
+                        / f"{output_path.stem}_thought{thought_count}{thought_ext}"
+                    )
+                else:
+                    timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
+                    thought_path = (
+                        output_dir / f"thought{thought_count}_{timestamp}{thought_ext}"
+                    )
+
+                thought_path.parent.mkdir(parents=True, exist_ok=True)
+                with open(thought_path, "wb") as f:
+                    f.write(thought_data)
+
+                if verbose:
+                    print(f"Thought image {thought_count} saved to: {thought_path}")  # noqa: T201
+
+        # Non-thought content (final output)
+        elif part.inline_data is not None:
+            # Final image
+            final_image_data = part.inline_data.data
+            final_mime_type = part.inline_data.mime_type
+
+            # Extract thought signature if available
+            if hasattr(part, "thought_signature") and part.thought_signature:
+                final_signature = part.thought_signature
+                if verbose:
+                    print(f"\n[Thought Signature]: {final_signature[:100]}...")  # noqa: T201
+
+        elif part.text is not None and verbose:
+            # Final text response
+            print(f"\nModel response: {part.text}")  # noqa: T201
+
+            # Extract thought signature from text part if available
+            if hasattr(part, "thought_signature") and part.thought_signature:
+                final_signature = part.thought_signature
+                if verbose:
+                    print(f"[Thought Signature]: {final_signature[:100]}...")  # noqa: T201
+
+    # Save final image
+    if final_image_data is not None:
+        # Determine output filename
+        if output_path is None:
+            timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
+            ext = get_file_extension(final_mime_type or "image/png")
+            prefix = "draft_" if is_draft else "generated_"
+            output_path = output_dir / f"{prefix}{timestamp}{ext}"
+        elif not output_path.is_absolute():
+            output_path = output_dir / output_path
+
+        # Ensure output directory exists
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+
+        # Write image
+        with open(output_path, "wb") as f:
+            f.write(final_image_data)
+
+        if verbose:
+            if thought_count > 0:
+                print(f"\nProcessed {thought_count} thought step(s)")  # noqa: T201
+            print(f"Final image saved to: {output_path}")  # noqa: T201
+
+        # Optionally save thought signature to sidecar file
+        if final_signature and verbose:
+            sig_path = output_path.with_suffix(".signature.bin")
+            with open(sig_path, "wb") as f:
+                if isinstance(final_signature, bytes):
+                    f.write(final_signature)
+                else:
+                    f.write(str(final_signature).encode())
+            print(f"Thought signature saved to: {sig_path}")  # noqa: T201
+
+        return output_path
+
+    if verbose:
+        print("Error: No image data in response.")  # noqa: T201
+    return None
+
+
+def generate_story_sequence(
+    base_prompt: str,
+    num_parts: int,
+    model_key: ModelKey = DEFAULT_MODEL,
+    output_prefix: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    verbose: bool = False,
+) -> list[Path]:
+    """Generate a multi-part story sequence using conversational refinement.
+
+    Each subsequent image uses the previous image as a reference for
+    visual continuity.
+
+    Args:
+        base_prompt: Base story description.
+        num_parts: Number of story parts to generate.
+        model_key: Model to use.
+        output_prefix: Prefix for output files (e.g., "story" ->
+            story_part1.png, story_part2.png).
+        output_dir: Output directory for generated images.
+        aspect_ratio: Aspect ratio for all images.
+        image_size: Image size for all images.
+        verbose: Show detailed process.
+
+    Returns:
+        List of paths to generated images.
+
+    Raises:
+        ValueError: If num_parts < 1.
+
+    """
+    if num_parts < 1:
+        msg = "Number of story parts must be at least 1"
+        raise ValueError(msg)
+
+    if output_dir is None:
+        output_dir = Path.cwd()
+
+    if output_prefix is None:
+        timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
+        output_prefix = Path(f"story_{timestamp}")
+
+    generated_images: list[Path] = []
+    previous_image_path: Path | None = None
+
+    if verbose:
+        print(f"Generating {num_parts}-part story sequence...")  # noqa: T201
+        print(f"Base prompt: {base_prompt}\n")  # noqa: T201
+
+    for part_num in range(1, num_parts + 1):
+        if verbose:
+            print(f"\n{'=' * 60}")  # noqa: T201
+            print(f"PART {part_num}/{num_parts}")  # noqa: T201
+            print(f"{'=' * 60}")  # noqa: T201
+
+        # Build prompt for this part
+        if part_num == 1:
+            prompt = (
+                f"{base_prompt}\n\n"
+                f"This is part 1 of {num_parts}. Create the opening scene that "
+                "establishes the context and visual style for the entire sequence."
+            )
+        elif part_num == num_parts:
+            prompt = (
+                f"This is part {part_num} of {num_parts}, the final scene. "
+                "Building on the previous image, create a concluding scene that "
+                "resolves the narrative. Maintain visual consistency with the "
+                "established style."
+            )
+        else:
+            prompt = (
+                f"This is part {part_num} of {num_parts}. Building on the previous "
+                "image, advance the narrative while maintaining visual consistency "
+                "with the established style."
+            )
+
+        # Build output path
+        output_path = Path(f"{output_prefix.stem}_part{part_num}.png")
+
+        # Build reference images list
+        reference_images = [previous_image_path] if previous_image_path else None
+
+        if verbose:
+            print(f"Prompt: {prompt[:100]}...")  # noqa: T201
+
+        # Generate this part
+        result = generate_image(
+            prompt=prompt,
+            model_key=model_key,
+            reference_images=reference_images,
+            output_path=output_path,
+            output_dir=output_dir,
+            aspect_ratio=aspect_ratio,
+            image_size=image_size,
+            use_search=False,
+            save_thoughts=False,
+            verbose=verbose,
+        )
+
+        if result:
+            generated_images.append(result)
+            previous_image_path = result
+            if verbose:
+                print(f"Part {part_num} complete: {result}")  # noqa: T201
+        else:
+            if verbose:
+                print(f"Failed to generate part {part_num}")  # noqa: T201
+            break
+
+    if verbose:
+        print(f"\n{'=' * 60}")  # noqa: T201
+        print(  # noqa: T201
+            f"Story sequence complete: {len(generated_images)}/{num_parts} parts generated"
+        )
+        print(f"{'=' * 60}\n")  # noqa: T201
+
+        for i, path in enumerate(generated_images, 1):
+            print(f"  Part {i}: {path}")  # noqa: T201
+
+    return generated_images
+
+
+def finalize_draft(
+    draft_path: Path,
+    prompt: str | None = None,
+    model_key: ModelKey = DEFAULT_MODEL,
+    output_path: Path | None = None,
+    output_dir: Path | None = None,
+    aspect_ratio: AspectRatio | None = None,
+    image_size: ImageSize | None = None,
+    verbose: bool = False,
+) -> Path | None:
+    """Finalize a draft image by regenerating at higher resolution.
+
+    Args:
+        draft_path: Path to the draft image.
+        prompt: Optional refinement prompt. If not provided, uses a
+            default upscaling prompt.
+        model_key: Model to use.
+        output_path: Output path for the final image.
+        output_dir: Output directory.
+        aspect_ratio: Aspect ratio (default: "16:9").
+        image_size: Target resolution (default: "2K").
+        verbose: Show detailed process.
+
+    Returns:
+        Path to the finalized image, or None on failure.
+
+    Raises:
+        FileNotFoundError: If the draft image doesn't exist.
+
+    """
+    if not draft_path.exists():
+        msg = f"Draft image not found: {draft_path}"
+        raise FileNotFoundError(msg)
+
+    # Determine final resolution
+    final_size = image_size if image_size else "2K"
+    final_aspect = aspect_ratio if aspect_ratio else "16:9"
+
+    if verbose:
+        print(f"Finalizing draft image: {draft_path}")  # noqa: T201
+        print(f"Target resolution: {final_size} ({final_aspect})")  # noqa: T201
+
+    # Use provided prompt or default upscaling prompt
+    final_prompt = prompt or (
+        "Recreate this image at higher resolution with the same "
+        "composition, style, and details"
+    )
+
+    # Determine output path
+    if output_path is None:
+        output_path = Path(f"{draft_path.stem}_final.png")
+
+    result = generate_image(
+        prompt=final_prompt,
+        model_key=model_key,
+        reference_images=[draft_path],
+        output_path=output_path,
+        output_dir=output_dir,
+        aspect_ratio=final_aspect,
+        image_size=final_size,
+        verbose=verbose,
+    )
+
+    if result and verbose:
+        print(f"\n{'=' * 60}")  # noqa: T201
+        print("Finalization complete!")  # noqa: T201
+        print(f"Draft: {draft_path}")  # noqa: T201
+        print(f"Final ({final_size}): {result}")  # noqa: T201
+        print(f"{'=' * 60}")  # noqa: T201
+
+    return result
diff --git a/packages/gemini-image/src/gemini_image/models.py b/packages/gemini-image/src/gemini_image/models.py
new file mode 100644
index 0000000..57db449
--- /dev/null
+++ b/packages/gemini-image/src/gemini_image/models.py
@@ -0,0 +1,45 @@
+"""Model configurations and type definitions for Gemini image generation."""
+
+from __future__ import annotations
+
+from typing import Literal, TypedDict
+
+# Type aliases for model configuration
+ModelKey = Literal["flash", "pro"]
+AspectRatio = Literal["1:1", "3:4", "4:3", "9:16", "16:9"]
+ImageSize = Literal["1K", "2K", "4K"]
+
+
+class ModelConfig(TypedDict):
+    """Configuration for a Gemini image generation model."""
+
+    id: str
+    name: str
+    description: str
+    supports_image_config: bool
+
+
+# Model configurations
+# Note: Actual API model IDs are gemini-2.5-flash-image and gemini-3-pro-image-preview
+MODELS: dict[ModelKey, ModelConfig] = {
+    "flash": {
+        "id": "gemini-2.5-flash-image",
+        "name": "Nano Banana (Gemini 2.5 Flash)",
+        "description": "Fast image generation model",
+        "supports_image_config": False,
+    },
+    "pro": {
+        "id": "gemini-3-pro-image-preview",
+        "name": "Nano Banana Pro (Gemini 3 Pro)",
+        "description": "4K resolution, better text rendering, Google Search grounding, thinking mode",
+        "supports_image_config": True,
+    },
+}
+
+DEFAULT_MODEL: ModelKey = "pro"
+
+# Valid aspect ratios for pro model
+ASPECT_RATIOS: list[AspectRatio] = ["1:1", "3:4", "4:3", "9:16", "16:9"]
+
+# Valid image sizes for pro model
+IMAGE_SIZES: list[ImageSize] = ["1K", "2K", "4K"]
diff --git a/packages/gemini-image/src/gemini_image/py.typed b/packages/gemini-image/src/gemini_image/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/packages/gemini-image/src/gemini_image/utils.py b/packages/gemini-image/src/gemini_image/utils.py
new file mode 100644
index 0000000..62de1da
--- /dev/null
+++ b/packages/gemini-image/src/gemini_image/utils.py
@@ -0,0 +1,110 @@
+"""Utility functions for Gemini image generation."""
+
+from __future__ import annotations
+
+import base64
+import os
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+def get_api_key(env_file: Path | None = None) -> str:
+    """Get the Gemini API key from environment or .env file.
+
+    Args:
+        env_file: Optional path to .env file. If not provided, checks
+            GEMINI_API_KEY environment variable only.
+
+    Returns:
+        The API key string.
+
+    Raises:
+        ValueError: If no API key is found.
+
+    """
+    api_key = os.environ.get("GEMINI_API_KEY")
+
+    if not api_key and env_file and env_file.exists():
+        with open(env_file) as f:
+            for line in f:
+                line = line.strip()
+                if line.startswith("GEMINI_API_KEY="):
+                    api_key = line.split("=", 1)[1].strip().strip('"').strip("'")
+                    break
+
+    if not api_key:
+        msg = (
+            "GEMINI_API_KEY environment variable not set. "
+            "Set it with: export GEMINI_API_KEY='your-api-key'"
+        )
+        raise ValueError(msg)
+
+    return api_key
+
+
+def load_image_as_base64(image_path: Path) -> tuple[str, str]:
+    """Load an image file and return base64 data and mime type.
+
+    Args:
+        image_path: Path to the image file.
+
+    Returns:
+        Tuple of (base64_encoded_data, mime_type).
+
+    Raises:
+        FileNotFoundError: If the image file doesn't exist.
+
+    """
+    if not image_path.exists():
+        msg = f"Image file not found: {image_path}"
+        raise FileNotFoundError(msg)
+
+    suffix = image_path.suffix.lower()
+    mime_types = {
+        ".png": "image/png",
+        ".jpg": "image/jpeg",
+        ".jpeg": "image/jpeg",
+        ".gif": "image/gif",
+        ".webp": "image/webp",
+    }
+
+    mime_type = mime_types.get(suffix, "image/png")
+
+    with open(image_path, "rb") as f:
+        data = base64.standard_b64encode(f.read()).decode("utf-8")
+
+    return data, mime_type
+
+
+def decode_base64_image(base64_data: str) -> bytes:
+    """Decode base64 image data to bytes.
+
+    Args:
+        base64_data: Base64-encoded image data.
+
+    Returns:
+        Raw image bytes.
+
+    """
+    return base64.standard_b64decode(base64_data)
+
+
+def get_file_extension(mime_type: str) -> str:
+    """Get file extension for a given MIME type.
+
+    Args:
+        mime_type: MIME type string (e.g., "image/png").
+
+    Returns:
+        File extension including the dot (e.g., ".png").
+
+    """
+    extensions = {
+        "image/png": ".png",
+        "image/jpeg": ".jpg",
+        "image/gif": ".gif",
+        "image/webp": ".webp",
+    }
+    return extensions.get(mime_type, ".png")
diff --git a/packages/gemini-image/tests/__init__.py b/packages/gemini-image/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/packages/gemini-image/tests/conftest.py b/packages/gemini-image/tests/conftest.py
new file mode 100644
index 0000000..8d3e09a
--- /dev/null
+++ b/packages/gemini-image/tests/conftest.py
@@ -0,0 +1,59 @@
+"""Pytest configuration and fixtures for gemini-image tests."""
+
+from __future__ import annotations
+
+import base64
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock
+
+import pytest
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+@pytest.fixture
+def sample_image_bytes() -> bytes:
+    """Return sample PNG image bytes (1x1 red pixel)."""
+    # Minimal valid PNG: 1x1 red pixel
+    return base64.b64decode(
+        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIA"
+        "X8jx0gAAAABJRU5ErkJggg=="
+    )
+
+
+@pytest.fixture
+def sample_image_path(tmp_path: Path, sample_image_bytes: bytes) -> Path:
+    """Create a temporary sample image file."""
+    image_path = tmp_path / "sample.png"
+    image_path.write_bytes(sample_image_bytes)
+    return image_path
+
+
+@pytest.fixture
+def mock_genai_response(sample_image_bytes: bytes) -> MagicMock:
+    """Create a mock Gemini API response with image data."""
+    mock_response = MagicMock()
+
+    # Create mock part with inline data
+    mock_part = MagicMock()
+    mock_part.thought = False
+    mock_part.text = None
+    mock_part.inline_data = MagicMock()
+    mock_part.inline_data.data = sample_image_bytes
+    mock_part.inline_data.mime_type = "image/png"
+
+    # Create mock candidate
+    mock_candidate = MagicMock()
+    mock_candidate.content.parts = [mock_part]
+
+    mock_response.candidates = [mock_candidate]
+    return mock_response
+
+
+@pytest.fixture
+def mock_genai_client(mock_genai_response: MagicMock) -> MagicMock:
+    """Create a mock Gemini client."""
+    mock_client = MagicMock()
+    mock_client.models.generate_content.return_value = mock_genai_response
+    return mock_client
diff --git a/packages/gemini-image/tests/test_generator.py b/packages/gemini-image/tests/test_generator.py
new file mode 100644
index 0000000..55a9e92
--- /dev/null
+++ b/packages/gemini-image/tests/test_generator.py
@@ -0,0 +1,182 @@
+"""Tests for image generation functions."""
+
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from gemini_image import generator
+from gemini_image.generator import finalize_draft, generate_story_sequence
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+class TestGenerateImage:
+    """Tests for generate_image function."""
+
+    def test_generate_image_invalid_model_raises(self) -> None:
+        """Test that invalid model key raises ValueError."""
+        # Mock genai to avoid ImportError
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}),
+            pytest.raises(ValueError, match="Unknown model"),
+        ):
+            generator.generate_image("test prompt", model_key="invalid")  # type: ignore[arg-type]
+
+    def test_generate_image_missing_api_key_raises(self) -> None:
+        """Test that missing API key raises ValueError."""
+        # Mock genai to avoid ImportError
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {}, clear=True),
+        ):
+            os.environ.pop("GEMINI_API_KEY", None)
+            with pytest.raises(ValueError, match="GEMINI_API_KEY"):
+                generator.generate_image("test prompt")
+
+    def test_generate_image_with_mock_client(
+        self,
+        tmp_path: Path,
+        mock_genai_response: MagicMock,
+    ) -> None:
+        """Test image generation with mocked Gemini client."""
+        # Create mock genai module
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        mock_client = MagicMock()
+        mock_client.models.generate_content.return_value = mock_genai_response
+        mock_genai.Client.return_value = mock_client
+
+        # Patch the lazy-loaded modules
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}),
+        ):
+            result = generator.generate_image(
+                prompt="A test image",
+                output_dir=tmp_path,
+                verbose=False,
+            )
+
+        assert result is not None
+        assert result.exists()
+        assert result.suffix == ".png"
+
+    def test_generate_image_draft_mode_uses_1k(
+        self,
+        tmp_path: Path,
+        mock_genai_response: MagicMock,
+    ) -> None:
+        """Test that draft mode sets 1K resolution."""
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        mock_client = MagicMock()
+        mock_client.models.generate_content.return_value = mock_genai_response
+        mock_genai.Client.return_value = mock_client
+
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}),
+        ):
+            result = generator.generate_image(
+                prompt="A test draft",
+                output_dir=tmp_path,
+                is_draft=True,
+            )
+
+        assert result is not None
+        assert "draft_" in result.name
+
+
+class TestGenerateStorySequence:
+    """Tests for generate_story_sequence function."""
+
+    def test_story_sequence_invalid_parts_raises(self) -> None:
+        """Test that num_parts < 1 raises ValueError."""
+        with pytest.raises(ValueError, match="at least 1"):
+            generate_story_sequence("test story", num_parts=0)
+
+    def test_story_sequence_generates_multiple_images(
+        self,
+        tmp_path: Path,
+        mock_genai_response: MagicMock,
+    ) -> None:
+        """Test that story sequence generates the correct number of images."""
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        mock_client = MagicMock()
+        mock_client.models.generate_content.return_value = mock_genai_response
+        mock_genai.Client.return_value = mock_client
+
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}),
+        ):
+            results = generator.generate_story_sequence(
+                base_prompt="A test story",
+                num_parts=3,
+                output_dir=tmp_path,
+                output_prefix=tmp_path / "story",
+            )
+
+        assert len(results) == 3
+        for i, path in enumerate(results, 1):
+            assert path.exists()
+            assert f"part{i}" in path.name
+
+
+class TestFinalizeDraft:
+    """Tests for finalize_draft function."""
+
+    def test_finalize_missing_draft_raises(self, tmp_path: Path) -> None:
+        """Test that missing draft image raises FileNotFoundError."""
+        missing_path = tmp_path / "nonexistent.png"
+
+        with pytest.raises(FileNotFoundError):
+            finalize_draft(missing_path)
+
+    def test_finalize_draft_uses_2k_by_default(
+        self,
+        sample_image_path: Path,
+        tmp_path: Path,
+        mock_genai_response: MagicMock,
+    ) -> None:
+        """Test that finalize_draft defaults to 2K resolution."""
+        mock_genai = MagicMock()
+        mock_types = MagicMock()
+
+        mock_client = MagicMock()
+        mock_client.models.generate_content.return_value = mock_genai_response
+        mock_genai.Client.return_value = mock_client
+
+        with (
+            patch.object(generator, "_genai", mock_genai),
+            patch.object(generator, "_types", mock_types),
+            patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}),
+        ):
+            result = generator.finalize_draft(
+                draft_path=sample_image_path,
+                output_dir=tmp_path,
+            )
+
+        assert result is not None
+        assert "_final" in result.name
diff --git a/packages/gemini-image/tests/test_models.py b/packages/gemini-image/tests/test_models.py
new file mode 100644
index 0000000..887e17a
--- /dev/null
+++ b/packages/gemini-image/tests/test_models.py
@@ -0,0 +1,38 @@
+"""Tests for model configurations."""
+
+from gemini_image.models import (
+    ASPECT_RATIOS,
+    DEFAULT_MODEL,
+    IMAGE_SIZES,
+    MODELS,
+)
+
+
+class TestModelConfigurations:
+    """Tests for model configuration constants."""
+
+    def test_models_has_flash(self) -> None:
+        """Test that flash model is defined."""
+        assert "flash" in MODELS
+        assert MODELS["flash"]["id"] == "gemini-2.5-flash-image"
+        assert MODELS["flash"]["supports_image_config"] is False
+
+    def test_models_has_pro(self) -> None:
+        """Test that pro model is defined."""
+        assert "pro" in MODELS
+        assert MODELS["pro"]["id"] == "gemini-3-pro-image-preview"
+        assert MODELS["pro"]["supports_image_config"] is True
+
+    def test_default_model_is_pro(self) -> None:
+        """Test that default model is pro."""
+        assert DEFAULT_MODEL == "pro"
+
+    def test_aspect_ratios(self) -> None:
+        """Test that all expected aspect ratios are defined."""
+        expected = ["1:1", "3:4", "4:3", "9:16", "16:9"]
+        assert expected == ASPECT_RATIOS
+
+    def test_image_sizes(self) -> None:
+        """Test that all expected image sizes are defined."""
+        expected = ["1K", "2K", "4K"]
+        assert expected == IMAGE_SIZES
diff --git a/packages/gemini-image/tests/test_utils.py b/packages/gemini-image/tests/test_utils.py
new file mode 100644
index 0000000..a5ba3d7
--- /dev/null
+++ b/packages/gemini-image/tests/test_utils.py
@@ -0,0 +1,111 @@
+"""Tests for utility functions."""
+
+from __future__ import annotations
+
+import base64
+import os
+from typing import TYPE_CHECKING
+from unittest.mock import patch
+
+import pytest
+
+from gemini_image.utils import (
+    decode_base64_image,
+    get_api_key,
+    get_file_extension,
+    load_image_as_base64,
+)
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+class TestGetApiKey:
+    """Tests for get_api_key function."""
+
+    def test_get_api_key_from_env(self) -> None:
+        """Test getting API key from environment variable."""
+        with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key-123"}):
+            assert get_api_key() == "test-key-123"
+
+    def test_get_api_key_from_env_file(self, tmp_path: Path) -> None:
+        """Test getting API key from .env file."""
+        env_file = tmp_path / ".env"
+        env_file.write_text('GEMINI_API_KEY="file-key-456"')
+
+        with patch.dict(os.environ, {}, clear=True):
+            # Remove GEMINI_API_KEY if it exists
+            os.environ.pop("GEMINI_API_KEY", None)
+            assert get_api_key(env_file=env_file) == "file-key-456"
+
+    def test_get_api_key_missing_raises(self) -> None:
+        """Test that missing API key raises ValueError."""
+        with patch.dict(os.environ, {}, clear=True):
+            os.environ.pop("GEMINI_API_KEY", None)
+            with pytest.raises(ValueError, match="GEMINI_API_KEY"):
+                get_api_key()
+
+
+class TestLoadImageAsBase64:
+    """Tests for load_image_as_base64 function."""
+
+    def test_load_png_image(self, sample_image_path: Path) -> None:
+        """Test loading a PNG image."""
+        data, mime_type = load_image_as_base64(sample_image_path)
+
+        assert isinstance(data, str)
+        assert mime_type == "image/png"
+        # Verify it's valid base64
+        decoded = base64.standard_b64decode(data)
+        assert len(decoded) > 0
+
+    def test_load_jpeg_image(self, tmp_path: Path, sample_image_bytes: bytes) -> None:
+        """Test loading a JPEG image (using PNG bytes, just testing extension)."""
+        image_path = tmp_path / "image.jpg"
+        image_path.write_bytes(sample_image_bytes)
+
+        _, mime_type = load_image_as_base64(image_path)
+        assert mime_type == "image/jpeg"
+
+    def test_load_missing_image_raises(self, tmp_path: Path) -> None:
+        """Test that loading missing image raises FileNotFoundError."""
+        missing_path = tmp_path / "nonexistent.png"
+
+        with pytest.raises(FileNotFoundError):
+            load_image_as_base64(missing_path)
+
+
+class TestDecodeBase64Image:
+    """Tests for decode_base64_image function."""
+
+    def test_decode_valid_base64(self) -> None:
+        """Test decoding valid base64 data."""
+        original = b"test image data"
+        encoded = base64.standard_b64encode(original).decode()
+
+        decoded = decode_base64_image(encoded)
+        assert decoded == original
+
+
+class TestGetFileExtension:
+    """Tests for get_file_extension function."""
+
+    def test_png_extension(self) -> None:
+        """Test PNG MIME type returns .png."""
+        assert get_file_extension("image/png") == ".png"
+
+    def test_jpeg_extension(self) -> None:
+        """Test JPEG MIME type returns .jpg."""
+        assert get_file_extension("image/jpeg") == ".jpg"
+
+    def test_gif_extension(self) -> None:
+        """Test GIF MIME type returns .gif."""
+        assert get_file_extension("image/gif") == ".gif"
+
+    def test_webp_extension(self) -> None:
+        """Test WebP MIME type returns .webp."""
+        assert get_file_extension("image/webp") == ".webp"
+
+    def test_unknown_extension_defaults_to_png(self) -> None:
+        """Test unknown MIME type defaults to .png."""
+        assert get_file_extension("image/unknown") == ".png"
diff --git a/pyproject.toml b/pyproject.toml
index f39e418..0e3a20b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -268,7 +268,7 @@ max-returns = 6        # Return statement limit
 convention = "google"
 
 [tool.ruff.lint.isort]
-known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities"]
+known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities", "gemini_image"]
 
 [tool.ruff.lint.per-file-ignores]
 # Module init files
@@ -462,7 +462,7 @@ pythonPlatform = "All"
 typeCheckingMode = "strict"
 
 # Source paths
-include = ["src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src"]
+include = ["src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src", "packages/gemini-image/src"]
 exclude = [
     "**/__pycache__",
     "**/node_modules",
@@ -525,8 +525,8 @@ deprecateTypingAliases = true          # Warn on deprecated typing module aliase
 # Pytest Configuration
 [tool.pytest.ini_options]
 minversion = "7.0"
-testpaths = ["tests", "packages/cloudflare-auth/tests", "packages/gcs-utilities/tests"]
-pythonpath = [".", "src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src"]
+testpaths = ["tests", "packages/cloudflare-auth/tests", "packages/gcs-utilities/tests", "packages/gemini-image/tests"]
+pythonpath = [".", "src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src", "packages/gemini-image/src"]
 python_files = ["test_*.py", "*_test.py"]
 python_classes = ["Test*"]
 python_functions = ["test_*"]
@@ -698,7 +698,9 @@ members = ["packages/*"]
 # Workspace source references - allow packages to depend on each other
 [tool.uv.sources]
 byronwilliamscpa-cloudflare-auth = { workspace = true }
+byronwilliamscpa-cloudflare-api = { workspace = true }
 byronwilliamscpa-gcs-utilities = { workspace = true }
+byronwilliamscpa-gemini-image = { workspace = true }
 
 # Package Index Configuration
 # Google Assured OSS as primary source with PyPI fallback
diff --git a/uv.lock b/uv.lock
index 4185a45..02cb36e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,6 +12,7 @@ resolution-markers = [
 members = [
     "byronwilliamscpa-cloudflare-auth",
     "byronwilliamscpa-gcs-utilities",
+    "byronwilliamscpa-gemini-image",
     "python-libs",
 ]
 
@@ -398,6 +399,30 @@ requires-dist = [
 ]
 provides-extras = ["async", "dev"]
 
+[[package]]
+name = "byronwilliamscpa-gemini-image"
+version = "0.1.0"
+source = { editable = "packages/gemini-image" }
+dependencies = [
+    { name = "google-genai" },
+]
+
+[package.optional-dependencies]
+dev = [
+    { name = "pytest" },
+    { name = "pytest-asyncio" },
+    { name = "pytest-cov" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "google-genai", specifier = ">=1.0.0" },
+    { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
+    { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
+    { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
+]
+provides-extras = ["dev"]
+
 [[package]]
 name = "cachecontrol"
 version = "0.14.4"
@@ -919,6 +944,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
 ]
 
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
 [[package]]
 name = "dparse"
 version = "0.6.4"
@@ -1070,6 +1104,11 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
 ]
 
+[package.optional-dependencies]
+requests = [
+    { name = "requests" },
+]
+
 [[package]]
 name = "google-cloud-core"
 version = "2.5.0"
@@ -1135,6 +1174,27 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/fd/3c/2a19a60a473de48717b4efb19398c3f914795b64a96cf3fbe82588044f78/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82", size = 28048, upload-time = "2025-03-26T14:41:46.696Z" },
 ]
 
+[[package]]
+name = "google-genai"
+version = "1.55.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/1d/7c/19b59750592702305ae211905985ec8ab56f34270af4a159fba5f0214846/google_genai-1.55.0.tar.gz", hash = "sha256:ae9f1318fedb05c7c1b671a4148724751201e8908a87568364a309804064d986", size = 477615, upload-time = "2025-12-11T02:49:28.624Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/3e/86/a5a8e32b2d40b30b5fb20e7b8113fafd1e38befa4d1801abd5ce6991065a/google_genai-1.55.0-py3-none-any.whl", hash = "sha256:98c422762b5ff6e16b8d9a1e4938e8e0ad910392a5422e47f5301498d7f373a1", size = 703389, upload-time = "2025-12-11T02:49:27.105Z" },
+]
+
 [[package]]
 name = "google-resumable-media"
 version = "2.8.0"
@@ -3711,6 +3771,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
 ]
 
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
 [[package]]
 name = "sortedcontainers"
 version = "2.4.0"
@@ -4086,6 +4155,65 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
 ]
 
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
+    { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
+    { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
+    { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
+    { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
+    { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
+    { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
+    { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
+    { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
+    { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
+    { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+    { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+    { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+    { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+    { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+    { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+    { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+    { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+    { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+    { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
+    { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
 [[package]]
 name = "widgetsnbextension"
 version = "4.0.15"
