diff --git a/docs/documentation/model-requirements.md b/docs/documentation/model-requirements.md index 974a0c513..741f31c7a 100644 --- a/docs/documentation/model-requirements.md +++ b/docs/documentation/model-requirements.md @@ -41,7 +41,7 @@ Usually, an `API_KEY` is required to integrate with 3P models. To do so, the [Mo These keys will be injected at runtime into the Lambda function Environment Variables; they won't be visible in the AWS Lambda Console. -For example, if you wish to be able to interact with AI21 Labs., OpenAI's and Cohere endpoints: +For example, if you wish to be able to interact with AI21 Labs., OpenAI's, Cohere and MiniMax endpoints: - Open the [Model Interface Keys Secret](https://github.com/aws-samples/aws-genai-llm-chatbot/blob/main/lib/model-interfaces/langchain/index.ts#L38) in Secrets Manager. You can find the secret name in the stack output, too. - Update the Secrets by adding a key to the JSON @@ -50,7 +50,8 @@ For example, if you wish to be able to interact with AI21 Labs., OpenAI's and Co { "AI21_API_KEY": "xxxxx", "OPENAI_API_KEY": "sk-xxxxxxxxxxxxxxx", - "COHERE_API_KEY": "xxxxx" + "COHERE_API_KEY": "xxxxx", + "MINIMAX_API_KEY": "xxxxx" } ``` @@ -59,6 +60,26 @@ N.B: In case of no keys needs, the secret value must be an empty JSON `{}`, NOT make sure that the environment variable matches what is expected by the framework in use, like Langchain ([see available langchain integrations](https://python.langchain.com/docs/integrations/llms/)). +### MiniMax integration as third party model + +[MiniMax](https://www.minimax.io/) provides OpenAI-compatible LLM APIs. The following models are supported: + +| Model | Context Length | Description | +|-------|---------------|-------------| +| MiniMax-M2.7 | 1M tokens | Latest flagship model | +| MiniMax-M2.5 | 204K tokens | Strong reasoning capabilities | +| MiniMax-M2.5-highspeed | 204K tokens | Faster variant of M2.5 | + +To enable MiniMax models, add your API key to the Secrets Manager secret: + +```json +{ + "MINIMAX_API_KEY": "your-minimax-api-key" +} +``` + +Once added, MiniMax models will automatically appear in the model selection UI. + ### Azure OpenAI integration as third party model - Open the SharedApiKeysSecretxyz in SecretManager diff --git a/lib/model-interfaces/langchain/functions/request-handler/adapters/__init__.py b/lib/model-interfaces/langchain/functions/request-handler/adapters/__init__.py index 49a5e14ed..f35cd50c6 100644 --- a/lib/model-interfaces/langchain/functions/request-handler/adapters/__init__.py +++ b/lib/model-interfaces/langchain/functions/request-handler/adapters/__init__.py @@ -4,6 +4,7 @@ from .sagemaker import * from .bedrock import * from .bedrock_agent import * +from .minimax import * from .base import Mode from .shared import * from .nexus import * diff --git a/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/__init__.py b/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/__init__.py new file mode 100644 index 000000000..693065fae --- /dev/null +++ b/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .minimax_chat import * diff --git a/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/minimax_chat.py b/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/minimax_chat.py new file mode 100644 index 000000000..7a3ad66d4 --- /dev/null +++ b/lib/model-interfaces/langchain/functions/request-handler/adapters/minimax/minimax_chat.py @@ -0,0 +1,40 @@ +import os + +from langchain_openai import ChatOpenAI + +from adapters.base import ModelAdapter +from genai_core.registry import registry + + +class MinimaxChatAdapter(ModelAdapter): + def __init__(self, model_id, *args, **kwargs): + self.model_id = model_id + + super().__init__(*args, **kwargs) + + def get_llm(self, model_kwargs={}): + api_key = os.environ.get("MINIMAX_API_KEY") + if not api_key: + raise Exception("MINIMAX_API_KEY must be set in the environment") + + params = {} + if "streaming" in model_kwargs: + params["streaming"] = model_kwargs["streaming"] + if "temperature" in model_kwargs: + temperature = model_kwargs["temperature"] + # MiniMax accepts temperature in [0, 1] + params["temperature"] = max(0.0, min(1.0, temperature)) + if "maxTokens" in model_kwargs: + params["max_tokens"] = model_kwargs["maxTokens"] + + return ChatOpenAI( + model_name=self.model_id, + openai_api_key=api_key, + openai_api_base="https://api.minimax.io/v1", + callbacks=[self.callback_handler], + **params, + ) + + +# Register the adapter +registry.register(r"^minimax.*", MinimaxChatAdapter) diff --git a/lib/shared/layers/python-sdk/python/genai_core/clients.py b/lib/shared/layers/python-sdk/python/genai_core/clients.py index 6f4767184..3db8768a3 100644 --- a/lib/shared/layers/python-sdk/python/genai_core/clients.py +++ b/lib/shared/layers/python-sdk/python/genai_core/clients.py @@ -21,6 +21,17 @@ def get_openai_client() -> Optional[Any]: return openai +def get_minimax_client() -> Optional[openai.OpenAI]: + api_key = genai_core.parameters.get_external_api_key("MINIMAX_API_KEY") + if not api_key: + return None + + return openai.OpenAI( + api_key=api_key, + base_url="https://api.minimax.io/v1", + ) + + def get_sagemaker_client() -> Any: config = Config(retries={"max_attempts": 15, "mode": "adaptive"}) diff --git a/lib/shared/layers/python-sdk/python/genai_core/model_providers/direct/provider.py b/lib/shared/layers/python-sdk/python/genai_core/model_providers/direct/provider.py index b0837271a..51f8b8b5e 100644 --- a/lib/shared/layers/python-sdk/python/genai_core/model_providers/direct/provider.py +++ b/lib/shared/layers/python-sdk/python/genai_core/model_providers/direct/provider.py @@ -60,6 +60,11 @@ def list_models(self) -> list[dict[str, Any]]: if azure_openai_models: models.extend(azure_openai_models) + # Get MiniMax models + minimax_models = _list_minimax_models() + if minimax_models: + models.extend(minimax_models) + return models def get_embedding_models(self) -> list[dict[str, Any]]: @@ -389,3 +394,43 @@ def _list_sagemaker_models(): } for model in models ] + + +# MiniMax models use the OpenAI-compatible API at https://api.minimax.io/v1 +_MINIMAX_MODELS = [ + { + "id": "MiniMax-M2.7", + "name": "MiniMax-M2.7", + "description": "MiniMax M2.7 — latest flagship model with 1M context", + }, + { + "id": "MiniMax-M2.5", + "name": "MiniMax-M2.5", + "description": "MiniMax M2.5 — 204K context, strong reasoning", + }, + { + "id": "MiniMax-M2.5-highspeed", + "name": "MiniMax-M2.5-highspeed", + "description": "MiniMax M2.5 high-speed variant — 204K context, faster", + }, +] + + +def _list_minimax_models(): + api_key = genai_core.parameters.get_external_api_key("MINIMAX_API_KEY") + if not api_key: + return None + + return [ + { + "provider": Provider.MINIMAX.value, + "name": model["id"], + "streaming": True, + "inputModalities": [Modality.TEXT.value], + "outputModalities": [Modality.TEXT.value], + "interface": ModelInterface.LANGCHAIN.value, + "ragSupported": True, + "bedrockGuardrails": False, + } + for model in _MINIMAX_MODELS + ] diff --git a/lib/shared/layers/python-sdk/python/genai_core/types.py b/lib/shared/layers/python-sdk/python/genai_core/types.py index 222b531ed..39f4af80f 100644 --- a/lib/shared/layers/python-sdk/python/genai_core/types.py +++ b/lib/shared/layers/python-sdk/python/genai_core/types.py @@ -42,6 +42,7 @@ class Provider(Enum): SAGEMAKER = "sagemaker" AMAZON = "amazon" COHERE = "cohere" + MINIMAX = "minimax" class Modality(Enum): diff --git a/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/__init__.py b/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_chat.py b/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_chat.py new file mode 100644 index 000000000..1097f644f --- /dev/null +++ b/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_chat.py @@ -0,0 +1,164 @@ +import os +import pytest +from unittest.mock import patch, MagicMock + +from adapters.minimax.minimax_chat import MinimaxChatAdapter +from genai_core.types import ChatbotMode + + +@pytest.fixture +def adapter(): + os.environ["MINIMAX_API_KEY"] = "test-minimax-key" + with ( + patch("genai_core.langchain.DynamoDBChatMessageHistory"), + patch("genai_core.clients.get_bedrock_client"), + ): + adapter = MinimaxChatAdapter( + model_id="MiniMax-M2.7", + session_id="test_session", + user_id="test_user", + mode=ChatbotMode.CHAIN.value, + model_kwargs={}, + ) + yield adapter + os.environ.pop("MINIMAX_API_KEY", None) + + +def test_get_llm_basic(adapter): + """Test that get_llm returns a ChatOpenAI configured for MiniMax.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + llm = adapter.get_llm() + + mock_chat.assert_called_once() + call_kwargs = mock_chat.call_args + assert call_kwargs.kwargs["model_name"] == "MiniMax-M2.7" + assert call_kwargs.kwargs["openai_api_key"] == "test-minimax-key" + assert call_kwargs.kwargs["openai_api_base"] == "https://api.minimax.io/v1" + + +def test_get_llm_with_streaming(adapter): + """Test that streaming is passed through to ChatOpenAI.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm(model_kwargs={"streaming": True}) + + call_kwargs = mock_chat.call_args.kwargs + assert call_kwargs["streaming"] is True + + +def test_get_llm_with_temperature(adapter): + """Test that temperature is clamped to [0, 1].""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm(model_kwargs={"temperature": 0.7}) + assert mock_chat.call_args.kwargs["temperature"] == 0.7 + + +def test_get_llm_clamps_temperature_high(adapter): + """Test that temperature > 1 is clamped to 1.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm(model_kwargs={"temperature": 2.0}) + assert mock_chat.call_args.kwargs["temperature"] == 1.0 + + +def test_get_llm_clamps_temperature_low(adapter): + """Test that temperature < 0 is clamped to 0.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm(model_kwargs={"temperature": -0.5}) + assert mock_chat.call_args.kwargs["temperature"] == 0.0 + + +def test_get_llm_with_max_tokens(adapter): + """Test that maxTokens is mapped to max_tokens.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm(model_kwargs={"maxTokens": 4096}) + assert mock_chat.call_args.kwargs["max_tokens"] == 4096 + + +def test_get_llm_missing_api_key(): + """Test that missing API key raises an exception during construction.""" + os.environ.pop("MINIMAX_API_KEY", None) + with ( + patch("genai_core.langchain.DynamoDBChatMessageHistory"), + patch("genai_core.clients.get_bedrock_client"), + ): + with pytest.raises(Exception, match="MINIMAX_API_KEY must be set"): + MinimaxChatAdapter( + model_id="MiniMax-M2.7", + session_id="test_session", + user_id="test_user", + mode=ChatbotMode.CHAIN.value, + model_kwargs={}, + ) + + +def test_get_llm_with_all_kwargs(adapter): + """Test that all model kwargs are passed correctly.""" + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm( + model_kwargs={ + "streaming": True, + "temperature": 0.5, + "maxTokens": 2048, + } + ) + call_kwargs = mock_chat.call_args.kwargs + assert call_kwargs["streaming"] is True + assert call_kwargs["temperature"] == 0.5 + assert call_kwargs["max_tokens"] == 2048 + assert call_kwargs["openai_api_base"] == "https://api.minimax.io/v1" + + +def test_adapter_model_id(adapter): + """Test that model_id is stored correctly.""" + assert adapter.model_id == "MiniMax-M2.7" + + +def test_get_llm_m25_model(): + """Test adapter works with M2.5 model.""" + os.environ["MINIMAX_API_KEY"] = "test-key" + with ( + patch("genai_core.langchain.DynamoDBChatMessageHistory"), + patch("genai_core.clients.get_bedrock_client"), + ): + adapter = MinimaxChatAdapter( + model_id="MiniMax-M2.5", + session_id="test_session", + user_id="test_user", + mode=ChatbotMode.CHAIN.value, + model_kwargs={}, + ) + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm() + assert mock_chat.call_args.kwargs["model_name"] == "MiniMax-M2.5" + os.environ.pop("MINIMAX_API_KEY", None) + + +def test_get_llm_m25_highspeed_model(): + """Test adapter works with M2.5-highspeed model.""" + os.environ["MINIMAX_API_KEY"] = "test-key" + with ( + patch("genai_core.langchain.DynamoDBChatMessageHistory"), + patch("genai_core.clients.get_bedrock_client"), + ): + adapter = MinimaxChatAdapter( + model_id="MiniMax-M2.5-highspeed", + session_id="test_session", + user_id="test_user", + mode=ChatbotMode.CHAIN.value, + model_kwargs={}, + ) + with patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat: + mock_chat.return_value = MagicMock() + adapter.get_llm() + assert ( + mock_chat.call_args.kwargs["model_name"] + == "MiniMax-M2.5-highspeed" + ) + os.environ.pop("MINIMAX_API_KEY", None) diff --git a/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_integration.py b/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_integration.py new file mode 100644 index 000000000..9b04bccf2 --- /dev/null +++ b/tests/model-interfaces/langchain/functions/request-handler/adapters/minimax/test_minimax_integration.py @@ -0,0 +1,77 @@ +"""Integration tests for MiniMax adapter with the registry system.""" +import os +import pytest +from unittest.mock import patch, MagicMock + +from genai_core.registry import AdapterRegistry +from adapters.minimax.minimax_chat import MinimaxChatAdapter + + +@pytest.fixture(autouse=True) +def mock_nexus(): + with patch("genai_core.clients.is_nexus_configured", return_value=(False, {})): + yield + + +@pytest.fixture(autouse=True) +def mock_models(): + with patch("genai_core.models.get_model_by_name", return_value=None): + yield + + +def test_registry_matches_minimax_models(): + """Test that the registry correctly matches minimax model patterns.""" + reg = AdapterRegistry() + reg.register(r"^minimax.*", MinimaxChatAdapter) + + adapter_class = reg._get_adapter("minimax.MiniMax-M2.7") + assert adapter_class is MinimaxChatAdapter + + adapter_class = reg._get_adapter("minimax.MiniMax-M2.5") + assert adapter_class is MinimaxChatAdapter + + adapter_class = reg._get_adapter("minimax.MiniMax-M2.5-highspeed") + assert adapter_class is MinimaxChatAdapter + + +def test_registry_does_not_match_other_providers(): + """Test that minimax pattern does not match other providers.""" + reg = AdapterRegistry() + reg.register(r"^minimax.*", MinimaxChatAdapter) + + with pytest.raises(ValueError): + reg._get_adapter("openai.gpt-4") + + +def test_adapter_end_to_end(): + """Test adapter can be instantiated and produces a valid LLM.""" + os.environ["MINIMAX_API_KEY"] = "test-integration-key" + try: + with ( + patch("genai_core.langchain.DynamoDBChatMessageHistory"), + patch("genai_core.clients.get_bedrock_client"), + patch("adapters.minimax.minimax_chat.ChatOpenAI") as mock_chat, + ): + mock_llm = MagicMock() + mock_chat.return_value = mock_llm + + adapter = MinimaxChatAdapter( + model_id="MiniMax-M2.7", + session_id="integration_session", + user_id="integration_user", + mode="chain", + model_kwargs={}, + ) + + llm = adapter.get_llm( + model_kwargs={"streaming": True, "temperature": 0.8} + ) + + assert llm == mock_llm + call_kwargs = mock_chat.call_args.kwargs + assert call_kwargs["model_name"] == "MiniMax-M2.7" + assert call_kwargs["openai_api_base"] == "https://api.minimax.io/v1" + assert call_kwargs["streaming"] is True + assert call_kwargs["temperature"] == 0.8 + finally: + os.environ.pop("MINIMAX_API_KEY", None) diff --git a/tests/shared/layers/python-sdk/genai_core/model_providers/direct/test_minimax_models.py b/tests/shared/layers/python-sdk/genai_core/model_providers/direct/test_minimax_models.py new file mode 100644 index 000000000..f3a8c0ab3 --- /dev/null +++ b/tests/shared/layers/python-sdk/genai_core/model_providers/direct/test_minimax_models.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +from genai_core.model_providers.direct.provider import ( + _list_minimax_models, + _MINIMAX_MODELS, +) +from genai_core.types import Modality, ModelInterface, Provider + + +def test_list_minimax_models_returns_models_when_api_key_set(): + """Test that MiniMax models are returned when MINIMAX_API_KEY is available.""" + with patch( + "genai_core.model_providers.direct.provider.genai_core.parameters.get_external_api_key" + ) as mock_get_key: + mock_get_key.return_value = "test-minimax-api-key" + models = _list_minimax_models() + + assert models is not None + assert len(models) == len(_MINIMAX_MODELS) + + for model in models: + assert model["provider"] == Provider.MINIMAX.value + assert model["streaming"] is True + assert model["inputModalities"] == [Modality.TEXT.value] + assert model["outputModalities"] == [Modality.TEXT.value] + assert model["interface"] == ModelInterface.LANGCHAIN.value + assert model["ragSupported"] is True + assert model["bedrockGuardrails"] is False + + +def test_list_minimax_models_returns_none_without_api_key(): + """Test that None is returned when MINIMAX_API_KEY is not available.""" + with patch( + "genai_core.model_providers.direct.provider.genai_core.parameters.get_external_api_key" + ) as mock_get_key: + mock_get_key.return_value = None + models = _list_minimax_models() + assert models is None + + +def test_list_minimax_models_contains_expected_model_names(): + """Test that all expected MiniMax model names are present.""" + with patch( + "genai_core.model_providers.direct.provider.genai_core.parameters.get_external_api_key" + ) as mock_get_key: + mock_get_key.return_value = "test-key" + models = _list_minimax_models() + + model_names = [m["name"] for m in models] + assert "MiniMax-M2.7" in model_names + assert "MiniMax-M2.5" in model_names + assert "MiniMax-M2.5-highspeed" in model_names + + +def test_minimax_provider_enum(): + """Test that MINIMAX is a valid Provider enum value.""" + assert Provider.MINIMAX.value == "minimax"