[Feat] Add Recraft API - Image Edits Support (#12874)

* test_recraft_image_edit_api

* add RecraftImageEditConfig

* complete RecraftImageEditConfig

* add RecraftImageEditRequestParams in types

* update RecraftImageEditRequestParams

* working

* transform_image_edit_request

* Image Edit docs recraft

* working transform_image_edit_request

* TestRecraftImageEditTransformation
This commit is contained in:
Ishaan Jaff
2025-07-22 15:03:08 -07:00
committed by GitHub
parent 31e9303232
commit d5ee93aa0c
7 changed files with 621 additions and 4 deletions
+144 -2
View File
@@ -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/<model_name>` 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:
@@ -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
+19 -1
View File
@@ -14,4 +14,22 @@ class RecraftImageGenerationRequestParams(TypedDict, total=False):
response_format: Optional[str]
size: Optional[str]
negative_prompt: Optional[str]
controls: Optional[Dict]
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
+7 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

After

Width:  |  Height:  |  Size: 2.1 MiB

+90
View File
@@ -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
@@ -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)