Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions litellm/llms/gemini/common_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import datetime
import math
from typing import Any, Dict, List, Optional, Union

import httpx
Expand All @@ -13,6 +14,151 @@
from litellm.types.utils import TokenCountResponse


GEMINI_IMAGE_ASPECT_RATIOS: Dict[str, float] = {
"1:1": 1 / 1,
"1:4": 1 / 4,
"1:8": 1 / 8,
"2:3": 2 / 3,
"3:2": 3 / 2,
"3:4": 3 / 4,
"4:1": 4 / 1,
"4:3": 4 / 3,
"4:5": 4 / 5,
"5:4": 5 / 4,
"8:1": 8 / 1,
"9:16": 9 / 16,
"16:9": 16 / 9,
"21:9": 21 / 9,
}

GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO: Dict[tuple[int, int], str] = {
(512, 512): "1:1",
(1024, 1024): "1:1",
(2048, 2048): "1:1",
(4096, 4096): "1:1",
(256, 1024): "1:4",
(512, 2048): "1:4",
(1024, 4096): "1:4",
(2048, 8192): "1:4",
(192, 1536): "1:8",
(384, 3072): "1:8",
(768, 6144): "1:8",
(1536, 12288): "1:8",
(424, 632): "2:3",
(848, 1264): "2:3",
(1696, 2528): "2:3",
(3392, 5056): "2:3",
(632, 424): "3:2",
(1264, 848): "3:2",
(2528, 1696): "3:2",
(5056, 3392): "3:2",
(448, 600): "3:4",
(896, 1200): "3:4",
(1792, 2400): "3:4",
(3584, 4800): "3:4",
(1024, 256): "4:1",
(2048, 512): "4:1",
(4096, 1024): "4:1",
(8192, 2048): "4:1",
(600, 448): "4:3",
(1200, 896): "4:3",
(2400, 1792): "4:3",
(4800, 3584): "4:3",
(464, 576): "4:5",
(928, 1152): "4:5",
(1856, 2304): "4:5",
(3712, 4608): "4:5",
(576, 464): "5:4",
(1152, 928): "5:4",
(2304, 1856): "5:4",
(4608, 3712): "5:4",
(1536, 192): "8:1",
(3072, 384): "8:1",
(6144, 768): "8:1",
(12288, 1536): "8:1",
(384, 688): "9:16",
(768, 1376): "9:16",
(1536, 2752): "9:16",
(3072, 5504): "9:16",
(688, 384): "16:9",
(1376, 768): "16:9",
(2752, 1536): "16:9",
(5504, 3072): "16:9",
(792, 336): "21:9",
(1584, 672): "21:9",
(3168, 1344): "21:9",
(6336, 2688): "21:9",
(1280, 896): "4:3",
(896, 1280): "3:4",
}


def map_openai_size_to_gemini_image_config(
size: str, model: str
) -> Optional[Dict[str, str]]:
dimensions = _parse_openai_image_size(size)
if dimensions is None:
return None

width, height = dimensions
image_config = {
"aspectRatio": _map_dimensions_to_gemini_aspect_ratio(width, height)
}
if supports_gemini_image_size(model):
image_config["imageSize"] = _map_dimensions_to_gemini_image_size(width, height)
return image_config


def supports_gemini_image_size(model: str) -> bool:
# Gemini 2.5 Flash image supports aspectRatio but rejects imageSize; newer
# Gemini image models are expected to support both fields.
return "2.5-flash" not in model
Comment on lines +112 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hardcoded model-specific flag violates project rule

supports_gemini_image_size hard-codes "2.5-flash" as a string guard. Per the project's custom rule, model-specific capability flags must live in model_prices_and_context_window.json and be queried through get_model_info, so that support for future models can be enabled without a code change. As written, any new model that also lacks imageSize support (or any future Gemini 2.5-flash variant with a slightly different name) will silently receive imageConfig objects that include imageSize, potentially causing API errors.

