Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions llmstack/processors/providers/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Literal

from pydantic import Field

from llmstack.processors.providers.config import ProviderConfig


class MiniMaxProviderConfig(ProviderConfig):
provider_slug: str = "minimax"
api_key: str = Field(
title="API Key",
description="Your MiniMax API key.",
default="",
json_schema_extra={"widget": "password", "advanced_parameter": False},
)
speech_api_url: Literal["https://api.minimax.io/v1/t2a_v2", "https://api.minimaxi.com/v1/t2a_v2"] = Field(
default="https://api.minimax.io/v1/t2a_v2",
title="Speech API URL",
description="Use the endpoint for the region of your MiniMax account.",
json_schema_extra={"advanced_parameter": False},
)
154 changes: 154 additions & 0 deletions llmstack/processors/providers/minimax/tts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from typing import Literal, Optional

import requests
from asgiref.sync import async_to_sync
from pydantic import BaseModel, Field, model_validator

from llmstack.apps.schemas import OutputTemplate
from llmstack.processors.providers.api_processor_interface import (
AUDIO_WIDGET_NAME,
ApiProcessorInterface,
ApiProcessorSchema,
)


class VoiceSetting(BaseModel):
voice_id: str = Field(min_length=1, description="MiniMax system or custom voice ID.")
speed: float = Field(default=1.0, ge=0.5, le=2.0)
vol: float = Field(default=1.0, gt=0.0, le=10.0)
pitch: int = Field(default=0, ge=-12, le=12)


class AudioSetting(BaseModel):
format: Literal["mp3", "wav", "flac", "pcm"] = "mp3"
sample_rate: Literal[8000, 16000, 22050, 24000, 32000, 44100] = 32000
bitrate: Literal[32000, 64000, 128000, 256000] = 128000
channel: Literal[1, 2] = 1


class PronunciationDict(BaseModel):
tone: list[str] = Field(default_factory=list, description="Pronunciation rules in original/replacement format.")


class VoiceModify(BaseModel):
pitch: Optional[int] = Field(default=None, ge=-100, le=100)
intensity: Optional[int] = Field(default=None, ge=-100, le=100)
timbre: Optional[int] = Field(default=None, ge=-100, le=100)
sound_effects: Optional[Literal["spacious_echo", "auditorium_echo", "lofi_telephone", "robotic"]] = None


class TextToSpeechInput(ApiProcessorSchema):
input_text: str = Field(min_length=1, max_length=9999, description="Text to convert to speech.")


class TextToSpeechConfiguration(ApiProcessorSchema):
model: Literal[
"speech-2.8-hd",
"speech-2.8-turbo",
"speech-2.6-hd",
"speech-2.6-turbo",
"speech-02-hd",
"speech-02-turbo",
"speech-01-hd",
"speech-01-turbo",
] = Field(
default="speech-2.8-hd",
description="MiniMax speech model.",
json_schema_extra={"advanced_parameter": False},
)
voice_setting: VoiceSetting = Field(
description="Voice ID and speech controls.",
json_schema_extra={"advanced_parameter": False},
)
audio_setting: AudioSetting = Field(default_factory=AudioSetting)
language_boost: Optional[str] = Field(default=None, description="Language hint, or auto for automatic detection.")
pronunciation_dict: Optional[PronunciationDict] = None
voice_modify: Optional[VoiceModify] = None
subtitle_enable: bool = False

@model_validator(mode="after")
def validate_voice_effects_format(self):
if self.voice_modify is not None and self.audio_setting.format == "pcm":
raise ValueError("Voice effects require mp3, wav, or flac audio.")
return self


class TextToSpeechOutput(ApiProcessorSchema):
audio_content: str = Field(
description="Reference to the generated audio asset.",
json_schema_extra={"widget": AUDIO_WIDGET_NAME},
)
mime_type: str = Field(description="The audio media type.")
subtitle_url: Optional[str] = Field(default=None, description="Download URL for generated subtitles, if requested.")


class MiniMaxTextToSpeechProcessor(
ApiProcessorInterface[TextToSpeechInput, TextToSpeechOutput, TextToSpeechConfiguration],
):
@staticmethod
def name() -> str:
return "Text to Speech"

@staticmethod
def slug() -> str:
return "text_to_speech"

@staticmethod
def description() -> str:
return "Convert text to speech with MiniMax."

@staticmethod
def provider_slug() -> str:
return "minimax"

@classmethod
def get_output_template(cls) -> OutputTemplate:
return OutputTemplate(
markdown='<pa-asset url="{{audio_content}}" controls type="{{mime_type}}"></pa-asset>',
jsonpath="$.audio_content",
)

def process(self) -> dict:
provider_config = self.get_provider_config(model_slug=self._config.model)
payload = self._config.model_dump(exclude_none=True)
payload.update(text=self._input.input_text, stream=False, output_format="hex")
response = requests.post(
provider_config.speech_api_url,
headers={"Authorization": f"Bearer {provider_config.api_key}"},
json=payload,
timeout=120,
allow_redirects=False,
)
response.raise_for_status()
result = response.json()
if not isinstance(result, dict):
raise ValueError("MiniMax returned an invalid speech response.")
base_resp = result.get("base_resp")
if not isinstance(base_resp, dict) or base_resp.get("status_code") != 0:
raise ValueError("MiniMax speech synthesis failed.")
data = result.get("data")
if not isinstance(data, dict) or data.get("status") != 2:
raise ValueError("MiniMax speech synthesis did not complete.")
audio_hex = data.get("audio")
if not isinstance(audio_hex, str) or not audio_hex:
raise ValueError("MiniMax returned no audio.")
try:
audio = bytes.fromhex(audio_hex)
except ValueError as exc:
raise ValueError("MiniMax returned invalid audio encoding.") from exc
if not audio:
raise ValueError("MiniMax returned no audio.")

audio_format = self._config.audio_setting.format
mime_type = {"mp3": "audio/mpeg", "wav": "audio/wav", "flac": "audio/flac", "pcm": "audio/pcm"}[audio_format]
asset_stream = self._create_asset_stream(mime_type=mime_type, file_name=f"speech.{audio_format}")
asset_stream.append_chunk(audio)
asset_stream.finalize()
async_to_sync(self._output_stream.write)(
TextToSpeechOutput(
audio_content=asset_stream.objref,
mime_type=mime_type,
subtitle_url=data.get("subtitle_file"),
)
)
return self._output_stream.finalize()
180 changes: 180 additions & 0 deletions llmstack/processors/providers/test_minimax_tts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch

import requests
from pydantic import ValidationError

from llmstack.common.utils.module_loader import get_all_sub_classes
from llmstack.processors.providers.api_processor_interface import ApiProcessorInterface
from llmstack.processors.providers.minimax import MiniMaxProviderConfig
from llmstack.processors.providers.minimax.tts import (
MiniMaxTextToSpeechProcessor,
TextToSpeechConfiguration,
TextToSpeechInput,
)
from llmstack.processors.providers.processors import ProcessorFactory
from llmstack.server.settings import PROVIDERS

EXPECTED_MODELS = [
"speech-2.8-hd",
"speech-2.8-turbo",
"speech-2.6-hd",
"speech-2.6-turbo",
"speech-02-hd",
"speech-02-turbo",
"speech-01-hd",
"speech-01-turbo",
]
EXPECTED_ENDPOINTS = ["https://api.minimax.io/v1/t2a_v2", "https://api.minimaxi.com/v1/t2a_v2"]


class MiniMaxTextToSpeechTest(unittest.TestCase):
def setUp(self):
self.processor = object.__new__(MiniMaxTextToSpeechProcessor)
self.processor._input = TextToSpeechInput(input_text="Hello world.")
self.processor._config = TextToSpeechConfiguration(voice_setting={"voice_id": "test-voice"})
self.provider = MiniMaxProviderConfig(api_key="test-key")
self.processor.get_provider_config = Mock(return_value=self.provider)
self.asset = Mock(objref="objref://sessionfiles/audio")
self.processor._create_asset_stream = Mock(return_value=self.asset)
self.output = SimpleNamespace(
write=AsyncMock(), finalize=Mock(return_value={"audio_content": self.asset.objref})
)
self.processor._output_stream = self.output
self.response = Mock()
self.response.json.return_value = {
"base_resp": {"status_code": 0},
"data": {"status": 2, "audio": b"audio bytes".hex()},
}

def test_registration_and_processor_discovery(self):
registrations = [provider for provider in PROVIDERS if provider["slug"] == "minimax"]
self.assertEqual(len(registrations), 1)
self.assertEqual(
registrations[0]["config_schema"], "llmstack.processors.providers.minimax.MiniMaxProviderConfig"
)
processors = get_all_sub_classes(registrations[0]["processor_packages"][0], ApiProcessorInterface)
self.assertIn(MiniMaxTextToSpeechProcessor, processors)
self.assertIs(ProcessorFactory.get_processor("text_to_speech", "minimax"), MiniMaxTextToSpeechProcessor)
self.assertEqual(MiniMaxProviderConfig.get_config_schema()["properties"]["api_key"]["widget"], "password")

@patch("llmstack.processors.providers.minimax.tts.requests.post")
def test_both_regions_and_all_models(self, post):
post.return_value = self.response
schema = TextToSpeechConfiguration.model_json_schema()
self.assertEqual(schema["properties"]["model"]["default"], "speech-2.8-hd")
self.assertEqual(schema["properties"]["model"]["enum"], EXPECTED_MODELS)
self.assertEqual(
MiniMaxProviderConfig.model_json_schema()["properties"]["speech_api_url"]["enum"], EXPECTED_ENDPOINTS
)
for endpoint in EXPECTED_ENDPOINTS:
for model in EXPECTED_MODELS:
with self.subTest(endpoint=endpoint, model=model):
self.provider.speech_api_url = endpoint
self.processor._config.model = model
result = self.processor.process()
self.assertEqual(result, {"audio_content": self.asset.objref})
args, kwargs = post.call_args
self.assertEqual(args, (endpoint,))
self.assertEqual(kwargs["headers"], {"Authorization": "Bearer test-key"})
self.assertEqual(kwargs["timeout"], 120)
self.assertFalse(kwargs["allow_redirects"])
self.assertEqual(kwargs["json"]["model"], model)
self.assertEqual(kwargs["json"]["text"], "Hello world.")
self.assertFalse(kwargs["json"]["stream"])
self.assertEqual(kwargs["json"]["output_format"], "hex")
self.assertNotIn("language_boost", kwargs["json"])
self.processor.get_provider_config.assert_called_with(model_slug=model)
self.asset.append_chunk.assert_called_with(b"audio bytes")
self.asset.finalize.assert_called_with()
self.assertEqual(self.output.write.call_args.args[0].audio_content, self.asset.objref)

