-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat: add MiniMax provider support for voice agent LLM #15590
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
82bd2d0
0adce68
7383c73
a7a1bc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
Check noticeCode scanning / CodeQL Unused local variable Note test
Variable svc is not used.
|
||
|
|
||
| call_kwargs = mock_init.call_args[1] | ||
| assert call_kwargs.get("base_url") == "https://custom.minimax.io/v1" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = [ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it work with MiniMax-M2.5 or other versions? We can probably drop this hardcoded |
||
| "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}") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can probably drop this test since the list of supported models may change frequently over time?