mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
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:
parent
4ba841c4d9
commit
d55bdb7628
2 changed files with 145 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue