fix(openrouter): send image generation to the dedicated /api/v1/images endpoint

litellm.image_generation and the proxy /v1/images/generations route posted
openrouter/ image models to OpenRouter /chat/completions. Image-only models
such as openai/gpt-image-1-mini get a 404 there that says to use
/api/v1/images instead, so the call fails and no spend is recorded

The image generation transform now posts {model, prompt, size, quality, n}
plus provider-specific fields to {api_base}/images and reads data[].b64_json.
usage.cost from that response becomes the response cost through the existing
llm_provider-x-litellm-response-cost path. Image+text models such as
google/gemini-2.5-flash-image are served by the same endpoint, so every
openrouter/ image generation call goes there

Older configs keep working: an api_base ending in /chat/completions is
rewritten to /images, and image_config.aspect_ratio and image_config.image_size
map to aspect_ratio and resolution. extra_headers no longer ends up in the JSON
body. Chat image output through completion(modalities=[...]) and image edit are
unchanged

Co-authored-by: Joly0 <13993216+Joly0@users.noreply.github.com>
Co-authored-by: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com>
This commit is contained in:
tinysolver 2026-10-03 11:58:36 +09:00
parent 8efb4a21f6
commit f545af5b7d
3 changed files with 387 additions and 788 deletions

View file

@ -1,32 +1,22 @@
"""
OpenRouter Image Generation Support
OpenRouter image generation through POST {api_base}/images
OpenRouter provides image generation through chat completion endpoints.
Models like google/gemini-2.5-flash-image return images in the message content.
Response format:
Response shape:
{
"choices": [{
"message": {
"content": "Here is a beautiful sunset for you! ",
"role": "assistant",
"images": [{
"image_url": {"url": "data:image/png;base64,..."},
"index": 0,
"type": "image_url"
}]
}
}],
"created": 1790994420,
"data": [{"b64_json": "...", "media_type": "image/png"}],
"usage": {
"completion_tokens": 1299,
"prompt_tokens": 6,
"total_tokens": 1305,
"completion_tokens_details": {"image_tokens": 1290},
"cost": 0.0387243
"prompt_tokens": 18,
"completion_tokens": 272,
"total_tokens": 290,
"cost": 0.002212,
"cost_details": {"upstream_inference_cost": 0.002212, ...},
"completion_tokens_details": {"image_tokens": 272}
}
}
"""
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final
import httpx
@ -55,23 +45,23 @@ if TYPE_CHECKING:
else:
LiteLLMLoggingObj = Any
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"})
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"}
)
class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
"""
Configuration for OpenRouter image generation via chat completions.
OpenRouter uses chat completion endpoints for image generation,
so we need to transform image generation requests to chat format
and extract images from chat responses.
OpenRouter image generation through the dedicated /images endpoint, which serves both
image-only models (openai/gpt-image-*) and image+text models (google/gemini-*-image)
"""
def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]:
"""
Get supported OpenAI parameters for OpenRouter image generation.
Since OpenRouter uses chat completions for image generation,
we support standard image generation params.
"""
return [
"size",
"quality",
@ -86,104 +76,16 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
drop_params: bool,
) -> dict:
"""
Map image generation params to OpenRouter chat completion format.
Maps OpenAI parameters to OpenRouter's image_config format:
- size -> image_config.aspect_ratio
- quality -> image_config.image_size
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
"""
supported_params: Final = self.get_supported_openai_params(model)
for key, value in non_default_params.items():
if key in supported_params:
if key == "size":
# Map OpenAI size to OpenRouter aspect_ratio
aspect_ratio = self._map_size_to_aspect_ratio(value)
if "image_config" not in optional_params:
optional_params["image_config"] = {}
optional_params["image_config"]["aspect_ratio"] = aspect_ratio
elif key == "quality":
# Map OpenAI quality to OpenRouter image_size
image_size = self._map_quality_to_image_size(value)
if image_size:
if "image_config" not in optional_params:
optional_params["image_config"] = {}
optional_params["image_config"]["image_size"] = image_size
else:
# Pass through other supported params (like n)
optional_params[key] = value
elif not drop_params:
# If not supported and drop_params is False, pass through
optional_params[key] = value
return optional_params
def _map_size_to_aspect_ratio(self, size: str) -> str:
"""
Map OpenAI size format to OpenRouter aspect_ratio format.
OpenAI sizes:
- 1024x1024 (square)
- 1536x1024 (landscape)
- 1024x1536 (portrait)
- 1792x1024 (wide landscape, dall-e-3)
- 1024x1792 (tall portrait, dall-e-3)
- 256x256, 512x512 (dall-e-2)
- auto (default)
OpenRouter aspect_ratios:
- 1:1 → 1024×1024 (default)
- 2:3 → 832×1248
- 3:2 → 1248×832
- 3:4 → 864×1184
- 4:3 → 1184×864
- 4:5 → 896×1152
- 5:4 → 1152×896
- 9:16 → 768×1344
- 16:9 → 1344×768
- 21:9 → 1536×672
"""
size_to_aspect_ratio: Final = {
# Square formats
"256x256": "1:1",
"512x512": "1:1",
"1024x1024": "1:1",
# Landscape formats
"1536x1024": "3:2", # 1.5:1 ratio, closest to 3:2
"1792x1024": "16:9", # 1.75:1 ratio, closest to 16:9
# Portrait formats
"1024x1536": "2:3", # 0.67:1 ratio, closest to 2:3
"1024x1792": "9:16", # 0.57:1 ratio, closest to 9:16
# Default
"auto": "1:1",
mapped_params: Final = {
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")
}
return size_to_aspect_ratio.get(size, "1:1")
def _map_quality_to_image_size(self, quality: str) -> str | None:
"""
Map OpenAI quality to OpenRouter image_size format.
OpenAI quality values:
- auto (default) - automatically select best quality
- high, medium, low - for GPT image models
- hd, standard - for dall-e-3
OpenRouter image_size values (Gemini only):
- 1K → Standard resolution (default)
- 2K → Higher resolution
- 4K → Highest resolution
"""
quality_to_image_size: Final = {
# OpenAI quality mappings
"low": "1K",
"standard": "1K",
"medium": "2K",
"high": "4K",
"hd": "4K",
# Auto defaults to standard
"auto": "1K",
}
return quality_to_image_size.get(quality)
return {**optional_params, **mapped_params}
def _set_usage_and_cost(
self,
@ -244,19 +146,10 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
litellm_params: dict,
stream: bool | None = None,
) -> str:
"""
Get the complete URL for OpenRouter image generation.
OpenRouter uses chat completions endpoint for image generation.
Default: https://openrouter.ai/api/v1/chat/completions
"""
if api_base:
if not api_base.endswith("/chat/completions"):
api_base = api_base.rstrip("/")
return f"{api_base}/chat/completions"
return api_base
return "https://openrouter.ai/api/v1/chat/completions"
base_url: Final = (api_base or OPENROUTER_API_BASE).rstrip("/")
if base_url.endswith(IMAGES_PATH):
return base_url
return base_url.removesuffix(LEGACY_CHAT_COMPLETIONS_SUFFIX) + IMAGES_PATH
def validate_environment(
self,
@ -285,30 +178,21 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
headers: dict,
) -> dict:
"""
Transform image generation request to OpenRouter chat completion format.
Args:
model: The model name
prompt: The image generation prompt
optional_params: Optional parameters (including image_config)
litellm_params: LiteLLM parameters
headers: Request headers
Returns:
dict: Request body in chat completion format with image_config
image_config is the request shape of the older chat-based path. Its fields map onto the
/images names so existing configs keep working, and explicit top-level values win
"""
request_body: Final = {
legacy_image_config: Final = optional_params.get("image_config") or {}
return {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"prompt": prompt,
**{
LEGACY_IMAGE_CONFIG_FIELDS[key]: value
for key, value in legacy_image_config.items()
if key in LEGACY_IMAGE_CONFIG_FIELDS
},
**{key: value for key, value in optional_params.items() if key not in NON_BODY_PARAMS},
}
# These will be passed through to OpenRouter
for key, value in optional_params.items():
if key not in ["model", "messages", "modalities"]:
request_body[key] = value
return request_body
def transform_image_generation_response(
self,
model: str,
@ -322,83 +206,21 @@ class OpenRouterImageGenerationConfig(BaseImageGenerationConfig):
api_key: str | None = None,
json_mode: bool | None = None,
) -> ImageResponse:
"""
Transform OpenRouter chat completion response to ImageResponse format.
Extracts images from the message content and maps usage/cost information.
Args:
model: The model name
raw_response: Raw HTTP response from OpenRouter
model_response: ImageResponse object to populate
logging_obj: Logging object
request_data: Original request data
optional_params: Optional parameters
litellm_params: LiteLLM parameters
encoding: Encoding
api_key: API key
json_mode: JSON mode flag
Returns:
ImageResponse: Populated image response
"""
try:
response_json: Final = raw_response.json()
except Exception as e:
except ValueError as e:
raise OpenRouterException(
message=f"Error parsing OpenRouter response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
) from e
if not model_response.data:
model_response.data = []
try:
choices: Final = response_json.get("choices", [])
for choice in choices:
message = choice.get("message", {})
images = message.get("images", [])
for image_data in images:
image_url_obj = image_data.get("image_url", {})
image_url = image_url_obj.get("url")
if image_url:
if image_url.startswith("data:"):
# Extract base64 data
# Format: data:image/png;base64,<base64_data>
parts = image_url.split(",", 1)
b64_data = parts[1] if len(parts) > 1 else None
model_response.data.append(
ImageObject(
b64_json=b64_data,
url=None,
revised_prompt=None,
)
)
else:
model_response.data.append(
ImageObject(
b64_json=None,
url=image_url,
revised_prompt=None,
)
)
# Extract and set usage and cost information
self._set_usage_and_cost(model_response, response_json, model)
return model_response
except Exception as e:
raise OpenRouterException(
message=f"Error transforming OpenRouter image generation response: {e}",
status_code=500,
headers={},
)
image_response: Final = ImageResponse(
created=response_json.get("created"),
data=[ImageObject(b64_json=item.get("b64_json")) for item in response_json.get("data") or []],
)
self._set_usage_and_cost(image_response, response_json, model)
return image_response
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
"""Get the appropriate error class for OpenRouter errors."""

View file

@ -2009,7 +2009,7 @@
"messages": true,
"responses": true,
"embeddings": true,
"image_generations": false,
"image_generations": true,
"audio_transcriptions": false,
"audio_speech": false,
"moderations": false,

View file

@ -1,582 +1,359 @@
import json
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.openrouter.common_utils import OpenRouterException
from litellm.llms.openrouter.image_generation.transformation import (
OpenRouterImageGenerationConfig,
)
from litellm.llms.openrouter.common_utils import OpenRouterException
from litellm.types.utils import ImageResponse
from litellm.types.utils import ImageResponse, ImageUsage, ImageUsageInputTokensDetails
CONFIG: Final = OpenRouterImageGenerationConfig()
IMAGE_ONLY_MODEL: Final = "openai/gpt-image-1-mini"
HYBRID_MODEL: Final = "google/gemini-2.5-flash-image"
PROMPT: Final = "a small red apple on a white table, simple flat illustration"
IMAGES_URL: Final = "https://openrouter.ai/api/v1/images"
# usage object returned by a real POST https://openrouter.ai/api/v1/images call for openai/gpt-image-1-mini
# (quality low, 1024x1024) on 2026-10-03
OPENROUTER_IMAGES_USAGE: Final = {
"prompt_tokens": 18,
"completion_tokens": 272,
"total_tokens": 290,
"cost": 0.002212,
"is_byok": False,
"prompt_tokens_details": {"cached_tokens": 0},
"cost_details": {
"upstream_inference_cost": 0.002212,
"upstream_inference_prompt_cost": 3.6e-05,
"upstream_inference_completions_cost": 0.002176,
},
"completion_tokens_details": {"reasoning_tokens": 0, "image_tokens": 272},
}
class TestOpenRouterImageGenerationTransformation:
def setup_method(self):
"""Set up test fixtures before each test method."""
self.config = OpenRouterImageGenerationConfig()
self.model = "google/gemini-2.5-flash-image"
self.logging_obj = MagicMock()
def _images_response(*b64_images: str, created: int = 1790994427) -> dict[str, object]:
return {
"created": created,
"data": [{"b64_json": image, "media_type": "image/png"} for image in b64_images],
"usage": OPENROUTER_IMAGES_USAGE,
}
def test_get_supported_openai_params(self):
"""Test that get_supported_openai_params returns correct parameters."""
supported_params = self.config.get_supported_openai_params(self.model)
assert "size" in supported_params
assert "quality" in supported_params
assert "n" in supported_params
assert len(supported_params) == 3
def _transform_response(raw_response: httpx.Response) -> ImageResponse:
return CONFIG.transform_image_generation_response(
model=IMAGE_ONLY_MODEL,
raw_response=raw_response,
model_response=ImageResponse(),
logging_obj=MagicMock(),
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
def test_map_size_to_aspect_ratio_square(self):
"""Test mapping square sizes to aspect ratio."""
assert self.config._map_size_to_aspect_ratio("256x256") == "1:1"
assert self.config._map_size_to_aspect_ratio("512x512") == "1:1"
assert self.config._map_size_to_aspect_ratio("1024x1024") == "1:1"
def test_map_size_to_aspect_ratio_landscape(self):
"""Test mapping landscape sizes to aspect ratio."""
assert self.config._map_size_to_aspect_ratio("1536x1024") == "3:2"
assert self.config._map_size_to_aspect_ratio("1792x1024") == "16:9"
class RequestRecorder:
"""httpx.MockTransport handler that keeps every request it was called with"""
def test_map_size_to_aspect_ratio_portrait(self):
"""Test mapping portrait sizes to aspect ratio."""
assert self.config._map_size_to_aspect_ratio("1024x1536") == "2:3"
assert self.config._map_size_to_aspect_ratio("1024x1792") == "9:16"
def __init__(self, response_payload: object, status_code: int = 200) -> None:
self.response_payload = response_payload
self.status_code = status_code
self.requests: list[httpx.Request] = []
def test_map_size_to_aspect_ratio_auto(self):
"""Test mapping auto size to default aspect ratio."""
assert self.config._map_size_to_aspect_ratio("auto") == "1:1"
def __call__(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return httpx.Response(status_code=self.status_code, json=self.response_payload)
def test_map_size_to_aspect_ratio_unknown(self):
"""Test mapping unknown size defaults to 1:1."""
assert self.config._map_size_to_aspect_ratio("999x999") == "1:1"
def test_map_quality_to_image_size_low(self):
"""Test mapping low quality values to 1K."""
assert self.config._map_quality_to_image_size("low") == "1K"
assert self.config._map_quality_to_image_size("standard") == "1K"
assert self.config._map_quality_to_image_size("auto") == "1K"
def _client(recorder: RequestRecorder) -> HTTPHandler:
return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(recorder)))
def test_map_quality_to_image_size_medium(self):
"""Test mapping medium quality to 2K."""
assert self.config._map_quality_to_image_size("medium") == "2K"
def test_map_quality_to_image_size_high(self):
"""Test mapping high quality values to 4K."""
assert self.config._map_quality_to_image_size("high") == "4K"
assert self.config._map_quality_to_image_size("hd") == "4K"
def test_get_supported_openai_params():
assert CONFIG.get_supported_openai_params(IMAGE_ONLY_MODEL) == ["size", "quality", "n"]
def test_map_quality_to_image_size_unknown(self):
"""Test mapping unknown quality returns None."""
assert self.config._map_quality_to_image_size("unknown") is None
def test_map_openai_params_size_only(self):
"""Test that map_openai_params correctly maps size parameter."""
non_default_params = {"size": "1024x1024"}
optional_params = {}
@pytest.mark.parametrize(
("api_base", "expected_url"),
[
(None, IMAGES_URL),
("https://openrouter.ai/api/v1", IMAGES_URL),
("https://openrouter.ai/api/v1/", IMAGES_URL),
("https://openrouter.ai/api/v1/chat/completions", IMAGES_URL),
("https://gateway.example.com/openrouter/v1", "https://gateway.example.com/openrouter/v1/images"),
("https://gateway.example.com/api/v1/images", "https://gateway.example.com/api/v1/images"),
],
)
def test_get_complete_url_points_at_the_images_endpoint(api_base: str | None, expected_url: str):
url = CONFIG.get_complete_url(
api_base=api_base,
api_key="sk-test",
model=IMAGE_ONLY_MODEL,
optional_params={},
litellm_params={},
)
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
assert url == expected_url
@pytest.mark.parametrize(
("non_default_params", "expected_params"),
[
({"size": "1536x1024"}, {"size": "1536x1024"}),
({"size": "auto"}, {}),
({"quality": "low"}, {"quality": "low"}),
({"quality": "medium"}, {"quality": "medium"}),
({"quality": "high"}, {"quality": "high"}),
({"quality": "auto"}, {"quality": "auto"}),
({"quality": "standard"}, {"quality": "low"}),
({"quality": "hd"}, {"quality": "high"}),
({"n": 2}, {"n": 2}),
],
)
def test_map_openai_params_sends_size_quality_and_n_as_images_fields(
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=IMAGE_ONLY_MODEL,
drop_params=False,
)
assert mapped == expected_params
@pytest.mark.parametrize(
("drop_params", "expected_params"),
[
(False, {"size": "1024x1024", "unsupported_param": "value"}),
(True, {"size": "1024x1024"}),
],
)
def test_map_openai_params_unsupported_param_follows_drop_params(drop_params: bool, expected_params: dict[str, object]):
mapped = CONFIG.map_openai_params(
non_default_params={"size": "1024x1024", "unsupported_param": "value"},
optional_params={},
model=IMAGE_ONLY_MODEL,
drop_params=drop_params,
)
assert mapped == expected_params
def test_map_openai_params_keeps_params_already_in_optional_params():
mapped = CONFIG.map_openai_params(
non_default_params={"n": 1},
optional_params={"resolution": "2K"},
model=HYBRID_MODEL,
drop_params=False,
)
assert mapped == {"resolution": "2K", "n": 1}
@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(
headers={},
model=HYBRID_MODEL,
messages=[],
optional_params={},
litellm_params={},
api_key="test_api_key",
)
assert result["Authorization"] == "Bearer test_api_key"
mock_get_secret.assert_not_called()
@patch("litellm.llms.openrouter.image_generation.transformation.get_secret_str")
def test_validate_environment_with_secret_key(mock_get_secret: MagicMock):
mock_get_secret.return_value = "secret_api_key"
result = CONFIG.validate_environment(
headers={},
model=HYBRID_MODEL,
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer secret_api_key"
mock_get_secret.assert_called_once_with("OPENROUTER_API_KEY")
def test_transform_request_body_holds_only_images_fields():
body = CONFIG.transform_image_generation_request(
model=IMAGE_ONLY_MODEL,
prompt=PROMPT,
optional_params={
"size": "1024x1024",
"quality": "low",
"n": 1,
"modalities": ["image", "text"],
"stream": True,
"extra_headers": {"Authorization": "Bearer sk-test"},
},
litellm_params={},
headers={},
)
assert body == {"model": IMAGE_ONLY_MODEL, "prompt": PROMPT, "size": "1024x1024", "quality": "low", "n": 1}
@pytest.mark.parametrize(
("optional_params", "expected_fields"),
[
(
{"image_config": {"aspect_ratio": "16:9", "image_size": "4K"}},
{"aspect_ratio": "16:9", "resolution": "4K"},
),
(
{"image_config": {"aspect_ratio": "16:9", "image_size": "4K"}, "aspect_ratio": "1:1", "resolution": "2K"},
{"aspect_ratio": "1:1", "resolution": "2K"},
),
],
)
def test_transform_request_maps_legacy_image_config_and_explicit_fields_win(
optional_params: dict[str, object], expected_fields: dict[str, object]
):
body = CONFIG.transform_image_generation_request(
model=HYBRID_MODEL,
prompt=PROMPT,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert body == {"model": HYBRID_MODEL, "prompt": PROMPT, **expected_fields}
def test_transform_response_returns_every_image_in_order():
response = _transform_response(httpx.Response(200, json=_images_response("aW1hZ2Ux", "aW1hZ2Uy")))
assert [(image.b64_json, image.url) for image in response.data] == [("aW1hZ2Ux", None), ("aW1hZ2Uy", None)]
def test_transform_response_copies_the_openrouter_created_timestamp():
response = _transform_response(httpx.Response(200, json=_images_response("aW1hZ2Ux", created=1790994427)))
assert response.created == 1790994427
def test_transform_response_with_zero_created_keeps_a_real_timestamp():
response = _transform_response(httpx.Response(200, json=_images_response("aW1hZ2Ux", created=0)))
assert response.created > 0
def test_transform_response_reports_openrouter_usage_and_cost():
response = _transform_response(httpx.Response(200, json=_images_response("aW1hZ2Ux")))
assert response.usage == ImageUsage(
input_tokens=18,
input_tokens_details=ImageUsageInputTokensDetails(image_tokens=0, text_tokens=18),
output_tokens=272,
total_tokens=290,
)
assert response._hidden_params["additional_headers"] == {
"llm_provider-x-litellm-response-cost": OPENROUTER_IMAGES_USAGE["cost"]
}
assert response._hidden_params["response_cost_details"] == OPENROUTER_IMAGES_USAGE["cost_details"]
assert response._hidden_params["model"] == IMAGE_ONLY_MODEL
def test_transform_response_with_non_json_body_raises_openrouter_exception():
with pytest.raises(OpenRouterException, match="Error parsing OpenRouter response") as exc_info:
_transform_response(httpx.Response(502, content=b"<html>bad gateway</html>"))
assert exc_info.value.status_code == 502
assert isinstance(exc_info.value.__cause__, json.JSONDecodeError)
def test_get_error_class():
error = CONFIG.get_error_class(
error_message="Test error",
status_code=400,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, OpenRouterException)
assert "Test error" in str(error)
assert error.status_code == 400
def test_image_only_model_is_sent_to_the_images_endpoint_and_charged_the_openrouter_cost():
recorder = RequestRecorder(_images_response("aW1hZ2Ux"))
response = litellm.image_generation(
model=f"openrouter/{IMAGE_ONLY_MODEL}",
prompt=PROMPT,
size="1024x1024",
quality="low",
n=1,
api_key="sk-test",
client=_client(recorder),
)
(request,) = recorder.requests
assert str(request.url) == IMAGES_URL
assert request.headers["Authorization"] == "Bearer sk-test"
assert json.loads(request.content) == {
"model": IMAGE_ONLY_MODEL,
"prompt": PROMPT,
"size": "1024x1024",
"quality": "low",
"n": 1,
}
assert [image.b64_json for image in response.data] == ["aW1hZ2Ux"]
assert response._hidden_params["response_cost"] == OPENROUTER_IMAGES_USAGE["cost"]
def test_hybrid_image_text_model_uses_the_same_images_endpoint():
recorder = RequestRecorder(_images_response("aW1hZ2Ux"))
litellm.image_generation(
model=f"openrouter/{HYBRID_MODEL}",
prompt=PROMPT,
api_key="sk-test",
client=_client(recorder),
)
(request,) = recorder.requests
assert str(request.url) == IMAGES_URL
assert json.loads(request.content) == {"model": HYBRID_MODEL, "prompt": PROMPT}
def test_legacy_chat_completions_api_base_still_reaches_the_images_endpoint():
recorder = RequestRecorder(_images_response("aW1hZ2Ux"))
litellm.image_generation(
model=f"openrouter/{IMAGE_ONLY_MODEL}",
prompt=PROMPT,
api_key="sk-test",
api_base="https://openrouter.ai/api/v1/chat/completions",
client=_client(recorder),
)
(request,) = recorder.requests
assert str(request.url) == IMAGES_URL
def test_openrouter_error_response_surfaces_as_not_found_error():
recorder = RequestRecorder({"error": {"code": 404, "message": "Resource not found"}}, status_code=404)
with pytest.raises(litellm.NotFoundError, match="Resource not found"):
litellm.image_generation(
model=f"openrouter/{IMAGE_ONLY_MODEL}",
prompt=PROMPT,
api_key="sk-test",
client=_client(recorder),
)
assert "image_config" in result
assert result["image_config"]["aspect_ratio"] == "1:1"
def test_map_openai_params_quality_only(self):
"""Test that map_openai_params correctly maps quality parameter."""
non_default_params = {"quality": "high"}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert "image_config" in result
assert result["image_config"]["image_size"] == "4K"
def test_map_openai_params_size_and_quality(self):
"""Test that map_openai_params correctly maps both size and quality."""
non_default_params = {"size": "1792x1024", "quality": "hd"}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert "image_config" in result
assert result["image_config"]["aspect_ratio"] == "16:9"
assert result["image_config"]["image_size"] == "4K"
def test_map_openai_params_with_n_parameter(self):
"""Test that map_openai_params correctly passes through n parameter."""
non_default_params = {"size": "1024x1024", "n": 2}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert "image_config" in result
assert result["image_config"]["aspect_ratio"] == "1:1"
assert result["n"] == 2
def test_map_openai_params_unsupported_param_drop_false(self):
"""Test that unsupported params are passed through when drop_params=False."""
non_default_params = {"size": "1024x1024", "unsupported_param": "value"}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=False,
)
assert "image_config" in result
assert result["unsupported_param"] == "value"
def test_map_openai_params_unsupported_param_drop_true(self):
"""Test that unsupported params are dropped when drop_params=True."""
non_default_params = {"size": "1024x1024", "unsupported_param": "value"}
optional_params = {}
result = self.config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=self.model,
drop_params=True,
)
assert "image_config" in result
assert "unsupported_param" not in result
def test_get_complete_url_default(self):
"""Test that get_complete_url returns default OpenRouter URL."""
result = self.config.get_complete_url(
api_base=None,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == "https://openrouter.ai/api/v1/chat/completions"
def test_get_complete_url_with_custom_base(self):
"""Test that get_complete_url uses custom api_base."""
custom_base = "https://custom.openrouter.ai/api/v1"
result = self.config.get_complete_url(
api_base=custom_base,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == f"{custom_base}/chat/completions"
def test_get_complete_url_with_base_already_complete(self):
"""Test that get_complete_url doesn't duplicate /chat/completions."""
custom_base = "https://custom.openrouter.ai/api/v1/chat/completions"
result = self.config.get_complete_url(
api_base=custom_base,
api_key="test_key",
model=self.model,
optional_params={},
litellm_params={},
)
assert result == custom_base
@patch("litellm.llms.openrouter.image_generation.transformation.get_secret_str")
def test_validate_environment_with_api_key(self, mock_get_secret):
"""Test that validate_environment correctly sets authorization header."""
headers = {}
api_key = "test_api_key"
result = self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=api_key,
)
assert result["Authorization"] == f"Bearer {api_key}"
mock_get_secret.assert_not_called()
@patch("litellm.llms.openrouter.image_generation.transformation.get_secret_str")
def test_validate_environment_with_secret_key(self, mock_get_secret):
"""Test that validate_environment uses secret API key when api_key is None."""
mock_get_secret.return_value = "secret_api_key"
headers = {}
result = self.config.validate_environment(
headers=headers,
model=self.model,
messages=[],
optional_params={},
litellm_params={},
api_key=None,
)
assert result["Authorization"] == "Bearer secret_api_key"
mock_get_secret.assert_called_once_with("OPENROUTER_API_KEY")
def test_transform_image_generation_request_basic(self):
"""Test that transform_image_generation_request creates correct request body."""
prompt = "A beautiful sunset over mountains"
optional_params = {}
result = self.config.transform_image_generation_request(
model=self.model,
prompt=prompt,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["model"] == self.model
assert result["messages"] == [{"role": "user", "content": prompt}]
assert "modalities" not in result # modalities should not be added by default
def test_transform_image_generation_request_with_image_config(self):
"""Test that transform_image_generation_request includes image_config."""
prompt = "A beautiful sunset"
optional_params = {
"image_config": {"aspect_ratio": "16:9", "image_size": "4K"},
"n": 2,
}
result = self.config.transform_image_generation_request(
model=self.model,
prompt=prompt,
optional_params=optional_params,
litellm_params={},
headers={},
)
assert result["model"] == self.model
assert result["messages"] == [{"role": "user", "content": prompt}]
assert result["image_config"]["aspect_ratio"] == "16:9"
assert result["image_config"]["image_size"] == "4K"
assert result["n"] == 2
def test_transform_image_generation_response_with_base64_images(self):
"""Test that transform_image_generation_response correctly extracts base64 images."""
response_data = {
"choices": [
{
"message": {
"content": "Here is your image!",
"role": "assistant",
"images": [
{
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANS"
},
"index": 0,
"type": "image_url",
}
],
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1300,
"total_tokens": 1310,
"completion_tokens_details": {"image_tokens": 1290},
"cost": 0.0387243,
},
"model": "google/gemini-2.5-flash-image",
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 1
assert result.data[0].b64_json == "iVBORw0KGgoAAAANS"
assert result.data[0].url is None
def test_transform_image_generation_response_with_url_images(self):
"""Test that transform_image_generation_response correctly extracts URL images."""
response_data = {
"choices": [
{
"message": {
"content": "Here is your image!",
"role": "assistant",
"images": [
{
"image_url": {"url": "https://example.com/image.png"},
"index": 0,
"type": "image_url",
}
],
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1300,
"total_tokens": 1310,
},
"model": "google/gemini-2.5-flash-image",
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 1
assert result.data[0].url == "https://example.com/image.png"
assert result.data[0].b64_json is None
def test_transform_image_generation_response_with_usage_and_cost(self):
"""Test that transform_image_generation_response correctly extracts usage and cost."""
response_data = {
"choices": [
{
"message": {
"content": "Here is your image!",
"role": "assistant",
"images": [
{
"image_url": {"url": "data:image/png;base64,abc123"},
"index": 0,
"type": "image_url",
}
],
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 1300,
"total_tokens": 1310,
"completion_tokens_details": {"image_tokens": 1290},
"cost": 0.0387243,
"cost_details": {"input_cost": 0.001, "output_cost": 0.037},
},
"model": "google/gemini-2.5-flash-image",
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
# Check usage
assert result.usage is not None
assert result.usage.input_tokens == 10
assert result.usage.output_tokens == 1290
assert result.usage.total_tokens == 1310
assert result.usage.input_tokens_details.text_tokens == 10
assert result.usage.input_tokens_details.image_tokens == 0
# Check cost
assert hasattr(result, "_hidden_params")
assert "additional_headers" in result._hidden_params
assert (
result._hidden_params["additional_headers"][
"llm_provider-x-litellm-response-cost"
]
== 0.0387243
)
# Check cost details
assert "response_cost_details" in result._hidden_params
assert result._hidden_params["response_cost_details"]["input_cost"] == 0.001
assert result._hidden_params["response_cost_details"]["output_cost"] == 0.037
# Check model
assert result._hidden_params["model"] == "google/gemini-2.5-flash-image"
def test_transform_image_generation_response_multiple_images(self):
"""Test that transform_image_generation_response handles multiple images."""
response_data = {
"choices": [
{
"message": {
"content": "Here are your images!",
"role": "assistant",
"images": [
{
"image_url": {
"url": "data:image/png;base64,image1data"
},
"index": 0,
"type": "image_url",
},
{
"image_url": {
"url": "data:image/png;base64,image2data"
},
"index": 1,
"type": "image_url",
},
],
}
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 2600,
"total_tokens": 2610,
},
"model": "google/gemini-2.5-flash-image",
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
result = self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert len(result.data) == 2
assert result.data[0].b64_json == "image1data"
assert result.data[1].b64_json == "image2data"
def test_transform_image_generation_response_json_error(self):
"""Test that transform_image_generation_response raises error on invalid JSON."""
mock_response = MagicMock()
mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0)
mock_response.status_code = 500
mock_response.headers = {}
model_response = ImageResponse(data=[])
with pytest.raises(OpenRouterException) as exc_info:
self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert "Error parsing OpenRouter response" in str(exc_info.value)
assert exc_info.value.status_code == 500
def test_transform_image_generation_response_transformation_error(self):
"""Test that transform_image_generation_response handles transformation errors."""
response_data = {
"choices": [
{
"message": {
"content": "Here is your image!",
"role": "assistant",
"images": "invalid_format", # Invalid format
}
}
]
}
mock_response = MagicMock()
mock_response.json.return_value = response_data
mock_response.status_code = 200
mock_response.headers = {}
model_response = ImageResponse(data=[])
with pytest.raises(OpenRouterException) as exc_info:
self.config.transform_image_generation_response(
model=self.model,
raw_response=mock_response,
model_response=model_response,
logging_obj=self.logging_obj,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert "Error transforming OpenRouter image generation response" in str(
exc_info.value
)
def test_get_error_class(self):
"""Test that get_error_class returns OpenRouterException."""
error = self.config.get_error_class(
error_message="Test error",
status_code=400,
headers={"Content-Type": "application/json"},
)
assert isinstance(error, OpenRouterException)
assert "Test error" in str(error)
assert error.status_code == 400