mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge 003004fca8 into 9071ca503e
This commit is contained in:
commit
60a07331c6
7 changed files with 448 additions and 0 deletions
|
|
@ -1400,6 +1400,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 *
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
13
litellm/llms/minimax/voice_clone/__init__.py
Normal file
13
litellm/llms/minimax/voice_clone/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""MiniMax voice-cloning request and response transformations."""
|
||||
|
||||
from .transformation import (
|
||||
MinimaxVoiceCloneConfig,
|
||||
MinimaxVoiceCloneError,
|
||||
VoiceCloneResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MinimaxVoiceCloneConfig",
|
||||
"MinimaxVoiceCloneError",
|
||||
"VoiceCloneResponse",
|
||||
]
|
||||
189
litellm/llms/minimax/voice_clone/transformation.py
Normal file
189
litellm/llms/minimax/voice_clone/transformation.py
Normal file
|
|
@ -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) -> 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))
|
||||
5
litellm/voice_clone/__init__.py
Normal file
5
litellm/voice_clone/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Provider-backed voice-cloning helpers."""
|
||||
|
||||
from .main import avoice_clone, voice_clone
|
||||
|
||||
__all__ = ["avoice_clone", "voice_clone"]
|
||||
122
litellm/voice_clone/main.py
Normal file
122
litellm/voice_clone/main.py
Normal file
|
|
@ -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.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:
|
||||
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
|
||||
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)
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Reference in a new issue