From d55bdb76284f8d2fdf8d2df5476f7bbf11c69e83 Mon Sep 17 00:00:00 2001 From: tinysolver Date: Sat, 3 Oct 2026 15:13:13 +0900 Subject: [PATCH] fix(openrouter): keep quality-based resolution for gemini on /images On the chat-based path, quality became image_config.image_size for every openrouter/ image model: low, standard and auto -> 1K, medium -> 2K, high and hd -> 4K. /images has its own quality field, which Google's image models ignore, so a Gemini caller asking for medium or high quality got the default 1K tier On google/ models, quality now fills image_config.image_size the same way, next to the aspect ratio main picked for an OpenAI pixel size, and reaches /images as resolution and aspect_ratio. A tier size, an explicit resolution or an image_config from litellm_params still wins. openai/ and other models keep the native quality field, which /images lists for gpt-image-* and gpt-5-image --- .../image_generation/transformation.py | 42 ++++++- ...est_openrouter_image_gen_transformation.py | 105 ++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) diff --git a/litellm/llms/openrouter/image_generation/transformation.py b/litellm/llms/openrouter/image_generation/transformation.py index d2dc190ff17..19c94144c54 100644 --- a/litellm/llms/openrouter/image_generation/transformation.py +++ b/litellm/llms/openrouter/image_generation/transformation.py @@ -50,6 +50,19 @@ OPENROUTER_API_BASE: Final = "https://openrouter.ai/api/v1" IMAGES_PATH: Final = "/images" LEGACY_CHAT_COMPLETIONS_SUFFIX: Final = "/chat/completions" QUALITY_ALIASES: Final = MappingProxyType({"standard": "low", "hd": "high"}) +RESOLUTION_TIER_MODEL_AUTHOR: Final = "google/" +QUALITY_RESOLUTION_TIERS: Final = MappingProxyType({"auto": "1K", "low": "1K", "medium": "2K", "high": "4K"}) +OPENAI_SIZE_ASPECT_RATIOS: Final = MappingProxyType( + { + "256x256": "1:1", + "512x512": "1:1", + "1024x1024": "1:1", + "1536x1024": "3:2", + "1024x1536": "2:3", + "1792x1024": "16:9", + "1024x1792": "9:16", + } +) LEGACY_IMAGE_CONFIG_FIELDS: Final = MappingProxyType({"aspect_ratio": "aspect_ratio", "image_size": "resolution"}) NON_BODY_PARAMS: Final = frozenset( {"model", "prompt", "messages", "modalities", "stream", "image_config", "extra_headers"} @@ -80,16 +93,41 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig): ) -> dict: """ size and n pass through as is: /images takes explicit pixel sizes and normalizes them per - provider. quality is native on /images, so only the dall-e-3 names are translated + provider. quality is native on /images, so only the dall-e-3 names are translated, except on + Google's models, see _map_quality_to_resolution_tier """ supported_params: Final = self.get_supported_openai_params(model) - mapped_params: Final = { + mapped_params: Final[dict[str, object]] = { key: QUALITY_ALIASES.get(value, value) if key == "quality" else value for key, value in non_default_params.items() if (key in supported_params or not drop_params) and (key, value) != ("size", "auto") } + if ( + "quality" in mapped_params + and "image_config" not in optional_params + and model.removeprefix("openrouter/").startswith(RESOLUTION_TIER_MODEL_AUTHOR) + ): + return {**optional_params, **self._map_quality_to_resolution_tier(mapped_params)} return {**optional_params, **mapped_params} + @staticmethod + def _map_quality_to_resolution_tier(mapped_params: dict[str, object]) -> dict[str, object]: + """ + Google's image models take a resolution tier on /images and ignore quality, so quality keeps the + meaning it had on the chat-based path: image_config.image_size (1K, 2K or 4K), next to the aspect + ratio of an OpenAI pixel size. A tier size or an image_config set by the caller wins + """ + size: Final = str(mapped_params.get("size") or "") + tier: Final = QUALITY_RESOLUTION_TIERS.get(str(mapped_params["quality"])) + params: Final = {key: value for key, value in mapped_params.items() if key != "quality"} + if tier is None or (size and PIXEL_SIZE.fullmatch(size) is None): + return params + aspect_ratio: Final = OPENAI_SIZE_ASPECT_RATIOS.get(size) + image_config: Final = ( + {"image_size": tier} if aspect_ratio is None else {"aspect_ratio": aspect_ratio, "image_size": tier} + ) + return {**params, "image_config": image_config} + def _set_usage_and_cost( self, model_response: ImageResponse, diff --git a/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py b/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py index 024e87d0007..d2f52e90fc5 100644 --- a/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py +++ b/tests/unit/llms/openrouter/image_generation/test_openrouter_image_gen_transformation.py @@ -16,6 +16,7 @@ from litellm.types.utils import ImageResponse, ImageUsage, ImageUsageInputTokens CONFIG: Final = OpenRouterImageGenerationConfig() IMAGE_ONLY_MODEL: Final = "openai/gpt-image-1-mini" HYBRID_MODEL: Final = "google/gemini-2.5-flash-image" +RESOLUTION_TIER_MODEL: Final = "google/gemini-3.1-flash-image" PROMPT: Final = "a small red apple on a white table, simple flat illustration" IMAGES_URL: Final = "https://openrouter.ai/api/v1/images" @@ -158,6 +159,81 @@ def test_map_openai_params_keeps_params_already_in_optional_params(): assert mapped == {"resolution": "2K", "n": 1} +# On 2026-10-03 GET https://openrouter.ai/api/v1/images/models//endpoints listed resolution and no quality for +# google/gemini-3-pro-image and google/gemini-3.1-flash-image, and quality and no resolution for openai/gpt-image-* +# and openai/gpt-5-image. The quality field in https://openrouter.ai/openapi.json says providers without a quality +# knob ignore it +@pytest.mark.parametrize( + ("non_default_params", "expected_params"), + [ + ({"quality": "low"}, {"image_config": {"image_size": "1K"}}), + ({"quality": "standard"}, {"image_config": {"image_size": "1K"}}), + ({"quality": "auto"}, {"image_config": {"image_size": "1K"}}), + ({"quality": "medium"}, {"image_config": {"image_size": "2K"}}), + ({"quality": "high"}, {"image_config": {"image_size": "4K"}}), + ({"quality": "hd"}, {"image_config": {"image_size": "4K"}}), + ( + {"quality": "medium", "size": "1024x1024", "n": 1}, + {"size": "1024x1024", "n": 1, "image_config": {"aspect_ratio": "1:1", "image_size": "2K"}}, + ), + ( + {"quality": "high", "size": "1536x1024"}, + {"size": "1536x1024", "image_config": {"aspect_ratio": "3:2", "image_size": "4K"}}, + ), + ( + {"quality": "hd", "size": "1024x1792"}, + {"size": "1024x1792", "image_config": {"aspect_ratio": "9:16", "image_size": "4K"}}, + ), + ({"quality": "medium", "size": "1344x768"}, {"size": "1344x768", "image_config": {"image_size": "2K"}}), + ({"quality": "high", "size": "2K"}, {"size": "2K"}), + ({"quality": "xhigh"}, {}), + ], +) +def test_map_openai_params_turns_quality_into_a_resolution_tier_on_google_models( + non_default_params: dict[str, object], expected_params: dict[str, object] +): + mapped = CONFIG.map_openai_params( + non_default_params=non_default_params, + optional_params={}, + model=RESOLUTION_TIER_MODEL, + drop_params=False, + ) + + assert mapped == expected_params + + +@pytest.mark.parametrize( + ("model", "expected_params"), + [ + ("google/gemini-2.5-flash-image", {"image_config": {"image_size": "4K"}}), + ("openrouter/google/gemini-3-pro-image", {"image_config": {"image_size": "4K"}}), + ("openai/gpt-image-1-mini", {"quality": "high"}), + ("openai/gpt-5-image", {"quality": "high"}), + ("x-ai/grok-imagine-image-2.0", {"quality": "high"}), + ], +) +def test_map_openai_params_keeps_native_quality_outside_google_models(model: str, expected_params: dict[str, object]): + mapped = CONFIG.map_openai_params( + non_default_params={"quality": "high"}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert mapped == expected_params + + +def test_map_openai_params_quality_tier_yields_to_an_image_config_already_set(): + mapped = CONFIG.map_openai_params( + non_default_params={"quality": "high", "size": "1536x1024"}, + optional_params={"image_config": {"image_size": "1K"}}, + model=RESOLUTION_TIER_MODEL, + drop_params=False, + ) + + assert mapped == {"image_config": {"image_size": "1K"}, "quality": "high", "size": "1536x1024"} + + @patch("litellm.llms.openrouter.image_generation.transformation.get_secret_str") def test_validate_environment_with_api_key(mock_get_secret: MagicMock): result = CONFIG.validate_environment( @@ -404,6 +480,35 @@ def test_legacy_image_config_with_an_openai_pixel_size_sends_only_the_aspect_rat assert json.loads(request.content) == {"model": HYBRID_MODEL, "prompt": PROMPT, "aspect_ratio": "16:9"} +@pytest.mark.parametrize( + ("extra_kwargs", "expected_fields"), + [ + ({}, {"aspect_ratio": "1:1", "resolution": "2K"}), + ({"resolution": "1K"}, {"aspect_ratio": "1:1", "resolution": "1K"}), + ({"image_config": {"image_size": "4K"}}, {"resolution": "4K"}), + ], +) +def test_google_model_quality_still_picks_the_resolution_tier_and_explicit_values_win( + extra_kwargs: dict[str, object], expected_fields: dict[str, object] +): + recorder = RequestRecorder(_images_response("aW1hZ2Ux")) + + litellm.image_generation( + model=f"openrouter/{RESOLUTION_TIER_MODEL}", + prompt=PROMPT, + size="1024x1024", + quality="medium", + n=1, + api_key="sk-test", + client=_client(recorder), + **extra_kwargs, + ) + + (request,) = recorder.requests + assert str(request.url) == IMAGES_URL + assert json.loads(request.content) == {"model": RESOLUTION_TIER_MODEL, "prompt": PROMPT, "n": 1, **expected_fields} + + def test_legacy_chat_completions_api_base_still_reaches_the_images_endpoint(): recorder = RequestRecorder(_images_response("aW1hZ2Ux"))