Rule Used: What: Do not hardcode model-specific flags in the ... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m going to keep the 2.5-flash guard local here rather than adding a new model_prices_and_context_window.json capability flag for this PR. This matches existing Gemini transformation patterns in this area: VertexGeminiConfig._is_gemini_3_or_newer() uses model-name detection for Gemini 3 behavior, _supports_penalty_parameters() has a model-name exception list, and the Gemini thinking mapping branches on gemini-2.5-flash-lite / gemini-2.5-pro / gemini-2.5-flash substrings.

For image generation, this is also an older-model exception: Gemini 2.5 Flash image supports aspectRatio but not imageSize, while newer Gemini image models are expected to support imageSize. Adding a new global model metadata flag for that narrow exception would add more surface area than this PR needs. I’ll add an inline code comment to make this intentional.

Comment on lines +112 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded model-specific flag for imageSize support

supports_gemini_image_size hard-codes the string "2.5-flash" to gate feature availability. Per the team's rule, model-specific capabilities should live in model_prices_and_context_window.json and be queried via get_model_info, so that a new model that also lacks imageSize support doesn't require a code change and a LiteLLM release to be handled correctly.

Suggested change
def supports_gemini_image_size(model: str) -> bool:
return "2.5-flash" not in model
def supports_gemini_image_size(model: str) -> bool:
# TODO: move this capability flag to model_prices_and_context_window.json
# and query via get_model_info so new models work without a code change.
return "2.5-flash" not in model

Rule Used: What: Do not hardcode model-specific flags in the ... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m going to keep the 2.5-flash guard local here rather than adding a new model_prices_and_context_window.json capability flag for this PR. This matches existing Gemini transformation patterns in this area: VertexGeminiConfig._is_gemini_3_or_newer() uses model-name detection for Gemini 3 behavior, _supports_penalty_parameters() has a model-name exception list, and the Gemini thinking mapping branches on gemini-2.5-flash-lite / gemini-2.5-pro / gemini-2.5-flash substrings.

For image generation, this is also an older-model exception: Gemini 2.5 Flash image supports aspectRatio but not imageSize, while newer Gemini image models are expected to support imageSize. Adding a new global model metadata flag for that narrow exception would add more surface area than this PR needs. I’ll add an inline code comment to make this intentional.



def _parse_openai_image_size(size: str) -> Optional[tuple[int, int]]:
if size == "auto":
return None

width_str, separator, height_str = size.lower().partition("x")
if not separator:
return None

try:
width = int(width_str)
height = int(height_str)
except ValueError:
return None

if width <= 0 or height <= 0:
return 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)]

requested_ratio = width / height
return min(
GEMINI_IMAGE_ASPECT_RATIOS,
key=lambda aspect_ratio: abs(
math.log(GEMINI_IMAGE_ASPECT_RATIOS[aspect_ratio] / requested_ratio)
),
)
Comment on lines +138 to +148

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent aspect-ratio change for previously-supported sizes 1280x896 and 896x1280

The old _map_size_to_aspect_ratio mapped "1280x896" → "4:3" and "896x1280" → "3:4". These dimensions are absent from GEMINI_IMAGE_SIZE_TO_ASPECT_RATIO, so they fall through to the log-distance snapper, which now picks "3:2" and "2:3" respectively. Users who relied on these specific sizes will get a different aspect ratio after the upgrade without any warning.

Rule Used: What: avoid backwards-incompatible changes without... (source)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in force-pushed commit 2dfdd6f. The mapping table now preserves the prior behavior for 1280x896 -> 4:3 and 896x1280 -> 3:4, with regression coverage added.



def _map_dimensions_to_gemini_image_size(width: int, height: int) -> str:
effective_square_side = math.sqrt(width * height)
if effective_square_side < 768:
return "512"
if effective_square_side < 1536:
return "1K"
if effective_square_side < 3072:
return "2K"
return "4K"


class GeminiError(BaseLLMException):
pass

Expand Down
50 changes: 30 additions & 20 deletions litellm/llms/gemini/image_edit/transformation.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import base64
import json
from io import BufferedReader, BytesIO
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast

