mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(minimax): add image_generation support
This commit is contained in:
parent
41722b1cbc
commit
b1e36cb396
7 changed files with 570 additions and 0 deletions
|
|
@ -387,6 +387,7 @@ def image_generation(
|
|||
litellm.LlmProviders.VERTEX_AI,
|
||||
litellm.LlmProviders.OPENROUTER,
|
||||
litellm.LlmProviders.DASHSCOPE,
|
||||
litellm.LlmProviders.MINIMAX,
|
||||
):
|
||||
if image_generation_config is None:
|
||||
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")
|
||||
|
|
|
|||
14
litellm/llms/minimax/image_generation/__init__.py
Normal file
14
litellm/llms/minimax/image_generation/__init__.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""MiniMax image generation transformation."""
|
||||
|
||||
from litellm.llms.base_llm.image_generation.transformation import (
|
||||
BaseImageGenerationConfig,
|
||||
)
|
||||
|
||||
from .transformation import MinimaxImageGenerationConfig
|
||||
|
||||
__all__ = ["MinimaxImageGenerationConfig", "get_minimax_image_generation_config"]
|
||||
|
||||
|
||||
def get_minimax_image_generation_config(model: str) -> BaseImageGenerationConfig:
|
||||
"""Get the MiniMax image generation config for the given model."""
|
||||
return MinimaxImageGenerationConfig()
|
||||
261
litellm/llms/minimax/image_generation/transformation.py
Normal file
261
litellm/llms/minimax/image_generation/transformation.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""
|
||||
MiniMax Image Generation Configuration
|
||||
|
||||
Maps OpenAI image generation params to the MiniMax image generation API.
|
||||
|
||||
API endpoint: POST https://api.minimax.io/v1/image_generation
|
||||
|
||||
Request format:
|
||||
{
|
||||
"model": "image-01",
|
||||
"prompt": "<prompt>",
|
||||
"n": 1,
|
||||
"aspect_ratio": "1:1",
|
||||
"response_format": "url"
|
||||
}
|
||||
|
||||
Response format:
|
||||
{
|
||||
"data": {"image_urls": ["<url>"], "image_base64": ["<base64>"]},
|
||||
"metadata": {"success_count": 1, "failed_count": 0},
|
||||
"base_resp": {"status_code": 0, "status_msg": "success"}
|
||||
}
|
||||
|
||||
Reference: https://platform.minimax.io/docs/api-reference/image-generation-t2i
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.image_generation.transformation import (
|
||||
BaseImageGenerationConfig,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
OpenAIImageGenerationOptionalParams,
|
||||
)
|
||||
from litellm.types.utils import ImageObject, ImageResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
DEFAULT_API_BASE = "https://api.minimax.io"
|
||||
IMAGE_GENERATION_ENDPOINT = "/v1/image_generation"
|
||||
|
||||
# OpenAI uses "b64_json", MiniMax uses "base64".
|
||||
OPENAI_TO_MINIMAX_RESPONSE_FORMAT = {
|
||||
"b64_json": "base64",
|
||||
}
|
||||
|
||||
|
||||
class MinimaxImageGenerationException(BaseLLMException):
|
||||
"""Exception raised for MiniMax image generation API errors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: dict | httpx.Headers | None = None,
|
||||
):
|
||||
super().__init__(status_code=status_code, message=message, headers=headers)
|
||||
|
||||
|
||||
class MinimaxImageGenerationConfig(BaseImageGenerationConfig):
|
||||
"""
|
||||
Configuration for MiniMax image generation models (image-01, image-01-live).
|
||||
"""
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]:
|
||||
return ["n", "size", "response_format", "seed", "user", "aspect_ratio"]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
non_default_params: dict,
|
||||
optional_params: dict,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict:
|
||||
"""
|
||||
Map OpenAI image generation params to MiniMax params.
|
||||
|
||||
- `size` (WxH) is expanded to `width` and `height`
|
||||
- `response_format` "b64_json" is mapped to "base64"
|
||||
- remaining supported params are passed through
|
||||
"""
|
||||
supported_params = self.get_supported_openai_params(model)
|
||||
for k, v in non_default_params.items():
|
||||
if k in optional_params:
|
||||
continue
|
||||
if k not in supported_params:
|
||||
continue
|
||||
if k == "size":
|
||||
width, height = self._parse_size(v)
|
||||
if width is not None and height is not None:
|
||||
optional_params["width"] = width
|
||||
optional_params["height"] = height
|
||||
elif k == "response_format":
|
||||
optional_params["response_format"] = OPENAI_TO_MINIMAX_RESPONSE_FORMAT.get(v, v)
|
||||
else:
|
||||
optional_params[k] = v
|
||||
return optional_params
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
api_base: str | None,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
stream: bool | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the MiniMax image generation endpoint URL.
|
||||
"""
|
||||
base_url: str = api_base or get_secret_str("MINIMAX_API_BASE") or DEFAULT_API_BASE
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith(IMAGE_GENERATION_ENDPOINT):
|
||||
base_url = base_url[: -len(IMAGE_GENERATION_ENDPOINT)]
|
||||
if not base_url.endswith("/v1"):
|
||||
base_url = f"{base_url}/v1"
|
||||
return f"{base_url}/image_generation"
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate the MiniMax environment and set auth headers.
|
||||
"""
|
||||
final_api_key: str | None = api_key or get_secret_str("MINIMAX_API_KEY") or litellm.api_key
|
||||
if not final_api_key:
|
||||
raise ValueError(
|
||||
"MiniMax API key is required. Set MINIMAX_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
headers["Authorization"] = f"Bearer {final_api_key}"
|
||||
headers["Content-Type"] = "application/json"
|
||||
return headers
|
||||
|
||||
def transform_image_generation_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Build the MiniMax image generation request body.
|
||||
"""
|
||||
request_data: dict = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
}
|
||||
|
||||
for k, v in optional_params.items():
|
||||
if v is None:
|
||||
continue
|
||||
if k in {"extra_headers", "extra_body", "user"}:
|
||||
continue
|
||||
request_data[k] = v
|
||||
|
||||
extra_body = optional_params.get("extra_body")
|
||||
if isinstance(extra_body, dict):
|
||||
request_data.update({k: v for k, v in extra_body.items() if v is not None})
|
||||
|
||||
return request_data
|
||||
|
||||
def transform_image_generation_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
model_response: ImageResponse,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
request_data: dict,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
encoding: Any,
|
||||
api_key: str | None = None,
|
||||
json_mode: bool | None = None,
|
||||
) -> ImageResponse:
|
||||
"""
|
||||
Transform the MiniMax response into a litellm ImageResponse.
|
||||
|
||||
MiniMax returns images under `data.image_urls` (response_format=url) or
|
||||
`data.image_base64` (response_format=base64).
|
||||
"""
|
||||
try:
|
||||
response_data = raw_response.json()
|
||||
except Exception as e:
|
||||
raise self.get_error_class(
|
||||
error_message=f"Failed to parse MiniMax image generation response: {e}",
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
base_resp = response_data.get("base_resp") or {}
|
||||
status_code = base_resp.get("status_code")
|
||||
if status_code not in (None, 0, "0"):
|
||||
raise self.get_error_class(
|
||||
error_message=str(base_resp.get("status_msg") or response_data),
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
if not model_response.data:
|
||||
model_response.data = []
|
||||
|
||||
data = response_data.get("data") or {}
|
||||
for image_url in data.get("image_urls") or []:
|
||||
model_response.data.append(ImageObject(url=image_url))
|
||||
for image_base64 in data.get("image_base64") or []:
|
||||
model_response.data.append(ImageObject(b64_json=image_base64))
|
||||
|
||||
return model_response
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict | httpx.Headers,
|
||||
) -> BaseLLMException:
|
||||
return MinimaxImageGenerationException(
|
||||
status_code=status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_size(size: Any) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Parse an OpenAI `WxH` size string into width/height integers.
|
||||
|
||||
MiniMax accepts width/height in [512, 2048] divisible by 8.
|
||||
"""
|
||||
if not isinstance(size, str) or "x" not in size:
|
||||
return None, None
|
||||
parts = size.split("x")
|
||||
if len(parts) != 2:
|
||||
return None, None
|
||||
try:
|
||||
width, height = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return None, None
|
||||
if width < 512 or width > 2048 or height < 512 or height > 2048:
|
||||
return None, None
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
return None, None
|
||||
return width, height
|
||||
|
|
@ -8662,6 +8662,12 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
return get_dashscope_image_generation_config(model)
|
||||
elif LlmProviders.MINIMAX == provider:
|
||||
from litellm.llms.minimax.image_generation import (
|
||||
get_minimax_image_generation_config,
|
||||
)
|
||||
|
||||
return get_minimax_image_generation_config(model)
|
||||
elif LlmProviders.MODELSCOPE == provider:
|
||||
from litellm.llms.modelscope.image_generation import (
|
||||
get_modelscope_image_generation_config,
|
||||
|
|
|
|||
|
|
@ -27266,6 +27266,22 @@
|
|||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000
|
||||
},
|
||||
"minimax/image-01": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "image_generation",
|
||||
"source": "https://platform.minimax.io/docs/api-reference/image-generation-t2i",
|
||||
"supported_endpoints": [
|
||||
"/v1/image_generation"
|
||||
]
|
||||
},
|
||||
"minimax/image-01-live": {
|
||||
"litellm_provider": "minimax",
|
||||
"mode": "image_generation",
|
||||
"source": "https://platform.minimax.io/docs/api-reference/image-generation-t2i",
|
||||
"supported_endpoints": [
|
||||
"/v1/image_generation"
|
||||
]
|
||||
},
|
||||
"mistral.devstral-2-123b": {
|
||||
"input_cost_per_token": 4e-07,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
# MiniMax image generation tests
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
"""
|
||||
Unit tests for the MiniMax image generation configuration.
|
||||
|
||||
These tests validate the MinimaxImageGenerationConfig class which handles
|
||||
transformation between OpenAI-compatible image generation params and the
|
||||
MiniMax image generation API (POST /v1/image_generation).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from litellm.llms.minimax.image_generation.transformation import (
|
||||
MinimaxImageGenerationConfig,
|
||||
)
|
||||
from litellm.types.utils import ImageResponse
|
||||
|
||||
|
||||
class TestMinimaxImageGenerationTransformation:
|
||||
def setup_method(self):
|
||||
self.config = MinimaxImageGenerationConfig()
|
||||
self.model = "image-01"
|
||||
self.logging_obj = MagicMock()
|
||||
|
||||
def test_get_supported_openai_params(self):
|
||||
supported_params = self.config.get_supported_openai_params(self.model)
|
||||
|
||||
assert "n" in supported_params
|
||||
assert "size" in supported_params
|
||||
assert "response_format" in supported_params
|
||||
assert "seed" in supported_params
|
||||
assert "aspect_ratio" in supported_params
|
||||
|
||||
def test_map_openai_params_passthrough(self):
|
||||
non_default_params = {
|
||||
"n": 2,
|
||||
"seed": 42,
|
||||
"aspect_ratio": "16:9",
|
||||
}
|
||||
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params=non_default_params,
|
||||
optional_params={},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["n"] == 2
|
||||
assert result["seed"] == 42
|
||||
assert result["aspect_ratio"] == "16:9"
|
||||
|
||||
def test_map_openai_params_size_to_width_height(self):
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"size": "1024x1024"},
|
||||
optional_params={},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["width"] == 1024
|
||||
assert result["height"] == 1024
|
||||
assert "size" not in result
|
||||
|
||||
def test_map_openai_params_unsupported_size_is_dropped(self):
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"size": "100x50"},
|
||||
optional_params={},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert "width" not in result
|
||||
assert "height" not in result
|
||||
|
||||
def test_map_openai_params_response_format_b64_json(self):
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"response_format": "b64_json"},
|
||||
optional_params={},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["response_format"] == "base64"
|
||||
|
||||
def test_map_openai_params_response_format_url(self):
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"response_format": "url"},
|
||||
optional_params={},
|
||||
model=self.model,
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["response_format"] == "url"
|
||||
|
||||
def test_get_complete_url_default(self):
|
||||
result = self.config.get_complete_url(
|
||||
api_base=None,
|
||||
api_key="test_key",
|
||||
model=self.model,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert result == "https://api.minimax.io/v1/image_generation"
|
||||
|
||||
def test_get_complete_url_with_custom_base(self):
|
||||
result = self.config.get_complete_url(
|
||||
api_base="https://api.minimaxi.com",
|
||||
api_key="test_key",
|
||||
model=self.model,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert result == "https://api.minimaxi.com/v1/image_generation"
|
||||
|
||||
def test_get_complete_url_with_full_endpoint_base(self):
|
||||
result = self.config.get_complete_url(
|
||||
api_base="https://api.minimax.io/v1/image_generation",
|
||||
api_key="test_key",
|
||||
model=self.model,
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
)
|
||||
|
||||
assert result == "https://api.minimax.io/v1/image_generation"
|
||||
|
||||
@patch("litellm.llms.minimax.image_generation.transformation.get_secret_str")
|
||||
def test_validate_environment(self, mock_get_secret):
|
||||
mock_get_secret.return_value = "test_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 test_api_key"
|
||||
assert result["Content-Type"] == "application/json"
|
||||
|
||||
@patch("litellm.llms.minimax.image_generation.transformation.get_secret_str")
|
||||
def test_validate_environment_missing_api_key(self, mock_get_secret):
|
||||
mock_get_secret.return_value = None
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
self.config.validate_environment(
|
||||
headers={},
|
||||
model=self.model,
|
||||
messages=[],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
api_key=None,
|
||||
)
|
||||
|
||||
def test_transform_image_generation_request(self):
|
||||
optional_params = {
|
||||
"n": 2,
|
||||
"response_format": "url",
|
||||
"prompt_optimizer": True,
|
||||
}
|
||||
|
||||
request_data = self.config.transform_image_generation_request(
|
||||
model=self.model,
|
||||
prompt="a red apple",
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request_data["model"] == "image-01"
|
||||
assert request_data["prompt"] == "a red apple"
|
||||
assert request_data["n"] == 2
|
||||
assert request_data["response_format"] == "url"
|
||||
assert request_data["prompt_optimizer"] is True
|
||||
|
||||
def test_transform_image_generation_request_merges_extra_body(self):
|
||||
optional_params = {
|
||||
"extra_body": {"seed": 7, "prompt_optimizer": True},
|
||||
}
|
||||
|
||||
request_data = self.config.transform_image_generation_request(
|
||||
model=self.model,
|
||||
prompt="a red apple",
|
||||
optional_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request_data["seed"] == 7
|
||||
assert request_data["prompt_optimizer"] is True
|
||||
|
||||
def test_transform_image_generation_response_urls(self):
|
||||
raw_response = self._make_response(
|
||||
{
|
||||
"data": {"image_urls": ["https://example.com/a.png", "https://example.com/b.png"]},
|
||||
"metadata": {"success_count": 2, "failed_count": 0},
|
||||
"base_resp": {"status_code": 0, "status_msg": "success"},
|
||||
}
|
||||
)
|
||||
|
||||
model_response = self.config.transform_image_generation_response(
|
||||
model=self.model,
|
||||
raw_response=raw_response,
|
||||
model_response=ImageResponse(),
|
||||
logging_obj=self.logging_obj,
|
||||
request_data={},
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
assert len(model_response.data) == 2
|
||||
assert model_response.data[0].url == "https://example.com/a.png"
|
||||
assert model_response.data[1].url == "https://example.com/b.png"
|
||||
|
||||
def test_transform_image_generation_response_base64(self):
|
||||
raw_response = self._make_response(
|
||||
{
|
||||
"data": {"image_base64": ["aGVsbG8=", "d29ybGQ="]},
|
||||
"metadata": {"success_count": 2, "failed_count": 0},
|
||||
"base_resp": {"status_code": 0, "status_msg": "success"},
|
||||
}
|
||||
)
|
||||
|
||||
model_response = self.config.transform_image_generation_response(
|
||||
model=self.model,
|
||||
raw_response=raw_response,
|
||||
model_response=ImageResponse(),
|
||||
logging_obj=self.logging_obj,
|
||||
request_data={},
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
assert len(model_response.data) == 2
|
||||
assert model_response.data[0].b64_json == "aGVsbG8="
|
||||
assert model_response.data[1].b64_json == "d29ybGQ="
|
||||
|
||||
def test_transform_image_generation_response_error_status_code(self):
|
||||
raw_response = self._make_response(
|
||||
{
|
||||
"base_resp": {"status_code": 1004, "status_msg": "invalid api key"},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
self.config.transform_image_generation_response(
|
||||
model=self.model,
|
||||
raw_response=raw_response,
|
||||
model_response=ImageResponse(),
|
||||
logging_obj=self.logging_obj,
|
||||
request_data={},
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_response(payload: dict) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json=payload,
|
||||
request=httpx.Request("POST", "https://api.minimax.io/v1/image_generation"),
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue