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)