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
This commit is contained in:
tinysolver 2026-10-03 15:13:13 +09:00
parent 4ba841c4d9
commit d55bdb7628
2 changed files with 145 additions and 2 deletions

View file

@ -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,

View file

@ -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/<id>/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"))