From 4986590bd7e8afa1250f8875632cfc641c0a1a58 Mon Sep 17 00:00:00 2001 From: stupidloud Date: Thu, 2 Jul 2026 22:33:45 +0800 Subject: [PATCH] fix(image): pass aspect ratio and size to Gemini Flash image models The Gemini Flash image path (`generateContent`) dropped both `aspect_ratio` and `image_size`: `generate()` never forwarded them and `_generate_gemini_flash` did not accept them, so every request fell back to 1:1 / input-matched output. The Imagen path was unaffected. Forward the hints and emit them under `generationConfig.responseFormat.image` per the current Gemini API. Aspect ratio is validated against the accepted set; `imageSize` is validated against {512,1K,2K,4K} and only sent to Gemini 3+ image models, since `gemini-2.5-flash-image` supports only `aspectRatio`. Co-Authored-By: Claude Opus 4.8 (1M context) --- nanobot/providers/image_generation.py | 47 ++++++++++++++++++- tests/providers/test_image_generation.py | 57 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 94b9a75e..0d27f04e 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -33,6 +33,13 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = { } _GEMINI_DEFAULT_TIMEOUT_S = 120.0 _GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"} +# Aspect ratios accepted by the Gemini Flash image (generateContent) models. +_GEMINI_FLASH_ASPECT_RATIOS = { + "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", + "9:16", "16:9", "21:9", "1:4", "4:1", "1:8", "8:1", +} +# Image-size tokens accepted by Gemini 3+ image models (2.5 Flash Image ignores it). +_GEMINI_FLASH_IMAGE_SIZES = {"512", "1K", "2K", "4K"} _OLLAMA_DEFAULT_SIDE = 1024 _OLLAMA_SIZE_PRESETS = { "1K": 1024, @@ -635,7 +642,11 @@ class GeminiImageGenerationClient(ImageGenerationProvider): prompt=prompt, model=model, aspect_ratio=aspect_ratio ) return await self._generate_gemini_flash( - prompt=prompt, model=model, reference_images=reference_images or [] + prompt=prompt, + model=model, + reference_images=reference_images or [], + aspect_ratio=aspect_ratio, + image_size=image_size, ) async def _generate_imagen( @@ -691,15 +702,22 @@ class GeminiImageGenerationClient(ImageGenerationProvider): prompt: str, model: str, reference_images: list[str], + aspect_ratio: str | None = None, + image_size: str | None = None, ) -> GeneratedImageResponse: parts: list[dict[str, Any]] = [ {"inlineData": image_path_to_inline_data(path)} for path in reference_images ] parts.append({"text": prompt}) + generation_config: dict[str, Any] = {"responseModalities": ["TEXT", "IMAGE"]} + image_config = _gemini_flash_image_config(model, aspect_ratio, image_size) + if image_config: + generation_config["responseFormat"] = {"image": image_config} + body: dict[str, Any] = { "contents": [{"role": "user", "parts": parts}], - "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]}, + "generationConfig": generation_config, } body.update(self.extra_body) @@ -748,6 +766,31 @@ class GeminiImageGenerationClient(ImageGenerationProvider): ) +def _gemini_flash_image_config( + model: str, + aspect_ratio: str | None, + image_size: str | None, +) -> dict[str, str]: + """Build the ``responseFormat.image`` config for Gemini Flash image models. + + Aspect ratio applies to all Flash image models; image size is only honored + by Gemini 3+ image models (``gemini-2.5-flash-image`` ignores it). + """ + config: dict[str, str] = {} + if aspect_ratio and aspect_ratio in _GEMINI_FLASH_ASPECT_RATIOS: + config["aspectRatio"] = aspect_ratio + if image_size and _gemini_flash_supports_image_size(model): + normalized = image_size.strip().upper() + if normalized in _GEMINI_FLASH_IMAGE_SIZES: + config["imageSize"] = normalized + return config + + +def _gemini_flash_supports_image_size(model: str) -> bool: + """Return whether the model honors ``imageSize`` (Gemini 3+ image models).""" + return "2.5" not in model.lower() + + async def _aihubmix_images_from_payload( client: httpx.AsyncClient, payload: dict[str, Any], diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py index cbb0f99a..8e311049 100644 --- a/tests/providers/test_image_generation.py +++ b/tests/providers/test_image_generation.py @@ -422,6 +422,63 @@ async def test_gemini_flash_reference_images(tmp_path: Path) -> None: assert parts[1] == {"text": "edit this"} +def _gemini_flash_image_response() -> FakeResponse: + return FakeResponse( + { + "candidates": [ + {"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": RAW_B64}}]}} + ] + } + ) + + +@pytest.mark.asyncio +async def test_gemini_flash_forwards_aspect_ratio_and_image_size() -> None: + fake = FakeClient(_gemini_flash_image_response()) + client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type] + + await client.generate( + prompt="draw a cat", + model="gemini-3-pro-image", + aspect_ratio="16:9", + image_size="2K", + ) + + image_config = fake.calls[0]["json"]["generationConfig"]["responseFormat"]["image"] + assert image_config == {"aspectRatio": "16:9", "imageSize": "2K"} + + +@pytest.mark.asyncio +async def test_gemini_flash_2_5_drops_image_size() -> None: + fake = FakeClient(_gemini_flash_image_response()) + client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type] + + await client.generate( + prompt="draw a cat", + model="gemini-2.5-flash-image", + aspect_ratio="4:3", + image_size="1K", + ) + + image_config = fake.calls[0]["json"]["generationConfig"]["responseFormat"]["image"] + assert image_config == {"aspectRatio": "4:3"} + + +@pytest.mark.asyncio +async def test_gemini_flash_ignores_unsupported_hints() -> None: + fake = FakeClient(_gemini_flash_image_response()) + client = GeminiImageGenerationClient(api_key="AIza-test", client=fake) # type: ignore[arg-type] + + await client.generate( + prompt="draw a cat", + model="gemini-3-pro-image", + aspect_ratio="7:5", + image_size="1024x1024", + ) + + assert "responseFormat" not in fake.calls[0]["json"]["generationConfig"] + + @pytest.mark.asyncio async def test_gemini_requires_api_key() -> None: client = GeminiImageGenerationClient(api_key=None)