diff --git a/litellm/llms/gemini/common_utils.py b/litellm/llms/gemini/common_utils.py index 9634c580858..88c6494e3f7 100644 --- a/litellm/llms/gemini/common_utils.py +++ b/litellm/llms/gemini/common_utils.py @@ -18,6 +18,25 @@ from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues from litellm.types.utils import CountContextManagement, TokenCountMessagesFormat, TokenCountResponse +# 2.5 Flash has no configurable imageSize and supports ten aspect ratios, excluding these four +# https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/2-5-flash-image +_GEMINI_2_5_FLASH_MODEL_FAMILY: Final = "2.5-flash" +_GEMINI_2_5_FLASH_UNSUPPORTED_ASPECT_RATIOS: Final = frozenset(("1:4", "4:1", "1:8", "8:1")) + +# 3.1 Flash supports the 512 tier, while other Gemini image models start at 1K +# https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-1-flash-image +_GEMINI_512_IMAGE_SIZE_MODEL: Final = "gemini-3.1-flash-image" + +# Flash Lite supports only 1K, even when the requested dimensions are larger +# https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-1-flash-lite-image +_GEMINI_1K_ONLY_IMAGE_SIZE_MODEL: Final = "gemini-3.1-flash-lite-image" + +# LiteLLM chooses the nearest resolution tier using the side length of a square with the same total number of pixels +# as the requested image +_GEMINI_IMAGE_SIZE_1K_THRESHOLD: Final = (512 + 1024) // 2 +_GEMINI_IMAGE_SIZE_2K_THRESHOLD: Final = (1024 + 2048) // 2 +_GEMINI_IMAGE_SIZE_4K_THRESHOLD: Final = (2048 + 4096) // 2 + GEMINI_IMAGE_ASPECT_RATIOS: Final[dict[str, float]] = { "1:1": 1 / 1, "1:4": 1 / 4, @@ -105,8 +124,8 @@ def map_openai_size_to_gemini_image_config(size: str, model: str) -> dict[str, s return None width, height = dimensions - image_config: Final = {"aspectRatio": _map_dimensions_to_gemini_aspect_ratio(width, height)} - image_size: Final = _map_dimensions_to_gemini_image_size(width, height) + image_config: Final = {"aspectRatio": _map_dimensions_to_gemini_aspect_ratio(width, height, model)} + image_size: Final = _map_dimensions_to_gemini_image_size(width, height, model) if is_gemini_image_model(model): if supports_gemini_image_size(model): image_config["imageSize"] = image_size @@ -123,7 +142,7 @@ def supports_gemini_image_size(model: str) -> bool: return bool(value) except Exception: pass - return "2.5-flash" not in model + return _GEMINI_2_5_FLASH_MODEL_FAMILY not in model def is_gemini_image_model(model: str) -> bool: @@ -333,24 +352,36 @@ def _parse_openai_image_size(size: str) -> tuple[int, int] | None: return width, height -def _map_dimensions_to_gemini_aspect_ratio(width: int, height: int) -> str: - if (width, height) in GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO: - return GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO[(width, height)] +def _map_dimensions_to_gemini_aspect_ratio(width: int, height: int, model: str) -> str: + aspect_ratios: Final = tuple( + ratio + for ratio in GEMINI_IMAGE_ASPECT_RATIOS + if _GEMINI_2_5_FLASH_MODEL_FAMILY not in model or ratio not in _GEMINI_2_5_FLASH_UNSUPPORTED_ASPECT_RATIOS + ) + known_ratio: Final = GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO.get((width, height)) + if known_ratio is not None and known_ratio in aspect_ratios: + return known_ratio requested_ratio: Final = width / height return min( - GEMINI_IMAGE_ASPECT_RATIOS, + aspect_ratios, key=lambda aspect_ratio: abs(math.log(GEMINI_IMAGE_ASPECT_RATIOS[aspect_ratio] / requested_ratio)), ) -def _map_dimensions_to_gemini_image_size(width: int, height: int) -> str: - effective_square_side: Final = math.sqrt(width * height) - if effective_square_side < 768: - return "512" - if effective_square_side < 1536: +def _map_dimensions_to_gemini_image_size(width: int, height: int, model: str) -> str: + if _GEMINI_1K_ONLY_IMAGE_SIZE_MODEL in model: return "1K" - if effective_square_side < 3072: + + effective_square_side: Final = math.sqrt(width * height) + if effective_square_side < _GEMINI_IMAGE_SIZE_1K_THRESHOLD: + if is_gemini_image_model(model) and _GEMINI_512_IMAGE_SIZE_MODEL not in model: + return "1K" + else: + return "512" + if effective_square_side < _GEMINI_IMAGE_SIZE_2K_THRESHOLD: + return "1K" + if effective_square_side < _GEMINI_IMAGE_SIZE_4K_THRESHOLD: return "2K" return "4K" diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 27f2aedb300..0fa80cb2cad 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1,3 +1,4 @@ +import math import re from collections.abc import Callable, Iterable, Mapping, Sequence from copy import deepcopy @@ -18,6 +19,11 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.litellm_core_utils.prompt_templates.common_utils import unpack_defs from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.gemini.common_utils import ( + GEMINI_IMAGE_ASPECT_RATIOS, + GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO, + map_openai_size_to_gemini_image_config, +) from litellm.types.llms.openai import AllMessageValues from litellm.types.llms.vertex_ai import ( VERTEX_AI_PROVIDER_METADATA_FIELDS, @@ -35,6 +41,31 @@ GEMINI_VIDEO_METADATA_KEYS: Final = MappingProxyType( GEMINI_FILES_API_URI_PREFIX: Final = "https://generativelanguage.googleapis.com/v1beta/files/" +# https://cloud.google.com/vertex-ai/generative-ai/docs/image/configure-aspect-ratio +_IMAGEN_ASPECT_RATIOS: Final = MappingProxyType( + {"1:1": 1.0, "3:4": 3 / 4, "4:3": 4 / 3, "9:16": 9 / 16, "16:9": 16 / 9} +) + + +def map_vertex_image_size(size: object, model: str, *, imagen: bool = False) -> Mapping[str, str] | None: + if not isinstance(size, str) or (image_config := map_openai_size_to_gemini_image_config(size, model)) is None: + return None + + width, height = map(int, size.lower().split("x")) + requested_ratio: Final = width / height + aspect_ratio: Final = ( + min(_IMAGEN_ASPECT_RATIOS, key=lambda ratio: abs(math.log(_IMAGEN_ASPECT_RATIOS[ratio] / requested_ratio))) + if imagen + else image_config["aspectRatio"] + ) + if ( + not math.isclose(GEMINI_IMAGE_ASPECT_RATIOS[aspect_ratio], requested_ratio) + and GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO.get((width, height)) != aspect_ratio + ): + verbose_logger.warning("Vertex AI size %s is approximated with aspectRatio %s", size, aspect_ratio) + return MappingProxyType({"aspectRatio": aspect_ratio} if imagen else image_config) + + def gemini_video_metadata_from_openai(video_metadata: Mapping[str, object]) -> dict[str, object]: return { gemini_key: video_metadata[openai_key] diff --git a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py index 6d071363823..0b90f74ea6d 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_gemini_transformation.py @@ -10,7 +10,7 @@ from httpx._types import RequestFiles import litellm from litellm.images.utils import ImageEditRequestUtils from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig -from litellm.llms.vertex_ai.common_utils import get_vertex_base_url +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -67,7 +67,7 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): return {} size: Final = image_edit_optional_params.get("size") - return {"aspectRatio": self._map_size_to_aspect_ratio(size or "")} + return dict(map_vertex_image_size(size, model) or {"aspectRatio": "1:1"}) def _resolve_vertex_project(self) -> str | None: return ( @@ -176,11 +176,11 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): contents: Final[dict[str, object]] = {"role": "USER", "parts": parts} # Add image-specific configuration - image_config: Final = ( - {"aspect_ratio": image_edit_optional_request_params["aspectRatio"]} - if "aspectRatio" in image_edit_optional_request_params - else None - ) + image_config: Final = { + field: image_edit_optional_request_params[param] + for param, field in (("aspectRatio", "aspect_ratio"), ("imageSize", "image_size")) + if param in image_edit_optional_request_params + } generation_config: Final[dict[str, object]] = { key: value for key, value in (("response_modalities", ["IMAGE"]), ("image_config", image_config)) if value @@ -222,17 +222,6 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM): model_response.data = cast(list[OpenAIImage], data_list) return model_response - def _map_size_to_aspect_ratio(self, size: str) -> str: - """Map OpenAI size format to Gemini aspect ratio format""" - aspect_ratio_map: Final = { - "1024x1024": "1:1", - "1792x1024": "16:9", - "1024x1792": "9:16", - "1280x896": "4:3", - "896x1280": "3:4", - } - return aspect_ratio_map.get(size, "1:1") - def _prepare_inline_image_parts(self, image: FileTypes | list[FileTypes]) -> list[HttpxPartType]: images: Final[list[FileTypes]] = image if isinstance(image, list) else [image] return [ diff --git a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py index deaa32f7308..87b0dfdbcbc 100644 --- a/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_edit/vertex_imagen_transformation.py @@ -15,7 +15,7 @@ import litellm from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig -from litellm.llms.vertex_ai.common_utils import get_vertex_base_url +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -70,13 +70,11 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): if "n" in filtered_params: mapped_params["sampleCount"] = filtered_params["n"] - if "size" in filtered_params: - mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(filtered_params["size"]) - if "mask" in filtered_params: mapped_params["mask"] = filtered_params["mask"] - return mapped_params + size_params: Final = map_vertex_image_size(filtered_params.get("size"), model, imagen=True) or {} + return {**mapped_params, **size_params} def _resolve_vertex_project(self) -> str | None: return ( @@ -189,6 +187,11 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): "sampleCount": sample_count, "editMode": edit_mode, "editConfig": {"baseSteps": base_steps}, + **( + {"aspectRatio": image_edit_optional_request_params["aspectRatio"]} + if "aspectRatio" in image_edit_optional_request_params + else {} + ), } # Set default values for Vertex AI-specific parameters (not configurable by users via OpenAI API) @@ -239,17 +242,6 @@ class VertexAIImagenImageEditConfig(BaseImageEditConfig, VertexLLM): model_response.data = cast(list[OpenAIImage], data_list) return model_response - def _map_size_to_aspect_ratio(self, size: str) -> str: - """Map OpenAI size format to Imagen aspect ratio format""" - aspect_ratio_map: Final = { - "1024x1024": "1:1", - "1792x1024": "16:9", - "1024x1792": "9:16", - "1280x896": "4:3", - "896x1280": "3:4", - } - return aspect_ratio_map.get(size, "1:1") - def _prepare_reference_images( self, image: FileTypes | list[FileTypes], diff --git a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py index f6df772dd56..e1049ef8ece 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_gemini_transformation.py @@ -14,7 +14,7 @@ from litellm.llms.gemini.common_utils import ( get_gemini_image_web_search_requests, map_gemini_image_tools_params, ) -from litellm.llms.vertex_ai.common_utils import get_vertex_base_url +from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues @@ -89,8 +89,7 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): if k == "n": mapped_params["candidate_count"] = v elif k == "size": - # Map OpenAI size format to Gemini aspectRatio - mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(v) + continue elif k in ("aspectRatio", "aspect_ratio"): mapped_params["aspectRatio"] = v elif k in ("imageSize", "image_size"): @@ -103,22 +102,23 @@ class VertexAIGeminiImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): elif k not in ("tools", "web_search_options", "imageConfig"): mapped_params[k] = v + if "size" in non_default_params and "size" not in optional_params: + image_params: Final = _JSON_OBJECT.validate_python(mapped_params) + image_config: Final = _JSON_OBJECT.validate_python(image_params.get("imageConfig", {})) + size_params: Final = { + key: value + for key, value in ( + map_vertex_image_size(non_default_params["size"], model) or {"aspectRatio": "1:1"} + ).items() + if key not in image_config + } + return map_gemini_image_tools_params( + _JSON_OBJECT.validate_python(non_default_params), {**size_params, **image_params} + ) + mapped_params = map_gemini_image_tools_params(non_default_params, mapped_params) return mapped_params - def _map_size_to_aspect_ratio(self, size: str) -> str: - """ - Map OpenAI size format to Gemini aspect ratio format - """ - aspect_ratio_map: Final = { - "1024x1024": "1:1", - "1792x1024": "16:9", - "1024x1792": "9:16", - "1280x896": "4:3", - "896x1280": "3:4", - } - return aspect_ratio_map.get(size, "1:1") - def _resolve_vertex_project(self) -> str | None: return ( getattr(self, "_vertex_project", None) diff --git a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py index 927c7b74e67..daf87b1ec58 100644 --- a/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py +++ b/litellm/llms/vertex_ai/image_generation/vertex_imagen_transformation.py @@ -11,6 +11,7 @@ from litellm.llms.base_llm.image_generation.transformation import ( ) from litellm.llms.vertex_ai.common_utils import ( get_vertex_base_url, + map_vertex_image_size, pop_vertex_request_labels, ) from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM @@ -72,25 +73,14 @@ class VertexAIImagenImageGenerationConfig(BaseImageGenerationConfig, VertexLLM): mapped_params["sampleCount"] = v elif k == "size": # Map OpenAI size format to Imagen aspectRatio - mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(v) + mapped_params["aspectRatio"] = ( + map_vertex_image_size(v, model, imagen=True) or {"aspectRatio": "1:1"} + )["aspectRatio"] else: mapped_params[k] = v return mapped_params - def _map_size_to_aspect_ratio(self, size: str) -> str: - """ - Map OpenAI size format to Imagen aspect ratio format - """ - aspect_ratio_map: Final = { - "1024x1024": "1:1", - "1792x1024": "16:9", - "1024x1792": "9:16", - "1280x896": "4:3", - "896x1280": "3:4", - } - return aspect_ratio_map.get(size, "1:1") - def _resolve_vertex_project(self) -> str | None: return ( getattr(self, "_vertex_project", None) diff --git a/tests/unit/llms/gemini/test_gemini_common_utils.py b/tests/unit/llms/gemini/test_gemini_common_utils.py index 20c36878fef..5f2af34e513 100644 --- a/tests/unit/llms/gemini/test_gemini_common_utils.py +++ b/tests/unit/llms/gemini/test_gemini_common_utils.py @@ -512,6 +512,40 @@ def test_map_openai_image_params_to_gemini_forwards_a_json_object_image_config() assert _map_image_config('{"aspectRatio": "1:1"}') == {"imageConfig": {"aspectRatio": "1:1"}} +@pytest.mark.parametrize("prefix", ("", "gemini/", "vertex_ai/")) +@pytest.mark.parametrize( + "model, size, expected", + ( + ("gemini-3-pro-image", "256x256", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3-pro-image", "512x512", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3-pro-image-preview", "512x512", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3-pro-image", "2048x2048", {"aspectRatio": "1:1", "imageSize": "2K"}), + ("gemini-3-pro-image", "4096x4096", {"aspectRatio": "1:1", "imageSize": "4K"}), + ("gemini-3.1-flash-image", "512x512", {"aspectRatio": "1:1", "imageSize": "512"}), + ("gemini-3.1-flash-image-preview", "512x512", {"aspectRatio": "1:1", "imageSize": "512"}), + ("gemini-3.1-flash-image", "2048x2048", {"aspectRatio": "1:1", "imageSize": "2K"}), + ("gemini-3.1-flash-image", "4096x4096", {"aspectRatio": "1:1", "imageSize": "4K"}), + ("gemini-3.1-flash-lite-image", "256x256", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3.1-flash-lite-image", "512x512", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3.1-flash-lite-image", "2048x2048", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-3.1-flash-lite-image", "4096x4096", {"aspectRatio": "1:1", "imageSize": "1K"}), + ("gemini-2.5-flash-image", "512x512", {"aspectRatio": "1:1"}), + ("gemini-2.5-flash-image", "1024x256", {"aspectRatio": "21:9"}), + ("gemini-2.5-flash-image", "256x1024", {"aspectRatio": "9:16"}), + ("gemini-2.5-flash-image", "8000x1000", {"aspectRatio": "21:9"}), + ("gemini-2.5-flash-image", "1000x8000", {"aspectRatio": "9:16"}), + ("gemini-3.1-flash-image", "1024x256", {"aspectRatio": "4:1", "imageSize": "512"}), + ), +) +def test_size_mapping_respects_model_image_limits( + prefix: str, model: str, size: str, expected: dict[str, str] +) -> None: + """Limits as of 2026-10-26, https://ai.google.dev/gemini-api/docs/image-generation#aspect_ratios_and_image_size.""" + assert map_openai_image_params_to_gemini({"size": size}, prefix + model, ["size"]) == { + "imageConfig": expected + } + + @pytest.mark.parametrize("raw_image_config", ["[1, 2]", '"16:9"', "null"]) def test_map_openai_image_params_to_gemini_drops_or_refuses_a_non_object_image_config(raw_image_config: str): with contextlib.suppress(litellm.UnsupportedParamsError): diff --git a/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 0f5c21d08c7..065d894e658 100644 --- a/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/unit/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -8289,11 +8289,12 @@ def test_gemini_image_generation_ignores_non_dict_image_config(): def test_gemini_image_generation_openai_size_maps_to_google_table( size: str, expected_aspect_ratio: str, expected_image_size: str ): + """Flash table as of 2026-10-26, https://ai.google.dev/gemini-api/docs/image-generation#aspect_ratios_and_image_size.""" from litellm.llms.gemini.common_utils import ( map_openai_size_to_gemini_image_config, ) - assert map_openai_size_to_gemini_image_config(size, "gemini-3-pro-image-preview") == { + assert map_openai_size_to_gemini_image_config(size, "gemini-3.1-flash-image") == { "aspectRatio": expected_aspect_ratio, "imageSize": expected_image_size, } @@ -8317,7 +8318,7 @@ def test_gemini_image_generation_openai_size_snaps_to_nearest_option( map_openai_size_to_gemini_image_config, ) - assert map_openai_size_to_gemini_image_config(size, "gemini-3-pro-image-preview") == { + assert map_openai_size_to_gemini_image_config(size, "gemini-3.1-flash-image") == { "aspectRatio": expected_aspect_ratio, "imageSize": expected_image_size, } diff --git a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py index 756e6441f1f..6ae0bbd0d72 100644 --- a/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py +++ b/tests/unit/llms/vertex_ai/image_edit/test_vertex_ai_image_edit_transformation.py @@ -3,22 +3,118 @@ import json import os from datetime import datetime from io import BytesIO -from typing import Dict +from typing import Dict, Final from unittest.mock import MagicMock, patch import httpx import pytest -from pydantic import ValidationError +from pydantic import JsonValue, TypeAdapter, ValidationError import litellm from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.gemini.common_utils import map_openai_size_to_gemini_image_config from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import ( VertexAIGeminiImageEditConfig, ) from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import ( VertexAIImagenImageEditConfig, ) +from litellm.types.router import GenericLiteLLMParams + + +@pytest.mark.parametrize("size", ("1536x1024", "1024x1536", "3072x2048", "256x256", "512x512", "4096x4096")) +@pytest.mark.parametrize("model", ("gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-3.1-flash-lite-image")) +def test_vertex_gemini_edit_size_reaches_request(size: str, model: str) -> None: + config: Final = VertexAIGeminiImageEditConfig() + payload, _ = config.transform_image_edit_request( + model, + "mountains", + BytesIO(b"image"), + config.map_openai_params({"size": size}, model, False), + GenericLiteLLMParams(), + {}, + ) + request: Final = TypeAdapter(dict[str, JsonValue]).validate_json(TypeAdapter(str).validate_python(payload)) + expected: Final = map_openai_size_to_gemini_image_config(size, model) or {} + assert request["generationConfig"] == { + "response_modalities": ["IMAGE"], + **( + {"image_config": {"aspect_ratio": expected["aspectRatio"], "image_size": expected["imageSize"]}} + if expected + else {} + ), + } + + +@pytest.mark.parametrize( + "size, expected_ratio", + (("1536x1024", "4:3"), ("1024x1536", "3:4"), ("100000x1", "16:9")), +) +def test_vertex_imagen_edit_size_reaches_request(size: str, expected_ratio: str | None) -> None: + model: Final = "imagen-3.0-capability-001" + config: Final = VertexAIImagenImageEditConfig() + payload, _ = config.transform_image_edit_request( + model, + "mountains", + BytesIO(b"image"), + config.map_openai_params({"size": size, "n": 2}, model, False), + GenericLiteLLMParams(), + {}, + ) + request: Final = TypeAdapter(dict[str, JsonValue]).validate_json(TypeAdapter(str).validate_python(payload)) + assert request["parameters"] == { + "sampleCount": 2, + "editMode": "EDIT_MODE_INPAINT_INSERTION", + "editConfig": {"baseSteps": 50}, + "guidanceScale": 7.5, + "seed": None, + **({"aspectRatio": expected_ratio} if expected_ratio is not None else {}), + } + + +@pytest.mark.parametrize("size", ("auto", "unknown", "0x1024", "-1x1024", "1024x", "3:2")) +@pytest.mark.parametrize( + "config_type, model, config_key, expected", + ( + ( + VertexAIGeminiImageEditConfig, + "gemini-3.1-flash-image", + "generationConfig", + {"response_modalities": ["IMAGE"], "image_config": {"aspect_ratio": "1:1"}}, + ), + ( + VertexAIImagenImageEditConfig, + "imagen-3.0-capability-001", + "parameters", + { + "sampleCount": 1, + "editMode": "EDIT_MODE_INPAINT_INSERTION", + "editConfig": {"baseSteps": 50}, + "guidanceScale": 7.5, + "seed": None, + }, + ), + ), +) +def test_vertex_edit_preserves_legacy_size_fallback( + size: str, + config_type: type[VertexAIGeminiImageEditConfig] | type[VertexAIImagenImageEditConfig], + model: str, + config_key: str, + expected: dict[str, object], +) -> None: + config: Final = config_type() + payload, _ = config.transform_image_edit_request( + model, + "mountains", + BytesIO(b"image"), + config.map_openai_params({"size": size}, model, False), + GenericLiteLLMParams(), + {}, + ) + request: Final = TypeAdapter(dict[str, JsonValue]).validate_json(TypeAdapter(str).validate_python(payload)) + assert request[config_key] == expected class TestVertexAIGeminiImageEditTransformation: diff --git a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py index 5ac16283094..10ce24e969c 100644 --- a/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py +++ b/tests/unit/llms/vertex_ai/image_generation/test_vertex_ai_image_generation_transformation.py @@ -11,6 +11,7 @@ from pydantic import ValidationError import litellm from litellm.litellm_core_utils.litellm_logging import Logging from litellm.llms.custom_httpx.http_handler import HTTPHandler +from litellm.llms.gemini.image_generation.transformation import GoogleImageGenConfig from litellm.llms.vertex_ai.image_generation import ( get_vertex_ai_image_generation_config, ) @@ -23,6 +24,98 @@ from litellm.llms.vertex_ai.image_generation.vertex_imagen_transformation import from litellm.types.utils import ImageResponse +@pytest.mark.parametrize("size", ("1536x1024", "1024x1536", "3072x2048", "256x256", "512x512", "4096x4096")) +@pytest.mark.parametrize("model", ("gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-3.1-flash-lite-image")) +def test_vertex_gemini_size_matches_direct_gemini_request(size: str, model: str) -> None: + vertex: Final = VertexAIGeminiImageGenerationConfig() + direct: Final = GoogleImageGenConfig() + vertex_request: Final = vertex.transform_image_generation_request( + model, "mountains", vertex.map_openai_params({"size": size}, {}, model, False), {}, {} + ) + direct_request: Final = direct.transform_image_generation_request( + model, "mountains", direct.map_openai_params({"size": size}, {}, model, False), {}, {} + ) + assert vertex_request["generationConfig"].get("imageConfig") == direct_request["generationConfig"].get( + "imageConfig" + ) + + +@pytest.mark.parametrize( + "size, expected_ratio", + (("1536x1024", "4:3"), ("1024x1536", "3:4"), ("100000x1", "16:9")), +) +def test_vertex_imagen_generation_size_reaches_request(size: str, expected_ratio: str | None) -> None: + model: Final = "imagen-4.0-generate-001" + config: Final = VertexAIImagenImageGenerationConfig() + request: Final = config.transform_image_generation_request( + model, "mountains", config.map_openai_params({"size": size, "n": 2}, {}, model, False), {}, {} + ) + assert request["parameters"] == { + "sampleCount": 2, + **({"aspectRatio": expected_ratio} if expected_ratio is not None else {}), + } + + +@pytest.mark.parametrize("size", ("auto", "unknown", "0x1024", "-1x1024", "1024x", "3:2")) +@pytest.mark.parametrize( + "config_type, model, config_key, expected", + ( + ( + VertexAIGeminiImageGenerationConfig, + "gemini-3.1-flash-image", + "generationConfig", + {"responseModalities": ["IMAGE"], "imageConfig": {"aspectRatio": "1:1"}}, + ), + ( + VertexAIImagenImageGenerationConfig, + "imagen-4.0-generate-001", + "parameters", + {"sampleCount": 1, "aspectRatio": "1:1"}, + ), + ), +) +def test_vertex_generation_preserves_legacy_size_fallback( + size: str, + config_type: type[VertexAIGeminiImageGenerationConfig] | type[VertexAIImagenImageGenerationConfig], + model: str, + config_key: str, + expected: dict[str, object], +) -> None: + config: Final = config_type() + request: Final = config.transform_image_generation_request( + model, "mountains", config.map_openai_params({"size": size}, {}, model, False), {}, {} + ) + assert request[config_key] == expected + + +@pytest.mark.parametrize("config_type", (VertexAIGeminiImageGenerationConfig, VertexAIImagenImageGenerationConfig)) +def test_vertex_generation_warns_when_size_ratio_is_approximated( + config_type: type[VertexAIGeminiImageGenerationConfig] | type[VertexAIImagenImageGenerationConfig], + caplog: pytest.LogCaptureFixture, +) -> None: + mapped: Final = config_type().map_openai_params({"size": "100000x1"}, {}, "gemini-3.1-flash-image", False) + assert "100000x1" in caplog.text + assert str(mapped["aspectRatio"]) in caplog.text + + +@pytest.mark.parametrize("size_first", (True, False)) +@pytest.mark.parametrize( + "overrides", + ( + {"aspect_ratio": "9:16", "image_size": "2K"}, + {"imageConfig": {"aspectRatio": "9:16", "imageSize": "2K"}}, + ), +) +def test_vertex_gemini_explicit_image_settings_override_size(size_first: bool, overrides: dict[str, object]) -> None: + model: Final = "gemini-3.1-flash-image" + config: Final = VertexAIGeminiImageGenerationConfig() + params: Final = {"size": "1536x1024", **overrides} if size_first else {**overrides, "size": "1536x1024"} + request: Final = config.transform_image_generation_request( + model, "mountains", config.map_openai_params(params, {}, model, False), {}, {} + ) + assert request["generationConfig"]["imageConfig"] == {"aspectRatio": "9:16", "imageSize": "2K"} + + class TestVertexAIGeminiImageGenerationConfig: def setup_method(self): """Set up test fixtures""" @@ -55,14 +148,13 @@ class TestVertexAIGeminiImageGenerationConfig: result = self.config.map_openai_params(non_default_params, optional_params, "gemini-2.5-flash-image", False) assert result.get("aspectRatio") == "16:9" - def test_map_size_to_aspect_ratio(self): - """Test size to aspect ratio mapping""" - assert self.config._map_size_to_aspect_ratio("1024x1024") == "1:1" - assert self.config._map_size_to_aspect_ratio("1792x1024") == "16:9" - assert self.config._map_size_to_aspect_ratio("1024x1792") == "9:16" - assert self.config._map_size_to_aspect_ratio("1280x896") == "4:3" - assert self.config._map_size_to_aspect_ratio("896x1280") == "3:4" - assert self.config._map_size_to_aspect_ratio("unknown") == "1:1" # default + @pytest.mark.parametrize( + "size, expected_ratio", + (("1024x1024", "1:1"), ("1792x1024", "16:9"), ("1024x1792", "9:16"), ("1280x896", "4:3"), ("896x1280", "3:4")), + ) + def test_map_openai_params_legacy_sizes(self, size: str, expected_ratio: str) -> None: + mapped: Final = self.config.map_openai_params({"size": size}, {}, "gemini-3.1-flash-image", False) + assert mapped["aspectRatio"] == expected_ratio def test_get_supported_openai_params_includes_native_gemini_params(self): """Test that native Gemini imageConfig params are supported""" @@ -474,11 +566,10 @@ class TestVertexAIImagenImageGenerationConfig: result = self.config.map_openai_params(non_default_params, optional_params, "imagegeneration@006", False) assert result.get("aspectRatio") == "1:1" - def test_map_size_to_aspect_ratio(self): - """Test size to aspect ratio mapping""" - assert self.config._map_size_to_aspect_ratio("1024x1024") == "1:1" - assert self.config._map_size_to_aspect_ratio("1792x1024") == "16:9" - assert self.config._map_size_to_aspect_ratio("unknown") == "1:1" # default + @pytest.mark.parametrize("size, expected_ratio", (("1024x1024", "1:1"), ("1792x1024", "16:9"))) + def test_map_openai_params_legacy_sizes(self, size: str, expected_ratio: str) -> None: + mapped: Final = self.config.map_openai_params({"size": size}, {}, "imagen-4.0-generate-001", False) + assert mapped["aspectRatio"] == expected_ratio def test_transform_image_generation_request_basic(self): """Test basic request transformation"""