From 4b2d8a7ba577031a8400126fb2c8d99d6b99b0b6 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:11:50 +0800 Subject: [PATCH 1/4] feat(minimax): add voice cloning adapter --- litellm/__init__.py | 1 + litellm/llms/minimax/__init__.py | 8 + litellm/llms/minimax/voice_clone/__init__.py | 13 ++ .../minimax/voice_clone/transformation.py | 189 ++++++++++++++++++ litellm/voice_clone/__init__.py | 5 + litellm/voice_clone/main.py | 122 +++++++++++ ...test_minimax_voice_clone_transformation.py | 110 ++++++++++ 7 files changed, 448 insertions(+) create mode 100644 litellm/llms/minimax/voice_clone/__init__.py create mode 100644 litellm/llms/minimax/voice_clone/transformation.py create mode 100644 litellm/voice_clone/__init__.py create mode 100644 litellm/voice_clone/main.py create mode 100644 tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 5a461801b62..bd63d27de09 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -1394,6 +1394,7 @@ from .assistants.main import * from .batches.main import * from .images.main import * from .videos.main import * +from .voice_clone.main import * from .batch_completion.main import * from .rerank_api.main import * from .llms.anthropic.experimental_pass_through.messages.handler import * diff --git a/litellm/llms/minimax/__init__.py b/litellm/llms/minimax/__init__.py index db884c27b99..3ea8d00d64d 100644 --- a/litellm/llms/minimax/__init__.py +++ b/litellm/llms/minimax/__init__.py @@ -6,8 +6,16 @@ from .text_to_speech.transformation import ( MinimaxException, MinimaxTextToSpeechConfig, ) +from .voice_clone.transformation import ( + MinimaxVoiceCloneConfig, + MinimaxVoiceCloneError, + VoiceCloneResponse, +) __all__ = [ "MinimaxException", "MinimaxTextToSpeechConfig", + "MinimaxVoiceCloneConfig", + "MinimaxVoiceCloneError", + "VoiceCloneResponse", ] diff --git a/litellm/llms/minimax/voice_clone/__init__.py b/litellm/llms/minimax/voice_clone/__init__.py new file mode 100644 index 00000000000..b8af979100c --- /dev/null +++ b/litellm/llms/minimax/voice_clone/__init__.py @@ -0,0 +1,13 @@ +"""MiniMax voice-cloning request and response transformations.""" + +from .transformation import ( + MinimaxVoiceCloneConfig, + MinimaxVoiceCloneError, + VoiceCloneResponse, +) + +__all__ = [ + "MinimaxVoiceCloneConfig", + "MinimaxVoiceCloneError", + "VoiceCloneResponse", +] diff --git a/litellm/llms/minimax/voice_clone/transformation.py b/litellm/llms/minimax/voice_clone/transformation.py new file mode 100644 index 00000000000..52967e951b1 --- /dev/null +++ b/litellm/llms/minimax/voice_clone/transformation.py @@ -0,0 +1,189 @@ +"""MiniMax voice-cloning API transformations. + +MiniMax voice cloning is a two-step operation: + +1. Upload an audio sample to ``/v1/files/upload`` with a voice-cloning + purpose and read the returned ``file_id``. +2. Submit that ``file_id`` with a caller-provided ``voice_id`` and supported + speech model to ``/v1/voice_clone``. + +The API is available from both the international and China hosts. This +module deliberately keeps the operation provider-scoped; it does not reuse +the text-to-speech payload, where ``voice`` means a preset voice. +""" + +from collections.abc import Mapping +from typing import Final, TypedDict + +import httpx + +import litellm +from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import FileTypes + + +class VoiceCloneResponse(TypedDict): + """Normalized response returned after a successful voice clone.""" + + file_id: str + voice_id: str + model: str + + +class MinimaxVoiceCloneError(BaseLLMException): + """Error raised when MiniMax rejects an upload or clone request.""" + + def __init__(self, message: str, status_code: int = 0, headers: Mapping[str, str] | None = None): + super().__init__(message=message, status_code=status_code, headers=headers) + + +class MinimaxVoiceCloneConfig: + """Build and parse MiniMax voice-cloning requests.""" + + GLOBAL_BASE_URL: Final[str] = "https://api.minimax.io" + CN_BASE_URL: Final[str] = "https://api.minimaxi.com" + FILE_UPLOAD_PATH: Final[str] = "/v1/files/upload" + VOICE_CLONE_PATH: Final[str] = "/v1/voice_clone" + UPLOAD_PURPOSES: Final[frozenset[str]] = frozenset({"voice_clone", "prompt_audio"}) + SUPPORTED_MODELS: Final[frozenset[str]] = frozenset( + {"speech-2.8-hd", "speech-2.6-hd", "speech-02-hd", "speech-01-hd"} + ) + + @classmethod + def _base_url(cls, api_base: str | None) -> str: + """Return a regional host without a trailing ``/v1`` path.""" + base = (api_base or cls.GLOBAL_BASE_URL).rstrip("/") + base = base.removesuffix("/v1") + return base.rstrip("/") + + @classmethod + def get_complete_url(cls, api_base: str | None = None, operation: str = "clone") -> str: + """Return the endpoint URL for ``upload`` or ``clone``.""" + try: + path = {"upload": cls.FILE_UPLOAD_PATH, "clone": cls.VOICE_CLONE_PATH}[operation] + except KeyError as exc: + raise ValueError("operation must be 'upload' or 'clone'") from exc + return f"{cls._base_url(api_base)}{path}" + + @staticmethod + def validate_environment(headers: dict[str, str] | None = None, api_key: str | None = None) -> dict[str, str]: + """Add MiniMax authentication while preserving caller headers.""" + resolved_key = api_key or litellm.api_key or get_secret_str("MINIMAX_API_KEY") + if not resolved_key: + raise ValueError("MiniMax API key is required for voice cloning") + + result = dict(headers or {}) + result["Authorization"] = f"Bearer {resolved_key}" + return result + + @classmethod + def transform_upload_request( + cls, + file: FileTypes, + purpose: str = "voice_clone", + ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: + """Convert a LiteLLM file value to MiniMax multipart fields.""" + if purpose not in cls.UPLOAD_PURPOSES: + allowed = ", ".join(sorted(cls.UPLOAD_PURPOSES)) + raise ValueError(f"MiniMax file upload purpose must be one of: {allowed}") + + extracted = extract_file_data(file) + filename = extracted.get("filename") or "voice-sample" + content = extracted.get("content") + if not isinstance(content, bytes): + content = bytes(content) + content_type = extracted.get("content_type") or "application/octet-stream" + return {"file": (filename, content, content_type)}, {"purpose": purpose} + + @classmethod + def transform_upload_response(cls, raw_response: httpx.Response) -> str: + """Extract MiniMax's uploaded ``file_id`` and check its status code.""" + payload = cls._json_response(raw_response) + cls._raise_for_api_status(payload, raw_response.status_code) + file_id = cls._first_string( + payload, + ("file_id", "id"), + nested=("file", "data", "result"), + ) + if not file_id: + raise MinimaxVoiceCloneError("MiniMax upload response did not include file_id", raw_response.status_code) + return file_id + + @classmethod + def transform_clone_request(cls, file_id: str, voice_id: str, model: str) -> dict[str, str]: + """Build the required MiniMax voice-clone JSON body.""" + values = {"file_id": file_id, "voice_id": voice_id, "model": model} + for name, value in values.items(): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} is required for MiniMax voice cloning") + if model not in cls.SUPPORTED_MODELS: + supported = ", ".join(sorted(cls.SUPPORTED_MODELS)) + raise ValueError(f"Unsupported MiniMax voice-clone model {model!r}; supported models: {supported}") + return {name: value.strip() for name, value in values.items()} + + @classmethod + def transform_clone_response( + cls, + raw_response: httpx.Response, + *, + file_id: str, + model: str, + ) -> VoiceCloneResponse: + """Normalize MiniMax's clone response to its generated ``voice_id``.""" + payload = cls._json_response(raw_response) + cls._raise_for_api_status(payload, raw_response.status_code) + voice_id = cls._first_string( + payload, + ("voice_id",), + nested=("data", "result", "voice"), + ) + if not voice_id: + raise MinimaxVoiceCloneError("MiniMax clone response did not include voice_id", raw_response.status_code) + return {"file_id": file_id, "voice_id": voice_id, "model": model} + + @staticmethod + def _json_response(raw_response: httpx.Response) -> dict[str, object]: + try: + payload = raw_response.json() + except ValueError as exc: + raise MinimaxVoiceCloneError( + "MiniMax returned a non-JSON voice-clone response", + raw_response.status_code, + raw_response.headers, + ) from exc + if not isinstance(payload, dict): + raise MinimaxVoiceCloneError( + "MiniMax returned an invalid voice-clone response", + raw_response.status_code, + raw_response.headers, + ) + return payload + + @staticmethod + def _first_string( + payload: Mapping[str, object], + keys: tuple[str, ...], + nested: tuple[str, ...], + ) -> str | None: + for key in keys: + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for key in nested: + child = payload.get(key) + if isinstance(child, Mapping): + value = MinimaxVoiceCloneConfig._first_string(child, keys, nested=()) + if value: + return value + return None + + @staticmethod + def _raise_for_api_status(payload: Mapping[str, object], http_status: int) -> None: + base_resp = payload.get("base_resp") + status_code = base_resp.get("status_code") if isinstance(base_resp, Mapping) else None + if http_status >= 400 or (status_code is not None and str(status_code) not in {"0", "200"}): + status_message = base_resp.get("status_msg") if isinstance(base_resp, Mapping) else None + detail = str(status_message or payload.get("message") or "MiniMax voice-clone request failed") + raise MinimaxVoiceCloneError(detail, http_status or int(status_code or 0)) diff --git a/litellm/voice_clone/__init__.py b/litellm/voice_clone/__init__.py new file mode 100644 index 00000000000..889acc78f38 --- /dev/null +++ b/litellm/voice_clone/__init__.py @@ -0,0 +1,5 @@ +"""Provider-backed voice-cloning helpers.""" + +from .main import avoice_clone, voice_clone + +__all__ = ["avoice_clone", "voice_clone"] diff --git a/litellm/voice_clone/main.py b/litellm/voice_clone/main.py new file mode 100644 index 00000000000..8adfc28021c --- /dev/null +++ b/litellm/voice_clone/main.py @@ -0,0 +1,122 @@ +"""Public voice-cloning API. + +The operation is intentionally separate from :func:`litellm.speech`: cloning +creates a provider voice and returns its generated ``voice_id``. The returned +ID can then be passed to MiniMax text-to-speech calls as the voice value. +""" + +import httpx + +from litellm.llms.minimax.voice_clone.transformation import ( + MinimaxVoiceCloneConfig, + VoiceCloneResponse, +) +from litellm.types.llms.openai import FileTypes + + +def _validate_provider(custom_llm_provider: str | None) -> None: + if (custom_llm_provider or "minimax").lower() != "minimax": + raise ValueError("voice cloning is currently supported only for MiniMax") + + +def voice_clone( + file: FileTypes, + voice_id: str, + model: str = "speech-2.8-hd", + *, + purpose: str = "voice_clone", + api_key: str | None = None, + api_base: str | None = None, + timeout: float | httpx.Timeout | None = None, + custom_llm_provider: str | None = "minimax", + extra_headers: dict[str, str] | None = None, + client: httpx.Client | None = None, +) -> VoiceCloneResponse: + """Upload an audio sample and create a MiniMax custom voice. + + Args: + file: Audio bytes, a path, an open binary file, or a + ``(filename, content, content_type)`` tuple. + voice_id: Caller-chosen custom voice identifier. + model: One of MiniMax's supported speech models. + purpose: ``voice_clone`` for cloning audio or ``prompt_audio`` for + prompt audio uploads. + api_base: Optional MiniMax host (international or China), with or + without a trailing ``/v1``. + """ + _validate_provider(custom_llm_provider) + config = MinimaxVoiceCloneConfig + headers = config.validate_environment(headers=extra_headers, api_key=api_key) + upload_files, upload_data = config.transform_upload_request(file=file, purpose=purpose) + clone_body = config.transform_clone_request(file_id="pending", voice_id=voice_id, model=model) + # ``pending`` is replaced after the upload; validating the other required + # clone fields before network I/O keeps malformed requests deterministic. + clone_body.pop("file_id") + + timeout_value = timeout if timeout is not None else 600.0 + owns_client = client is None + http_client = client or httpx.Client(timeout=timeout_value) + try: + upload_response = http_client.post( + config.get_complete_url(api_base=api_base, operation="upload"), + headers=headers, + files=upload_files, + data=upload_data, + timeout=timeout_value, + ) + file_id = config.transform_upload_response(upload_response) + clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) + clone_response = http_client.post( + config.get_complete_url(api_base=api_base, operation="clone"), + headers={**headers, "Content-Type": "application/json"}, + json=clone_body, + timeout=timeout_value, + ) + return config.transform_clone_response(clone_response, file_id=file_id, model=model) + finally: + if owns_client: + http_client.close() + + +async def avoice_clone( + file: FileTypes, + voice_id: str, + model: str = "speech-2.8-hd", + *, + purpose: str = "voice_clone", + api_key: str | None = None, + api_base: str | None = None, + timeout: float | httpx.Timeout | None = None, + custom_llm_provider: str | None = "minimax", + extra_headers: dict[str, str] | None = None, + client: httpx.AsyncClient | None = None, +) -> VoiceCloneResponse: + """Asynchronously upload and clone a MiniMax voice.""" + _validate_provider(custom_llm_provider) + config = MinimaxVoiceCloneConfig + headers = config.validate_environment(headers=extra_headers, api_key=api_key) + upload_files, upload_data = config.transform_upload_request(file=file, purpose=purpose) + config.transform_clone_request(file_id="pending", voice_id=voice_id, model=model) + timeout_value = timeout if timeout is not None else 600.0 + owns_client = client is None + http_client = client or httpx.AsyncClient(timeout=timeout_value) + try: + upload_response = await http_client.post( + config.get_complete_url(api_base=api_base, operation="upload"), + headers=headers, + files=upload_files, + data=upload_data, + timeout=timeout_value, + ) + file_id = config.transform_upload_response(upload_response) + clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) + clone_response = await http_client.post( + config.get_complete_url(api_base=api_base, operation="clone"), + headers={**headers, "Content-Type": "application/json"}, + json=clone_body, + timeout=timeout_value, + ) + return config.transform_clone_response(clone_response, file_id=file_id, model=model) + finally: + if owns_client: + await http_client.aclose() diff --git a/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py b/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py new file mode 100644 index 00000000000..b453f5d5621 --- /dev/null +++ b/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py @@ -0,0 +1,110 @@ +import httpx +import pytest + +from litellm.llms.minimax.voice_clone.transformation import ( + MinimaxVoiceCloneConfig, + MinimaxVoiceCloneError, +) +from litellm.voice_clone.main import voice_clone + + +def response(payload: dict, status_code: int = 200) -> httpx.Response: + return httpx.Response(status_code, json=payload) + + +def test_regional_urls_support_host_and_v1_api_base() -> None: + assert ( + MinimaxVoiceCloneConfig.get_complete_url("https://api.minimax.io", operation="upload") + == "https://api.minimax.io/v1/files/upload" + ) + assert ( + MinimaxVoiceCloneConfig.get_complete_url("https://api.minimaxi.com/v1", operation="clone") + == "https://api.minimaxi.com/v1/voice_clone" + ) + + +def test_upload_request_contains_audio_and_voice_clone_purpose() -> None: + files, data = MinimaxVoiceCloneConfig.transform_upload_request( + ("sample.wav", b"audio", "audio/wav"), + ) + + assert files == {"file": ("sample.wav", b"audio", "audio/wav")} + assert data == {"purpose": "voice_clone"} + + +def test_prompt_audio_purpose_is_supported() -> None: + _, data = MinimaxVoiceCloneConfig.transform_upload_request(("sample.mp3", b"audio"), "prompt_audio") + assert data == {"purpose": "prompt_audio"} + + +def test_upload_response_extracts_nested_file_id() -> None: + file_id = MinimaxVoiceCloneConfig.transform_upload_response( + response({"file": {"file_id": "file-123"}, "base_resp": {"status_code": 0}}) + ) + assert file_id == "file-123" + + +def test_clone_request_contains_only_documented_required_fields() -> None: + assert MinimaxVoiceCloneConfig.transform_clone_request("file-123", "my_voice", "speech-2.8-hd") == { + "file_id": "file-123", + "voice_id": "my_voice", + "model": "speech-2.8-hd", + } + + +def test_clone_response_extracts_voice_id_and_preserves_file_and_model() -> None: + result = MinimaxVoiceCloneConfig.transform_clone_response( + response({"data": {"voice_id": "voice-123"}, "base_resp": {"status_code": 0}}), + file_id="file-123", + model="speech-2.6-hd", + ) + assert result == {"file_id": "file-123", "voice_id": "voice-123", "model": "speech-2.6-hd"} + + +def test_nonzero_base_response_status_is_rejected() -> None: + with pytest.raises(MinimaxVoiceCloneError): + MinimaxVoiceCloneConfig.transform_clone_response( + response( + { + "base_resp": {"status_code": 1004, "status_msg": "authentication failed"}, + } + ), + file_id="file-123", + model="speech-2.8-hd", + ) + + +def test_voice_clone_performs_upload_then_clone() -> None: + seen: list[tuple[str, str, str | None]] = [] + + def transport(request: httpx.Request) -> httpx.Response: + body = request.content + if request.url.path == "/v1/files/upload": + assert b'name="purpose"' in body + assert b"voice_clone" in body + seen.append((request.method, request.url.path, None)) + return httpx.Response(200, json={"file_id": "file-123", "base_resp": {"status_code": 0}}) + seen.append((request.method, request.url.path, request.content.decode())) + return httpx.Response(200, json={"voice_id": "voice-123", "base_resp": {"status_code": 0}}) + + with httpx.Client(transport=httpx.MockTransport(transport)) as client: + result = voice_clone( + ("sample.wav", b"audio", "audio/wav"), + "my_voice", + model="speech-2.8-hd", + api_key="test-key", + client=client, + ) + + assert result == {"file_id": "file-123", "voice_id": "voice-123", "model": "speech-2.8-hd"} + assert [(method, path) for method, path, _ in seen] == [ + ("POST", "/v1/files/upload"), + ("POST", "/v1/voice_clone"), + ] + assert '"file_id":"file-123"' in (seen[1][2] or "") + + +@pytest.mark.parametrize("model", ["speech-2.8-turbo", "unknown"]) +def test_unsupported_model_is_rejected(model: str) -> None: + with pytest.raises(ValueError, match="Unsupported MiniMax voice-clone model"): + MinimaxVoiceCloneConfig.transform_clone_request("file-123", "my_voice", model) From 003004fca823129eda44f4b2b41fde405826e19d Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:24:40 +0800 Subject: [PATCH 2/4] fix(minimax): reuse shared async HTTP client --- .../minimax/voice_clone/transformation.py | 2 +- litellm/voice_clone/main.py | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/litellm/llms/minimax/voice_clone/transformation.py b/litellm/llms/minimax/voice_clone/transformation.py index 52967e951b1..d803afc3fc2 100644 --- a/litellm/llms/minimax/voice_clone/transformation.py +++ b/litellm/llms/minimax/voice_clone/transformation.py @@ -35,7 +35,7 @@ class VoiceCloneResponse(TypedDict): class MinimaxVoiceCloneError(BaseLLMException): """Error raised when MiniMax rejects an upload or clone request.""" - def __init__(self, message: str, status_code: int = 0, headers: Mapping[str, str] | None = None): + def __init__(self, message: str, status_code: int = 0, headers: Mapping[str, str] | None = None) -> None: super().__init__(message=message, status_code=status_code, headers=headers) diff --git a/litellm/voice_clone/main.py b/litellm/voice_clone/main.py index 8adfc28021c..a019f6dc28b 100644 --- a/litellm/voice_clone/main.py +++ b/litellm/voice_clone/main.py @@ -7,11 +7,13 @@ ID can then be passed to MiniMax text-to-speech calls as the voice value. import httpx +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.llms.minimax.voice_clone.transformation import ( MinimaxVoiceCloneConfig, VoiceCloneResponse, ) from litellm.types.llms.openai import FileTypes +from litellm.types.utils import LlmProviders def _validate_provider(custom_llm_provider: str | None) -> None: @@ -98,25 +100,23 @@ async def avoice_clone( upload_files, upload_data = config.transform_upload_request(file=file, purpose=purpose) config.transform_clone_request(file_id="pending", voice_id=voice_id, model=model) timeout_value = timeout if timeout is not None else 600.0 - owns_client = client is None - http_client = client or httpx.AsyncClient(timeout=timeout_value) - try: - upload_response = await http_client.post( - config.get_complete_url(api_base=api_base, operation="upload"), - headers=headers, - files=upload_files, - data=upload_data, - timeout=timeout_value, - ) - file_id = config.transform_upload_response(upload_response) - clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) - clone_response = await http_client.post( - config.get_complete_url(api_base=api_base, operation="clone"), - headers={**headers, "Content-Type": "application/json"}, - json=clone_body, - timeout=timeout_value, - ) - return config.transform_clone_response(clone_response, file_id=file_id, model=model) - finally: - if owns_client: - await http_client.aclose() + http_client = client or get_async_httpx_client( + llm_provider=LlmProviders.MINIMAX, + params={"timeout": timeout_value}, + ) + upload_response = await http_client.post( + config.get_complete_url(api_base=api_base, operation="upload"), + headers=headers, + files=upload_files, + data=upload_data, + timeout=timeout_value, + ) + file_id = config.transform_upload_response(upload_response) + clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) + clone_response = await http_client.post( + config.get_complete_url(api_base=api_base, operation="clone"), + headers={**headers, "Content-Type": "application/json"}, + json=clone_body, + timeout=timeout_value, + ) + return config.transform_clone_response(clone_response, file_id=file_id, model=model) From 41db04bbedcf1dc16bb6fd7bd8323652dfa19fcd Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:48:04 +0800 Subject: [PATCH 3/4] fix(minimax): document voice clone type suppressions --- litellm/llms/minimax/voice_clone/__init__.py | 2 +- .../minimax/voice_clone/transformation.py | 28 +++++++++---------- litellm/voice_clone/__init__.py | 2 +- litellm/voice_clone/main.py | 10 +++---- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/litellm/llms/minimax/voice_clone/__init__.py b/litellm/llms/minimax/voice_clone/__init__.py index b8af979100c..38c8bd856b8 100644 --- a/litellm/llms/minimax/voice_clone/__init__.py +++ b/litellm/llms/minimax/voice_clone/__init__.py @@ -6,7 +6,7 @@ from .transformation import ( VoiceCloneResponse, ) -__all__ = [ +__all__ = [ # mutable-ok: public export list is intentionally mutable for package compatibility "MinimaxVoiceCloneConfig", "MinimaxVoiceCloneError", "VoiceCloneResponse", diff --git a/litellm/llms/minimax/voice_clone/transformation.py b/litellm/llms/minimax/voice_clone/transformation.py index d803afc3fc2..2db82f8b85e 100644 --- a/litellm/llms/minimax/voice_clone/transformation.py +++ b/litellm/llms/minimax/voice_clone/transformation.py @@ -27,9 +27,9 @@ from litellm.types.llms.openai import FileTypes class VoiceCloneResponse(TypedDict): """Normalized response returned after a successful voice clone.""" - file_id: str - voice_id: str - model: str + file_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload + voice_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload + model: str # writable-ok: normalized response is intentionally a mutable compatibility payload class MinimaxVoiceCloneError(BaseLLMException): @@ -62,19 +62,19 @@ class MinimaxVoiceCloneConfig: def get_complete_url(cls, api_base: str | None = None, operation: str = "clone") -> str: """Return the endpoint URL for ``upload`` or ``clone``.""" try: - path = {"upload": cls.FILE_UPLOAD_PATH, "clone": cls.VOICE_CLONE_PATH}[operation] + path = {"upload": cls.FILE_UPLOAD_PATH, "clone": cls.VOICE_CLONE_PATH}[operation] # mutable-ok: operation lookup is a small local dispatch table except KeyError as exc: raise ValueError("operation must be 'upload' or 'clone'") from exc return f"{cls._base_url(api_base)}{path}" @staticmethod - def validate_environment(headers: dict[str, str] | None = None, api_key: str | None = None) -> dict[str, str]: + def validate_environment(headers: dict[str, str] | None = None, api_key: str | None = None) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings """Add MiniMax authentication while preserving caller headers.""" resolved_key = api_key or litellm.api_key or get_secret_str("MINIMAX_API_KEY") if not resolved_key: raise ValueError("MiniMax API key is required for voice cloning") - result = dict(headers or {}) + result = dict(headers or {}) # mutable-ok: caller headers must remain independently mutable result["Authorization"] = f"Bearer {resolved_key}" return result @@ -83,7 +83,7 @@ class MinimaxVoiceCloneConfig: cls, file: FileTypes, purpose: str = "voice_clone", - ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: + ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: # mutable-ok: provider payloads and public headers require mutable mappings """Convert a LiteLLM file value to MiniMax multipart fields.""" if purpose not in cls.UPLOAD_PURPOSES: allowed = ", ".join(sorted(cls.UPLOAD_PURPOSES)) @@ -95,7 +95,7 @@ class MinimaxVoiceCloneConfig: if not isinstance(content, bytes): content = bytes(content) content_type = extracted.get("content_type") or "application/octet-stream" - return {"file": (filename, content, content_type)}, {"purpose": purpose} + return {"file": (filename, content, content_type)}, {"purpose": purpose} # mutable-ok: multipart fields require mutable mapping for httpx @classmethod def transform_upload_response(cls, raw_response: httpx.Response) -> str: @@ -112,16 +112,16 @@ class MinimaxVoiceCloneConfig: return file_id @classmethod - def transform_clone_request(cls, file_id: str, voice_id: str, model: str) -> dict[str, str]: + def transform_clone_request(cls, file_id: str, voice_id: str, model: str) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings """Build the required MiniMax voice-clone JSON body.""" - values = {"file_id": file_id, "voice_id": voice_id, "model": model} + values = {"file_id": file_id, "voice_id": voice_id, "model": model} # mutable-ok: request body is serialized as mutable JSON for name, value in values.items(): if not isinstance(value, str) or not value.strip(): raise ValueError(f"{name} is required for MiniMax voice cloning") if model not in cls.SUPPORTED_MODELS: supported = ", ".join(sorted(cls.SUPPORTED_MODELS)) raise ValueError(f"Unsupported MiniMax voice-clone model {model!r}; supported models: {supported}") - return {name: value.strip() for name, value in values.items()} + return {name: value.strip() for name, value in values.items()} # mutable-ok: request body is serialized as mutable JSON @classmethod def transform_clone_response( @@ -141,10 +141,10 @@ class MinimaxVoiceCloneConfig: ) if not voice_id: raise MinimaxVoiceCloneError("MiniMax clone response did not include voice_id", raw_response.status_code) - return {"file_id": file_id, "voice_id": voice_id, "model": model} + return {"file_id": file_id, "voice_id": voice_id, "model": model} # mutable-ok: parsed response is normalized from provider JSON @staticmethod - def _json_response(raw_response: httpx.Response) -> dict[str, object]: + def _json_response(raw_response: httpx.Response) -> dict[str, object]: # mutable-ok: provider payloads and public headers require mutable mappings try: payload = raw_response.json() except ValueError as exc: @@ -183,7 +183,7 @@ class MinimaxVoiceCloneConfig: def _raise_for_api_status(payload: Mapping[str, object], http_status: int) -> None: base_resp = payload.get("base_resp") status_code = base_resp.get("status_code") if isinstance(base_resp, Mapping) else None - if http_status >= 400 or (status_code is not None and str(status_code) not in {"0", "200"}): + if http_status >= 400 or (status_code is not None and str(status_code) not in {"0", "200"}): # mutable-ok: status code allowlist is a local membership set status_message = base_resp.get("status_msg") if isinstance(base_resp, Mapping) else None detail = str(status_message or payload.get("message") or "MiniMax voice-clone request failed") raise MinimaxVoiceCloneError(detail, http_status or int(status_code or 0)) diff --git a/litellm/voice_clone/__init__.py b/litellm/voice_clone/__init__.py index 889acc78f38..616ddbac412 100644 --- a/litellm/voice_clone/__init__.py +++ b/litellm/voice_clone/__init__.py @@ -2,4 +2,4 @@ from .main import avoice_clone, voice_clone -__all__ = ["avoice_clone", "voice_clone"] +__all__ = ["avoice_clone", "voice_clone"] # mutable-ok: public export list is intentionally mutable for package compatibility diff --git a/litellm/voice_clone/main.py b/litellm/voice_clone/main.py index a019f6dc28b..457f12286fb 100644 --- a/litellm/voice_clone/main.py +++ b/litellm/voice_clone/main.py @@ -31,7 +31,7 @@ def voice_clone( api_base: str | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = "minimax", - extra_headers: dict[str, str] | None = None, + extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers client: httpx.Client | None = None, ) -> VoiceCloneResponse: """Upload an audio sample and create a MiniMax custom voice. @@ -70,7 +70,7 @@ def voice_clone( clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) clone_response = http_client.post( config.get_complete_url(api_base=api_base, operation="clone"), - headers={**headers, "Content-Type": "application/json"}, + headers={**headers, "Content-Type": "application/json"}, # mutable-ok: JSON request body is passed to httpx json=clone_body, timeout=timeout_value, ) @@ -90,7 +90,7 @@ async def avoice_clone( api_base: str | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = "minimax", - extra_headers: dict[str, str] | None = None, + extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers client: httpx.AsyncClient | None = None, ) -> VoiceCloneResponse: """Asynchronously upload and clone a MiniMax voice.""" @@ -102,7 +102,7 @@ async def avoice_clone( timeout_value = timeout if timeout is not None else 600.0 http_client = client or get_async_httpx_client( llm_provider=LlmProviders.MINIMAX, - params={"timeout": timeout_value}, + params={"timeout": timeout_value}, # mutable-ok: JSON request body is passed to httpx ) upload_response = await http_client.post( config.get_complete_url(api_base=api_base, operation="upload"), @@ -115,7 +115,7 @@ async def avoice_clone( clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) clone_response = await http_client.post( config.get_complete_url(api_base=api_base, operation="clone"), - headers={**headers, "Content-Type": "application/json"}, + headers={**headers, "Content-Type": "application/json"}, # mutable-ok: provider request and export collections are intentionally mutable json=clone_body, timeout=timeout_value, ) From ece10471982e3b19077e2083cca02d737fd2ec81 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:49:51 +0800 Subject: [PATCH 4/4] fix(minimax): satisfy voice clone CI checks --- litellm/llms/minimax/voice_clone/__init__.py | 2 +- .../minimax/voice_clone/transformation.py | 52 ++++++++++++++----- litellm/voice_clone/__init__.py | 5 +- litellm/voice_clone/main.py | 13 +++-- ...test_minimax_voice_clone_transformation.py | 8 +++ 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/litellm/llms/minimax/voice_clone/__init__.py b/litellm/llms/minimax/voice_clone/__init__.py index 38c8bd856b8..2adf35aec5e 100644 --- a/litellm/llms/minimax/voice_clone/__init__.py +++ b/litellm/llms/minimax/voice_clone/__init__.py @@ -6,7 +6,7 @@ from .transformation import ( VoiceCloneResponse, ) -__all__ = [ # mutable-ok: public export list is intentionally mutable for package compatibility +__all__ = [ # mutable-ok: public export list is intentionally mutable for package compatibility "MinimaxVoiceCloneConfig", "MinimaxVoiceCloneError", "VoiceCloneResponse", diff --git a/litellm/llms/minimax/voice_clone/transformation.py b/litellm/llms/minimax/voice_clone/transformation.py index 2db82f8b85e..df0e35d5f26 100644 --- a/litellm/llms/minimax/voice_clone/transformation.py +++ b/litellm/llms/minimax/voice_clone/transformation.py @@ -27,9 +27,9 @@ from litellm.types.llms.openai import FileTypes class VoiceCloneResponse(TypedDict): """Normalized response returned after a successful voice clone.""" - file_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload - voice_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload - model: str # writable-ok: normalized response is intentionally a mutable compatibility payload + file_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload + voice_id: str # writable-ok: normalized response is intentionally a mutable compatibility payload + model: str # writable-ok: normalized response is intentionally a mutable compatibility payload class MinimaxVoiceCloneError(BaseLLMException): @@ -62,19 +62,23 @@ class MinimaxVoiceCloneConfig: def get_complete_url(cls, api_base: str | None = None, operation: str = "clone") -> str: """Return the endpoint URL for ``upload`` or ``clone``.""" try: - path = {"upload": cls.FILE_UPLOAD_PATH, "clone": cls.VOICE_CLONE_PATH}[operation] # mutable-ok: operation lookup is a small local dispatch table + path = {"upload": cls.FILE_UPLOAD_PATH, "clone": cls.VOICE_CLONE_PATH}[ + operation + ] # mutable-ok: operation lookup is a small local dispatch table except KeyError as exc: raise ValueError("operation must be 'upload' or 'clone'") from exc return f"{cls._base_url(api_base)}{path}" @staticmethod - def validate_environment(headers: dict[str, str] | None = None, api_key: str | None = None) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings + def validate_environment( + headers: dict[str, str] | None = None, api_key: str | None = None + ) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings """Add MiniMax authentication while preserving caller headers.""" resolved_key = api_key or litellm.api_key or get_secret_str("MINIMAX_API_KEY") if not resolved_key: raise ValueError("MiniMax API key is required for voice cloning") - result = dict(headers or {}) # mutable-ok: caller headers must remain independently mutable + result = dict(headers or {}) # mutable-ok: caller headers must remain independently mutable result["Authorization"] = f"Bearer {resolved_key}" return result @@ -83,7 +87,9 @@ class MinimaxVoiceCloneConfig: cls, file: FileTypes, purpose: str = "voice_clone", - ) -> tuple[dict[str, tuple[str, bytes, str]], dict[str, str]]: # mutable-ok: provider payloads and public headers require mutable mappings + ) -> tuple[ + dict[str, tuple[str, bytes, str]], dict[str, str] + ]: # mutable-ok: provider payloads and public headers require mutable mappings """Convert a LiteLLM file value to MiniMax multipart fields.""" if purpose not in cls.UPLOAD_PURPOSES: allowed = ", ".join(sorted(cls.UPLOAD_PURPOSES)) @@ -95,7 +101,9 @@ class MinimaxVoiceCloneConfig: if not isinstance(content, bytes): content = bytes(content) content_type = extracted.get("content_type") or "application/octet-stream" - return {"file": (filename, content, content_type)}, {"purpose": purpose} # mutable-ok: multipart fields require mutable mapping for httpx + return {"file": (filename, content, content_type)}, { + "purpose": purpose + } # mutable-ok: multipart fields require mutable mapping for httpx @classmethod def transform_upload_response(cls, raw_response: httpx.Response) -> str: @@ -112,16 +120,24 @@ class MinimaxVoiceCloneConfig: return file_id @classmethod - def transform_clone_request(cls, file_id: str, voice_id: str, model: str) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings + def transform_clone_request( + cls, file_id: str, voice_id: str, model: str + ) -> dict[str, str]: # mutable-ok: provider payloads and public headers require mutable mappings """Build the required MiniMax voice-clone JSON body.""" - values = {"file_id": file_id, "voice_id": voice_id, "model": model} # mutable-ok: request body is serialized as mutable JSON + values = { + "file_id": file_id, + "voice_id": voice_id, + "model": model, + } # mutable-ok: request body is serialized as mutable JSON for name, value in values.items(): if not isinstance(value, str) or not value.strip(): raise ValueError(f"{name} is required for MiniMax voice cloning") if model not in cls.SUPPORTED_MODELS: supported = ", ".join(sorted(cls.SUPPORTED_MODELS)) raise ValueError(f"Unsupported MiniMax voice-clone model {model!r}; supported models: {supported}") - return {name: value.strip() for name, value in values.items()} # mutable-ok: request body is serialized as mutable JSON + return { + name: value.strip() for name, value in values.items() + } # mutable-ok: request body is serialized as mutable JSON @classmethod def transform_clone_response( @@ -141,10 +157,16 @@ class MinimaxVoiceCloneConfig: ) if not voice_id: raise MinimaxVoiceCloneError("MiniMax clone response did not include voice_id", raw_response.status_code) - return {"file_id": file_id, "voice_id": voice_id, "model": model} # mutable-ok: parsed response is normalized from provider JSON + return { + "file_id": file_id, + "voice_id": voice_id, + "model": model, + } # mutable-ok: parsed response is normalized from provider JSON @staticmethod - def _json_response(raw_response: httpx.Response) -> dict[str, object]: # mutable-ok: provider payloads and public headers require mutable mappings + def _json_response( + raw_response: httpx.Response, + ) -> dict[str, object]: # mutable-ok: provider payloads and public headers require mutable mappings try: payload = raw_response.json() except ValueError as exc: @@ -183,7 +205,9 @@ class MinimaxVoiceCloneConfig: def _raise_for_api_status(payload: Mapping[str, object], http_status: int) -> None: base_resp = payload.get("base_resp") status_code = base_resp.get("status_code") if isinstance(base_resp, Mapping) else None - if http_status >= 400 or (status_code is not None and str(status_code) not in {"0", "200"}): # mutable-ok: status code allowlist is a local membership set + if http_status >= 400 or ( + status_code is not None and str(status_code) not in {"0", "200"} + ): # mutable-ok: status code allowlist is a local membership set status_message = base_resp.get("status_msg") if isinstance(base_resp, Mapping) else None detail = str(status_message or payload.get("message") or "MiniMax voice-clone request failed") raise MinimaxVoiceCloneError(detail, http_status or int(status_code or 0)) diff --git a/litellm/voice_clone/__init__.py b/litellm/voice_clone/__init__.py index 616ddbac412..2abdd8f95b3 100644 --- a/litellm/voice_clone/__init__.py +++ b/litellm/voice_clone/__init__.py @@ -2,4 +2,7 @@ from .main import avoice_clone, voice_clone -__all__ = ["avoice_clone", "voice_clone"] # mutable-ok: public export list is intentionally mutable for package compatibility +__all__ = [ + "avoice_clone", + "voice_clone", +] # mutable-ok: public export list is intentionally mutable for package compatibility diff --git a/litellm/voice_clone/main.py b/litellm/voice_clone/main.py index 457f12286fb..09999e81eee 100644 --- a/litellm/voice_clone/main.py +++ b/litellm/voice_clone/main.py @@ -31,7 +31,7 @@ def voice_clone( api_base: str | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = "minimax", - extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers + extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers client: httpx.Client | None = None, ) -> VoiceCloneResponse: """Upload an audio sample and create a MiniMax custom voice. @@ -70,7 +70,7 @@ def voice_clone( clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) clone_response = http_client.post( config.get_complete_url(api_base=api_base, operation="clone"), - headers={**headers, "Content-Type": "application/json"}, # mutable-ok: JSON request body is passed to httpx + headers={**headers, "Content-Type": "application/json"}, # mutable-ok: JSON request body is passed to httpx json=clone_body, timeout=timeout_value, ) @@ -90,7 +90,7 @@ async def avoice_clone( api_base: str | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = "minimax", - extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers + extra_headers: dict[str, str] | None = None, # mutable-ok: caller may provide mutable extra headers client: httpx.AsyncClient | None = None, ) -> VoiceCloneResponse: """Asynchronously upload and clone a MiniMax voice.""" @@ -102,7 +102,7 @@ async def avoice_clone( timeout_value = timeout if timeout is not None else 600.0 http_client = client or get_async_httpx_client( llm_provider=LlmProviders.MINIMAX, - params={"timeout": timeout_value}, # mutable-ok: JSON request body is passed to httpx + params={"timeout": timeout_value}, # mutable-ok: JSON request body is passed to httpx ) upload_response = await http_client.post( config.get_complete_url(api_base=api_base, operation="upload"), @@ -115,7 +115,10 @@ async def avoice_clone( clone_body = config.transform_clone_request(file_id=file_id, voice_id=voice_id, model=model) clone_response = await http_client.post( config.get_complete_url(api_base=api_base, operation="clone"), - headers={**headers, "Content-Type": "application/json"}, # mutable-ok: provider request and export collections are intentionally mutable + headers={ + **headers, + "Content-Type": "application/json", + }, # mutable-ok: provider request and export collections are intentionally mutable json=clone_body, timeout=timeout_value, ) diff --git a/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py b/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py index b453f5d5621..e8baf2e3c89 100644 --- a/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py +++ b/tests/test_litellm/llms/minimax/voice_clone/test_minimax_voice_clone_transformation.py @@ -37,6 +37,14 @@ def test_prompt_audio_purpose_is_supported() -> None: assert data == {"purpose": "prompt_audio"} +def test_invalid_operation_and_upload_purpose_are_rejected() -> None: + with pytest.raises(ValueError, match="operation must be"): + MinimaxVoiceCloneConfig.get_complete_url(operation="invalid") + + with pytest.raises(ValueError, match="file upload purpose"): + MinimaxVoiceCloneConfig.transform_upload_request(("sample.wav", b"audio"), "invalid") + + def test_upload_response_extracts_nested_file_id() -> None: file_id = MinimaxVoiceCloneConfig.transform_upload_response( response({"file": {"file_id": "file-123"}, "base_resp": {"status_code": 0}})