From 3775ba3eaa1f7423a8b338f9c85191c96af42bd5 Mon Sep 17 00:00:00 2001 From: Shivaang Date: Sat, 28 Feb 2026 11:22:54 -0500 Subject: [PATCH 1/4] feat(openrouter): add image edit support for OpenRouter models OpenRouter supports image editing through its chat completions endpoint for models like google/gemini-2.5-flash-image, but LiteLLM raised ValueError("image edit is not supported for openrouter") because OpenRouter was not registered as an image edit provider. Add OpenRouterImageEditConfig that routes image edit requests through the chat completions endpoint with the source image as a base64 data URL in the message content array. Fixes https://github.com/BerriAI/litellm/issues/22305 --- .../llms/openrouter/image_edit/__init__.py | 11 + .../openrouter/image_edit/transformation.py | 366 ++++++++++++ litellm/utils.py | 6 + .../llms/openrouter/image_edit/__init__.py | 0 ...st_openrouter_image_edit_transformation.py | 526 ++++++++++++++++++ 5 files changed, 909 insertions(+) create mode 100644 litellm/llms/openrouter/image_edit/__init__.py create mode 100644 litellm/llms/openrouter/image_edit/transformation.py create mode 100644 tests/test_litellm/llms/openrouter/image_edit/__init__.py create mode 100644 tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py diff --git a/litellm/llms/openrouter/image_edit/__init__.py b/litellm/llms/openrouter/image_edit/__init__.py new file mode 100644 index 0000000000..6edd133f27 --- /dev/null +++ b/litellm/llms/openrouter/image_edit/__init__.py @@ -0,0 +1,11 @@ +from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig + +from .transformation import OpenRouterImageEditConfig + +__all__ = [ + "OpenRouterImageEditConfig", +] + + +def get_openrouter_image_edit_config(model: str) -> BaseImageEditConfig: + return OpenRouterImageEditConfig() diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py new file mode 100644 index 0000000000..488aeb1e55 --- /dev/null +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -0,0 +1,366 @@ +""" +OpenRouter Image Edit Support + +OpenRouter provides image editing through chat completion endpoints. +The source image is sent as a base64 data URL in the message content, +and the response contains edited images in the message's images array. + +Request format: +{ + "model": "google/gemini-2.5-flash-image", + "messages": [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}, + {"type": "text", "text": "Edit this image by..."} + ] + }], + "modalities": ["image", "text"] +} + +Response format: +{ + "choices": [{ + "message": { + "content": "Here is the edited image.", + "role": "assistant", + "images": [{ + "image_url": {"url": "data:image/png;base64,..."}, + "type": "image_url" + }] + } + }], + "usage": { + "completion_tokens": 1299, + "prompt_tokens": 300, + "total_tokens": 1599, + "completion_tokens_details": {"image_tokens": 1290}, + "cost": 0.0387243 + } +} +""" + +import base64 +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.chat.transformation import BaseLLMException +from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig +from litellm.llms.openrouter.common_utils import OpenRouterException +from litellm.secret_managers.main import get_secret_str +from litellm.types.images.main import ImageEditOptionalRequestParams +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import FileTypes, ImageObject, ImageResponse, ImageUsage, ImageUsageInputTokensDetails + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class OpenRouterImageEditConfig(BaseImageEditConfig): + """ + Configuration for OpenRouter image editing via chat completions. + + OpenRouter uses the chat completions endpoint for image editing. + The source image is sent as a base64 data URL in the message content, + and the response contains edited images in the message's images array. + """ + + def get_supported_openai_params(self, model: str) -> list: + return ["size", "quality", "n"] + + def map_openai_params( + self, + image_edit_optional_params: ImageEditOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + supported_params = self.get_supported_openai_params(model) + mapped_params: Dict[str, Any] = {} + + for key, value in image_edit_optional_params.items(): + if key in supported_params: + if key == "size": + if "image_config" not in mapped_params: + mapped_params["image_config"] = {} + mapped_params["image_config"]["aspect_ratio"] = self._map_size_to_aspect_ratio(value) + elif key == "quality": + image_size = self._map_quality_to_image_size(value) + if image_size: + if "image_config" not in mapped_params: + mapped_params["image_config"] = {} + mapped_params["image_config"]["image_size"] = image_size + else: + mapped_params[key] = value + + return mapped_params + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + ) -> dict: + api_key = ( + api_key + or litellm.api_key + or get_secret_str("OPENROUTER_API_KEY") + ) + headers.update( + { + "Authorization": f"Bearer {api_key}", + } + ) + return headers + + def use_multipart_form_data(self) -> bool: + """OpenRouter uses JSON requests, not multipart/form-data.""" + return False + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + 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" + + def transform_image_edit_request( + self, + model: str, + prompt: Optional[str], + image: Optional[FileTypes], + image_edit_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[Dict, RequestFiles]: + content_parts: List[Dict[str, Any]] = [] + + # Add source image(s) as base64 data URLs + if image is not None: + images = image if isinstance(image, list) else [image] + for img in images: + if img is None: + continue + mime_type = ImageEditRequestUtils.get_image_content_type(img) + image_bytes = self._read_image_bytes(img) + b64_data = base64.b64encode(image_bytes).decode("utf-8") + content_parts.append( + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{b64_data}" + }, + } + ) + + # Add the text prompt + if prompt: + content_parts.append({"type": "text", "text": prompt}) + + request_body: Dict[str, Any] = { + "model": model, + "messages": [ + { + "role": "user", + "content": content_parts, + } + ], + "modalities": ["image", "text"], + } + + # Add mapped optional params (image_config, n, etc.) + for key, value in image_edit_optional_request_params.items(): + if key not in ("model", "messages", "modalities"): + request_body[key] = value + + empty_files = cast(RequestFiles, []) + return request_body, empty_files + + def transform_image_edit_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> ImageResponse: + try: + response_json = raw_response.json() + except Exception as e: + raise OpenRouterException( + message=f"Error parsing OpenRouter response: {str(e)}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + + model_response = ImageResponse() + model_response.data = [] + + try: + choices = 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 from data URL + 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, + ) + ) + + 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 edit response: {str(e)}", + status_code=500, + headers={}, + ) + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + return OpenRouterException( + message=error_message, + status_code=status_code, + headers=headers, + ) + + # Private helper methods + + def _map_size_to_aspect_ratio(self, size: str) -> str: + """ + Map OpenAI size format to OpenRouter aspect_ratio format. + + Uses the same mapping as image generation since OpenRouter + handles both through the same chat completions endpoint. + """ + size_to_aspect_ratio = { + "256x256": "1:1", + "512x512": "1:1", + "1024x1024": "1:1", + "1536x1024": "3:2", + "1792x1024": "16:9", + "1024x1536": "2:3", + "1024x1792": "9:16", + "auto": "1:1", + } + return size_to_aspect_ratio.get(size, "1:1") + + def _map_quality_to_image_size(self, quality: str) -> Optional[str]: + """ + Map OpenAI quality to OpenRouter image_size format. + + Uses the same mapping as image generation since OpenRouter + handles both through the same chat completions endpoint. + """ + quality_to_image_size = { + "low": "1K", + "standard": "1K", + "medium": "2K", + "high": "4K", + "hd": "4K", + "auto": "1K", + } + return quality_to_image_size.get(quality) + + def _set_usage_and_cost( + self, + model_response: ImageResponse, + response_json: dict, + model: str, + ) -> None: + """Extract and set usage and cost information from OpenRouter response.""" + usage_data = response_json.get("usage", {}) + if usage_data: + prompt_tokens = usage_data.get("prompt_tokens", 0) + total_tokens = usage_data.get("total_tokens", 0) + + completion_tokens_details = usage_data.get("completion_tokens_details", {}) + image_tokens = completion_tokens_details.get("image_tokens", 0) + + # For image edit, input may include image tokens + input_image_tokens = 0 + prompt_tokens_details = usage_data.get("prompt_tokens_details", {}) + if prompt_tokens_details: + input_image_tokens = prompt_tokens_details.get("image_tokens", 0) + + model_response.usage = ImageUsage( + input_tokens=prompt_tokens, + input_tokens_details=ImageUsageInputTokensDetails( + image_tokens=input_image_tokens, + text_tokens=prompt_tokens - input_image_tokens, + ), + output_tokens=image_tokens, + total_tokens=total_tokens, + ) + + cost = usage_data.get("cost") + if cost is not None: + if not hasattr(model_response, "_hidden_params"): + model_response._hidden_params = {} + if "additional_headers" not in model_response._hidden_params: + model_response._hidden_params["additional_headers"] = {} + model_response._hidden_params["additional_headers"][ + "llm_provider-x-litellm-response-cost" + ] = float(cost) + + cost_details = usage_data.get("cost_details", {}) + if cost_details: + if "response_cost_details" not in model_response._hidden_params: + model_response._hidden_params["response_cost_details"] = {} + model_response._hidden_params["response_cost_details"].update(cost_details) + + model_response._hidden_params["model"] = response_json.get("model", model) + + def _read_image_bytes(self, image: FileTypes) -> bytes: + """Read raw bytes from various image input types.""" + if isinstance(image, bytes): + return image + if isinstance(image, BytesIO): + current_pos = image.tell() + image.seek(0) + data = image.read() + image.seek(current_pos) + return data + if isinstance(image, BufferedReader): + current_pos = image.tell() + image.seek(0) + data = image.read() + image.seek(current_pos) + return data + raise ValueError("Unsupported image type for OpenRouter image edit.") diff --git a/litellm/utils.py b/litellm/utils.py index 81e772b176..e4e8e1135a 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8772,6 +8772,12 @@ class ProviderConfigManager: ) return BedrockStabilityImageEditConfig() + elif LlmProviders.OPENROUTER == provider: + from litellm.llms.openrouter.image_edit import ( + get_openrouter_image_edit_config, + ) + + return get_openrouter_image_edit_config(model) return None @staticmethod diff --git a/tests/test_litellm/llms/openrouter/image_edit/__init__.py b/tests/test_litellm/llms/openrouter/image_edit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py new file mode 100644 index 0000000000..787acc3402 --- /dev/null +++ b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py @@ -0,0 +1,526 @@ +import base64 +import json +import os +import sys +from io import BytesIO +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.openrouter.common_utils import OpenRouterException +from litellm.llms.openrouter.image_edit.transformation import ( + OpenRouterImageEditConfig, +) +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import ImageResponse + + +class TestOpenRouterImageEditTransformation: + def setup_method(self): + """Set up test fixtures before each test method.""" + self.config = OpenRouterImageEditConfig() + self.model = "google/gemini-2.5-flash-image" + self.logging_obj = MagicMock() + self.sample_image_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + 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 test_use_multipart_form_data_returns_false(self): + """Test that OpenRouter uses JSON, not multipart/form-data.""" + assert self.config.use_multipart_form_data() is False + + # Parameter mapping tests + + def test_map_openai_params_size(self): + """Test that size is mapped to image_config.aspect_ratio.""" + result = self.config.map_openai_params( + image_edit_optional_params={"size": "1024x1024"}, + model=self.model, + drop_params=False, + ) + + assert "image_config" in result + assert result["image_config"]["aspect_ratio"] == "1:1" + + def test_map_openai_params_quality(self): + """Test that quality is mapped to image_config.image_size.""" + result = self.config.map_openai_params( + image_edit_optional_params={"quality": "high"}, + 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 both size and quality are mapped correctly.""" + result = self.config.map_openai_params( + image_edit_optional_params={"size": "1792x1024", "quality": "hd"}, + model=self.model, + drop_params=False, + ) + + assert result["image_config"]["aspect_ratio"] == "16:9" + assert result["image_config"]["image_size"] == "4K" + + def test_map_openai_params_n_passthrough(self): + """Test that n parameter is passed through directly.""" + result = self.config.map_openai_params( + image_edit_optional_params={"n": 2}, + model=self.model, + drop_params=False, + ) + + assert result["n"] == 2 + + def test_map_openai_params_unknown_quality_ignored(self): + """Test that unknown quality values produce no image_size mapping.""" + result = self.config.map_openai_params( + image_edit_optional_params={"quality": "unknown_value"}, + model=self.model, + drop_params=False, + ) + + assert "image_config" not in result + + # Size-to-aspect-ratio mapping tests + + def test_map_size_to_aspect_ratio_square(self): + """Test mapping square sizes to 1:1 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 correct aspect ratios.""" + assert self.config._map_size_to_aspect_ratio("1536x1024") == "3:2" + assert self.config._map_size_to_aspect_ratio("1792x1024") == "16:9" + + def test_map_size_to_aspect_ratio_portrait(self): + """Test mapping portrait sizes to correct aspect ratios.""" + assert self.config._map_size_to_aspect_ratio("1024x1536") == "2:3" + assert self.config._map_size_to_aspect_ratio("1024x1792") == "9:16" + + def test_map_size_to_aspect_ratio_unknown_defaults_to_1_1(self): + """Test that unknown size defaults to 1:1.""" + assert self.config._map_size_to_aspect_ratio("999x999") == "1:1" + + # Quality-to-image-size mapping tests + + def test_map_quality_to_image_size(self): + """Test quality to image size mappings.""" + 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" + assert self.config._map_quality_to_image_size("medium") == "2K" + assert self.config._map_quality_to_image_size("high") == "4K" + assert self.config._map_quality_to_image_size("hd") == "4K" + + def test_map_quality_to_image_size_unknown_returns_none(self): + """Test that unknown quality returns None.""" + assert self.config._map_quality_to_image_size("unknown") is None + + # URL tests + + def test_get_complete_url_default(self): + """Test that default URL is OpenRouter chat completions endpoint.""" + result = self.config.get_complete_url( + model=self.model, + api_base=None, + litellm_params={}, + ) + + assert result == "https://openrouter.ai/api/v1/chat/completions" + + def test_get_complete_url_with_custom_base(self): + """Test that custom api_base gets /chat/completions appended.""" + result = self.config.get_complete_url( + model=self.model, + api_base="https://custom.openrouter.ai/api/v1", + litellm_params={}, + ) + + assert result == "https://custom.openrouter.ai/api/v1/chat/completions" + + def test_get_complete_url_with_complete_base(self): + """Test that api_base already ending in /chat/completions is not duplicated.""" + url = "https://custom.openrouter.ai/api/v1/chat/completions" + result = self.config.get_complete_url( + model=self.model, + api_base=url, + litellm_params={}, + ) + + assert result == url + + # Validate environment tests + + @patch("litellm.llms.openrouter.image_edit.transformation.get_secret_str") + def test_validate_environment_with_api_key(self, mock_get_secret): + """Test that validate_environment sets authorization header with provided key.""" + headers = {} + result = self.config.validate_environment( + headers=headers, + model=self.model, + api_key="test_api_key", + ) + + assert result["Authorization"] == "Bearer test_api_key" + mock_get_secret.assert_not_called() + + @patch("litellm.llms.openrouter.image_edit.transformation.get_secret_str") + def test_validate_environment_with_secret_key(self, mock_get_secret): + """Test that validate_environment falls back to secret key.""" + mock_get_secret.return_value = "secret_api_key" + headers = {} + result = self.config.validate_environment( + headers=headers, + model=self.model, + api_key=None, + ) + + assert result["Authorization"] == "Bearer secret_api_key" + + # Request transformation tests + + def test_transform_image_edit_request_basic(self): + """Test basic request transformation with image and prompt.""" + data, files = self.config.transform_image_edit_request( + model=self.model, + prompt="Add a sunset to this image", + image=self.sample_image_bytes, + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data["model"] == self.model + assert data["modalities"] == ["image", "text"] + assert len(data["messages"]) == 1 + assert data["messages"][0]["role"] == "user" + + content = data["messages"][0]["content"] + assert len(content) == 2 + + # First content part should be the image + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/png;base64,") + + # Second content part should be the text prompt + assert content[1]["type"] == "text" + assert content[1]["text"] == "Add a sunset to this image" + + # Files should be empty (JSON mode) + assert list(files) == [] + + def test_transform_image_edit_request_with_bytesio(self): + """Test request transformation with BytesIO image input.""" + image = BytesIO(self.sample_image_bytes) + data, files = self.config.transform_image_edit_request( + model=self.model, + prompt="Edit this", + image=image, + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + content = data["messages"][0]["content"] + assert content[0]["type"] == "image_url" + assert content[0]["image_url"]["url"].startswith("data:image/png;base64,") + + def test_transform_image_edit_request_with_multiple_images(self): + """Test request transformation with a list of images.""" + images = [self.sample_image_bytes, self.sample_image_bytes] + data, files = self.config.transform_image_edit_request( + model=self.model, + prompt="Combine these images", + image=images, + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + content = data["messages"][0]["content"] + # Two image parts + one text part + assert len(content) == 3 + assert content[0]["type"] == "image_url" + assert content[1]["type"] == "image_url" + assert content[2]["type"] == "text" + + def test_transform_image_edit_request_with_optional_params(self): + """Test that optional params are included in request body.""" + data, files = self.config.transform_image_edit_request( + model=self.model, + prompt="Edit this", + image=self.sample_image_bytes, + image_edit_optional_request_params={ + "image_config": {"aspect_ratio": "16:9"}, + "n": 2, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data["image_config"]["aspect_ratio"] == "16:9" + assert data["n"] == 2 + + def test_transform_image_edit_request_base64_encoding(self): + """Test that image bytes are correctly base64-encoded in the request.""" + raw_bytes = b"test_image_data" + expected_b64 = base64.b64encode(raw_bytes).decode("utf-8") + + data, _ = self.config.transform_image_edit_request( + model=self.model, + prompt="Edit", + image=raw_bytes, + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + image_url = data["messages"][0]["content"][0]["image_url"]["url"] + # Extract the base64 part after the data URL prefix + b64_part = image_url.split(",", 1)[1] + assert b64_part == expected_b64 + + def test_transform_image_edit_request_no_prompt(self): + """Test request transformation with no prompt (image-only).""" + data, _ = self.config.transform_image_edit_request( + model=self.model, + prompt=None, + image=self.sample_image_bytes, + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + content = data["messages"][0]["content"] + # Only image, no text part + assert len(content) == 1 + assert content[0]["type"] == "image_url" + + # Response transformation tests + + def test_transform_image_edit_response_with_base64(self): + """Test response transformation with base64 image data.""" + response_data = { + "choices": [{ + "message": { + "content": "Here is the edited image.", + "role": "assistant", + "images": [{ + "image_url": {"url": "data:image/png;base64,iVBORw0KGgoAAAANS"}, + "type": "image_url" + }] + } + }], + "usage": { + "prompt_tokens": 300, + "completion_tokens": 1299, + "total_tokens": 1599, + "completion_tokens_details": {"image_tokens": 1290}, + "cost": 0.05 + }, + "model": self.model + } + + mock_response = MagicMock() + mock_response.json.return_value = response_data + mock_response.status_code = 200 + mock_response.headers = {} + + result = self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj, + ) + + assert len(result.data) == 1 + assert result.data[0].b64_json == "iVBORw0KGgoAAAANS" + assert result.data[0].url is None + + def test_transform_image_edit_response_with_url(self): + """Test response transformation with URL image data.""" + response_data = { + "choices": [{ + "message": { + "content": "Edited.", + "role": "assistant", + "images": [{ + "image_url": {"url": "https://example.com/edited.png"}, + "type": "image_url" + }] + } + }], + "usage": {"prompt_tokens": 10, "total_tokens": 1310}, + "model": self.model + } + + mock_response = MagicMock() + mock_response.json.return_value = response_data + mock_response.status_code = 200 + mock_response.headers = {} + + result = self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj, + ) + + assert len(result.data) == 1 + assert result.data[0].url == "https://example.com/edited.png" + assert result.data[0].b64_json is None + + def test_transform_image_edit_response_usage_and_cost(self): + """Test that usage and cost are correctly extracted from response.""" + response_data = { + "choices": [{ + "message": { + "content": "Edited.", + "role": "assistant", + "images": [{ + "image_url": {"url": "data:image/png;base64,abc123"}, + "type": "image_url" + }] + } + }], + "usage": { + "prompt_tokens": 300, + "completion_tokens": 1299, + "total_tokens": 1599, + "completion_tokens_details": {"image_tokens": 1290}, + "prompt_tokens_details": {"image_tokens": 258}, + "cost": 0.05, + "cost_details": {"input_cost": 0.01, "output_cost": 0.04} + }, + "model": self.model + } + + mock_response = MagicMock() + mock_response.json.return_value = response_data + mock_response.status_code = 200 + mock_response.headers = {} + + result = self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj, + ) + + # Check usage + assert result.usage is not None + assert result.usage.input_tokens == 300 + assert result.usage.output_tokens == 1290 + assert result.usage.total_tokens == 1599 + assert result.usage.input_tokens_details.image_tokens == 258 + assert result.usage.input_tokens_details.text_tokens == 42 + + # Check cost + assert result._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] == 0.05 + + # Check cost details + assert result._hidden_params["response_cost_details"]["input_cost"] == 0.01 + assert result._hidden_params["response_cost_details"]["output_cost"] == 0.04 + + # Check model + assert result._hidden_params["model"] == self.model + + def test_transform_image_edit_response_multiple_images(self): + """Test response transformation with multiple output images.""" + response_data = { + "choices": [{ + "message": { + "content": "Here are your edits.", + "role": "assistant", + "images": [ + { + "image_url": {"url": "data:image/png;base64,img1data"}, + "type": "image_url" + }, + { + "image_url": {"url": "data:image/png;base64,img2data"}, + "type": "image_url" + } + ] + } + }], + "usage": {"prompt_tokens": 300, "total_tokens": 2600}, + "model": self.model + } + + mock_response = MagicMock() + mock_response.json.return_value = response_data + mock_response.status_code = 200 + mock_response.headers = {} + + result = self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj, + ) + + assert len(result.data) == 2 + assert result.data[0].b64_json == "img1data" + assert result.data[1].b64_json == "img2data" + + def test_transform_image_edit_response_json_error(self): + """Test that invalid JSON response raises OpenRouterException.""" + mock_response = MagicMock() + mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0) + mock_response.status_code = 500 + mock_response.headers = {} + + with pytest.raises(OpenRouterException) as exc_info: + self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj, + ) + + assert "Error parsing OpenRouter response" in str(exc_info.value) + assert exc_info.value.status_code == 500 + + 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 error.status_code == 400 + + # Read image bytes tests + + def test_read_image_bytes_from_bytes(self): + """Test reading bytes directly.""" + result = self.config._read_image_bytes(b"raw_bytes") + assert result == b"raw_bytes" + + def test_read_image_bytes_from_bytesio(self): + """Test reading bytes from BytesIO.""" + bio = BytesIO(b"bytesio_data") + bio.seek(5) # Move position to test seek reset + result = self.config._read_image_bytes(bio) + assert result == b"bytesio_data" + assert bio.tell() == 5 # Position should be restored + + def test_read_image_bytes_unsupported_type(self): + """Test that unsupported image type raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported image type"): + self.config._read_image_bytes("not_an_image") # type: ignore From 7c4e576400c48f9153b80b63e7af57ca209d9d18 Mon Sep 17 00:00:00 2001 From: Shivaang Date: Sat, 28 Feb 2026 11:44:32 -0500 Subject: [PATCH 2/4] fix: add API key validation in OpenRouter image edit config Raise ValueError when OPENROUTER_API_KEY is not set instead of sending "Bearer None" and getting a confusing 401 from the API. --- .../llms/openrouter/image_edit/transformation.py | 2 ++ .../test_openrouter_image_edit_transformation.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index 488aeb1e55..d06536367e 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -114,6 +114,8 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): or litellm.api_key or get_secret_str("OPENROUTER_API_KEY") ) + if not api_key: + raise ValueError("OPENROUTER_API_KEY is not set") headers.update( { "Authorization": f"Bearer {api_key}", diff --git a/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py index 787acc3402..924e45dbf3 100644 --- a/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py +++ b/tests/test_litellm/llms/openrouter/image_edit/test_openrouter_image_edit_transformation.py @@ -194,6 +194,20 @@ class TestOpenRouterImageEditTransformation: assert result["Authorization"] == "Bearer secret_api_key" + @patch("litellm.llms.openrouter.image_edit.transformation.litellm") + @patch("litellm.llms.openrouter.image_edit.transformation.get_secret_str") + def test_validate_environment_missing_api_key_raises(self, mock_get_secret, mock_litellm): + """Test that validate_environment raises ValueError when no API key is available.""" + mock_get_secret.return_value = None + mock_litellm.api_key = None + + with pytest.raises(ValueError, match="OPENROUTER_API_KEY is not set"): + self.config.validate_environment( + headers={}, + model=self.model, + api_key=None, + ) + # Request transformation tests def test_transform_image_edit_request_basic(self): From f6264a9c0fde055be2cdb2b84a2dee6c32c8885a Mon Sep 17 00:00:00 2001 From: Chesars Date: Tue, 3 Mar 2026 14:50:41 -0300 Subject: [PATCH 3/4] docs(openrouter): add image edit documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenRouter image edit docs to both the provider page and the main image_edits reference page, including supported models, parameter mappings (size→aspect_ratio, quality→image_size), usage examples, proxy configuration, and a note about 4K quality model support. --- docs/my-website/docs/image_edits.md | 71 +++++++++++++++- docs/my-website/docs/providers/openrouter.md | 87 ++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/docs/my-website/docs/image_edits.md b/docs/my-website/docs/image_edits.md index a843833454..f1cfc0ed8e 100644 --- a/docs/my-website/docs/image_edits.md +++ b/docs/my-website/docs/image_edits.md @@ -16,7 +16,7 @@ LiteLLM provides image editing functionality that maps to OpenAI's `/images/edit | Supported operations | Create image edits | Single and multiple images supported | | Supported LiteLLM SDK Versions | 1.63.8+ | Gemini support requires 1.79.3+ | | Supported LiteLLM Proxy Versions | 1.71.1+ | Gemini support requires 1.79.3+ | -| Supported LLM providers | **OpenAI**, **Gemini (Google AI Studio)**, **Vertex AI**, **Stability AI**, **AWS Bedrock (Stability)** | Gemini supports the new `gemini-2.5-flash-image` family. Vertex AI supports both Gemini and Imagen models. Stability AI and Bedrock Stability support various image editing operations. | +| Supported LLM providers | **OpenAI**, **Gemini (Google AI Studio)**, **Vertex AI**, **OpenRouter**, **Stability AI**, **AWS Bedrock (Stability)** | Gemini supports the new `gemini-2.5-flash-image` family. Vertex AI supports both Gemini and Imagen models. OpenRouter routes image edits through chat completions. Stability AI and Bedrock Stability support various image editing operations. | #### ⚡️See all supported models and providers at [models.litellm.ai](https://models.litellm.ai/) @@ -244,6 +244,47 @@ response = litellm.image_edit( print(response) ``` + + + + +#### Basic Image Edit +```python showLineNumbers title="OpenRouter Image Edit" +import os +from litellm import image_edit + +os.environ["OPENROUTER_API_KEY"] = "your-api-key" + +response = image_edit( + model="openrouter/google/gemini-2.5-flash-image", + image=open("original_image.png", "rb"), + prompt="Add aurora borealis to the night sky", +) + +print(response) +``` + +#### Multiple Images Edit +```python showLineNumbers title="OpenRouter Multiple Images Edit" +import os +from litellm import image_edit + +os.environ["OPENROUTER_API_KEY"] = "your-api-key" + +response = image_edit( + model="openrouter/google/gemini-2.5-flash-image", + image=[ + open("scene.png", "rb"), + open("style_reference.png", "rb"), + ], + prompt="Blend the reference style into the scene", + size="1536x1024", # mapped to aspect_ratio 3:2 + quality="high", # mapped to image_size 4K +) + +print(response) +``` + @@ -398,6 +439,34 @@ curl -X POST "http://0.0.0.0:4000/v1/images/edits" \ -F "size=1024x1024" ``` + + + + +1. Add the OpenRouter image edit model to your `config.yaml`: +```yaml showLineNumbers title="OpenRouter Proxy Configuration" +model_list: + - model_name: openrouter-image-edit + litellm_params: + model: openrouter/google/gemini-2.5-flash-image + api_key: os.environ/OPENROUTER_API_KEY +``` + +2. Start the LiteLLM proxy server: +```bash showLineNumbers title="Start LiteLLM Proxy Server" +litellm --config /path/to/config.yaml +``` + +3. Make an image edit request: +```bash showLineNumbers title="OpenRouter Proxy Image Edit" +curl -X POST "http://0.0.0.0:4000/v1/images/edits" \ + -H "Authorization: Bearer " \ + -F "model=openrouter-image-edit" \ + -F "image=@original_image.png" \ + -F "prompt=Make the sky a vibrant purple sunset" \ + -F "size=1024x1024" +``` + diff --git a/docs/my-website/docs/providers/openrouter.md b/docs/my-website/docs/providers/openrouter.md index 38eb998c98..4c79c41cfd 100644 --- a/docs/my-website/docs/providers/openrouter.md +++ b/docs/my-website/docs/providers/openrouter.md @@ -210,3 +210,90 @@ response = image_generation( # Cost is available in the response metadata print(f"Request cost: ${response._hidden_params['additional_headers']['llm_provider-x-litellm-response-cost']}") ``` + +## Image Edit + +OpenRouter supports image editing through select models like Google Gemini image models. LiteLLM routes image edit requests to OpenRouter's chat completions endpoint with the source image sent as a base64 data URL and `modalities: ["image", "text"]`. + +### Supported Models + +| Model | Description | +|-------|-------------| +| `openrouter/google/gemini-2.5-flash-image` | Gemini 2.5 Flash with image editing | + +See all available image models on [OpenRouter's model list](https://openrouter.ai/models?modality=image). + +### Supported Parameters + +| Parameter | OpenRouter Mapping | Notes | +|-----------|--------------------|-------| +| `size` | `image_config.aspect_ratio` | `1024x1024` → `1:1`, `1536x1024` → `3:2`, `1024x1536` → `2:3`, `1792x1024` → `16:9`, `1024x1792` → `9:16` | +| `quality` | `image_config.image_size` | `low`/`standard` → `1K`, `medium` → `2K`, `high`/`hd` → `4K` | +| `n` | `n` | Number of images | + +:::note +`quality=high` (4K) is only supported by `google/gemini-3-pro-image-preview` and `google/gemini-3.1-flash-image-preview`. The `google/gemini-2.5-flash-image` model supports up to `medium` (2K). +::: + +### Usage + +```python +from litellm import image_edit +import os + +os.environ["OPENROUTER_API_KEY"] = "your-api-key" + +# Basic image edit +response = image_edit( + model="openrouter/google/gemini-2.5-flash-image", + image=open("original_image.png", "rb"), + prompt="Make the sky a vibrant purple sunset", +) + +print(response) +``` + +### Advanced Usage with Parameters + +```python +from litellm import image_edit +import os + +os.environ["OPENROUTER_API_KEY"] = "your-api-key" + +# Edit with size and quality parameters +response = image_edit( + model="openrouter/google/gemini-2.5-flash-image", + image=open("photo.png", "rb"), + prompt="Add northern lights to the sky", + size="1536x1024", # Maps to aspect_ratio 3:2 + quality="high", # Maps to image_size 4K +) + +# Access the edited image +image_data = response.data[0] +if image_data.b64_json: + import base64 + with open("edited.png", "wb") as f: + f.write(base64.b64decode(image_data.b64_json)) +``` + +### Multiple Images Edit + +```python +from litellm import image_edit +import os + +os.environ["OPENROUTER_API_KEY"] = "your-api-key" + +response = image_edit( + model="openrouter/google/gemini-2.5-flash-image", + image=[ + open("scene.png", "rb"), + open("style_reference.png", "rb"), + ], + prompt="Blend the reference style into the scene", +) + +print(response) +``` From fecb3016847d1c8faea966c21581242748801118 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:16:12 -0300 Subject: [PATCH 4/4] Update litellm/llms/openrouter/image_edit/transformation.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- litellm/llms/openrouter/image_edit/transformation.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index d06536367e..19931f0024 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -133,12 +133,11 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): api_base: Optional[str], litellm_params: dict, ) -> str: - 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 = api_base or get_secret_str("OPENROUTER_API_BASE") or "https://openrouter.ai/api/v1" + base_url = base_url.rstrip("/") + if not base_url.endswith("/chat/completions"): + return f"{base_url}/chat/completions" + return base_url def transform_image_edit_request( self,