import httpx
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.gemini.common_utils import (
map_openai_size_to_gemini_image_config,
supports_gemini_image_size,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
Expand All @@ -22,7 +28,7 @@

class GeminiImageEditConfig(BaseImageEditConfig):
DEFAULT_BASE_URL: str = "https://generativelanguage.googleapis.com/v1beta"
SUPPORTED_PARAMS: List[str] = ["size"]
SUPPORTED_PARAMS: List[str] = ["size", "imageConfig"]

def get_supported_openai_params(self, model: str) -> List[str]:
return list(self.SUPPORTED_PARAMS)
Expand All @@ -43,9 +49,24 @@ def map_openai_params(
mapped_params: Dict[str, Any] = {}

if "size" in filtered_params:
mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(
filtered_params["size"] # type: ignore[arg-type]
image_config = map_openai_size_to_gemini_image_config(
filtered_params["size"], # type: ignore[arg-type]
model,
)
if image_config is not None:
mapped_params["imageConfig"] = image_config

image_config_param = filtered_params.get("imageConfig")
if isinstance(image_config_param, str):
try:
image_config_param = json.loads(image_config_param)
except json.JSONDecodeError as exc:
raise litellm.UnsupportedParamsError(
model=model,
message="`imageConfig` must be valid JSON when provided as a string.",
) from exc
if isinstance(image_config_param, dict):
mapped_params["imageConfig"] = image_config_param
Comment on lines +59 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 json.loads here is uncaught. When a multipart form request supplies a malformed string for imageConfig (e.g. "{bad") this raises json.JSONDecodeError, which propagates all the way up as an unhandled exception and surfaces to the caller as a 500 error instead of a 400 validation error.

Suggested change
image_config_param = filtered_params.get("imageConfig")
if isinstance(image_config_param, str):
image_config_param = json.loads(image_config_param)
if isinstance(image_config_param, dict):
mapped_params["imageConfig"] = image_config_param
image_config_param = filtered_params.get("imageConfig")
if isinstance(image_config_param, str):
try:
image_config_param = json.loads(image_config_param)
except json.JSONDecodeError:
image_config_param = None
if isinstance(image_config_param, dict):
mapped_params["imageConfig"] = image_config_param

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 82a9bba880 by converting malformed string imageConfig into an UnsupportedParamsError/400 instead of allowing a raw JSONDecodeError/500, with regression coverage.


return mapped_params

Expand Down Expand Up @@ -109,13 +130,12 @@ def transform_image_edit_request( # type: ignore[override]

generation_config: Dict[str, Any] = {}

if "aspectRatio" in image_edit_optional_request_params:
# Move aspectRatio into imageConfig inside generationConfig
if "imageConfig" not in generation_config:
generation_config["imageConfig"] = {}
generation_config["imageConfig"]["aspectRatio"] = (
image_edit_optional_request_params["aspectRatio"]
)
if isinstance(image_edit_optional_request_params.get("imageConfig"), dict):
image_config = dict(image_edit_optional_request_params["imageConfig"])
if not supports_gemini_image_size(model):
image_config.pop("imageSize", None)
if image_config:
generation_config["imageConfig"] = image_config

if generation_config:
request_body["generationConfig"] = generation_config
Expand Down Expand Up @@ -158,16 +178,6 @@ def transform_image_edit_response(
model_response.data = cast(List[OpenAIImage], data_list)
return model_response

def _map_size_to_aspect_ratio(self, size: str) -> str:
aspect_ratio_map = {
"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: Union[FileTypes, List[FileTypes]]
) -> List[Dict[str, Any]]:
Expand Down
77 changes: 50 additions & 27 deletions litellm/llms/gemini/image_generation/transformation.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from typing import TYPE_CHECKING, Any, List, Optional
from typing import TYPE_CHECKING, Any, Dict, List, Optional

import httpx

from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from litellm.llms.gemini.common_utils import (
map_openai_size_to_gemini_image_config,
supports_gemini_image_size,
)
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.gemini import GeminiImageGenerationRequest
from litellm.types.llms.openai import (
Expand Down Expand Up @@ -36,7 +40,10 @@ def get_supported_openai_params(
Google AI Imagen API supported parameters
https://ai.google.dev/gemini-api/docs/imagen
"""
return ["n", "size"]
supported_params = ["n", "size"]
if "gemini" in model:
supported_params.append("imageConfig")
return supported_params # type: ignore[return-value]

def map_openai_params(
self,
Expand All @@ -48,32 +55,34 @@ def map_openai_params(
supported_params = self.get_supported_openai_params(model)
mapped_params = {}

for k, v in non_default_params.items():
if k not in optional_params.keys():
if k in supported_params:
# Map OpenAI parameters to Google format
if k == "n":
mapped_params["sampleCount"] = v
elif k == "size":
# Map OpenAI size format to Google aspectRatio
mapped_params["aspectRatio"] = self._map_size_to_aspect_ratio(v)
else:
mapped_params[k] = v
return mapped_params
if "n" in non_default_params and "n" not in optional_params:
mapped_params["sampleCount"] = non_default_params["n"]

def _map_size_to_aspect_ratio(self, size: str) -> str:
"""
https://ai.google.dev/gemini-api/docs/image-generation
if "size" in non_default_params and "size" not in optional_params:
image_config = map_openai_size_to_gemini_image_config(
non_default_params["size"], model
)
if image_config is not None:
if "gemini" in model:
mapped_params["imageConfig"] = image_config
else:
mapped_params["aspectRatio"] = image_config["aspectRatio"]
if "imageSize" in image_config:
mapped_params["imageSize"] = image_config["imageSize"]

"""
aspect_ratio_map = {
"1024x1024": "1:1",
"1792x1024": "16:9",
"1024x1792": "9:16",
"1280x896": "4:3",
"896x1280": "3:4",
}
return aspect_ratio_map.get(size, "1:1")
if "imageConfig" in supported_params and isinstance(
non_default_params.get("imageConfig"), dict
):
mapped_params["imageConfig"] = non_default_params["imageConfig"]

for k, v in non_default_params.items():
if (
k not in ("n", "size", "imageConfig")
and k not in optional_params
and k in supported_params
):
mapped_params[k] = v
return mapped_params

def _transform_image_usage(self, usage_metadata: dict) -> ImageUsage:
"""
Expand Down Expand Up @@ -180,9 +189,23 @@ def transform_image_generation_request(
"""
# For Gemini Flash Image Preview models, use standard Gemini format
if "gemini" in model:
generation_config: Dict[str, Any] = {
"response_modalities": ["IMAGE", "TEXT"]
}
image_config: Dict[str, Any] = {}

if isinstance(optional_params.get("imageConfig"), dict):
image_config.update(optional_params["imageConfig"])

if not supports_gemini_image_size(model):
image_config.pop("imageSize", None)

if image_config:
generation_config["imageConfig"] = image_config

request_body: dict = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"response_modalities": ["IMAGE", "TEXT"]},
"generationConfig": generation_config,
}
return request_body
else:
Expand Down
1 change: 1 addition & 0 deletions litellm/types/images/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ImageEditOptionalRequestParams(TypedDict, total=False):
response_format: Optional[Literal["url", "b64_json"]]
size: Optional[str]
user: Optional[str]
imageConfig: Optional[Dict[str, Any]]


class ImageEditRequestParams(ImageEditOptionalRequestParams, total=False):
Expand Down
3 changes: 3 additions & 0 deletions litellm/types/llms/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ class GeminiImageGenerationParameters(BaseModel):
aspectRatio: Optional[str] = None
"""Aspect ratio for generated images (e.g., '1:1', '16:9', '9:16', '4:3', '3:4')"""

imageSize: Optional[str] = None
"""Image size for generated images (e.g., '512', '1K', '2K', '4K')"""

personGeneration: Optional[str] = None
"""Controls person generation in images"""

Expand Down
1 change: 1 addition & 0 deletions litellm/types/llms/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,7 @@ class LiteLLMFineTuningJobCreate(FineTuningJobCreate):
"image_url",
"image_prompt_strength",
"aspect_ratio",
"imageConfig",
]

OpenAIImageEditOptionalParams = Literal[
Expand Down
Loading
Loading