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) <noreply@anthropic.com>
This commit is contained in:
stupidloud
2026-07-27 03:07:41 +08:00
committed by Xubin Ren
co-authored by Claude Opus 4.8
parent b695a7e875
commit 4986590bd7
2 changed files with 102 additions and 2 deletions
+45 -2
View File
@@ -33,6 +33,13 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
} }
_GEMINI_DEFAULT_TIMEOUT_S = 120.0 _GEMINI_DEFAULT_TIMEOUT_S = 120.0
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"} _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_DEFAULT_SIDE = 1024
_OLLAMA_SIZE_PRESETS = { _OLLAMA_SIZE_PRESETS = {
"1K": 1024, "1K": 1024,
@@ -635,7 +642,11 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
prompt=prompt, model=model, aspect_ratio=aspect_ratio prompt=prompt, model=model, aspect_ratio=aspect_ratio
) )
return await self._generate_gemini_flash( 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( async def _generate_imagen(
@@ -691,15 +702,22 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
prompt: str, prompt: str,
model: str, model: str,
reference_images: list[str], reference_images: list[str],
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse: ) -> GeneratedImageResponse:
parts: list[dict[str, Any]] = [ parts: list[dict[str, Any]] = [
{"inlineData": image_path_to_inline_data(path)} for path in reference_images {"inlineData": image_path_to_inline_data(path)} for path in reference_images
] ]
parts.append({"text": prompt}) 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] = { body: dict[str, Any] = {
"contents": [{"role": "user", "parts": parts}], "contents": [{"role": "user", "parts": parts}],
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]}, "generationConfig": generation_config,
} }
body.update(self.extra_body) 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( async def _aihubmix_images_from_payload(
client: httpx.AsyncClient, client: httpx.AsyncClient,
payload: dict[str, Any], payload: dict[str, Any],
+57
View File
@@ -422,6 +422,63 @@ async def test_gemini_flash_reference_images(tmp_path: Path) -> None:
assert parts[1] == {"text": "edit this"} 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 @pytest.mark.asyncio
async def test_gemini_requires_api_key() -> None: async def test_gemini_requires_api_key() -> None:
client = GeminiImageGenerationClient(api_key=None) client = GeminiImageGenerationClient(api_key=None)