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:
committed by
Xubin Ren
co-authored by
Claude Opus 4.8
parent
b695a7e875
commit
4986590bd7
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user