diff --git a/examples/voice_agent/server/server_configs/llm_configs/minimax.yaml b/examples/voice_agent/server/server_configs/llm_configs/minimax.yaml new file mode 100644 index 000000000000..e49ee7ac5f7f --- /dev/null +++ b/examples/voice_agent/server/server_configs/llm_configs/minimax.yaml @@ -0,0 +1,21 @@ +# This is an example config for using MiniMax as the LLM backend for a NeMo Voice Agent server. +# Please refer to https://github.com/NVIDIA-NeMo/NeMo/tree/main/examples/voice_agent/README.md for more details +# MiniMax API documentation: https://platform.minimax.io/docs/api-reference/text-openai-api + +# type: minimax +# model: "MiniMax-M2.7" # choices: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"] + +################################## +######## MiniMax config ########## +################################## +# API key for MiniMax. If not set here, MINIMAX_API_KEY environment variable will be used. +# api_key: null +base_url: "https://api.minimax.io/v1" + +# Inference parameters passed to the MiniMax OpenAI-compatible API +# Note: MiniMax temperature must be in range (0.0, 1.0], default is 1.0 +minimax_generation_params: + temperature: 1.0 # Sampling temperature (0.0 exclusive to 1.0 inclusive) + top_p: 0.9 # Top-p (nucleus) sampling (0.0 to 1.0) + max_completion_tokens: 256 # Max number of output tokens + extra: null # Additional model-specific params in dict format diff --git a/examples/voice_agent/tests/test_minimax_llm.py b/examples/voice_agent/tests/test_minimax_llm.py new file mode 100644 index 000000000000..63ce606e3f35 --- /dev/null +++ b/examples/voice_agent/tests/test_minimax_llm.py @@ -0,0 +1,161 @@ +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for MiniMaxService LLM provider.""" + +import os +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Add the local NeMo directory to Python path to use development version +nemo_root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(nemo_root)) + +# Mock heavy dependencies before importing llm module +sys.modules["vllm"] = MagicMock() +sys.modules["vllm.config"] = MagicMock() +sys.modules["transformers"] = MagicMock() +sys.modules["psutil"] = MagicMock() + +from omegaconf import OmegaConf + +from nemo.agents.voice_agent.pipecat.services.nemo.llm import MiniMaxService, get_llm_service_from_config + + +class TestMiniMaxService: + """Tests for MiniMaxService.""" + + def test_instantiation_with_api_key(self): + """MiniMaxService can be created with an explicit api_key.""" + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None): + svc = MiniMaxService(model="MiniMax-M2.7", api_key="test-key-123") + assert svc is not None + + def test_instantiation_uses_env_var(self): + """MiniMaxService reads MINIMAX_API_KEY from environment when api_key is not provided.""" + with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key-456"}): + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None): + svc = MiniMaxService(model="MiniMax-M2.7") + assert svc is not None + + def test_missing_api_key_raises(self): + """MiniMaxService raises ValueError when no API key is available.""" + env_without_key = {k: v for k, v in os.environ.items() if k != "MINIMAX_API_KEY"} + with patch.dict(os.environ, env_without_key, clear=True): + with pytest.raises(ValueError, match="MINIMAX_API_KEY"): + MiniMaxService(model="MiniMax-M2.7") + + def test_default_base_url(self): + """MiniMaxService uses the MiniMax overseas API base URL by default.""" + assert MiniMaxService.DEFAULT_BASE_URL == "https://api.minimax.io/v1" + + def test_custom_base_url(self): + """MiniMaxService accepts a custom base_url.""" + custom_url = "https://custom.minimax.io/v1" + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + MiniMaxService(model="MiniMax-M2.7", api_key="test-key", base_url=custom_url) + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("base_url") == custom_url + + def test_supported_models_list(self): + """MiniMaxService exposes the two supported models.""" + assert "MiniMax-M2.7" in MiniMaxService.SUPPORTED_MODELS + assert "MiniMax-M2.7-highspeed" in MiniMaxService.SUPPORTED_MODELS + assert len(MiniMaxService.SUPPORTED_MODELS) == 2 + + def test_api_key_passed_to_super(self): + """The resolved API key is forwarded to OpenAILLMService.""" + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + MiniMaxService(model="MiniMax-M2.7", api_key="my-secret-key") + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("api_key") == "my-secret-key" + + def test_default_model_is_m27(self): + """MiniMaxService defaults to MiniMax-M2.7 model.""" + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + with patch.dict(os.environ, {"MINIMAX_API_KEY": "key"}): + MiniMaxService() + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("model") == "MiniMax-M2.7" + + def test_highspeed_model(self): + """MiniMaxService accepts MiniMax-M2.7-highspeed model.""" + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + MiniMaxService(model="MiniMax-M2.7-highspeed", api_key="key") + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("model") == "MiniMax-M2.7-highspeed" + + +class TestGetLLMServiceFromConfigMiniMax: + """Tests for get_llm_service_from_config with minimax backend.""" + + def test_factory_creates_minimax_service(self): + """get_llm_service_from_config returns a MiniMaxService for type=minimax.""" + cfg = OmegaConf.create( + { + "type": "minimax", + "model": "MiniMax-M2.7", + "api_key": "test-factory-key", + } + ) + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None): + svc = get_llm_service_from_config(cfg) + assert isinstance(svc, MiniMaxService) + + def test_factory_minimax_uses_env_key(self): + """Factory resolves MINIMAX_API_KEY when api_key is not in config.""" + cfg = OmegaConf.create({"type": "minimax", "model": "MiniMax-M2.7"}) + with patch.dict(os.environ, {"MINIMAX_API_KEY": "env-key"}): + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None): + svc = get_llm_service_from_config(cfg) + assert isinstance(svc, MiniMaxService) + + def test_factory_invalid_backend_raises(self): + """get_llm_service_from_config raises AssertionError for unknown backend.""" + cfg = OmegaConf.create({"type": "unknown_backend", "model": "some-model"}) + with pytest.raises(AssertionError): + get_llm_service_from_config(cfg) + + def test_factory_minimax_highspeed_model(self): + """Factory correctly passes MiniMax-M2.7-highspeed model to MiniMaxService.""" + cfg = OmegaConf.create( + { + "type": "minimax", + "model": "MiniMax-M2.7-highspeed", + "api_key": "key", + } + ) + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + svc = get_llm_service_from_config(cfg) + assert isinstance(svc, MiniMaxService) + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("model") == "MiniMax-M2.7-highspeed" + + def test_factory_minimax_custom_base_url(self): + """Factory forwards a custom base_url to MiniMaxService.""" + cfg = OmegaConf.create( + { + "type": "minimax", + "model": "MiniMax-M2.7", + "api_key": "key", + "base_url": "https://custom.minimax.io/v1", + } + ) + with patch("pipecat.services.openai.llm.OpenAILLMService.__init__", return_value=None) as mock_init: + svc = get_llm_service_from_config(cfg) + call_kwargs = mock_init.call_args[1] + assert call_kwargs.get("base_url") == "https://custom.minimax.io/v1" diff --git a/nemo/agents/voice_agent/pipecat/services/nemo/llm.py b/nemo/agents/voice_agent/pipecat/services/nemo/llm.py index 2e635a5078ca..321699143c57 100644 --- a/nemo/agents/voice_agent/pipecat/services/nemo/llm.py +++ b/nemo/agents/voice_agent/pipecat/services/nemo/llm.py @@ -668,6 +668,46 @@ async def _get_response_from_client( return chunks +class MiniMaxService(OpenAILLMService): + """ + LLM service that connects to MiniMax API using the OpenAI-compatible interface. + Supports MiniMax-M2.7 and MiniMax-M2.7-highspeed models. + + Requires the MINIMAX_API_KEY environment variable or passing api_key explicitly. + API documentation: https://platform.minimax.io/docs/api-reference/text-openai-api + """ + + SUPPORTED_MODELS = [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + ] + DEFAULT_BASE_URL = "https://api.minimax.io/v1" + + def __init__( + self, + *, + model: str = "MiniMax-M2.7", + api_key: Optional[str] = None, + base_url: Optional[str] = None, + params: Optional[OpenAILLMService.InputParams] = None, + **kwargs, + ): + resolved_api_key = api_key or os.environ.get("MINIMAX_API_KEY") + if not resolved_api_key: + raise ValueError( + "MiniMax API key is required. Set the MINIMAX_API_KEY environment variable or pass api_key." + ) + resolved_base_url = base_url or self.DEFAULT_BASE_URL + super().__init__( + model=model, + api_key=resolved_api_key, + base_url=resolved_base_url, + params=params, + **kwargs, + ) + logger.info(f"MiniMaxService initialized with model: {model}, base_url: {resolved_base_url}") + + def get_llm_service_from_config(config: DictConfig) -> OpenAILLMService: """Get an LLM service from the configuration.""" backend = config.type @@ -696,7 +736,8 @@ def get_llm_service_from_config(config: DictConfig) -> OpenAILLMService: "hf", "vllm", "auto", - ], f"Invalid backend: {backend}, only `hf`, `vllm`, and `auto` are supported." + "minimax", + ], f"Invalid backend: {backend}, only `hf`, `vllm`, `auto`, and `minimax` are supported." if backend == "hf": llm_model = config.model @@ -756,5 +797,26 @@ def get_llm_service_from_config(config: DictConfig) -> OpenAILLMService: start_vllm_on_init=config.get("start_vllm_on_init", False), vllm_server_params=vllm_server_params, ) + elif backend == "minimax": + llm_model = config.get("model", "MiniMax-M2.7") + llm_api_key = config.get("api_key", None) + llm_base_url = config.get("base_url", None) + llm_params = config.get("minimax_generation_params", None) + if llm_params is not None: + llm_params = OmegaConf.to_container(llm_params, resolve=True) + extra = llm_params.get("extra", None) + if extra is None: + llm_params["extra"] = {} + elif not isinstance(extra, dict): + raise ValueError(f"extra must be a dictionary, got {type(extra)}") + llm_params = OpenAILLMService.InputParams(**llm_params) + else: + llm_params = OpenAILLMService.InputParams() + return MiniMaxService( + model=llm_model, + api_key=llm_api_key, + base_url=llm_base_url, + params=llm_params, + ) else: raise ValueError(f"Invalid LLM backend: {backend}")