Skip to content
Closed
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
3 changes: 2 additions & 1 deletion python/semantic_kernel/connectors/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ All base clients inherit from the [`AIServiceClientBase`](../../services/ai_serv
| | [`HuggingFaceTextEmbedding`](./hugging_face/services/hf_text_embedding.py) |
| Mistral AI | [`MistralAIChatCompletion`](./mistral_ai/services/mistral_ai_chat_completion.py) |
| | [`MistralAITextEmbedding`](./mistral_ai/services/mistral_ai_text_embedding.py) |
| MiniMax | [`MiniMaxTextToAudio`](./minimax/services/minimax_text_to_audio.py) |
| [Nvidia](./nvidia/README.md) | [`NvidiaTextEmbedding`](./nvidia/services/nvidia_text_embedding.py) |
| Ollama | [`OllamaChatCompletion`](./ollama/services/ollama_chat_completion.py) |
| | [`OllamaTextCompletion`](./ollama/services/ollama_text_completion.py) |
| | [`OllamaTextEmbedding`](./ollama/services/ollama_text_embedding.py) |
| Onnx | [`OnnxGenAIChatCompletion`](./onnx/services/onnx_gen_ai_chat_completion.py) |
| | [`OnnxGenAITextCompletion`](./onnx/services/onnx_gen_ai_text_completion.py) |
| | [`OnnxGenAITextCompletion`](./onnx/services/onnx_gen_ai_text_completion.py) |
9 changes: 9 additions & 0 deletions python/semantic_kernel/connectors/ai/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.

from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_text_to_audio_execution_settings import (
MiniMaxTextToAudioExecutionSettings,
)
from semantic_kernel.connectors.ai.minimax.services.minimax_text_to_audio import MiniMaxTextToAudio
from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings

__all__ = ["MiniMaxSettings", "MiniMaxTextToAudio", "MiniMaxTextToAudioExecutionSettings"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.

from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_text_to_audio_execution_settings import (
MiniMaxTextToAudioExecutionSettings,
)

__all__ = ["MiniMaxTextToAudioExecutionSettings"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright (c) Microsoft. All rights reserved.

from typing import Any

from pydantic import Field

from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings


class MiniMaxTextToAudioExecutionSettings(PromptExecutionSettings):
"""Request settings for MiniMax text-to-audio (T2A v2)."""

ai_model_id: str | None = Field(None, serialization_alias="model")
input: str | None = Field(None, serialization_alias="text")
stream: bool | None = None
language_boost: str | None = None
output_format: str | None = None
voice_setting: dict[str, Any] | None = None
pronunciation_dict: dict[str, Any] | None = None
audio_setting: dict[str, Any] | None = None
voice_modify: dict[str, Any] | None = None
subtitle_enable: bool | None = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.

from semantic_kernel.connectors.ai.minimax.services.minimax_text_to_audio import MiniMaxTextToAudio

__all__ = ["MiniMaxTextToAudio"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft. All rights reserved.

import base64
import binascii
from collections.abc import Mapping
from typing import Any, TypeVar

import aiohttp
from pydantic import ValidationError

from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_text_to_audio_execution_settings import (
MiniMaxTextToAudioExecutionSettings,
)
from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.connectors.ai.text_to_audio_client_base import TextToAudioClientBase
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError, ServiceResponseException

T_ = TypeVar("T_", bound="MiniMaxTextToAudio")


class MiniMaxTextToAudio(TextToAudioClientBase):
"""MiniMax speech-2.x text-to-audio HTTP service."""

api_key: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api_key is declared as a bare field on this KernelBaseModel subclass and is populated with the
unwrapped secret (api_key=settings.api_key.get_secret_value() at line 56). Because the client is a
Pydantic model, the plaintext key is then included in repr(self), model_dump(), and
model_dump_json():

DUMP: {'ai_model_id': 'speech-2.8-hd', 'service_id': '', 'api_key': 'SECRET-abc123', ...}

Any diagnostic logging, str(service) in a stack trace, or serialization of the service object
writes the raw MiniMax key in cleartext. Every other connector avoids this by handing the secret to
an SDK client rather than storing it as a model field, and MiniMaxSettings.api_key is already a
SecretStr whose protection is discarded here. Store the credential as a SecretStr or a
PrivateAttr (or mark it Field(exclude=True, repr=False)) so it is not exposed via repr/dump.

base_url: str
default_headers: dict[str, str]

def __init__(
self,
ai_model_id: str | None = None,
api_key: str | None = None,
service_id: str | None = None,
base_url: str | None = None,
default_headers: Mapping[str, str] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Initialize a MiniMax text-to-audio client."""
try:
settings = MiniMaxSettings(
api_key=api_key,
text_to_audio_model_id=ai_model_id,
text_to_audio_base_url=base_url,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create MiniMax settings.", ex) from ex
if not settings.text_to_audio_model_id:
raise ServiceInitializationError("The MiniMax text to audio model ID is required.")
super().__init__(
ai_model_id=settings.text_to_audio_model_id,
service_id=service_id or settings.text_to_audio_model_id,
api_key=settings.api_key.get_secret_value(),
base_url=settings.text_to_audio_base_url,
default_headers=dict(default_headers or {}),
)

@classmethod
def from_dict(cls: type[T_], settings: dict[str, Any]) -> T_:
"""Initialize a client from a settings dictionary."""
return cls(
ai_model_id=settings.get("ai_model_id"),
api_key=settings.get("api_key"),
service_id=settings.get("service_id"),
base_url=settings.get("base_url"),
default_headers=settings.get("default_headers"),
env_file_path=settings.get("env_file_path"),
)

def get_prompt_execution_settings_class(self) -> type[PromptExecutionSettings]:
"""Return the request settings type accepted by this client."""
return MiniMaxTextToAudioExecutionSettings

def service_url(self) -> str:
"""Return the configured MiniMax speech endpoint."""
return self.base_url

async def get_audio_contents(
self,
text: str,
settings: PromptExecutionSettings | None = None,
**kwargs: Any,
) -> list[AudioContent]:
"""Convert text into audio using MiniMax's regional HTTP endpoint."""
if settings is None:
request = MiniMaxTextToAudioExecutionSettings(ai_model_id=self.ai_model_id)
elif isinstance(settings, MiniMaxTextToAudioExecutionSettings):
request = settings
else:
request = self.get_prompt_execution_settings_from_settings(settings)
request.ai_model_id = request.ai_model_id or self.ai_model_id
request.input = text
payload = request.prepare_settings_dict()
payload.update(kwargs)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.default_headers,
}
try:
timeout = aiohttp.ClientTimeout(total=120)
async with (
aiohttp.ClientSession(timeout=timeout, headers=headers) as session,
session.post(self.base_url, json=payload) as response,
):
body = await response.json(content_type=None)
response.raise_for_status()
Comment on lines +109 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On any non-2xx response, response.raise_for_status() raises before the base_resp.status_code /
status_msg inspection a few lines below, so MiniMax's descriptive error message is discarded and
the caller only sees the bare HTTP status. Additionally, when an error response has an empty or
non-JSON body, response.json() on the preceding line raises JSONDecodeError first, masking the
real status entirely. Both cases collapse into a generic ServiceResponseException, making auth,
quota, and validation failures hard to diagnose. Inspect the parsed base_resp (and surface its
status_msg) before calling raise_for_status(), and guard the JSON parse so a non-JSON error body
still yields the HTTP status.

if body.get("base_resp", {}).get("status_code") not in (None, 0):
raise RuntimeError(body.get("base_resp", {}).get("status_msg", "MiniMax request failed"))
encoded = body.get("data", {}).get("audio")
if not encoded:
raise RuntimeError("MiniMax response did not contain audio data")
try:
audio = bytes.fromhex(encoded)
except (ValueError, TypeError, binascii.Error):
audio = base64.b64decode(encoded)
return [AudioContent(ai_model_id=request.ai_model_id, data=audio, data_format="base64", inner_content=body)]
except Exception as ex:
raise ServiceResponseException(f"{type(self)} service failed to generate audio", ex) from ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.

from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings

__all__ = ["MiniMaxSettings"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.

from typing import ClassVar

from pydantic import SecretStr

from semantic_kernel.kernel_pydantic import KernelBaseSettings


class MiniMaxSettings(KernelBaseSettings):
"""MiniMax credentials and text-to-audio defaults.

The ``MINIMAX_`` environment prefix is used for API clients. Set
``MINIMAX_API_KEY`` and optionally ``MINIMAX_TEXT_TO_AUDIO_MODEL_ID``.
"""

env_prefix: ClassVar[str] = "MINIMAX_"

api_key: SecretStr
text_to_audio_model_id: str | None = None
text_to_audio_base_url: str = "https://api.minimax.io/v1/t2a_v2"
1 change: 1 addition & 0 deletions python/tests/unit/connectors/ai/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Copyright (c) Microsoft. All rights reserved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft. All rights reserved.

from semantic_kernel.connectors.ai.minimax import MiniMaxTextToAudio, MiniMaxTextToAudioExecutionSettings


def test_text_to_audio_settings_prepare_request() -> None:
settings = MiniMaxTextToAudioExecutionSettings(
ai_model_id="speech-2.8-hd",
input="Hello",
output_format="mp3",
audio_setting={"sample_rate": 32000},
)

assert settings.prepare_settings_dict() == {
"model": "speech-2.8-hd",
"text": "Hello",
"output_format": "mp3",
"audio_setting": {"sample_rate": 32000},
}


def test_text_to_audio_from_environment(monkeypatch) -> None:
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
monkeypatch.setenv("MINIMAX_TEXT_TO_AUDIO_MODEL_ID", "speech-2.8-hd")

service = MiniMaxTextToAudio()
Comment on lines +22 to +26

assert service.ai_model_id == "speech-2.8-hd"
assert service.base_url == "https://api.minimax.io/v1/t2a_v2"
assert service.get_prompt_execution_settings_class() is MiniMaxTextToAudioExecutionSettings
Loading