diff --git a/litellm/images/main.py b/litellm/images/main.py index d26c9d54f83..769266b97c8 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -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}") diff --git a/litellm/llms/minimax/image_generation/__init__.py b/litellm/llms/minimax/image_generation/__init__.py new file mode 100644 index 00000000000..396651c427d --- /dev/null +++ b/litellm/llms/minimax/image_generation/__init__.py @@ -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() diff --git a/litellm/llms/minimax/image_generation/transformation.py b/litellm/llms/minimax/image_generation/transformation.py new file mode 100644 index 00000000000..2bcdd7ba1ce --- /dev/null +++ b/litellm/llms/minimax/image_generation/transformation.py @@ -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": "", + "n": 1, + "aspect_ratio": "1:1", + "response_format": "url" +} + +Response format: +{ + "data": {"image_urls": [""], "image_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 diff --git a/litellm/utils.py b/litellm/utils.py index eb3e578b7e8..1995665e387 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 56e0391f419..64d5c282bfd 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/tests/test_litellm/llms/minimax/image_generation/__init__.py b/tests/test_litellm/llms/minimax/image_generation/__init__.py new file mode 100644 index 00000000000..1c78f1015ba --- /dev/null +++ b/tests/test_litellm/llms/minimax/image_generation/__init__.py @@ -0,0 +1 @@ +# MiniMax image generation tests diff --git a/tests/test_litellm/llms/minimax/image_generation/test_minimax_image_generation_transformation.py b/tests/test_litellm/llms/minimax/image_generation/test_minimax_image_generation_transformation.py new file mode 100644 index 00000000000..35e17557109 --- /dev/null +++ b/tests/test_litellm/llms/minimax/image_generation/test_minimax_image_generation_transformation.py @@ -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"), + )