diff --git a/docs/my-website/docs/providers/recraft.md b/docs/my-website/docs/providers/recraft.md index dd4a91ddcf..d4a29c38aa 100644 --- a/docs/my-website/docs/providers/recraft.md +++ b/docs/my-website/docs/providers/recraft.md @@ -8,9 +8,9 @@ https://www.recraft.ai/ | Description | Recraft is an AI-powered design tool that generates high-quality images with precise control over style and content. | | Provider Route on LiteLLM | `recraft/` | | Link to Provider Doc | [Recraft ↗](https://www.recraft.ai/docs) | -| Supported Operations | [`/images/generations`](#image-generation) | +| Supported Operations | [`/images/generations`](#image-generation), [`/images/edits`](#image-edit) | -LiteLLM supports Recraft Image Generation calls. +LiteLLM supports Recraft Image Generation and Image Edit calls. ## API Base, Key ```python @@ -152,6 +152,148 @@ print(response) For more details on available models and features, see: https://www.recraft.ai/docs +## Image Edit + +### Usage - LiteLLM Python SDK + +```python showLineNumbers +from litellm import image_edit +import os + +os.environ['RECRAFT_API_KEY'] = "your-api-key" + +# Open the image file +with open("reference_image.png", "rb") as image_file: + # recraft image edit call + response = image_edit( + model="recraft/recraftv3", + prompt="Create a studio ghibli style image that combines all the reference images. Make sure the person looks like a CTO.", + image=image_file, + ) +print(response) +``` + +### Usage - LiteLLM Proxy Server + +#### 1. Setup config.yaml + +```yaml showLineNumbers +model_list: + - model_name: recraft-v3 + litellm_params: + model: recraft/recraftv3 + api_key: os.environ/RECRAFT_API_KEY + model_info: + mode: image_edit + +general_settings: + master_key: sk-1234 +``` + +#### 2. Start the proxy + +```bash showLineNumbers +litellm --config config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +#### 3. Test it + +```bash showLineNumbers +curl --location 'http://0.0.0.0:4000/v1/images/edits' \ +--header 'Authorization: Bearer sk-1234' \ +--form 'model="recraft-v3"' \ +--form 'prompt="Create a studio ghibli style image that combines all the reference images. Make sure the person looks like a CTO."' \ +--form 'image=@"reference_image.png"' +``` + +### Advanced Usage - With Additional Parameters + +```python showLineNumbers +from litellm import image_edit +import os + +os.environ['RECRAFT_API_KEY'] = "your-api-key" + +with open("reference_image.png", "rb") as image_file: + response = image_edit( + model="recraft/recraftv3", + prompt="Create a studio ghibli style image", + image=image_file, + n=2, # Generate 2 variations + response_format="url", # Return URLs instead of base64 + style="realistic_image", # Set artistic style + strength=0.5 # Control transformation strength (0-1) + ) +print(response) +``` + +### Supported Image Edit Parameters + +Recraft supports the following OpenAI-compatible parameters for image editing: + +| Parameter | Type | Description | Default | Example | +|-----------|------|-------------|---------|---------| +| `n` | integer | Number of images to generate (1-4) | `1` | `2` | +| `response_format` | string | Format of response (`url` or `b64_json`) | `"url"` | `"b64_json"` | +| `style` | string | Image style/artistic direction | - | `"realistic_image"` | +| `strength` | float | Controls how much to transform the image (0.0-1.0) | `0.2` | `0.5` | + +### Using Non-OpenAI Parameters + +You can pass Recraft-specific parameters that are not part of the OpenAI API by including them in your request: + +**Usage with LiteLLM Python SDK** + +```python showLineNumbers +from litellm import image_edit +import os + +os.environ['RECRAFT_API_KEY'] = "your-api-key" + +with open("reference_image.png", "rb") as image_file: + response = image_edit( + model="recraft/recraftv3", + prompt="Create a studio ghibli style image", + image=image_file, + style_id="your-style-id", # Recraft-specific parameter + strength=0.7 + ) +``` + +**Usage with LiteLLM Proxy Server + OpenAI Python SDK** + +```python showLineNumbers +from openai import OpenAI +import os + +client = OpenAI( + api_key="sk-1234", # your LiteLLM proxy master key + base_url="http://0.0.0.0:4000" # your LiteLLM proxy URL +) + +with open("reference_image.png", "rb") as image_file: + response = client.images.edit( + model="recraft-v3", + prompt="Create a studio ghibli style image", + image=image_file, + extra_body={ + "style_id": "your-style-id", + "strength": 0.7 + } + ) +print(response) +``` + +### Supported Image Edit Models + +**Note: All recraft models are supported by LiteLLM** Just pass the model name with `recraft/` and litellm will route it to recraft. + +| Model Name | Function Call | +|------------|---------------| +| recraftv3 | `image_edit(model="recraft/recraftv3", ...)` | + ## API Key Setup Get your API key from [Recraft's website](https://www.recraft.ai/) and set it as an environment variable: diff --git a/litellm/llms/recraft/image_edit/transformation.py b/litellm/llms/recraft/image_edit/transformation.py new file mode 100644 index 0000000000..9444925769 --- /dev/null +++ b/litellm/llms/recraft/image_edit/transformation.py @@ -0,0 +1,184 @@ +from io import BufferedReader +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast + +import httpx +from httpx._types import RequestFiles + +from litellm.images.utils import ImageEditRequestUtils +from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.images.main import ImageEditOptionalRequestParams +from litellm.types.llms.recraft import RecraftImageEditRequestParams +from litellm.types.responses.main import * +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import FileTypes, ImageObject, ImageResponse + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class RecraftImageEditConfig(BaseImageEditConfig): + DEFAULT_BASE_URL: str = "https://external.api.recraft.ai" + IMAGE_EDIT_ENDPOINT: str = "v1/images/imageToImage" + DEFAULT_STRENGTH: float = 0.2 + + def get_supported_openai_params( + self, model: str + ) -> List: + """ + Supported OpenAI parameters that can be mapped to Recraft image edit API. + + Based on Recraft API docs: https://www.recraft.ai/docs#image-to-image + """ + return [ + "n", # Maps to n (number of images) + "response_format", # Maps to response_format (url or b64_json) + "style" # Maps to style parameter + ] + + def map_openai_params( + self, + image_edit_optional_params: ImageEditOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + """ + Map OpenAI image edit parameters to Recraft parameters. + Reuses OpenAI logic but filters to supported params only. + """ + # Start with all params like OpenAI does + all_params = dict(image_edit_optional_params) + + # Filter to only supported Recraft parameters + supported_params = self.get_supported_openai_params(model) + filtered_params = {k: v for k, v in all_params.items() if k in supported_params} + + return filtered_params + + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """ + Get the complete url for the request + + Some providers need `model` in `api_base` + """ + complete_url: str = ( + api_base + or get_secret_str("RECRAFT_API_BASE") + or self.DEFAULT_BASE_URL + ) + + complete_url = complete_url.rstrip("/") + complete_url = f"{complete_url}/{self.IMAGE_EDIT_ENDPOINT}" + return complete_url + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + ) -> dict: + final_api_key: Optional[str] = ( + api_key or + get_secret_str("RECRAFT_API_KEY") + ) + if not final_api_key: + raise ValueError("RECRAFT_API_KEY is not set") + + headers["Authorization"] = f"Bearer {final_api_key}" + return headers + + + def transform_image_edit_request( + self, + model: str, + prompt: str, + image: FileTypes, + image_edit_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[Dict, RequestFiles]: + """ + Transform the image edit request to Recraft's multipart form format. + Reuses OpenAI file handling logic but adapts for Recraft API structure. + + https://www.recraft.ai/docs#image-to-image + """ + + request_body: RecraftImageEditRequestParams = RecraftImageEditRequestParams( + model=model, + prompt=prompt, + strength=image_edit_optional_request_params.pop("strength", self.DEFAULT_STRENGTH), + **image_edit_optional_request_params, + ) + request_dict = cast(Dict, request_body) + ######################################################### + # Reuse OpenAI logic: Separate images as `files` and send other parameters as `data` + ######################################################### + files_list = self._get_image_files_for_request(image=image) + data_without_images = {k: v for k, v in request_dict.items() if k != "image"} + + return data_without_images, files_list + + + def _get_image_files_for_request( + self, + image: FileTypes, + ) -> List[Tuple[str, Any]]: + files_list: List[Tuple[str, Any]] = [] + + # Handle single image (Recraft expects single image, not array) + if image: + # OpenAI wraps images in arrays, but for Recraft we need single image + if isinstance(image, list): + _image = image[0] if image else None # Take first image for Recraft + else: + _image = image + + if _image is not None: + image_content_type: str = ImageEditRequestUtils.get_image_content_type(_image) + if isinstance(_image, BufferedReader): + files_list.append( + ("image", (_image.name, _image, image_content_type)) + ) + else: + files_list.append( + ("image", ("image.png", _image, image_content_type)) + ) + + return files_list + + def transform_image_edit_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> ImageResponse: + model_response = ImageResponse() + try: + response_data = raw_response.json() + except Exception as e: + raise self.get_error_class( + error_message=f"Error transforming image edit response: {e}", + status_code=raw_response.status_code, + headers=raw_response.headers, + ) + if not model_response.data: + model_response.data = [] + + for image_data in response_data["data"]: + model_response.data.append(ImageObject( + url=image_data.get("url", None), + b64_json=image_data.get("b64_json", None), + )) + + return model_response \ No newline at end of file diff --git a/litellm/types/llms/recraft.py b/litellm/types/llms/recraft.py index 176810970b..8de63549c3 100644 --- a/litellm/types/llms/recraft.py +++ b/litellm/types/llms/recraft.py @@ -14,4 +14,22 @@ class RecraftImageGenerationRequestParams(TypedDict, total=False): response_format: Optional[str] size: Optional[str] negative_prompt: Optional[str] - controls: Optional[Dict] \ No newline at end of file + controls: Optional[Dict] + + +class RecraftImageEditRequestParams(TypedDict, total=False): + """ + TypedDict for Recraft image edit request parameters. + + Based on Recraft API docs: https://www.recraft.ai/docs#image-to-image + """ + prompt: str # required - A text description of areas to change. Max 1000 bytes + strength: float # required - Defines difference with original image, [0, 1] + model: Optional[str] # The model to use, default is recraftv3 + n: Optional[int] # The number of images to generate, must be between 1 and 6 + style_id: Optional[str] # Use a previously uploaded style as reference + style: Optional[str] # The style of generated images, default is realistic_image + substyle: Optional[str] # Additional style specification + response_format: Optional[str] # Format of returned images: url or b64_json + negative_prompt: Optional[str] # Description of undesired elements + controls: Optional[Dict] # Custom parameters to tweak generation process \ No newline at end of file diff --git a/litellm/utils.py b/litellm/utils.py index 676e40f6b2..f5d4022d62 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -7177,12 +7177,18 @@ class ProviderConfigManager: ) return OpenAIImageEditConfig() - if LlmProviders.AZURE == provider: + elif LlmProviders.AZURE == provider: from litellm.llms.azure.image_edit.transformation import ( AzureImageEditConfig, ) return AzureImageEditConfig() + elif LlmProviders.RECRAFT == provider: + from litellm.llms.recraft.image_edit.transformation import ( + RecraftImageEditConfig, + ) + + return RecraftImageEditConfig() return None @staticmethod diff --git a/tests/image_gen_tests/test_image_edit.png b/tests/image_gen_tests/test_image_edit.png index 0f2de3749d..3b9d865ce6 100644 Binary files a/tests/image_gen_tests/test_image_edit.png and b/tests/image_gen_tests/test_image_edit.png differ diff --git a/tests/image_gen_tests/test_image_edits.py b/tests/image_gen_tests/test_image_edits.py index 6c6df3627f..c69dc63513 100644 --- a/tests/image_gen_tests/test_image_edits.py +++ b/tests/image_gen_tests/test_image_edits.py @@ -411,3 +411,93 @@ async def test_azure_image_edit_cost_tracking(): # check response_cost assert test_custom_logger.standard_logging_payload["response_cost"] is not None assert test_custom_logger.standard_logging_payload["response_cost"] > 0 + + +@pytest.mark.asyncio +async def test_recraft_image_edit_api(): + from litellm import aimage_edit + import requests + litellm._turn_on_debug() + global TEST_IMAGES + try: + prompt = """ + Create a studio ghibli style image that combines all the reference images. Make sure the person looks like a CTO. + """ + result = await aimage_edit( + prompt=prompt, + model="recraft/recraftv3", + image=TEST_IMAGES, + ) + print("result from image edit", result) + + # Validate the response meets expected schema + ImageResponse.model_validate(result) + + if isinstance(result, ImageResponse) and result.data: + image_url = result.data[0].url + + # download the image + image_bytes = requests.get(image_url).content + with open("test_image_edit.png", "wb") as f: + f.write(image_bytes) + except litellm.ContentPolicyViolationError as e: + pass + + +def test_recraft_image_edit_config(): + """ + Test Recraft image edit configuration parameter mapping and request transformation. + """ + from litellm.llms.recraft.image_edit.transformation import RecraftImageEditConfig + from litellm.types.images.main import ImageEditOptionalRequestParams + from litellm.types.router import GenericLiteLLMParams + + config = RecraftImageEditConfig() + + # Test supported OpenAI params + supported_params = config.get_supported_openai_params("recraftv3") + expected_params = ["n", "response_format", "style"] + assert supported_params == expected_params + + # Test parameter mapping (reuses OpenAI logic with filtering) + image_edit_params = ImageEditOptionalRequestParams({ + "n": 2, + "response_format": "b64_json", + "style": "realistic_image", + "size": "1024x1024", # Should be dropped + "quality": "high" # Should be dropped + }) + + mapped_params = config.map_openai_params(image_edit_params, "recraftv3", drop_params=True) + + # Should only contain supported params + assert mapped_params["n"] == 2 + assert mapped_params["response_format"] == "b64_json" + assert mapped_params["style"] == "realistic_image" + assert "size" not in mapped_params # Should be dropped + assert "quality" not in mapped_params # Should be dropped + + # Test request transformation (reuses OpenAI file handling) + mock_image = b"fake_image_data" + prompt = "winter landscape" + litellm_params = GenericLiteLLMParams(api_key="test_key") + + data, files = config.transform_image_edit_request( + model="recraftv3", + prompt=prompt, + image=mock_image, + image_edit_optional_request_params={"strength": 0.7, "n": 1}, + litellm_params=litellm_params, + headers={} + ) + + # Check data structure (like OpenAI but with Recraft additions) + assert data["prompt"] == prompt + assert data["strength"] == 0.7 # Recraft-specific parameter + assert data["model"] == "recraftv3" + + # Check file structure (reuses OpenAI logic) + assert len(files) == 1 + assert files[0][0] == "image" # Field name (not image[] like OpenAI) + assert files[0][1][1] == mock_image # Image data + assert files[0][1][2] == "image/png" # Content type diff --git a/tests/test_litellm/llms/recraft/image_edit/test_recraft_image_edit_transformation.py b/tests/test_litellm/llms/recraft/image_edit/test_recraft_image_edit_transformation.py new file mode 100644 index 0000000000..a315138655 --- /dev/null +++ b/tests/test_litellm/llms/recraft/image_edit/test_recraft_image_edit_transformation.py @@ -0,0 +1,177 @@ +import json +import os +import sys +from io import BufferedReader, BytesIO +from typing import Dict, List +from unittest.mock import MagicMock, mock_open, patch + +import httpx +import pytest + +sys.path.insert( + 0, os.path.abspath("../../../../..") +) # Adds the parent directory to the system path + +from litellm.llms.recraft.image_edit.transformation import RecraftImageEditConfig +from litellm.types.images.main import ImageEditOptionalRequestParams +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import ImageObject, ImageResponse + + +class TestRecraftImageEditTransformation: + """ + Unit tests for Recraft image edit transformation functionality. + """ + + def setup_method(self): + """Set up test fixtures before each test method.""" + self.config = RecraftImageEditConfig() + self.model = "recraft-v3" + self.logging_obj = MagicMock() + self.prompt = "Add more trees to this landscape" + + def test_transform_image_edit_request(self): + """ + Test that transform_image_edit_request correctly transforms request parameters + and separates files from data. + """ + # Mock image data + image_data = b"fake_image_data" + image = BytesIO(image_data) + + image_edit_optional_params = { + "n": 2, + "response_format": "url", + "strength": 0.5, + "style": "photographic" + } + + litellm_params = GenericLiteLLMParams() + headers = {} + + data, files = self.config.transform_image_edit_request( + model=self.model, + prompt=self.prompt, + image=image, + image_edit_optional_request_params=image_edit_optional_params, + litellm_params=litellm_params, + headers=headers + ) + + # Check that data contains the expected parameters + assert data["model"] == self.model + assert data["prompt"] == self.prompt + assert data["strength"] == 0.5 + assert data["n"] == 2 + assert data["response_format"] == "url" + assert data["style"] == "photographic" + + # Check that image is not in data (should be in files) + assert "image" not in data + + # Check that files contains the image + assert len(files) == 1 + assert files[0][0] == "image" # field name + assert files[0][1][0] == "image.png" # filename (default for non-BufferedReader) + assert files[0][1][1] == image # file object + + def test_get_image_files_for_request_single_image(self): + """ + Test that _get_image_files_for_request correctly handles a single image. + """ + image_data = b"fake_image_data" + image = BytesIO(image_data) + + files = self.config._get_image_files_for_request(image=image) + + assert len(files) == 1 + assert files[0][0] == "image" + assert files[0][1][0] == "image.png" # Default filename for non-BufferedReader + assert files[0][1][1] == image + assert "image/png" in files[0][1][2] + + def test_get_image_files_for_request_list_with_single_image(self): + """ + Test that _get_image_files_for_request correctly handles a list containing a single image + (takes the first image for Recraft API). + """ + image_data = b"fake_image_data" + image = BytesIO(image_data) + + # Pass as list (OpenAI format) + files = self.config._get_image_files_for_request(image=[image]) + + assert len(files) == 1 + assert files[0][0] == "image" + assert files[0][1][0] == "image.png" # Default filename for non-BufferedReader + assert files[0][1][1] == image + + def test_get_image_files_for_request_buffered_reader(self): + """ + Test that _get_image_files_for_request correctly handles BufferedReader objects. + """ + # Create a mock BufferedReader + mock_file = MagicMock(spec=BufferedReader) + mock_file.name = "buffered_image.jpg" + + files = self.config._get_image_files_for_request(image=mock_file) + + assert len(files) == 1 + assert files[0][0] == "image" + assert files[0][1][0] == "buffered_image.jpg" + assert files[0][1][1] == mock_file + + def test_get_image_files_for_request_no_image(self): + """ + Test that _get_image_files_for_request returns empty list when no image is provided. + """ + files = self.config._get_image_files_for_request(image=None) + assert files == [] + + def test_transform_image_edit_response_success(self): + """ + Test that transform_image_edit_response correctly transforms a successful response. + """ + # Mock response data + response_data = { + "data": [ + {"url": "https://example.com/edited_image1.jpg", "b64_json": None}, + {"url": None, "b64_json": "base64encodeddata"} + ] + } + + # Create mock response + mock_response = MagicMock() + mock_response.json.return_value = response_data + + result = self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj + ) + + assert isinstance(result, ImageResponse) + assert len(result.data) == 2 + assert result.data[0].url == "https://example.com/edited_image1.jpg" + assert result.data[0].b64_json is None + assert result.data[1].url is None + assert result.data[1].b64_json == "base64encodeddata" + + def test_transform_image_edit_response_json_error(self): + """ + Test that transform_image_edit_response raises appropriate error when response JSON is invalid. + """ + # Create mock response that raises JSON decode error + mock_response = MagicMock() + mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0) + mock_response.status_code = 500 + mock_response.headers = {} + + with pytest.raises(Exception) as exc_info: + self.config.transform_image_edit_response( + model=self.model, + raw_response=mock_response, + logging_obj=self.logging_obj + ) + + assert "Error transforming image edit response" in str(exc_info.value) \ No newline at end of file