diff --git a/python/semantic_kernel/connectors/ai/README.md b/python/semantic_kernel/connectors/ai/README.md index 6fe7510a0697..93ea630595e3 100644 --- a/python/semantic_kernel/connectors/ai/README.md +++ b/python/semantic_kernel/connectors/ai/README.md @@ -42,6 +42,7 @@ All base clients inherit from the [`AIServiceClientBase`](../../services/ai_serv | | [`GoogleAITextEmbedding`](./google/google_ai/services/google_ai_text_embedding.py) | | HuggingFace | [`HuggingFaceTextCompletion`](./hugging_face/services/hf_text_completion.py) | | | [`HuggingFaceTextEmbedding`](./hugging_face/services/hf_text_embedding.py) | +| [MiniMax](./minimax/README.md) | [`MiniMaxChatCompletion`](./minimax/services/minimax_chat_completion.py) | | Mistral AI | [`MistralAIChatCompletion`](./mistral_ai/services/mistral_ai_chat_completion.py) | | | [`MistralAITextEmbedding`](./mistral_ai/services/mistral_ai_text_embedding.py) | | [Nvidia](./nvidia/README.md) | [`NvidiaTextEmbedding`](./nvidia/services/nvidia_text_embedding.py) | diff --git a/python/semantic_kernel/connectors/ai/minimax/README.md b/python/semantic_kernel/connectors/ai/minimax/README.md new file mode 100644 index 000000000000..5c765f1621b8 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/README.md @@ -0,0 +1,67 @@ +# semantic_kernel.connectors.ai.minimax + +This connector enables integration with the MiniMax API for chat completion. MiniMax provides an +OpenAI-compatible chat completion endpoint, so this connector reuses the OpenAI Python client. + +## Regional endpoints + +MiniMax exposes two regional OpenAI-compatible endpoints. Select the region through +`MiniMaxSettings` (or the `MINIMAX_REGION` environment variable); the base URL is resolved +automatically when `base_url` is not set explicitly. + +| Region | OpenAI-compatible endpoint | +|-------------|-----------------------------------| +| `global_en` | `https://api.minimax.io/v1` | +| `cn_zh` | `https://api.minimaxi.com/v1` | + +## Available models + +- `MiniMax-M3` - Latest flagship model with a 1,000,000 token context window and text/image/video + input support (default). +- `MiniMax-M2.7` - Previous generation flagship model with a 204,800 token context window. + +## Quick start + +### Initialize the kernel +```python +import semantic_kernel as sk +kernel = sk.Kernel() +``` + +### Add the MiniMax chat completion service +Provide your API key directly or through environment variables. +```python +from semantic_kernel.connectors.ai.minimax import MiniMaxChatCompletion + +chat_service = MiniMaxChatCompletion( + ai_model_id="MiniMax-M3", # Defaults to MiniMax-M3 + api_key="...", # Can also use MINIMAX_API_KEY env variable + service_id="minimax-chat", +) +kernel.add_service(chat_service) +``` + +### Target the China region +```python +from semantic_kernel.connectors.ai.minimax import MiniMaxChatCompletion + +chat_service = MiniMaxChatCompletion(ai_model_id="MiniMax-M3", region="cn_zh") +``` + +### Basic chat completion +```python +response = await kernel.invoke_prompt("Hello, how are you?") +``` + +## Environment variables + +| Variable | Description | +|------------------------|--------------------------------------------------------------------------| +| `MINIMAX_API_KEY` | Your MiniMax API key | +| `MINIMAX_REGION` | `global_en` (default) or `cn_zh` | +| `MINIMAX_BASE_URL` | API endpoint; resolved from the region when not provided | +| `MINIMAX_CHAT_MODEL_ID`| Default chat model ID | + +## Notes + +- The MiniMax API accepts `temperature` in the range `[0.0, 1.0]`. diff --git a/python/semantic_kernel/connectors/ai/minimax/__init__.py b/python/semantic_kernel/connectors/ai/minimax/__init__.py new file mode 100644 index 000000000000..b5ee00a1a55d --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, + MiniMaxPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.minimax.services.minimax_chat_completion import MiniMaxChatCompletion +from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings + +__all__ = [ + "MiniMaxChatCompletion", + "MiniMaxChatPromptExecutionSettings", + "MiniMaxPromptExecutionSettings", + "MiniMaxSettings", +] diff --git a/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/__init__.py b/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/__init__.py new file mode 100644 index 000000000000..2a50eae89411 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/minimax_prompt_execution_settings.py b/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/minimax_prompt_execution_settings.py new file mode 100644 index 000000000000..b4e51afd6be5 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/prompt_execution_settings/minimax_prompt_execution_settings.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Field + +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class MiniMaxPromptExecutionSettings(PromptExecutionSettings): + """Settings for MiniMax prompt execution.""" + + format: Literal["json"] | None = None + options: dict[str, Any] | None = None + + +class MiniMaxChatPromptExecutionSettings(MiniMaxPromptExecutionSettings): + """Settings for MiniMax chat prompt execution. + + MiniMax accepts temperature in the range [0.0, 1.0]. + """ + + messages: list[dict[str, str]] | None = None + ai_model_id: Annotated[str | None, Field(serialization_alias="model")] = None + temperature: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + top_p: float | None = None + n: int | None = None + stream: bool = False + stop: str | list[str] | None = None + max_tokens: int | None = None + presence_penalty: float | None = None + frequency_penalty: float | None = None + logit_bias: dict[str, float] | None = None + user: str | None = None + tools: list[dict[str, Any]] | None = None + tool_choice: str | dict[str, Any] | None = None + response_format: ( + dict[Literal["type"], Literal["text", "json_object"]] | dict[str, Any] | type[BaseModel] | type | None + ) = None + seed: int | None = None + extra_headers: dict | None = None + extra_body: dict | None = None + timeout: float | None = None + + def prepare_settings_dict(self, **kwargs) -> dict[str, Any]: + """Prepare the settings as a dictionary for the API request.""" + return self.model_dump( + exclude={"service_id", "extension_data", "structured_json_response", "response_format"}, + exclude_none=True, + by_alias=True, + ) diff --git a/python/semantic_kernel/connectors/ai/minimax/services/__init__.py b/python/semantic_kernel/connectors/ai/minimax/services/__init__.py new file mode 100644 index 000000000000..2a50eae89411 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/services/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/semantic_kernel/connectors/ai/minimax/services/minimax_chat_completion.py b/python/semantic_kernel/connectors/ai/minimax/services/minimax_chat_completion.py new file mode 100644 index 000000000000..0f19c4e62216 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/services/minimax_chat_completion.py @@ -0,0 +1,295 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +import sys +from collections.abc import AsyncGenerator +from typing import Any, Literal + +from openai import AsyncOpenAI +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice +from pydantic import ValidationError + +from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase +from semantic_kernel.connectors.ai.completion_usage import CompletionUsage +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.minimax.services.minimax_handler import MiniMaxHandler +from semantic_kernel.connectors.ai.minimax.services.minimax_model_types import MiniMaxModelTypes +from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.contents import ( + AuthorRole, + ChatMessageContent, + FinishReason, + FunctionCallContent, + StreamingChatMessageContent, + StreamingTextContent, + TextContent, +) +from semantic_kernel.contents.chat_history import ChatHistory +from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError +from semantic_kernel.utils.feature_stage_decorator import experimental +from semantic_kernel.utils.telemetry.model_diagnostics.decorators import ( + trace_chat_completion, + trace_streaming_chat_completion, +) + +if sys.version_info >= (3, 12): + from typing import override # pragma: no cover +else: + from typing_extensions import override # pragma: no cover + +logger: logging.Logger = logging.getLogger(__name__) + +# Default MiniMax chat model when none is specified. +# MiniMax-M3 supports a 1,000,000 token context window. +DEFAULT_MINIMAX_CHAT_MODEL = "MiniMax-M3" + + +@experimental +class MiniMaxChatCompletion(MiniMaxHandler, ChatCompletionClientBase): + """MiniMax Chat completion class. + + This class connects to the MiniMax OpenAI-compatible chat completion endpoint. + The region can be selected through ``MiniMaxSettings`` to target the global + (``https://api.minimax.io/v1``) or China (``https://api.minimaxi.com/v1``) endpoint. + """ + + def __init__( + self, + ai_model_id: str | None = None, + api_key: str | None = None, + base_url: str | None = None, + region: Literal["global_en", "cn_zh"] | None = None, + service_id: str | None = None, + client: AsyncOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: Literal["system", "user", "assistant", "developer"] | None = None, + ) -> None: + """Initialize a MiniMaxChatCompletion service. + + Args: + ai_model_id (str): MiniMax model name, for example, MiniMax-M3 or MiniMax-M2.7. + If not provided, defaults to DEFAULT_MINIMAX_CHAT_MODEL. + api_key (str | None): The optional API key to use. If provided will override, + the env vars or .env file value. + base_url (str | None): Custom API endpoint. When not provided it is resolved + from the selected region. (Optional) + region (Literal["global_en", "cn_zh"] | None): The MiniMax region to target. + Defaults to "global_en". (Optional) + service_id (str | None): Service ID tied to the execution settings. + client (Optional[AsyncOpenAI]): An existing client to use. (Optional) + env_file_path (str | None): Use the environment settings file as a fallback + to environment variables. (Optional) + env_file_encoding (str | None): The encoding of the environment settings file. (Optional) + instruction_role (Literal["system", "user", "assistant", "developer"] | None): The role to use for + 'instruction' messages. Defaults to "system". (Optional) + """ + try: + minimax_settings = MiniMaxSettings( + api_key=api_key, + base_url=base_url, + region=region, + chat_model_id=ai_model_id, + 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 client and not minimax_settings.api_key: + raise ServiceInitializationError("The MiniMax API key is required.") + if not minimax_settings.chat_model_id: + minimax_settings.chat_model_id = DEFAULT_MINIMAX_CHAT_MODEL + logger.warning(f"Default chat model set as: {minimax_settings.chat_model_id}") + + if not client: + client = AsyncOpenAI( + api_key=minimax_settings.api_key.get_secret_value() if minimax_settings.api_key else None, + base_url=minimax_settings.base_url, + ) + + super().__init__( + ai_model_id=minimax_settings.chat_model_id, + api_key=minimax_settings.api_key.get_secret_value() if minimax_settings.api_key else None, + base_url=minimax_settings.base_url, + service_id=service_id or "", + ai_model_type=MiniMaxModelTypes.CHAT, + client=client, + instruction_role=instruction_role or "system", + ) + + @classmethod + def from_dict(cls: type["MiniMaxChatCompletion"], settings: dict[str, Any]) -> "MiniMaxChatCompletion": + """Initialize a MiniMax service from a dictionary of settings. + + Args: + settings: A dictionary of settings for the service. + """ + return cls( + ai_model_id=settings.get("ai_model_id"), + api_key=settings.get("api_key"), + base_url=settings.get("base_url"), + region=settings.get("region"), + service_id=settings.get("service_id"), + env_file_path=settings.get("env_file_path"), + ) + + @override + def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]: + return MiniMaxChatPromptExecutionSettings + + @override + @trace_chat_completion("minimax") + async def _inner_get_chat_message_contents( + self, + chat_history: "ChatHistory", + settings: "PromptExecutionSettings", + ) -> list["ChatMessageContent"]: + if not isinstance(settings, MiniMaxChatPromptExecutionSettings): + settings = self.get_prompt_execution_settings_from_settings(settings) + assert isinstance(settings, MiniMaxChatPromptExecutionSettings) # nosec + + settings.stream = False + settings.messages = self._prepare_chat_history_for_request(chat_history) + settings.ai_model_id = settings.ai_model_id or self.ai_model_id + + response = await self._send_request(settings) + assert isinstance(response, ChatCompletion) # nosec + response_metadata = self._get_metadata_from_chat_response(response) + return [self._create_chat_message_content(response, choice, response_metadata) for choice in response.choices] + + @override + @trace_streaming_chat_completion("minimax") + async def _inner_get_streaming_chat_message_contents( + self, + chat_history: "ChatHistory", + settings: "PromptExecutionSettings", + function_invoke_attempt: int = 0, + ) -> AsyncGenerator[list["StreamingChatMessageContent"], Any]: + if not isinstance(settings, MiniMaxChatPromptExecutionSettings): + settings = self.get_prompt_execution_settings_from_settings(settings) + assert isinstance(settings, MiniMaxChatPromptExecutionSettings) # nosec + + settings.stream = True + settings.messages = self._prepare_chat_history_for_request(chat_history) + settings.ai_model_id = settings.ai_model_id or self.ai_model_id + + response = await self._send_request(settings) + assert isinstance(response, AsyncGenerator) # nosec + + async for chunk in response: + if len(chunk.choices) == 0: + continue + chunk_metadata = self._get_metadata_from_chat_response(chunk) + yield [ + self._create_streaming_chat_message_content(chunk, choice, chunk_metadata, function_invoke_attempt) + for choice in chunk.choices + ] + + def _create_chat_message_content( + self, response: ChatCompletion, choice: Choice, response_metadata: dict[str, Any] + ) -> "ChatMessageContent": + """Create a chat message content object from a choice.""" + metadata = self._get_metadata_from_chat_choice(choice) + metadata.update(response_metadata) + + items: list[Any] = self._get_tool_calls_from_chat_choice(choice) + items.extend(self._get_function_call_from_chat_choice(choice)) + if choice.message.content: + items.append(TextContent(text=choice.message.content)) + + return ChatMessageContent( + inner_content=response, + ai_model_id=self.ai_model_id, + metadata=metadata, + role=AuthorRole(choice.message.role), + items=items, + finish_reason=(FinishReason(choice.finish_reason) if choice.finish_reason else None), + ) + + def _create_streaming_chat_message_content( + self, + chunk: ChatCompletionChunk, + choice: ChunkChoice, + chunk_metadata: dict[str, Any], + function_invoke_attempt: int, + ) -> StreamingChatMessageContent: + """Create a streaming chat message content object from a choice.""" + metadata = self._get_metadata_from_chat_choice(choice) + metadata.update(chunk_metadata) + + items: list[Any] = self._get_tool_calls_from_chat_choice(choice) + items.extend(self._get_function_call_from_chat_choice(choice)) + if choice.delta and choice.delta.content is not None: + items.append(StreamingTextContent(choice_index=choice.index, text=choice.delta.content)) + return StreamingChatMessageContent( + choice_index=choice.index, + inner_content=chunk, + ai_model_id=self.ai_model_id, + metadata=metadata, + role=(AuthorRole(choice.delta.role) if choice.delta and choice.delta.role else AuthorRole.ASSISTANT), + finish_reason=(FinishReason(choice.finish_reason) if choice.finish_reason else None), + items=items, + function_invoke_attempt=function_invoke_attempt, + ) + + def _get_metadata_from_chat_response(self, response: ChatCompletion | ChatCompletionChunk) -> dict[str, Any]: + """Get metadata from a chat response.""" + return { + "id": response.id, + "created": response.created, + "system_fingerprint": getattr(response, "system_fingerprint", None), + "usage": CompletionUsage.from_openai(response.usage) if response.usage is not None else None, + } + + def _get_metadata_from_chat_choice(self, choice: Choice | ChunkChoice) -> dict[str, Any]: + """Get metadata from a chat choice.""" + return { + "logprobs": getattr(choice, "logprobs", None), + } + + def _get_tool_calls_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[FunctionCallContent]: + """Get tool calls from a chat choice.""" + content = choice.message if isinstance(choice, Choice) else choice.delta + if content and (tool_calls := getattr(content, "tool_calls", None)) is not None: + return [ + FunctionCallContent( + id=tool.id, + index=getattr(tool, "index", None), + name=tool.function.name, + arguments=tool.function.arguments, + ) + for tool in tool_calls + ] + return [] + + def _get_function_call_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[FunctionCallContent]: + """Get function calls from a chat choice.""" + content = choice.message if isinstance(choice, Choice) else choice.delta + if content and (function_call := getattr(content, "function_call", None)) is not None: + return [ + FunctionCallContent( + id="", + name=function_call.name, + arguments=function_call.arguments, + ) + ] + return [] + + def _prepare_chat_history_for_request( + self, + chat_history: ChatHistory, + role_key: str = "role", + content_key: str = "content", + ) -> list[dict[str, str]]: + """Prepare chat history for request.""" + messages = [] + for message in chat_history.messages: + message_dict = {role_key: message.role.value, content_key: message.content} + messages.append(message_dict) + return messages diff --git a/python/semantic_kernel/connectors/ai/minimax/services/minimax_handler.py b/python/semantic_kernel/connectors/ai/minimax/services/minimax_handler.py new file mode 100644 index 000000000000..7314585bfcba --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/services/minimax_handler.py @@ -0,0 +1,99 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +from abc import ABC +from typing import Any, ClassVar, Union + +from openai import AsyncOpenAI, AsyncStream +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.chat.chat_completion_chunk import ChatCompletionChunk +from openai.types.completion import Completion +from openai.types.create_embedding_response import CreateEmbeddingResponse + +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.minimax.services.minimax_model_types import MiniMaxModelTypes +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.const import USER_AGENT +from semantic_kernel.exceptions import ServiceResponseException +from semantic_kernel.kernel_pydantic import KernelBaseModel + +logger: logging.Logger = logging.getLogger(__name__) + +RESPONSE_TYPE = Union[list[Any], ChatCompletion, Completion, AsyncStream[Any]] + + +class MiniMaxHandler(KernelBaseModel, ABC): + """Internal class for calls to the MiniMax API.""" + + MODEL_PROVIDER_NAME: ClassVar[str] = "minimax" + client: AsyncOpenAI + ai_model_type: MiniMaxModelTypes = MiniMaxModelTypes.CHAT + base_url: str | None = None + completion_tokens: int = 0 + total_tokens: int = 0 + prompt_tokens: int = 0 + + async def _send_request(self, settings: PromptExecutionSettings) -> RESPONSE_TYPE: + """Send a request to the MiniMax API.""" + if self.ai_model_type == MiniMaxModelTypes.CHAT: + assert isinstance(settings, MiniMaxChatPromptExecutionSettings) # nosec + return await self._send_chat_completion_request(settings) + + raise NotImplementedError(f"Model type {self.ai_model_type} is not supported") + + async def _send_chat_completion_request( + self, settings: MiniMaxChatPromptExecutionSettings + ) -> ChatCompletion | AsyncStream[Any]: + """Send a request to the MiniMax chat completion endpoint.""" + try: + response = await self.client.chat.completions.create(**settings.prepare_settings_dict()) + self.store_usage(response) + return response + except Exception as ex: + raise ServiceResponseException( + f"{type(self)} service failed to complete the chat", + ex, + ) from ex + + def store_usage( + self, + response: ChatCompletion + | Completion + | AsyncStream[ChatCompletionChunk] + | AsyncStream[Completion] + | CreateEmbeddingResponse, + ): + """Store the usage information from the response.""" + if not isinstance(response, AsyncStream) and response.usage: + logger.info(f"OpenAI usage: {response.usage}") + self.prompt_tokens += response.usage.prompt_tokens + self.total_tokens += response.usage.total_tokens + if hasattr(response.usage, "completion_tokens"): + self.completion_tokens += response.usage.completion_tokens + + def to_dict(self) -> dict[str, str]: + """Create a dict of the service settings.""" + client_settings = { + "api_key": self.client.api_key, + "default_headers": {k: v for k, v in self.client.default_headers.items() if k != USER_AGENT}, + } + if self.client.organization: + client_settings["org_id"] = self.client.organization + base = self.model_dump( + exclude={ + "prompt_tokens", + "completion_tokens", + "total_tokens", + "api_type", + "ai_model_type", + "service_id", + "client", + "base_url", + }, + by_alias=True, + exclude_none=True, + ) + base.update(client_settings) + return base diff --git a/python/semantic_kernel/connectors/ai/minimax/services/minimax_model_types.py b/python/semantic_kernel/connectors/ai/minimax/services/minimax_model_types.py new file mode 100644 index 000000000000..5a407270117c --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/services/minimax_model_types.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + +from enum import Enum + + +class MiniMaxModelTypes(Enum): + """MiniMax model types, can be chat or embedding.""" + + CHAT = "chat" + EMBEDDING = "embedding" diff --git a/python/semantic_kernel/connectors/ai/minimax/settings/__init__.py b/python/semantic_kernel/connectors/ai/minimax/settings/__init__.py new file mode 100644 index 000000000000..2a50eae89411 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/settings/__init__.py @@ -0,0 +1 @@ +# Copyright (c) Microsoft. All rights reserved. diff --git a/python/semantic_kernel/connectors/ai/minimax/settings/minimax_settings.py b/python/semantic_kernel/connectors/ai/minimax/settings/minimax_settings.py new file mode 100644 index 000000000000..775500a056ad --- /dev/null +++ b/python/semantic_kernel/connectors/ai/minimax/settings/minimax_settings.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import ClassVar, Literal + +from pydantic import SecretStr, model_validator + +from semantic_kernel.kernel_pydantic import KernelBaseSettings + + +class MiniMaxSettings(KernelBaseSettings): + """MiniMax model settings. + + The settings are first loaded from environment variables with the prefix 'MINIMAX_'. If the + environment variables are not found, the settings can be loaded from a .env file with the + encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored; + however, validation will fail alerting that the settings are missing. + + Optional settings for prefix 'MINIMAX_' are: + - api_key: SecretStr - MiniMax API key, see https://platform.minimax.io/docs + (Env var MINIMAX_API_KEY) + - region: Literal["global_en", "cn_zh"] - The MiniMax region to target. The global endpoint + is used by default; select "cn_zh" to use the China region endpoint. + (Env var MINIMAX_REGION) + - base_url: str | None - The MiniMax OpenAI-compatible endpoint. When not provided it is + resolved from the selected region. + (Env var MINIMAX_BASE_URL) + - chat_model_id: str | None - The MiniMax chat model ID to use, for example, MiniMax-M3. + (Env var MINIMAX_CHAT_MODEL_ID) + - env_file_path: if provided, the .env settings are read from this file path location + """ + + # Regional OpenAI-compatible endpoints documented at + # https://platform.minimax.io/docs/api-reference/api-overview + REGIONAL_BASE_URLS: ClassVar[dict[str, str]] = { + "global_en": "https://api.minimax.io/v1", + "cn_zh": "https://api.minimaxi.com/v1", + } + + env_prefix: ClassVar[str] = "MINIMAX_" + + api_key: SecretStr | None = None + region: Literal["global_en", "cn_zh"] = "global_en" + base_url: str | None = None + chat_model_id: str | None = None + + @model_validator(mode="after") + def _resolve_base_url(self) -> "MiniMaxSettings": + """Resolve the base URL from the selected region when it is not explicitly provided.""" + if self.base_url is None: + self.base_url = self.REGIONAL_BASE_URLS[self.region] + return self diff --git a/python/tests/unit/connectors/ai/minimax/prompt_execution_settings/test_minimax_prompt_execution_settings.py b/python/tests/unit/connectors/ai/minimax/prompt_execution_settings/test_minimax_prompt_execution_settings.py new file mode 100644 index 000000000000..89ddc37b7ae7 --- /dev/null +++ b/python/tests/unit/connectors/ai/minimax/prompt_execution_settings/test_minimax_prompt_execution_settings.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, + MiniMaxPromptExecutionSettings, +) + + +class TestMiniMaxPromptExecutionSettings: + """Test cases for MiniMaxPromptExecutionSettings.""" + + def test_default_init(self): + """Test default initialization.""" + settings = MiniMaxChatPromptExecutionSettings() + assert settings.temperature is None + assert settings.stream is False + + def test_prepare_settings_dict(self): + """Test that prepare_settings_dict serializes with model alias.""" + settings = MiniMaxChatPromptExecutionSettings( + messages=[{"role": "user", "content": "Hello"}], + ai_model_id="MiniMax-M3", + temperature=0.5, + ) + result = settings.prepare_settings_dict() + assert result["model"] == "MiniMax-M3" + assert result["temperature"] == 0.5 + assert result["messages"] == [{"role": "user", "content": "Hello"}] + assert "response_format" not in result + assert "service_id" not in result + + @pytest.mark.parametrize("temperature", [0.0, 0.5, 1.0]) + def test_temperature_valid_range(self, temperature): + """Test that valid temperatures are accepted.""" + settings = MiniMaxChatPromptExecutionSettings(temperature=temperature) + assert settings.temperature == temperature + + @pytest.mark.parametrize("temperature", [-0.1, 1.1, 2.0]) + def test_temperature_invalid_range(self, temperature): + """Test that out-of-range temperatures are rejected.""" + with pytest.raises(ValueError): + MiniMaxChatPromptExecutionSettings(temperature=temperature) + + def test_base_prompt_execution_settings(self): + """Test the base prompt execution settings.""" + settings = MiniMaxPromptExecutionSettings() + assert settings.format is None + assert settings.options is None diff --git a/python/tests/unit/connectors/ai/minimax/services/test_minimax_chat_completion.py b/python/tests/unit/connectors/ai/minimax/services/test_minimax_chat_completion.py new file mode 100644 index 000000000000..1c30326fff0b --- /dev/null +++ b/python/tests/unit/connectors/ai/minimax/services/test_minimax_chat_completion.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, patch + +import pytest +from openai.resources.chat.completions import AsyncCompletions +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +from openai.types.completion_usage import CompletionUsage + +from semantic_kernel.connectors.ai.minimax import MiniMaxChatCompletion +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.minimax.services.minimax_chat_completion import DEFAULT_MINIMAX_CHAT_MODEL +from semantic_kernel.contents import ChatHistory +from semantic_kernel.exceptions import ServiceInitializationError, ServiceResponseException + + +def _create_mock_chat_completion(content: str = "Hello!") -> ChatCompletion: + """Helper function to create a mock ChatCompletion response.""" + message = ChatCompletionMessage(role="assistant", content=content) + choice = Choice( + finish_reason="stop", + index=0, + message=message, + ) + usage = CompletionUsage(completion_tokens=20, prompt_tokens=10, total_tokens=30) + return ChatCompletion( + id="test-id", + choices=[choice], + created=1234567890, + model="MiniMax-M3", + object="chat.completion", + usage=usage, + ) + + +class TestMiniMaxChatCompletion: + """Test cases for MiniMaxChatCompletion.""" + + def test_init_with_defaults(self, minimax_unit_test_env): + """Test initialization with default values.""" + service = MiniMaxChatCompletion() + assert service.ai_model_id == minimax_unit_test_env["MINIMAX_CHAT_MODEL_ID"] + assert service.base_url == "https://api.minimax.io/v1" + + def test_get_prompt_execution_settings_class(self, minimax_unit_test_env): + """Test getting the prompt execution settings class.""" + service = MiniMaxChatCompletion() + assert service.get_prompt_execution_settings_class() == MiniMaxChatPromptExecutionSettings + + @pytest.mark.parametrize("exclude_list", [["MINIMAX_API_KEY"]], indirect=True) + def test_init_with_empty_api_key(self, minimax_unit_test_env): + """Test initialization fails with empty API key.""" + with pytest.raises(ServiceInitializationError): + MiniMaxChatCompletion() + + @pytest.mark.parametrize("exclude_list", [["MINIMAX_CHAT_MODEL_ID"]], indirect=True) + def test_init_with_empty_model_id(self, minimax_unit_test_env): + """Test initialization with empty model ID uses default.""" + service = MiniMaxChatCompletion() + assert service.ai_model_id == DEFAULT_MINIMAX_CHAT_MODEL + + def test_init_with_custom_model_id(self, minimax_unit_test_env): + """Test initialization with custom model ID.""" + custom_model = "MiniMax-M2.7" + service = MiniMaxChatCompletion(ai_model_id=custom_model) + assert service.ai_model_id == custom_model + + def test_init_with_cn_region(self, minimax_unit_test_env): + """Test initialization with the China region resolves the CN base URL.""" + service = MiniMaxChatCompletion(ai_model_id="MiniMax-M3", region="cn_zh") + assert service.base_url == "https://api.minimaxi.com/v1" + + @pytest.mark.asyncio + @patch.object(AsyncCompletions, "create", new_callable=AsyncMock) + async def test_get_chat_message_contents(self, mock_create, minimax_unit_test_env): + """Test basic chat completion.""" + mock_create.return_value = _create_mock_chat_completion("Hello!") + + service = MiniMaxChatCompletion() + chat_history = ChatHistory() + chat_history.add_user_message("Hello") + settings = MiniMaxChatPromptExecutionSettings() + + result = await service.get_chat_message_contents(chat_history, settings) + + assert len(result) == 1 + assert result[0].content == "Hello!" + + @pytest.mark.asyncio + @patch.object(AsyncCompletions, "create", new_callable=AsyncMock) + async def test_error_handling(self, mock_create, minimax_unit_test_env): + """Test error handling.""" + mock_create.side_effect = Exception("API Error") + + service = MiniMaxChatCompletion() + chat_history = ChatHistory() + chat_history.add_user_message("Hello") + settings = MiniMaxChatPromptExecutionSettings() + + with pytest.raises(ServiceResponseException): + await service.get_chat_message_contents(chat_history, settings) diff --git a/python/tests/unit/connectors/ai/minimax/services/test_minimax_handler.py b/python/tests/unit/connectors/ai/minimax/services/test_minimax_handler.py new file mode 100644 index 000000000000..32ba5c5034f9 --- /dev/null +++ b/python/tests/unit/connectors/ai/minimax/services/test_minimax_handler.py @@ -0,0 +1,86 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from openai import AsyncOpenAI + +from semantic_kernel.connectors.ai.minimax.prompt_execution_settings.minimax_prompt_execution_settings import ( + MiniMaxChatPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.minimax.services.minimax_handler import MiniMaxHandler +from semantic_kernel.connectors.ai.minimax.services.minimax_model_types import MiniMaxModelTypes + + +@pytest.fixture +def mock_openai_client(): + """Create a mock OpenAI client.""" + return AsyncMock(spec=AsyncOpenAI) + + +@pytest.fixture +def minimax_handler(mock_openai_client): + """Create a MiniMaxHandler instance with mocked client.""" + return MiniMaxHandler( + client=mock_openai_client, + ai_model_type=MiniMaxModelTypes.CHAT, + ai_model_id="test-model", + api_key="test-key", + ) + + +class TestMiniMaxHandler: + """Test cases for MiniMaxHandler.""" + + def test_init(self, mock_openai_client): + """Test initialization.""" + handler = MiniMaxHandler( + client=mock_openai_client, + ai_model_type=MiniMaxModelTypes.CHAT, + ) + + assert handler.client == mock_openai_client + assert handler.ai_model_type == MiniMaxModelTypes.CHAT + assert handler.MODEL_PROVIDER_NAME == "minimax" + + @pytest.mark.asyncio + async def test_send_chat_completion_request(self, minimax_handler, mock_openai_client): + """Test sending chat completion request.""" + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock(role="assistant", content="Hello!"), + finish_reason="stop", + ) + ] + mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=20, total_tokens=30) + mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response) + + settings = MiniMaxChatPromptExecutionSettings( + messages=[{"role": "user", "content": "Hello"}], + ai_model_id="test-model", + ) + + result = await minimax_handler._send_chat_completion_request(settings) + assert result == mock_response + + assert minimax_handler.prompt_tokens == 10 + assert minimax_handler.completion_tokens == 20 + assert minimax_handler.total_tokens == 30 + + @pytest.mark.asyncio + async def test_send_request_unsupported_model_type(self, mock_openai_client): + """Test send_request with unsupported model type.""" + handler = MiniMaxHandler( + client=mock_openai_client, + ai_model_type=MiniMaxModelTypes.CHAT, + ) + object.__setattr__(handler, "ai_model_type", "UNSUPPORTED") + + settings = MiniMaxChatPromptExecutionSettings( + messages=[{"role": "user", "content": "Hello"}], + ai_model_id="test-model", + ) + + with pytest.raises(NotImplementedError, match="Model type UNSUPPORTED is not supported"): + await handler._send_request(settings) diff --git a/python/tests/unit/connectors/ai/minimax/settings/test_minimax_settings.py b/python/tests/unit/connectors/ai/minimax/settings/test_minimax_settings.py new file mode 100644 index 000000000000..6f7e7ea61407 --- /dev/null +++ b/python/tests/unit/connectors/ai/minimax/settings/test_minimax_settings.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from semantic_kernel.connectors.ai.minimax.settings.minimax_settings import MiniMaxSettings + + +class TestMiniMaxSettings: + """Test cases for MiniMaxSettings.""" + + def test_init_with_defaults(self, minimax_unit_test_env): + """Test initialization with default values.""" + settings = MiniMaxSettings() + assert settings.api_key.get_secret_value() == "test_api_key" + assert settings.region == "global_en" + assert settings.base_url == "https://api.minimax.io/v1" + assert settings.chat_model_id == "MiniMax-M3" + + def test_init_with_values(self): + """Test initialization with specific values.""" + settings = MiniMaxSettings( + api_key="test-api-key", + base_url="https://custom.minimax.io/v1", + region="cn_zh", + chat_model_id="MiniMax-M2.7", + ) + + assert settings.api_key.get_secret_value() == "test-api-key" + assert settings.base_url == "https://custom.minimax.io/v1" + assert settings.region == "cn_zh" + assert settings.chat_model_id == "MiniMax-M2.7" + + def test_env_prefix(self): + """Test environment variable prefix.""" + assert MiniMaxSettings.env_prefix == "MINIMAX_" + + def test_api_key_secret_str(self): + """Test that api_key is properly handled as SecretStr.""" + settings = MiniMaxSettings(api_key="secret-key") + + assert hasattr(settings.api_key, "get_secret_value") + assert settings.api_key.get_secret_value() == "secret-key" + + str_repr = str(settings) + assert "secret-key" not in str_repr + + def test_region_resolves_global_base_url(self, minimax_unit_test_env): + """Test that the global region resolves the global base URL.""" + settings = MiniMaxSettings() + assert settings.base_url == "https://api.minimax.io/v1" + + @pytest.mark.parametrize("override_env_param_dict", [{"MINIMAX_REGION": "cn_zh"}], indirect=True) + def test_region_cn_resolves_cn_base_url(self, minimax_unit_test_env): + """Test that the China region resolves the China base URL.""" + settings = MiniMaxSettings() + assert settings.region == "cn_zh" + assert settings.base_url == "https://api.minimaxi.com/v1" + + def test_explicit_base_url_overrides_region(self, minimax_unit_test_env): + """Test that an explicit base_url overrides the region-derived URL.""" + settings = MiniMaxSettings(base_url="https://custom.minimax.io/v1", region="cn_zh") + assert settings.base_url == "https://custom.minimax.io/v1" diff --git a/python/tests/unit/connectors/conftest.py b/python/tests/unit/connectors/conftest.py index bd9111a70c55..afe97ec09d79 100644 --- a/python/tests/unit/connectors/conftest.py +++ b/python/tests/unit/connectors/conftest.py @@ -148,3 +148,28 @@ def google_search_unit_test_env(monkeypatch, exclude_list, override_env_param_di monkeypatch.delenv(key, raising=False) return env_vars + + +@fixture() +def minimax_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): + """Fixture to set environment variables for MiniMaxSettings.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = { + "MINIMAX_API_KEY": "test_api_key", + "MINIMAX_CHAT_MODEL_ID": "MiniMax-M3", + } + + env_vars.update(override_env_param_dict) + + for key, value in env_vars.items(): + if key not in exclude_list: + monkeypatch.setenv(key, value) + else: + monkeypatch.delenv(key, raising=False) + + return env_vars