From 74bab168c9d6efcc8081afceeb94ae740f0d4bca Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:58:43 +0800 Subject: [PATCH 1/3] feat(minimax): add video generation support --- litellm/llms/minimax/videos/__init__.py | 5 + litellm/llms/minimax/videos/transformation.py | 435 ++++++++++++++++++ litellm/utils.py | 4 + .../test_minimax_video_transformation.py | 144 ++++++ 4 files changed, 588 insertions(+) create mode 100644 litellm/llms/minimax/videos/__init__.py create mode 100644 litellm/llms/minimax/videos/transformation.py create mode 100644 tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py diff --git a/litellm/llms/minimax/videos/__init__.py b/litellm/llms/minimax/videos/__init__.py new file mode 100644 index 00000000000..1dcd26b077e --- /dev/null +++ b/litellm/llms/minimax/videos/__init__.py @@ -0,0 +1,5 @@ +"""MiniMax video generation transformation.""" + +from .transformation import MinimaxVideoConfig + +__all__ = ["MinimaxVideoConfig"] diff --git a/litellm/llms/minimax/videos/transformation.py b/litellm/llms/minimax/videos/transformation.py new file mode 100644 index 00000000000..e42b849e732 --- /dev/null +++ b/litellm/llms/minimax/videos/transformation.py @@ -0,0 +1,435 @@ +"""MiniMax v1 video generation transformations.""" + +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union +from urllib.parse import quote, urlsplit, urlunsplit + +import httpx +from httpx._types import RequestFiles + +import litellm +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.videos.transformation import BaseVideoConfig +from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, + HTTPHandler, + _get_httpx_client, + get_async_httpx_client, +) +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject +from litellm.types.videos.utils import ( + encode_video_id_with_provider, + extract_original_video_id, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + + LiteLLMLoggingObj = _LiteLLMLoggingObj +else: + LiteLLMLoggingObj = Any + + +class MinimaxVideoConfig(BaseVideoConfig): + """Configuration for MiniMax's v1 text-to-video and image-to-video API.""" + + def get_supported_openai_params(self, model: str) -> list: + return [ + "model", + "prompt", + "input_reference", + "seconds", + "size", + "user", + "extra_headers", + "extra_body", + "prompt_optimizer", + "fast_pretreatment", + "duration", + "resolution", + "callback_url", + ] + + def map_openai_params( + self, + video_create_optional_params: VideoCreateOptionalRequestParams, + model: str, + drop_params: bool, + ) -> Dict: + mapped_params: Dict[str, Any] = {} + + for key, value in video_create_optional_params.items(): + if value is None or key in {"model", "prompt", "extra_headers", "user"}: + continue + if key == "input_reference": + mapped_params["first_frame_image"] = value + elif key == "seconds": + mapped_params["duration"] = self._coerce_duration(value) + elif key == "size": + mapped_params["resolution"] = value + elif key != "extra_body": + mapped_params[key] = value + + extra_body = video_create_optional_params.get("extra_body") + if isinstance(extra_body, dict): + mapped_params.update({key: value for key, value in extra_body.items() if value is not None}) + + return mapped_params + + def validate_environment( + self, + headers: dict, + model: str, + api_key: Optional[str] = None, + litellm_params: Optional[GenericLiteLLMParams] = None, + ) -> dict: + if litellm_params and litellm_params.api_key: + api_key = api_key or litellm_params.api_key + + api_key = api_key or litellm.api_key or get_secret_str("MINIMAX_API_KEY") + if not api_key: + raise ValueError( + "MiniMax API key is required. Set MINIMAX_API_KEY environment variable or pass api_key parameter." + ) + + headers.update( + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + ) + return headers + + def get_complete_url( + self, + model: str, + api_base: Optional[str], + litellm_params: dict, + ) -> str: + """Return the regional MiniMax v1 root used by all video operations.""" + base_url = api_base or get_secret_str("MINIMAX_API_BASE") or "https://api.minimax.io/v1" + base_url = base_url.rstrip("/") + for suffix in ("/video_generation", "/query/video_generation", "/files/retrieve"): + if base_url.endswith(suffix): + base_url = base_url[: -len(suffix)] + break + if not base_url.endswith("/v1"): + base_url = f"{base_url}/v1" + return base_url + + def transform_video_create_request( + self, + model: str, + prompt: str, + api_base: str, + video_create_optional_request_params: Dict, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[Dict, RequestFiles, str]: + request_data: Dict[str, Any] = {"model": model, "prompt": prompt} + request_data.update(video_create_optional_request_params) + request_data.pop("extra_headers", None) + request_data.pop("extra_body", None) + request_data.pop("user", None) + return request_data, [], f"{api_base.rstrip('/')}/video_generation" + + def transform_video_create_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + request_data: Optional[Dict] = None, + ) -> VideoObject: + response_data = self._parse_json_response(raw_response) + self._raise_for_provider_error(raw_response, response_data) + task_id = response_data.get("task_id") + if task_id is None: + raise ValueError("MiniMax did not return a task_id for video generation") + + video_data: Dict[str, Any] = { + "id": str(task_id), + "object": "video", + "status": self._map_status(response_data.get("status", "queueing")), + "model": model, + } + self._add_request_metadata(video_data, request_data) + video_obj = VideoObject(**video_data) + self._wrap_video_id(video_obj, custom_llm_provider, model) + video_obj.usage = self._usage_from_video(video_obj) + return video_obj + + def transform_video_content_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + variant: Optional[str] = None, + ) -> Tuple[str, Dict]: + task_id = quote(extract_original_video_id(video_id), safe="") + return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} + + def transform_video_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> bytes: + query_data = self._parse_json_response(raw_response) + self._raise_for_provider_error(raw_response, query_data) + file_id = query_data.get("file_id") + if file_id is None: + status = self._map_status(query_data.get("status", "processing")) + raise ValueError(f"MiniMax video is not ready for download (status: {status})") + + headers = self._request_headers(raw_response) + api_base = self._api_base_from_response(raw_response) + file_url = f"{api_base}/files/retrieve?file_id={quote(str(file_id), safe='')}" + client: HTTPHandler = _get_httpx_client() + file_response = client.get(file_url, headers=headers) + self._raise_for_status(file_response) + if self._is_binary_response(file_response): + return file_response.content + + file_data = self._parse_json_response(file_response) + self._raise_for_provider_error(file_response, file_data) + download_url = self._get_download_url(file_data) + if not download_url: + raise ValueError("MiniMax did not return a video download URL") + + video_response = client.get(download_url) + self._raise_for_status(video_response) + return video_response.content + + async def async_transform_video_content_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> bytes: + query_data = self._parse_json_response(raw_response) + self._raise_for_provider_error(raw_response, query_data) + file_id = query_data.get("file_id") + if file_id is None: + status = self._map_status(query_data.get("status", "processing")) + raise ValueError(f"MiniMax video is not ready for download (status: {status})") + + headers = self._request_headers(raw_response) + api_base = self._api_base_from_response(raw_response) + file_url = f"{api_base}/files/retrieve?file_id={quote(str(file_id), safe='')}" + client: AsyncHTTPHandler = get_async_httpx_client(llm_provider=litellm.LlmProviders.MINIMAX) + file_response = await client.get(file_url, headers=headers) + self._raise_for_status(file_response) + if self._is_binary_response(file_response): + return file_response.content + + file_data = self._parse_json_response(file_response) + self._raise_for_provider_error(file_response, file_data) + download_url = self._get_download_url(file_data) + if not download_url: + raise ValueError("MiniMax did not return a video download URL") + + video_response = await client.get(download_url) + self._raise_for_status(video_response) + return video_response.content + + def transform_video_status_retrieve_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[str, Dict]: + task_id = quote(extract_original_video_id(video_id), safe="") + return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} + + def transform_video_status_retrieve_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> VideoObject: + response_data = self._parse_json_response(raw_response) + self._raise_for_provider_error(raw_response, response_data) + task_id = response_data.get("task_id") + if task_id is None: + raise ValueError("MiniMax did not return a task_id for video status") + model = response_data.get("model") + video_data: Dict[str, Any] = { + "id": str(task_id), + "object": "video", + "status": self._map_status(response_data.get("status", "processing")), + "model": model, + } + if response_data.get("status") and self._map_status(response_data["status"]) == "failed": + video_data["error"] = { + "code": "generation_failed", + "message": str(response_data.get("status")), + } + if response_data.get("duration") is not None: + video_data["seconds"] = str(response_data["duration"]) + if response_data.get("resolution") is not None: + video_data["size"] = str(response_data["resolution"]) + + video_obj = VideoObject(**video_data) + self._wrap_video_id(video_obj, custom_llm_provider, model) + return video_obj + + def transform_video_remix_request( + self, + video_id: str, + prompt: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + extra_body: Optional[Dict[str, Any]] = None, + ) -> Tuple[str, Dict]: + raise NotImplementedError("Video remix is not supported by the MiniMax v1 API") + + def transform_video_remix_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> VideoObject: + raise NotImplementedError("Video remix is not supported by the MiniMax v1 API") + + def transform_video_list_request( + self, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + after: Optional[str] = None, + limit: Optional[int] = None, + order: Optional[str] = None, + extra_query: Optional[Dict[str, Any]] = None, + ) -> Tuple[str, Dict]: + raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") + + def transform_video_list_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: Optional[str] = None, + ) -> Dict[str, str]: + raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") + + def transform_video_delete_request( + self, + video_id: str, + api_base: str, + litellm_params: GenericLiteLLMParams, + headers: dict, + ) -> Tuple[str, Dict]: + raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") + + def transform_video_delete_response( + self, + raw_response: httpx.Response, + logging_obj: LiteLLMLoggingObj, + ) -> VideoObject: + raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") + + def get_error_class( + self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] + ) -> BaseLLMException: + return BaseLLMException(status_code=status_code, message=error_message, headers=headers) + + @staticmethod + def _coerce_duration(value: Any) -> Any: + try: + return int(float(value)) + except (TypeError, ValueError): + return value + + @staticmethod + def _map_status(status: Any) -> str: + normalized = str(status or "").strip().lower().replace(" ", "_") + if normalized in {"success", "succeeded", "completed", "complete"}: + return "completed" + if normalized in {"fail", "failed", "error", "cancelled", "canceled"}: + return "failed" + if normalized in {"queueing", "queued", "preparing", "pending"}: + return "queued" + return "in_progress" + + @staticmethod + def _add_request_metadata(video_data: Dict[str, Any], request_data: Optional[Dict]) -> None: + if not request_data: + return + if request_data.get("duration") is not None: + video_data["seconds"] = str(request_data["duration"]) + if request_data.get("resolution") is not None: + video_data["size"] = str(request_data["resolution"]) + + @staticmethod + def _usage_from_video(video_obj: VideoObject) -> Dict[str, Any]: + if video_obj.seconds is None: + return {} + try: + return {"duration_seconds": float(video_obj.seconds)} + except (TypeError, ValueError): + return {} + + @staticmethod + def _wrap_video_id(video_obj: VideoObject, provider: Optional[str], model: Optional[str]) -> None: + if provider and video_obj.id: + video_obj.id = encode_video_id_with_provider(video_obj.id, provider, model) + + @staticmethod + def _parse_json_response(raw_response: httpx.Response) -> Dict[str, Any]: + try: + return raw_response.json() + except Exception as exc: + raise ValueError(f"MiniMax returned an invalid JSON response: {exc}") from exc + + def _raise_for_provider_error(self, raw_response: httpx.Response, response_data: Dict[str, Any]) -> None: + self._raise_for_status(raw_response) + base_resp = response_data.get("base_resp") or {} + status_code = base_resp.get("status_code") + if status_code not in (None, 0, "0"): + message = base_resp.get("status_msg") or "MiniMax video request failed" + raise self.get_error_class(str(message), raw_response.status_code, raw_response.headers) + + def _raise_for_status(self, raw_response: httpx.Response) -> None: + if raw_response.status_code >= 400: + raise self.get_error_class(raw_response.text, raw_response.status_code, raw_response.headers) + + @staticmethod + def _request_headers(raw_response: httpx.Response) -> Dict[str, str]: + request = getattr(raw_response, "request", None) + request_headers = getattr(request, "headers", None) + if isinstance(request_headers, (dict, httpx.Headers)): + authorization = request_headers.get("Authorization") + if authorization: + return {"Authorization": authorization} + return {} + + @staticmethod + def _api_base_from_response(raw_response: httpx.Response) -> str: + request = getattr(raw_response, "request", None) + request_url = getattr(request, "url", None) or getattr(raw_response, "url", None) + if request_url is None: + return "https://api.minimax.io/v1" + parsed = urlsplit(str(request_url)) + path = parsed.path + v1_index = path.find("/v1/") + root_path = path[: v1_index + len("/v1")] if v1_index >= 0 else "/v1" + return urlunsplit((parsed.scheme, parsed.netloc, root_path, "", "")) + + @staticmethod + def _is_binary_response(raw_response: httpx.Response) -> bool: + content_type = raw_response.headers.get("content-type", "") + return content_type.startswith("video/") or content_type == "application/octet-stream" + + @staticmethod + def _get_download_url(response_data: Dict[str, Any]) -> Optional[str]: + file_data = response_data.get("file") + if isinstance(file_data, dict): + for key in ("download_url", "url"): + if file_data.get(key): + return str(file_data[key]) + for key in ("download_url", "url"): + if response_data.get(key): + return str(response_data[key]) + return None diff --git a/litellm/utils.py b/litellm/utils.py index ad661fd3d16..bca58da6cf9 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8710,6 +8710,10 @@ class ProviderConfigManager: from litellm.llms.runwayml.videos.transformation import RunwayMLVideoConfig return RunwayMLVideoConfig() + elif LlmProviders.MINIMAX == provider: + from litellm.llms.minimax.videos.transformation import MinimaxVideoConfig + + return MinimaxVideoConfig() return None @staticmethod diff --git a/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py b/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py new file mode 100644 index 00000000000..2b686d44634 --- /dev/null +++ b/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py @@ -0,0 +1,144 @@ +"""Tests for MiniMax v1 video generation transformations.""" + +from unittest.mock import Mock, patch + +import httpx + +import litellm +from litellm.llms.minimax.videos.transformation import MinimaxVideoConfig +from litellm.types.router import GenericLiteLLMParams +from litellm.types.videos.main import VideoObject +from litellm.types.videos.utils import decode_video_id_with_provider, encode_video_id_with_provider +from litellm.utils import ProviderConfigManager + + +class TestMinimaxVideoTransformation: + def setup_method(self): + self.config = MinimaxVideoConfig() + self.logging_obj = Mock() + + def test_provider_config_is_registered(self): + config = ProviderConfigManager.get_provider_video_config( + model="MiniMax-Hailuo-2.3", + provider=litellm.LlmProviders.MINIMAX, + ) + assert isinstance(config, MinimaxVideoConfig) + + def test_transform_create_request_maps_text_and_image_parameters(self): + params = self.config.map_openai_params( + { + "input_reference": "https://example.com/frame.png", + "seconds": "6", + "size": "768P", + "extra_body": {"prompt_optimizer": True}, + }, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + data, files, url = self.config.transform_video_create_request( + model="MiniMax-Hailuo-2.3", + prompt="A city at sunrise", + api_base="https://api.minimax.io/v1", + video_create_optional_request_params=params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data == { + "model": "MiniMax-Hailuo-2.3", + "prompt": "A city at sunrise", + "first_frame_image": "https://example.com/frame.png", + "duration": 6, + "resolution": "768P", + "prompt_optimizer": True, + } + assert files == [] + assert url == "https://api.minimax.io/v1/video_generation" + + def test_create_response_wraps_task_id_and_maps_status(self): + response = httpx.Response( + 200, + json={"task_id": "task-123", "base_resp": {"status_code": 0}}, + ) + result = self.config.transform_video_create_response( + model="MiniMax-Hailuo-2.3", + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="minimax", + request_data={"duration": 6, "resolution": "768P"}, + ) + + assert isinstance(result, VideoObject) + decoded = decode_video_id_with_provider(result.id) + assert decoded["custom_llm_provider"] == "minimax" + assert decoded["model_id"] == "MiniMax-Hailuo-2.3" + assert decoded["video_id"] == "task-123" + assert result.status == "queued" + assert result.seconds == "6" + assert result.size == "768P" + + def test_status_request_and_response(self): + encoded_id = encode_video_id_with_provider("task-123", "minimax", "MiniMax-Hailuo-2.3") + url, data = self.config.transform_video_status_retrieve_request( + video_id=encoded_id, + api_base="https://api.minimaxi.com/v1", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert url == "https://api.minimaxi.com/v1/query/video_generation?task_id=task-123" + assert data == {} + + response = httpx.Response( + 200, + json={ + "task_id": "task-123", + "status": "Success", + "file_id": "file-123", + "base_resp": {"status_code": 0}, + }, + ) + result = self.config.transform_video_status_retrieve_response( + raw_response=response, + logging_obj=self.logging_obj, + custom_llm_provider="minimax", + ) + decoded = decode_video_id_with_provider(result.id) + assert decoded["video_id"] == "task-123" + assert decoded["custom_llm_provider"] == "minimax" + assert result.status == "completed" + + def test_content_response_retrieves_file_and_downloads_video(self): + query_request = httpx.Request( + "GET", + "https://api.minimax.io/v1/query/video_generation?task_id=task-123", + headers={"Authorization": "Bearer test-key"}, + ) + query_response = httpx.Response( + 200, + json={"task_id": "task-123", "status": "Success", "file_id": "file-123"}, + request=query_request, + ) + file_response = httpx.Response( + 200, + json={"file": {"download_url": "https://cdn.example.com/video.mp4"}}, + request=httpx.Request("GET", "https://api.minimax.io/v1/files/retrieve"), + ) + video_response = httpx.Response( + 200, + content=b"video-bytes", + headers={"content-type": "video/mp4"}, + request=httpx.Request("GET", "https://cdn.example.com/video.mp4"), + ) + client = Mock() + client.get.side_effect = [file_response, video_response] + + with patch( + "litellm.llms.minimax.videos.transformation._get_httpx_client", + return_value=client, + ): + result = self.config.transform_video_content_response(query_response, self.logging_obj) + + assert result == b"video-bytes" + assert client.get.call_args_list[0].args[0] == ("https://api.minimax.io/v1/files/retrieve?file_id=file-123") + assert client.get.call_args_list[0].kwargs["headers"]["Authorization"] == "Bearer test-key" + assert client.get.call_args_list[1].args[0] == "https://cdn.example.com/video.mp4" From 915348690eef785ce0eeb8d6d11af1cfa26dbd41 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:52:09 +0800 Subject: [PATCH 2/3] fix(minimax): harden video adapter checks --- litellm/llms/minimax/videos/transformation.py | 135 +++-- .../test_minimax_video_transformation.py | 487 +++++++++++++++++- 2 files changed, 567 insertions(+), 55 deletions(-) diff --git a/litellm/llms/minimax/videos/transformation.py b/litellm/llms/minimax/videos/transformation.py index e42b849e732..72627c74fbc 100644 --- a/litellm/llms/minimax/videos/transformation.py +++ b/litellm/llms/minimax/videos/transformation.py @@ -1,12 +1,16 @@ """MiniMax v1 video generation transformations.""" -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union +import base64 +from os import PathLike +from typing import TYPE_CHECKING from urllib.parse import quote, urlsplit, urlunsplit import httpx from httpx._types import RequestFiles import litellm +from litellm.images.utils import ImageEditRequestUtils +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.videos.transformation import BaseVideoConfig from litellm.llms.custom_httpx.http_handler import ( @@ -28,7 +32,7 @@ if TYPE_CHECKING: LiteLLMLoggingObj = _LiteLLMLoggingObj else: - LiteLLMLoggingObj = Any + LiteLLMLoggingObj = object class MinimaxVideoConfig(BaseVideoConfig): @@ -56,14 +60,14 @@ class MinimaxVideoConfig(BaseVideoConfig): video_create_optional_params: VideoCreateOptionalRequestParams, model: str, drop_params: bool, - ) -> Dict: - mapped_params: Dict[str, Any] = {} + ) -> dict: + mapped_params: dict = {} for key, value in video_create_optional_params.items(): if value is None or key in {"model", "prompt", "extra_headers", "user"}: continue if key == "input_reference": - mapped_params["first_frame_image"] = value + mapped_params["first_frame_image"] = self._prepare_first_frame_image(value) elif key == "seconds": mapped_params["duration"] = self._coerce_duration(value) elif key == "size": @@ -81,8 +85,8 @@ class MinimaxVideoConfig(BaseVideoConfig): self, headers: dict, model: str, - api_key: Optional[str] = None, - litellm_params: Optional[GenericLiteLLMParams] = None, + api_key: str | None = None, + litellm_params: GenericLiteLLMParams | None = None, ) -> dict: if litellm_params and litellm_params.api_key: api_key = api_key or litellm_params.api_key @@ -104,13 +108,13 @@ class MinimaxVideoConfig(BaseVideoConfig): def get_complete_url( self, model: str, - api_base: Optional[str], + api_base: str | None, litellm_params: dict, ) -> str: """Return the regional MiniMax v1 root used by all video operations.""" base_url = api_base or get_secret_str("MINIMAX_API_BASE") or "https://api.minimax.io/v1" base_url = base_url.rstrip("/") - for suffix in ("/video_generation", "/query/video_generation", "/files/retrieve"): + for suffix in ("/query/video_generation", "/video_generation", "/files/retrieve"): if base_url.endswith(suffix): base_url = base_url[: -len(suffix)] break @@ -123,11 +127,11 @@ class MinimaxVideoConfig(BaseVideoConfig): model: str, prompt: str, api_base: str, - video_create_optional_request_params: Dict, + video_create_optional_request_params: dict, litellm_params: GenericLiteLLMParams, headers: dict, - ) -> Tuple[Dict, RequestFiles, str]: - request_data: Dict[str, Any] = {"model": model, "prompt": prompt} + ) -> tuple[dict, RequestFiles, str]: + request_data: dict = {"model": model, "prompt": prompt} request_data.update(video_create_optional_request_params) request_data.pop("extra_headers", None) request_data.pop("extra_body", None) @@ -139,8 +143,8 @@ class MinimaxVideoConfig(BaseVideoConfig): model: str, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str] = None, - request_data: Optional[Dict] = None, + custom_llm_provider: str | None = None, + request_data: dict | None = None, ) -> VideoObject: response_data = self._parse_json_response(raw_response) self._raise_for_provider_error(raw_response, response_data) @@ -148,7 +152,7 @@ class MinimaxVideoConfig(BaseVideoConfig): if task_id is None: raise ValueError("MiniMax did not return a task_id for video generation") - video_data: Dict[str, Any] = { + video_data: dict = { "id": str(task_id), "object": "video", "status": self._map_status(response_data.get("status", "queueing")), @@ -166,8 +170,8 @@ class MinimaxVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, - variant: Optional[str] = None, - ) -> Tuple[str, Dict]: + variant: str | None = None, + ) -> tuple[str, dict]: task_id = quote(extract_original_video_id(video_id), safe="") return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} @@ -198,7 +202,7 @@ class MinimaxVideoConfig(BaseVideoConfig): if not download_url: raise ValueError("MiniMax did not return a video download URL") - video_response = client.get(download_url) + video_response = safe_get(client, download_url) self._raise_for_status(video_response) return video_response.content @@ -229,7 +233,7 @@ class MinimaxVideoConfig(BaseVideoConfig): if not download_url: raise ValueError("MiniMax did not return a video download URL") - video_response = await client.get(download_url) + video_response = await async_safe_get(client, download_url) self._raise_for_status(video_response) return video_response.content @@ -239,7 +243,7 @@ class MinimaxVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, - ) -> Tuple[str, Dict]: + ) -> tuple[str, dict]: task_id = quote(extract_original_video_id(video_id), safe="") return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} @@ -247,7 +251,7 @@ class MinimaxVideoConfig(BaseVideoConfig): self, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str] = None, + custom_llm_provider: str | None = None, ) -> VideoObject: response_data = self._parse_json_response(raw_response) self._raise_for_provider_error(raw_response, response_data) @@ -255,7 +259,7 @@ class MinimaxVideoConfig(BaseVideoConfig): if task_id is None: raise ValueError("MiniMax did not return a task_id for video status") model = response_data.get("model") - video_data: Dict[str, Any] = { + video_data: dict = { "id": str(task_id), "object": "video", "status": self._map_status(response_data.get("status", "processing")), @@ -282,15 +286,15 @@ class MinimaxVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, - extra_body: Optional[Dict[str, Any]] = None, - ) -> Tuple[str, Dict]: + extra_body: dict | None = None, + ) -> tuple[str, dict]: raise NotImplementedError("Video remix is not supported by the MiniMax v1 API") def transform_video_remix_response( self, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str] = None, + custom_llm_provider: str | None = None, ) -> VideoObject: raise NotImplementedError("Video remix is not supported by the MiniMax v1 API") @@ -299,19 +303,19 @@ class MinimaxVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, - after: Optional[str] = None, - limit: Optional[int] = None, - order: Optional[str] = None, - extra_query: Optional[Dict[str, Any]] = None, - ) -> Tuple[str, Dict]: + after: str | None = None, + limit: int | None = None, + order: str | None = None, + extra_query: dict | None = None, + ) -> tuple[str, dict]: raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") def transform_video_list_response( self, raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, - custom_llm_provider: Optional[str] = None, - ) -> Dict[str, str]: + custom_llm_provider: str | None = None, + ) -> dict[str, str]: raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") def transform_video_delete_request( @@ -320,7 +324,7 @@ class MinimaxVideoConfig(BaseVideoConfig): api_base: str, litellm_params: GenericLiteLLMParams, headers: dict, - ) -> Tuple[str, Dict]: + ) -> tuple[str, dict]: raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") def transform_video_delete_response( @@ -330,20 +334,18 @@ class MinimaxVideoConfig(BaseVideoConfig): ) -> VideoObject: raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") - def get_error_class( - self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] - ) -> BaseLLMException: + def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException: return BaseLLMException(status_code=status_code, message=error_message, headers=headers) @staticmethod - def _coerce_duration(value: Any) -> Any: + def _coerce_duration(value: object) -> object: try: return int(float(value)) except (TypeError, ValueError): return value @staticmethod - def _map_status(status: Any) -> str: + def _map_status(status: object) -> str: normalized = str(status or "").strip().lower().replace(" ", "_") if normalized in {"success", "succeeded", "completed", "complete"}: return "completed" @@ -354,7 +356,7 @@ class MinimaxVideoConfig(BaseVideoConfig): return "in_progress" @staticmethod - def _add_request_metadata(video_data: Dict[str, Any], request_data: Optional[Dict]) -> None: + def _add_request_metadata(video_data: dict, request_data: dict | None) -> None: if not request_data: return if request_data.get("duration") is not None: @@ -363,7 +365,7 @@ class MinimaxVideoConfig(BaseVideoConfig): video_data["size"] = str(request_data["resolution"]) @staticmethod - def _usage_from_video(video_obj: VideoObject) -> Dict[str, Any]: + def _usage_from_video(video_obj: VideoObject) -> dict: if video_obj.seconds is None: return {} try: @@ -372,18 +374,18 @@ class MinimaxVideoConfig(BaseVideoConfig): return {} @staticmethod - def _wrap_video_id(video_obj: VideoObject, provider: Optional[str], model: Optional[str]) -> None: + def _wrap_video_id(video_obj: VideoObject, provider: str | None, model: str | None) -> None: if provider and video_obj.id: video_obj.id = encode_video_id_with_provider(video_obj.id, provider, model) - @staticmethod - def _parse_json_response(raw_response: httpx.Response) -> Dict[str, Any]: + def _parse_json_response(self, raw_response: httpx.Response) -> dict: + self._raise_for_status(raw_response) try: return raw_response.json() except Exception as exc: raise ValueError(f"MiniMax returned an invalid JSON response: {exc}") from exc - def _raise_for_provider_error(self, raw_response: httpx.Response, response_data: Dict[str, Any]) -> None: + def _raise_for_provider_error(self, raw_response: httpx.Response, response_data: dict) -> None: self._raise_for_status(raw_response) base_resp = response_data.get("base_resp") or {} status_code = base_resp.get("status_code") @@ -396,8 +398,8 @@ class MinimaxVideoConfig(BaseVideoConfig): raise self.get_error_class(raw_response.text, raw_response.status_code, raw_response.headers) @staticmethod - def _request_headers(raw_response: httpx.Response) -> Dict[str, str]: - request = getattr(raw_response, "request", None) + def _request_headers(raw_response: httpx.Response) -> dict[str, str]: + request = getattr(raw_response, "_request", None) request_headers = getattr(request, "headers", None) if isinstance(request_headers, (dict, httpx.Headers)): authorization = request_headers.get("Authorization") @@ -407,8 +409,8 @@ class MinimaxVideoConfig(BaseVideoConfig): @staticmethod def _api_base_from_response(raw_response: httpx.Response) -> str: - request = getattr(raw_response, "request", None) - request_url = getattr(request, "url", None) or getattr(raw_response, "url", None) + request = getattr(raw_response, "_request", None) + request_url = getattr(request, "url", None) if request_url is None: return "https://api.minimax.io/v1" parsed = urlsplit(str(request_url)) @@ -423,7 +425,7 @@ class MinimaxVideoConfig(BaseVideoConfig): return content_type.startswith("video/") or content_type == "application/octet-stream" @staticmethod - def _get_download_url(response_data: Dict[str, Any]) -> Optional[str]: + def _get_download_url(response_data: dict) -> str | None: file_data = response_data.get("file") if isinstance(file_data, dict): for key in ("download_url", "url"): @@ -433,3 +435,38 @@ class MinimaxVideoConfig(BaseVideoConfig): if response_data.get(key): return str(response_data[key]) return None + + @staticmethod + def _prepare_first_frame_image(image: object) -> object: + if isinstance(image, str): + return image + + content = image + content_type = None + if isinstance(image, tuple): + if len(image) < 2: + raise ValueError("MiniMax input_reference tuple must include file content") + content = image[1] + if len(image) >= 3 and isinstance(image[2], str): + content_type = image[2] + + if isinstance(content, PathLike): + with open(content, "rb") as image_file: + image_bytes = image_file.read() + elif isinstance(content, bytes): + image_bytes = content + elif hasattr(content, "read"): + current_position = content.tell() if hasattr(content, "tell") else None + if hasattr(content, "seek"): + content.seek(0) + image_bytes = content.read() + if current_position is not None and hasattr(content, "seek"): + content.seek(current_position) + else: + raise TypeError("MiniMax input_reference must be a URL, path, bytes, or file object") + + if not isinstance(image_bytes, bytes): + raise TypeError("MiniMax input_reference file content must be bytes") + content_type = content_type or ImageEditRequestUtils.get_image_content_type(image_bytes) + encoded = base64.b64encode(image_bytes).decode("ascii") + return f"data:{content_type};base64,{encoded}" diff --git a/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py b/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py index 2b686d44634..84e58efd2f9 100644 --- a/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py +++ b/tests/test_litellm/llms/minimax/videos/test_minimax_video_transformation.py @@ -1,8 +1,10 @@ """Tests for MiniMax v1 video generation transformations.""" -from unittest.mock import Mock, patch +from io import BytesIO +from unittest.mock import AsyncMock, Mock, patch import httpx +import pytest import litellm from litellm.llms.minimax.videos.transformation import MinimaxVideoConfig @@ -24,6 +26,88 @@ class TestMinimaxVideoTransformation: ) assert isinstance(config, MinimaxVideoConfig) + def test_supported_params_and_environment(self): + assert self.config.get_supported_openai_params("MiniMax-Hailuo-2.3") == [ + "model", + "prompt", + "input_reference", + "seconds", + "size", + "user", + "extra_headers", + "extra_body", + "prompt_optimizer", + "fast_pretreatment", + "duration", + "resolution", + "callback_url", + ] + + headers = self.config.validate_environment( + headers={"X-Test": "value"}, + model="MiniMax-Hailuo-2.3", + litellm_params=GenericLiteLLMParams(api_key="params-key"), + ) + assert headers == { + "X-Test": "value", + "Authorization": "Bearer params-key", + "Content-Type": "application/json", + } + + explicit_headers = self.config.validate_environment( + headers={}, + model="MiniMax-Hailuo-2.3", + api_key="explicit-key", + litellm_params=GenericLiteLLMParams(api_key="params-key"), + ) + assert explicit_headers["Authorization"] == "Bearer explicit-key" + + with ( + patch.object(litellm, "api_key", None), + patch( + "litellm.llms.minimax.videos.transformation.get_secret_str", + return_value=None, + ), + pytest.raises(ValueError, match="MiniMax API key is required"), + ): + self.config.validate_environment( + headers={}, + model="MiniMax-Hailuo-2.3", + litellm_params=GenericLiteLLMParams(), + ) + + @pytest.mark.parametrize( + ("api_base", "expected"), + [ + ("https://api.minimax.io/v1/video_generation", "https://api.minimax.io/v1"), + ("https://api.minimaxi.com/v1/query/video_generation", "https://api.minimaxi.com/v1"), + ("https://api.minimax.io/custom/", "https://api.minimax.io/custom/v1"), + ], + ) + def test_get_complete_url(self, api_base, expected): + assert ( + self.config.get_complete_url( + model="MiniMax-Hailuo-2.3", + api_base=api_base, + litellm_params={}, + ) + == expected + ) + + def test_get_complete_url_uses_configured_default(self): + with patch( + "litellm.llms.minimax.videos.transformation.get_secret_str", + return_value="https://api.minimaxi.com/v1/files/retrieve", + ): + assert ( + self.config.get_complete_url( + model="MiniMax-Hailuo-2.3", + api_base=None, + litellm_params={}, + ) + == "https://api.minimaxi.com/v1" + ) + def test_transform_create_request_maps_text_and_image_parameters(self): params = self.config.map_openai_params( { @@ -55,6 +139,121 @@ class TestMinimaxVideoTransformation: assert files == [] assert url == "https://api.minimax.io/v1/video_generation" + def test_file_inputs_are_encoded_as_data_urls(self, tmp_path): + image_bytes = b"\x89PNG\r\n\x1a\nimage" + + byte_params = self.config.map_openai_params( + {"input_reference": image_bytes}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + assert byte_params["first_frame_image"].startswith("data:image/png;base64,") + + image_file = BytesIO(image_bytes) + image_file.seek(2) + file_params = self.config.map_openai_params( + {"input_reference": image_file}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + assert file_params["first_frame_image"] == byte_params["first_frame_image"] + assert image_file.tell() == 2 + + tuple_params = self.config.map_openai_params( + {"input_reference": ("frame.webp", image_bytes, "image/webp")}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + assert tuple_params["first_frame_image"].startswith("data:image/webp;base64,") + + image_path = tmp_path / "frame.png" + image_path.write_bytes(image_bytes) + path_params = self.config.map_openai_params( + {"input_reference": image_path}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + assert path_params["first_frame_image"] == byte_params["first_frame_image"] + + def test_invalid_file_inputs_are_rejected(self): + with pytest.raises(ValueError, match="tuple must include file content"): + self.config.map_openai_params( + {"input_reference": ("frame.png",)}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + + with pytest.raises(TypeError, match="URL, path, bytes, or file object"): + self.config.map_openai_params( + {"input_reference": object()}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + + text_file = Mock() + text_file.read.return_value = "not-bytes" + with pytest.raises(TypeError, match="file content must be bytes"): + self.config.map_openai_params( + {"input_reference": text_file}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + + def test_map_params_skips_empty_values_and_preserves_provider_fields(self): + params = self.config.map_openai_params( + { + "model": "ignored", + "prompt": "ignored", + "user": "ignored", + "extra_headers": {"X-Test": "ignored"}, + "seconds": "not-a-number", + "prompt_optimizer": False, + "callback_url": None, + "extra_body": { + "fast_pretreatment": True, + "prompt_optimizer": None, + }, + }, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + assert params == { + "duration": "not-a-number", + "prompt_optimizer": False, + "fast_pretreatment": True, + } + + assert ( + self.config.map_openai_params( + {"extra_body": "ignored"}, + model="MiniMax-Hailuo-2.3", + drop_params=False, + ) + == {} + ) + + def test_transform_create_request_removes_sdk_only_fields(self): + data, files, url = self.config.transform_video_create_request( + model="MiniMax-Hailuo-2.3", + prompt="A city at sunrise", + api_base="https://api.minimax.io/v1/", + video_create_optional_request_params={ + "duration": 6, + "extra_headers": {"X-Test": "value"}, + "extra_body": {"ignored": True}, + "user": "user-123", + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert data == { + "model": "MiniMax-Hailuo-2.3", + "prompt": "A city at sunrise", + "duration": 6, + } + assert files == [] + assert url == "https://api.minimax.io/v1/video_generation" + def test_create_response_wraps_task_id_and_maps_status(self): response = httpx.Response( 200, @@ -76,6 +275,44 @@ class TestMinimaxVideoTransformation: assert result.status == "queued" assert result.seconds == "6" assert result.size == "768P" + assert result.usage == {"duration_seconds": 6.0} + + def test_create_response_validates_provider_payload(self): + with pytest.raises(ValueError, match="did not return a task_id"): + self.config.transform_video_create_response( + model="MiniMax-Hailuo-2.3", + raw_response=httpx.Response(200, json={"base_resp": {"status_code": 0}}), + logging_obj=self.logging_obj, + ) + + with pytest.raises(Exception, match="quota exceeded"): + self.config.transform_video_create_response( + model="MiniMax-Hailuo-2.3", + raw_response=httpx.Response( + 200, + json={ + "base_resp": { + "status_code": 1001, + "status_msg": "quota exceeded", + } + }, + ), + logging_obj=self.logging_obj, + ) + + with pytest.raises(Exception, match="upstream error"): + self.config.transform_video_create_response( + model="MiniMax-Hailuo-2.3", + raw_response=httpx.Response(500, text="upstream error"), + logging_obj=self.logging_obj, + ) + + with pytest.raises(ValueError, match="invalid JSON response"): + self.config.transform_video_create_response( + model="MiniMax-Hailuo-2.3", + raw_response=httpx.Response(200, text="not-json"), + logging_obj=self.logging_obj, + ) def test_status_request_and_response(self): encoded_id = encode_video_id_with_provider("task-123", "minimax", "MiniMax-Hailuo-2.3") @@ -107,6 +344,46 @@ class TestMinimaxVideoTransformation: assert decoded["custom_llm_provider"] == "minimax" assert result.status == "completed" + def test_content_request_and_failed_status_response(self): + encoded_id = encode_video_id_with_provider("task/id", "minimax", "MiniMax-Hailuo-2.3") + content_url, content_data = self.config.transform_video_content_request( + video_id=encoded_id, + api_base="https://api.minimax.io/v1/", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + assert content_url == "https://api.minimax.io/v1/query/video_generation?task_id=task%2Fid" + assert content_data == {} + + result = self.config.transform_video_status_retrieve_response( + raw_response=httpx.Response( + 200, + json={ + "task_id": "task-123", + "model": "MiniMax-Hailuo-2.3", + "status": "Failed", + "duration": 10, + "resolution": "1080P", + "base_resp": {"status_code": "0"}, + }, + ), + logging_obj=self.logging_obj, + ) + assert result.id == "task-123" + assert result.status == "failed" + assert result.error == { + "code": "generation_failed", + "message": "Failed", + } + assert result.seconds == "10" + assert result.size == "1080P" + + with pytest.raises(ValueError, match="did not return a task_id"): + self.config.transform_video_status_retrieve_response( + raw_response=httpx.Response(200, json={"base_resp": {"status_code": 0}}), + logging_obj=self.logging_obj, + ) + def test_content_response_retrieves_file_and_downloads_video(self): query_request = httpx.Request( "GET", @@ -130,15 +407,213 @@ class TestMinimaxVideoTransformation: request=httpx.Request("GET", "https://cdn.example.com/video.mp4"), ) client = Mock() - client.get.side_effect = [file_response, video_response] + client.get.return_value = file_response - with patch( - "litellm.llms.minimax.videos.transformation._get_httpx_client", - return_value=client, + with ( + patch( + "litellm.llms.minimax.videos.transformation._get_httpx_client", + return_value=client, + ), + patch( + "litellm.llms.minimax.videos.transformation.safe_get", + return_value=video_response, + ) as safe_get_mock, ): result = self.config.transform_video_content_response(query_response, self.logging_obj) assert result == b"video-bytes" assert client.get.call_args_list[0].args[0] == ("https://api.minimax.io/v1/files/retrieve?file_id=file-123") assert client.get.call_args_list[0].kwargs["headers"]["Authorization"] == "Bearer test-key" - assert client.get.call_args_list[1].args[0] == "https://cdn.example.com/video.mp4" + safe_get_mock.assert_called_once_with(client, "https://cdn.example.com/video.mp4") + + def test_content_response_handles_binary_and_incomplete_results(self): + query_response = httpx.Response( + 200, + json={"task_id": "task-123", "status": "Success", "file_id": "file-123"}, + request=httpx.Request( + "GET", + "https://api.minimax.io/v1/query/video_generation?task_id=task-123", + ), + ) + binary_response = httpx.Response( + 200, + content=b"video-bytes", + headers={"content-type": "application/octet-stream"}, + ) + client = Mock() + client.get.return_value = binary_response + + with patch( + "litellm.llms.minimax.videos.transformation._get_httpx_client", + return_value=client, + ): + assert self.config.transform_video_content_response(query_response, self.logging_obj) == b"video-bytes" + + with pytest.raises(ValueError, match="not ready for download"): + self.config.transform_video_content_response( + httpx.Response(200, json={"status": "Processing"}), + self.logging_obj, + ) + + client.get.return_value = httpx.Response(200, json={"base_resp": {"status_code": 0}}) + with ( + patch( + "litellm.llms.minimax.videos.transformation._get_httpx_client", + return_value=client, + ), + pytest.raises(ValueError, match="did not return a video download URL"), + ): + self.config.transform_video_content_response(query_response, self.logging_obj) + + @pytest.mark.asyncio + async def test_async_content_response_downloads_video(self): + query_response = httpx.Response( + 200, + json={"task_id": "task-123", "status": "Success", "file_id": "file-123"}, + request=httpx.Request( + "GET", + "https://api.minimax.io/v1/query/video_generation?task_id=task-123", + headers={"Authorization": "Bearer test-key"}, + ), + ) + file_response = httpx.Response( + 200, + json={"download_url": "https://cdn.example.com/video.mp4"}, + ) + video_response = httpx.Response(200, content=b"async-video") + client = Mock() + client.get = AsyncMock(return_value=file_response) + + with ( + patch( + "litellm.llms.minimax.videos.transformation.get_async_httpx_client", + return_value=client, + ), + patch( + "litellm.llms.minimax.videos.transformation.async_safe_get", + new=AsyncMock(return_value=video_response), + ) as safe_get_mock, + ): + result = await self.config.async_transform_video_content_response( + query_response, + self.logging_obj, + ) + + assert result == b"async-video" + safe_get_mock.assert_awaited_once_with(client, "https://cdn.example.com/video.mp4") + + @pytest.mark.asyncio + async def test_async_content_response_handles_binary_and_incomplete_results(self): + query_response = httpx.Response( + 200, + json={"file_id": "file-123"}, + request=httpx.Request("GET", "https://custom.example.com/query/video_generation"), + ) + client = Mock() + client.get = AsyncMock( + return_value=httpx.Response( + 200, + content=b"video-bytes", + headers={"content-type": "video/mp4"}, + ) + ) + with patch( + "litellm.llms.minimax.videos.transformation.get_async_httpx_client", + return_value=client, + ): + assert ( + await self.config.async_transform_video_content_response( + query_response, + self.logging_obj, + ) + == b"video-bytes" + ) + + with pytest.raises(ValueError, match="not ready for download"): + await self.config.async_transform_video_content_response( + httpx.Response(200, json={"status": "Queued"}), + self.logging_obj, + ) + + client.get = AsyncMock(return_value=httpx.Response(200, json={"base_resp": {"status_code": 0}})) + with ( + patch( + "litellm.llms.minimax.videos.transformation.get_async_httpx_client", + return_value=client, + ), + pytest.raises(ValueError, match="did not return a video download URL"), + ): + await self.config.async_transform_video_content_response( + query_response, + self.logging_obj, + ) + + def test_unsupported_video_operations(self): + request_args = { + "api_base": "https://api.minimax.io/v1", + "litellm_params": GenericLiteLLMParams(), + "headers": {}, + } + with pytest.raises(NotImplementedError, match="remix"): + self.config.transform_video_remix_request( + video_id="task-123", + prompt="new prompt", + **request_args, + ) + with pytest.raises(NotImplementedError, match="remix"): + self.config.transform_video_remix_response( + raw_response=httpx.Response(200), + logging_obj=self.logging_obj, + ) + with pytest.raises(NotImplementedError, match="listing"): + self.config.transform_video_list_request(**request_args) + with pytest.raises(NotImplementedError, match="listing"): + self.config.transform_video_list_response( + raw_response=httpx.Response(200), + logging_obj=self.logging_obj, + ) + with pytest.raises(NotImplementedError, match="deletion"): + self.config.transform_video_delete_request(video_id="task-123", **request_args) + with pytest.raises(NotImplementedError, match="deletion"): + self.config.transform_video_delete_response( + raw_response=httpx.Response(200), + logging_obj=self.logging_obj, + ) + + @pytest.mark.parametrize( + ("provider_status", "openai_status"), + [ + ("Succeeded", "completed"), + ("Canceled", "failed"), + ("Preparing", "queued"), + ("Processing", "in_progress"), + (None, "in_progress"), + ], + ) + def test_status_mapping(self, provider_status, openai_status): + assert self.config._map_status(provider_status) == openai_status + + def test_response_helpers(self): + assert self.config._request_headers(httpx.Response(200)) == {} + assert self.config._api_base_from_response(httpx.Response(200)) == "https://api.minimax.io/v1" + custom_response = httpx.Response( + 200, + request=httpx.Request("GET", "https://custom.example.com/query/video_generation"), + ) + assert self.config._api_base_from_response(custom_response) == "https://custom.example.com/v1" + assert self.config._get_download_url({"url": "https://cdn.example.com/top.mp4"}) == ( + "https://cdn.example.com/top.mp4" + ) + assert self.config._get_download_url({"file": {"url": "https://cdn.example.com/file.mp4"}}) == ( + "https://cdn.example.com/file.mp4" + ) + assert self.config._get_download_url({}) is None + + empty_video = VideoObject(id="task-123", object="video", status="queued") + assert self.config._usage_from_video(empty_video) == {} + empty_video.seconds = "invalid" + assert self.config._usage_from_video(empty_video) == {} + + video_data = {} + self.config._add_request_metadata(video_data, None) + assert video_data == {} From b0aea9bc5b89a93d51be3e22dfee92be5e4e333e Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:06:00 +0800 Subject: [PATCH 3/3] fix(minimax): satisfy type discipline gate --- litellm/llms/minimax/videos/__init__.py | 2 +- litellm/llms/minimax/videos/transformation.py | 133 +++++++++++------- 2 files changed, 84 insertions(+), 51 deletions(-) diff --git a/litellm/llms/minimax/videos/__init__.py b/litellm/llms/minimax/videos/__init__.py index 1dcd26b077e..010c1c91d03 100644 --- a/litellm/llms/minimax/videos/__init__.py +++ b/litellm/llms/minimax/videos/__init__.py @@ -2,4 +2,4 @@ from .transformation import MinimaxVideoConfig -__all__ = ["MinimaxVideoConfig"] +__all__ = ("MinimaxVideoConfig",) diff --git a/litellm/llms/minimax/videos/transformation.py b/litellm/llms/minimax/videos/transformation.py index 72627c74fbc..7f54e62e3be 100644 --- a/litellm/llms/minimax/videos/transformation.py +++ b/litellm/llms/minimax/videos/transformation.py @@ -38,8 +38,8 @@ else: class MinimaxVideoConfig(BaseVideoConfig): """Configuration for MiniMax's v1 text-to-video and image-to-video API.""" - def get_supported_openai_params(self, model: str) -> list: - return [ + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseVideoConfig requires a list. + return [ # mutable-ok: BaseVideoConfig requires a list result. "model", "prompt", "input_reference", @@ -60,11 +60,11 @@ class MinimaxVideoConfig(BaseVideoConfig): video_create_optional_params: VideoCreateOptionalRequestParams, model: str, drop_params: bool, - ) -> dict: - mapped_params: dict = {} + ) -> dict: # mutable-ok: BaseVideoConfig requires a mutable parameter mapping. + mapped_params: dict = {} # mutable-ok: Provider parameters are assembled incrementally. for key, value in video_create_optional_params.items(): - if value is None or key in {"model", "prompt", "extra_headers", "user"}: + if value is None or key in ("model", "prompt", "extra_headers", "user"): continue if key == "input_reference": mapped_params["first_frame_image"] = self._prepare_first_frame_image(value) @@ -77,17 +77,21 @@ class MinimaxVideoConfig(BaseVideoConfig): extra_body = video_create_optional_params.get("extra_body") if isinstance(extra_body, dict): - mapped_params.update({key: value for key, value in extra_body.items() if value is not None}) + mapped_params.update( # mutable-ok: Provider extra fields must merge into the request mapping. + { # mutable-ok: Provider extra fields must merge into the request mapping. + key: value for key, value in extra_body.items() if value is not None + } + ) return mapped_params def validate_environment( self, - headers: dict, + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. model: str, api_key: str | None = None, litellm_params: GenericLiteLLMParams | None = None, - ) -> dict: + ) -> dict: # mutable-ok: BaseVideoConfig requires mutable headers. if litellm_params and litellm_params.api_key: api_key = api_key or litellm_params.api_key @@ -98,7 +102,7 @@ class MinimaxVideoConfig(BaseVideoConfig): ) headers.update( - { + { # mutable-ok: Headers are updated in place by the provider interface. "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } @@ -109,7 +113,7 @@ class MinimaxVideoConfig(BaseVideoConfig): self, model: str, api_base: str | None, - litellm_params: dict, + litellm_params: dict, # mutable-ok: BaseVideoConfig defines this parameter as a dict. ) -> str: """Return the regional MiniMax v1 root used by all video operations.""" base_url = api_base or get_secret_str("MINIMAX_API_BASE") or "https://api.minimax.io/v1" @@ -127,16 +131,19 @@ class MinimaxVideoConfig(BaseVideoConfig): model: str, prompt: str, api_base: str, - video_create_optional_request_params: dict, + video_create_optional_request_params: dict, # mutable-ok: BaseVideoConfig defines mutable request parameters. litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[dict, RequestFiles, str]: - request_data: dict = {"model": model, "prompt": prompt} + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. + ) -> tuple[dict, RequestFiles, str]: # mutable-ok: BaseVideoConfig requires a dict payload. + request_data: dict = { # mutable-ok: Provider request fields are assembled incrementally. + "model": model, + "prompt": prompt, + } request_data.update(video_create_optional_request_params) request_data.pop("extra_headers", None) request_data.pop("extra_body", None) request_data.pop("user", None) - return request_data, [], f"{api_base.rstrip('/')}/video_generation" + return request_data, [], f"{api_base.rstrip('/')}/video_generation" # mutable-ok: No multipart files are sent. def transform_video_create_response( self, @@ -144,7 +151,7 @@ class MinimaxVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, - request_data: dict | None = None, + request_data: dict | None = None, # mutable-ok: BaseVideoConfig supplies request metadata as a dict. ) -> VideoObject: response_data = self._parse_json_response(raw_response) self._raise_for_provider_error(raw_response, response_data) @@ -152,7 +159,7 @@ class MinimaxVideoConfig(BaseVideoConfig): if task_id is None: raise ValueError("MiniMax did not return a task_id for video generation") - video_data: dict = { + video_data: dict = { # mutable-ok: Optional response metadata is added after validation. "id": str(task_id), "object": "video", "status": self._map_status(response_data.get("status", "queueing")), @@ -169,11 +176,14 @@ class MinimaxVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. variant: str | None = None, - ) -> tuple[str, dict]: + ) -> tuple[str, dict]: # mutable-ok: BaseVideoConfig requires a dict query payload. task_id = quote(extract_original_video_id(video_id), safe="") - return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} + return ( # mutable-ok: The provider query has no separate parameter payload. + f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", + {}, # mutable-ok: The provider query has no separate parameter payload. + ) def transform_video_content_response( self, @@ -242,10 +252,13 @@ class MinimaxVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[str, dict]: + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. + ) -> tuple[str, dict]: # mutable-ok: BaseVideoConfig requires a dict query payload. task_id = quote(extract_original_video_id(video_id), safe="") - return f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", {} + return ( # mutable-ok: The provider query has no separate parameter payload. + f"{api_base.rstrip('/')}/query/video_generation?task_id={task_id}", + {}, # mutable-ok: The provider query has no separate parameter payload. + ) def transform_video_status_retrieve_response( self, @@ -259,14 +272,14 @@ class MinimaxVideoConfig(BaseVideoConfig): if task_id is None: raise ValueError("MiniMax did not return a task_id for video status") model = response_data.get("model") - video_data: dict = { + video_data: dict = { # mutable-ok: Optional status fields are added after validation. "id": str(task_id), "object": "video", "status": self._map_status(response_data.get("status", "processing")), "model": model, } if response_data.get("status") and self._map_status(response_data["status"]) == "failed": - video_data["error"] = { + video_data["error"] = { # mutable-ok: VideoObject expects a mutable error payload. "code": "generation_failed", "message": str(response_data.get("status")), } @@ -285,9 +298,9 @@ class MinimaxVideoConfig(BaseVideoConfig): prompt: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - extra_body: dict | None = None, - ) -> tuple[str, dict]: + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. + extra_body: dict | None = None, # mutable-ok: BaseVideoConfig defines provider extras as a dict. + ) -> tuple[str, dict]: # mutable-ok: BaseVideoConfig requires a dict payload. raise NotImplementedError("Video remix is not supported by the MiniMax v1 API") def transform_video_remix_response( @@ -302,12 +315,12 @@ class MinimaxVideoConfig(BaseVideoConfig): self, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. after: str | None = None, limit: int | None = None, order: str | None = None, - extra_query: dict | None = None, - ) -> tuple[str, dict]: + extra_query: dict | None = None, # mutable-ok: BaseVideoConfig defines query extras as a dict. + ) -> tuple[str, dict]: # mutable-ok: BaseVideoConfig requires a dict payload. raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") def transform_video_list_response( @@ -315,7 +328,7 @@ class MinimaxVideoConfig(BaseVideoConfig): raw_response: httpx.Response, logging_obj: LiteLLMLoggingObj, custom_llm_provider: str | None = None, - ) -> dict[str, str]: + ) -> dict[str, str]: # mutable-ok: BaseVideoConfig requires a dict response. raise NotImplementedError("Video listing is not supported by the MiniMax v1 API") def transform_video_delete_request( @@ -323,8 +336,8 @@ class MinimaxVideoConfig(BaseVideoConfig): video_id: str, api_base: str, litellm_params: GenericLiteLLMParams, - headers: dict, - ) -> tuple[str, dict]: + headers: dict, # mutable-ok: BaseVideoConfig passes a mutable header mapping. + ) -> tuple[str, dict]: # mutable-ok: BaseVideoConfig requires a dict payload. raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") def transform_video_delete_response( @@ -334,7 +347,12 @@ class MinimaxVideoConfig(BaseVideoConfig): ) -> VideoObject: raise NotImplementedError("Video deletion is not supported by the MiniMax v1 API") - def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException: + def get_error_class( + self, + error_message: str, + status_code: int, + headers: dict | httpx.Headers, # mutable-ok: BaseLLMException accepts the response header mapping. + ) -> BaseLLMException: return BaseLLMException(status_code=status_code, message=error_message, headers=headers) @staticmethod @@ -347,16 +365,19 @@ class MinimaxVideoConfig(BaseVideoConfig): @staticmethod def _map_status(status: object) -> str: normalized = str(status or "").strip().lower().replace(" ", "_") - if normalized in {"success", "succeeded", "completed", "complete"}: + if normalized in ("success", "succeeded", "completed", "complete"): return "completed" - if normalized in {"fail", "failed", "error", "cancelled", "canceled"}: + if normalized in ("fail", "failed", "error", "cancelled", "canceled"): return "failed" - if normalized in {"queueing", "queued", "preparing", "pending"}: + if normalized in ("queueing", "queued", "preparing", "pending"): return "queued" return "in_progress" @staticmethod - def _add_request_metadata(video_data: dict, request_data: dict | None) -> None: + def _add_request_metadata( + video_data: dict, # mutable-ok: VideoObject metadata is assembled in place. + request_data: dict | None, # mutable-ok: Request metadata arrives as a dict. + ) -> None: if not request_data: return if request_data.get("duration") is not None: @@ -365,29 +386,37 @@ class MinimaxVideoConfig(BaseVideoConfig): video_data["size"] = str(request_data["resolution"]) @staticmethod - def _usage_from_video(video_obj: VideoObject) -> dict: + def _usage_from_video(video_obj: VideoObject) -> dict: # mutable-ok: VideoObject usage requires a dict. if video_obj.seconds is None: - return {} + return {} # mutable-ok: VideoObject usage requires a dict. try: - return {"duration_seconds": float(video_obj.seconds)} + return { # mutable-ok: VideoObject usage requires a dict. + "duration_seconds": float(video_obj.seconds) + } except (TypeError, ValueError): - return {} + return {} # mutable-ok: VideoObject usage requires a dict. @staticmethod def _wrap_video_id(video_obj: VideoObject, provider: str | None, model: str | None) -> None: if provider and video_obj.id: video_obj.id = encode_video_id_with_provider(video_obj.id, provider, model) - def _parse_json_response(self, raw_response: httpx.Response) -> dict: + def _parse_json_response( + self, raw_response: httpx.Response + ) -> dict: # mutable-ok: JSON objects are provider dicts. self._raise_for_status(raw_response) try: return raw_response.json() except Exception as exc: raise ValueError(f"MiniMax returned an invalid JSON response: {exc}") from exc - def _raise_for_provider_error(self, raw_response: httpx.Response, response_data: dict) -> None: + def _raise_for_provider_error( + self, + raw_response: httpx.Response, + response_data: dict, # mutable-ok: Parsed provider JSON is represented as a dict. + ) -> None: self._raise_for_status(raw_response) - base_resp = response_data.get("base_resp") or {} + base_resp = response_data.get("base_resp") or {} # mutable-ok: Missing provider metadata uses an empty dict. status_code = base_resp.get("status_code") if status_code not in (None, 0, "0"): message = base_resp.get("status_msg") or "MiniMax video request failed" @@ -398,14 +427,16 @@ class MinimaxVideoConfig(BaseVideoConfig): raise self.get_error_class(raw_response.text, raw_response.status_code, raw_response.headers) @staticmethod - def _request_headers(raw_response: httpx.Response) -> dict[str, str]: + def _request_headers( + raw_response: httpx.Response, + ) -> dict[str, str]: # mutable-ok: HTTP handlers require a dict of headers. request = getattr(raw_response, "_request", None) request_headers = getattr(request, "headers", None) if isinstance(request_headers, (dict, httpx.Headers)): authorization = request_headers.get("Authorization") if authorization: - return {"Authorization": authorization} - return {} + return {"Authorization": authorization} # mutable-ok: HTTP handlers require a dict of headers. + return {} # mutable-ok: HTTP handlers require a dict of headers. @staticmethod def _api_base_from_response(raw_response: httpx.Response) -> str: @@ -425,7 +456,9 @@ class MinimaxVideoConfig(BaseVideoConfig): return content_type.startswith("video/") or content_type == "application/octet-stream" @staticmethod - def _get_download_url(response_data: dict) -> str | None: + def _get_download_url( + response_data: dict, # mutable-ok: Parsed provider JSON is represented as a dict. + ) -> str | None: file_data = response_data.get("file") if isinstance(file_data, dict): for key in ("download_url", "url"):