@patch("llmstack.processors.providers.minimax.tts.requests.post")
def test_optional_parameters_and_subtitle_output(self, post):
post.return_value = self.response
self.response.json.return_value["data"]["subtitle_file"] = "https://example.com/subtitles.json"
self.processor._config = TextToSpeechConfiguration(
voice_setting={"voice_id": "test-voice", "speed": 1.2, "vol": 2, "pitch": 3},
audio_setting={"format": "wav", "sample_rate": 44100, "channel": 2},
language_boost="auto",
pronunciation_dict={"tone": ["API/A P I"]},
voice_modify={"pitch": 10, "sound_effects": "robotic"},
subtitle_enable=True,
)
self.processor.process()
payload = post.call_args.kwargs["json"]
self.assertEqual(payload["voice_setting"], {"voice_id": "test-voice", "speed": 1.2, "vol": 2, "pitch": 3})
self.assertEqual(payload["audio_setting"]["format"], "wav")
self.assertEqual(payload["language_boost"], "auto")
self.assertEqual(payload["pronunciation_dict"], {"tone": ["API/A P I"]})
self.assertEqual(payload["voice_modify"], {"pitch": 10, "sound_effects": "robotic"})
self.assertTrue(payload["subtitle_enable"])
self.assertEqual(self.output.write.call_args.args[0].subtitle_url, "https://example.com/subtitles.json")

@patch("llmstack.processors.providers.minimax.tts.requests.post")
def test_audio_formats_use_matching_asset_media_types(self, post):
post.return_value = self.response
for audio_format, mime_type in {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"flac": "audio/flac",
"pcm": "audio/pcm",
}.items():
with self.subTest(audio_format=audio_format):
self.processor._config.audio_setting.format = audio_format
self.processor.process()
self.processor._create_asset_stream.assert_called_with(
mime_type=mime_type, file_name=f"speech.{audio_format}"
)
self.assertEqual(self.output.write.call_args.args[0].mime_type, mime_type)

@patch("llmstack.processors.providers.minimax.tts.requests.post")
def test_errors_and_invalid_audio_never_create_assets(self, post):
post.return_value = self.response
responses = [
[],
{},
{"base_resp": {"status_code": 1004}, "data": None},
{"base_resp": {"status_code": 0}, "data": None},
{"base_resp": {"status_code": 0}, "data": {"status": 1, "audio": "00"}},
*(
{"base_resp": {"status_code": 0}, "data": {"status": 2, "audio": audio}}
for audio in (None, "", " ", "bad", "zz", 123)
),
]
for response in responses:
with self.subTest(response=response):
self.response.json.return_value = response
with self.assertRaises(ValueError):
self.processor.process()
self.processor._create_asset_stream.assert_not_called()
self.output.write.assert_not_called()

@patch("llmstack.processors.providers.minimax.tts.requests.post")
def test_http_failure_is_propagated(self, post):
post.return_value = self.response
self.response.raise_for_status.side_effect = requests.HTTPError("Request failed")
with self.assertRaises(requests.HTTPError):
self.processor.process()
self.response.json.assert_not_called()
self.processor._create_asset_stream.assert_not_called()

def test_invalid_request_configuration(self):
for config in (
{"voice_setting": {"voice_id": ""}},
{"voice_setting": {"voice_id": "test-voice", "speed": 3}},
{"voice_setting": {"voice_id": "test-voice", "vol": 0}},
{"voice_setting": {"voice_id": "test-voice"}, "audio_setting": {"format": "pcm"}, "voice_modify": {}},
{"voice_setting": {"voice_id": "test-voice"}, "model": "invalid"},
):
with self.subTest(config=config), self.assertRaises(ValidationError):
TextToSpeechConfiguration(**config)
with self.assertRaises(ValidationError):
TextToSpeechInput(input_text="")
with self.assertRaises(ValidationError):
MiniMaxProviderConfig(speech_api_url="https://example.com")


if __name__ == "__main__":
unittest.main()
6 changes: 6 additions & 0 deletions llmstack/server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,12 @@
"slug": "mistral",
"config_schema": "llmstack.processors.providers.mistral.MistralProviderConfig",
},
{
"name": "MiniMax",
"processor_packages": ["llmstack.processors.providers.minimax"],
"slug": "minimax",
"config_schema": "llmstack.processors.providers.minimax.MiniMaxProviderConfig",
},
{
"name": "Meta",
"processor_packages": ["llmstack.processors.providers.meta"],
Expand Down