diff --git a/py/README.md b/py/README.md index c7f392902..5c8b28840 100644 --- a/py/README.md +++ b/py/README.md @@ -60,6 +60,11 @@ pip install r2r export OPENAI_API_KEY=sk-... python -m r2r.serve +# Or run with MiniMax as the LLM provider +# export MINIMAX_API_KEY=your-key +# export R2R_CONFIG_NAME=minimax +# python -m r2r.serve + # Or run in full mode with Docker # git clone git@github.com:SciPhi-AI/R2R.git && cd R2R # export R2R_CONFIG_NAME=full OPENAI_API_KEY=sk-... diff --git a/py/core/configs/minimax.toml b/py/core/configs/minimax.toml new file mode 100644 index 000000000..7ae735d20 --- /dev/null +++ b/py/core/configs/minimax.toml @@ -0,0 +1,17 @@ +[app] +# MiniMax models via OpenAI-compatible API (https://api.minimax.io/v1) +# Requires MINIMAX_API_KEY environment variable +fast_llm = "minimax/MiniMax-M2.5-highspeed" +quality_llm = "minimax/MiniMax-M2.7" +vlm = "minimax/MiniMax-M2.7" +audio_lm = "minimax/MiniMax-M2.7" + +[completion] +provider = "openai" +concurrent_request_limit = 256 + + [completion.generation_config] + temperature = 0.1 + top_p = 1 + max_tokens_to_sample = 4096 + stream = false diff --git a/py/core/providers/llm/openai.py b/py/core/providers/llm/openai.py index e6155d784..832f7e934 100644 --- a/py/core/providers/llm/openai.py +++ b/py/core/providers/llm/openai.py @@ -25,6 +25,8 @@ def __init__(self, config: CompletionConfig, *args, **kwargs) -> None: self.async_ollama_client = None self.lmstudio_client = None self.async_lmstudio_client = None + self.minimax_client = None + self.async_minimax_client = None # NEW: Azure Foundry clients using the Azure Inference API self.azure_foundry_client = None self.async_azure_foundry_client = None @@ -101,6 +103,22 @@ def __init__(self, config: CompletionConfig, *args, **kwargs) -> None: ) logger.debug("LMStudio OpenAI clients initialized successfully") + # Initialize MiniMax clients if credentials exist + minimax_api_key = os.getenv("MINIMAX_API_KEY") + minimax_api_base = os.getenv( + "MINIMAX_API_BASE", "https://api.minimax.io/v1" + ) + if minimax_api_key: + self.minimax_client = OpenAI( + api_key=minimax_api_key, + base_url=minimax_api_base, + ) + self.async_minimax_client = AsyncOpenAI( + api_key=minimax_api_key, + base_url=minimax_api_base, + ) + logger.debug("MiniMax clients initialized successfully") + # Initialize Azure Foundry clients if credentials exist. # These use the Azure Inference API (currently pasted into this handler). azure_foundry_api_key = os.getenv("AZURE_FOUNDRY_API_KEY") @@ -136,13 +154,15 @@ def __init__(self, config: CompletionConfig, *args, **kwargs) -> None: self.azure_client, self.ollama_client, self.lmstudio_client, + self.minimax_client, self.azure_foundry_client, ] ): raise ValueError( "No valid client credentials found. Please set either OPENAI_API_KEY, " "both AZURE_API_KEY and AZURE_API_BASE environment variables, " - "OLLAMA_API_BASE, LMSTUDIO_API_BASE, or AZURE_FOUNDRY_API_KEY and AZURE_FOUNDRY_API_ENDPOINT." + "OLLAMA_API_BASE, LMSTUDIO_API_BASE, MINIMAX_API_KEY, " + "or AZURE_FOUNDRY_API_KEY and AZURE_FOUNDRY_API_ENDPOINT." ) def _get_client_and_model(self, model: str): @@ -178,6 +198,12 @@ def _get_client_and_model(self, model: str): "LMStudio credentials not configured but lmstudio/ model prefix used" ) return self.lmstudio_client, model[9:] # Strip 'lmstudio/' prefix + elif model.startswith("minimax/"): + if not self.minimax_client: + raise ValueError( + "MiniMax credentials not configured but minimax/ model prefix used" + ) + return self.minimax_client, model[8:] # Strip 'minimax/' prefix elif model.startswith("azure-foundry/"): if not self.azure_foundry_client: raise ValueError( @@ -197,6 +223,8 @@ def _get_client_and_model(self, model: str): return self.ollama_client, model elif self.lmstudio_client: return self.lmstudio_client, model + elif self.minimax_client: + return self.minimax_client, model elif self.azure_foundry_client: return self.azure_foundry_client, model else: @@ -234,6 +262,12 @@ def _get_async_client_and_model(self, model: str): "LMStudio credentials not configured but lmstudio/ model prefix used" ) return self.async_lmstudio_client, model[9:] + elif model.startswith("minimax/"): + if not self.async_minimax_client: + raise ValueError( + "MiniMax credentials not configured but minimax/ model prefix used" + ) + return self.async_minimax_client, model[8:] elif model.startswith("azure-foundry/"): if not self.async_azure_foundry_client: raise ValueError( @@ -249,6 +283,8 @@ def _get_async_client_and_model(self, model: str): return self.async_ollama_client, model elif self.async_lmstudio_client: return self.async_lmstudio_client, model + elif self.async_minimax_client: + return self.async_minimax_client, model elif self.async_azure_foundry_client: return self.async_azure_foundry_client, model else: @@ -398,6 +434,7 @@ def _get_base_args(self, generation_config: GenerationConfig) -> dict: } model_str = generation_config.model or "" + is_minimax = model_str.startswith("minimax/") if any( model_prefix in model_str.lower() @@ -409,7 +446,11 @@ def _get_base_args(self, generation_config: GenerationConfig) -> dict: else: args["max_tokens"] = generation_config.max_tokens_to_sample - args["temperature"] = generation_config.temperature + temperature = generation_config.temperature + # MiniMax API requires temperature in [0, 1] + if is_minimax: + temperature = max(0.0, min(1.0, temperature)) + args["temperature"] = temperature args["top_p"] = generation_config.top_p if generation_config.reasoning_effort is not None: diff --git a/py/core/providers/llm/r2r_llm.py b/py/core/providers/llm/r2r_llm.py index b95b310a8..813278655 100644 --- a/py/core/providers/llm/r2r_llm.py +++ b/py/core/providers/llm/r2r_llm.py @@ -66,6 +66,7 @@ def _choose_subprovider_by_model( "deepseek/", "ollama/", "lmstudio/", + "minimax/", ] if ( any( diff --git a/py/tests/integration/test_minimax_integration.py b/py/tests/integration/test_minimax_integration.py new file mode 100644 index 000000000..21b0ee29d --- /dev/null +++ b/py/tests/integration/test_minimax_integration.py @@ -0,0 +1,108 @@ +"""Integration tests for MiniMax LLM provider. + +These tests require a valid MINIMAX_API_KEY environment variable. +They make real API calls to the MiniMax API. +""" + +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.getenv("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set", +) + + +@pytest.fixture +def minimax_provider(): + """Create an OpenAICompletionProvider with MiniMax credentials.""" + from unittest.mock import patch + + with patch.dict( + os.environ, + {"MINIMAX_API_KEY": os.environ.get("MINIMAX_API_KEY", "")}, + clear=False, + ): + from core.base.providers.llm import CompletionConfig + from core.providers.llm.openai import OpenAICompletionProvider + + config = CompletionConfig(provider="openai") + return OpenAICompletionProvider(config) + + +@pytest.mark.asyncio +async def test_minimax_async_completion(minimax_provider): + """Test basic async completion with MiniMax M2.7.""" + from core.base.abstractions import GenerationConfig + + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", + temperature=0.1, + max_tokens_to_sample=64, + ) + messages = [{"role": "user", "content": "Say hello in one word."}] + + response = await minimax_provider.aget_completion( + messages=messages, + generation_config=gen_config, + ) + assert response is not None + assert len(response.choices) > 0 + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + +@pytest.mark.asyncio +async def test_minimax_streaming(minimax_provider): + """Test streaming completion with MiniMax M2.7.""" + from core.base.abstractions import GenerationConfig + + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", + temperature=0.1, + max_tokens_to_sample=32, + ) + messages = [{"role": "user", "content": "Count to 3."}] + + chunks = [] + async for chunk in minimax_provider.aget_completion_stream( + messages=messages, + generation_config=gen_config, + ): + chunks.append(chunk) + + assert len(chunks) > 0 + + +@pytest.mark.asyncio +async def test_minimax_json_mode(minimax_provider): + """Test JSON response format with MiniMax M2.5-highspeed (no thinking tags).""" + import json + import re + + from core.base.abstractions import GenerationConfig + + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.5-highspeed", + temperature=0.1, + max_tokens_to_sample=512, + response_format={"type": "json_object"}, + ) + messages = [ + { + "role": "user", + "content": 'Return a JSON object with a single key "greeting" and value "hello".', + } + ] + + response = await minimax_provider.aget_completion( + messages=messages, + generation_config=gen_config, + ) + content = response.choices[0].message.content + assert content is not None + # Strip any ... tags that some models may include + content = re.sub(r".*?", "", content, flags=re.DOTALL).strip() + data = json.loads(content) + assert "greeting" in data diff --git a/py/tests/unit/llm/__init__.py b/py/tests/unit/llm/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/py/tests/unit/llm/test_minimax_provider.py b/py/tests/unit/llm/test_minimax_provider.py new file mode 100644 index 000000000..d96417724 --- /dev/null +++ b/py/tests/unit/llm/test_minimax_provider.py @@ -0,0 +1,422 @@ +"""Tests for MiniMax LLM provider integration in OpenAICompletionProvider.""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from core.base.abstractions import GenerationConfig +from core.base.providers.llm import CompletionConfig + + +class TestMiniMaxClientInitialization: + """Test MiniMax client initialization in OpenAICompletionProvider.""" + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-minimax-key", + "OPENAI_API_KEY": "", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ) + @patch("core.providers.llm.openai.OpenAI") + @patch("core.providers.llm.openai.AsyncOpenAI") + def test_minimax_client_initialized_with_api_key( + self, mock_async_openai, mock_openai + ): + """MiniMax clients should be initialized when MINIMAX_API_KEY is set.""" + from core.providers.llm.openai import OpenAICompletionProvider + + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + + assert provider.minimax_client is not None + assert provider.async_minimax_client is not None + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-minimax-key", + "MINIMAX_API_BASE": "https://custom.minimax.api/v1", + "OPENAI_API_KEY": "", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ) + @patch("core.providers.llm.openai.OpenAI") + @patch("core.providers.llm.openai.AsyncOpenAI") + def test_minimax_custom_api_base(self, mock_async_openai, mock_openai): + """MiniMax clients should use custom MINIMAX_API_BASE when set.""" + from core.providers.llm.openai import OpenAICompletionProvider + + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + + # Verify that the sync client was created with custom base_url + calls = [ + c + for c in mock_openai.call_args_list + if c[1].get("base_url") == "https://custom.minimax.api/v1" + ] + assert len(calls) == 1 + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ) + @patch("core.providers.llm.openai.OpenAI") + @patch("core.providers.llm.openai.AsyncOpenAI") + def test_minimax_not_initialized_without_key( + self, mock_async_openai, mock_openai + ): + """MiniMax clients should not be initialized without MINIMAX_API_KEY.""" + from core.providers.llm.openai import OpenAICompletionProvider + + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + + assert provider.minimax_client is None + assert provider.async_minimax_client is None + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-minimax-key", + "OPENAI_API_KEY": "", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ) + @patch("core.providers.llm.openai.OpenAI") + @patch("core.providers.llm.openai.AsyncOpenAI") + def test_minimax_only_credentials_passes_validation( + self, mock_async_openai, mock_openai + ): + """Provider should not raise when only MINIMAX_API_KEY is set.""" + from core.providers.llm.openai import OpenAICompletionProvider + + config = CompletionConfig(provider="openai") + # Should not raise ValueError + provider = OpenAICompletionProvider(config) + assert provider.minimax_client is not None + + +class TestMiniMaxClientRouting: + """Test model prefix routing for MiniMax.""" + + def _create_provider_with_minimax(self): + """Helper to create a provider with MiniMax clients mocked.""" + from core.providers.llm.openai import OpenAICompletionProvider + + with patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-key", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ), patch("core.providers.llm.openai.OpenAI") as mock_openai, patch( + "core.providers.llm.openai.AsyncOpenAI" + ) as mock_async_openai: + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + return provider + + def test_sync_routing_minimax_prefix(self): + """minimax/ prefix should route to MiniMax sync client.""" + provider = self._create_provider_with_minimax() + client, model_name = provider._get_client_and_model( + "minimax/MiniMax-M2.7" + ) + assert client == provider.minimax_client + assert model_name == "MiniMax-M2.7" + + def test_async_routing_minimax_prefix(self): + """minimax/ prefix should route to MiniMax async client.""" + provider = self._create_provider_with_minimax() + client, model_name = provider._get_async_client_and_model( + "minimax/MiniMax-M2.7" + ) + assert client == provider.async_minimax_client + assert model_name == "MiniMax-M2.7" + + def test_sync_routing_minimax_m25_highspeed(self): + """minimax/ prefix should work with M2.5-highspeed model.""" + provider = self._create_provider_with_minimax() + client, model_name = provider._get_client_and_model( + "minimax/MiniMax-M2.5-highspeed" + ) + assert client == provider.minimax_client + assert model_name == "MiniMax-M2.5-highspeed" + + def test_sync_routing_minimax_raises_without_client(self): + """Should raise ValueError when minimax/ prefix used without credentials.""" + from core.providers.llm.openai import OpenAICompletionProvider + + with patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ), patch("core.providers.llm.openai.OpenAI"), patch( + "core.providers.llm.openai.AsyncOpenAI" + ): + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + + with pytest.raises(ValueError, match="MiniMax credentials"): + provider._get_client_and_model("minimax/MiniMax-M2.7") + + def test_async_routing_minimax_raises_without_client(self): + """Should raise ValueError when minimax/ prefix used without async credentials.""" + from core.providers.llm.openai import OpenAICompletionProvider + + with patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ), patch("core.providers.llm.openai.OpenAI"), patch( + "core.providers.llm.openai.AsyncOpenAI" + ): + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + + with pytest.raises(ValueError, match="MiniMax credentials"): + provider._get_async_client_and_model("minimax/MiniMax-M2.7") + + +class TestMiniMaxTemperatureClamping: + """Test temperature clamping for MiniMax models.""" + + def _create_provider_with_minimax(self): + """Helper to create a provider with MiniMax clients mocked.""" + from core.providers.llm.openai import OpenAICompletionProvider + + with patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-key", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ), patch("core.providers.llm.openai.OpenAI"), patch( + "core.providers.llm.openai.AsyncOpenAI" + ): + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + return provider + + def test_minimax_temperature_clamped_to_max_1(self): + """Temperature above 1.0 should be clamped to 1.0 for MiniMax models.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", temperature=1.5 + ) + args = provider._get_base_args(gen_config) + assert args["temperature"] == 1.0 + + def test_minimax_temperature_clamped_to_min_0(self): + """Negative temperature should be clamped to 0.0 for MiniMax models.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", temperature=-0.5 + ) + args = provider._get_base_args(gen_config) + assert args["temperature"] == 0.0 + + def test_minimax_temperature_within_range_unchanged(self): + """Temperature within [0, 1] should remain unchanged for MiniMax.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", temperature=0.7 + ) + args = provider._get_base_args(gen_config) + assert args["temperature"] == 0.7 + + def test_openai_temperature_not_clamped(self): + """Temperature should not be clamped for non-MiniMax models.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="openai/gpt-4.1", temperature=1.5 + ) + args = provider._get_base_args(gen_config) + assert args["temperature"] == 1.5 + + +class TestMiniMaxBaseArgs: + """Test _get_base_args for MiniMax models.""" + + def _create_provider_with_minimax(self): + from core.providers.llm.openai import OpenAICompletionProvider + + with patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-key", + "OPENAI_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ), patch("core.providers.llm.openai.OpenAI"), patch( + "core.providers.llm.openai.AsyncOpenAI" + ): + config = CompletionConfig(provider="openai") + provider = OpenAICompletionProvider(config) + return provider + + def test_minimax_base_args_include_standard_fields(self): + """MiniMax should produce standard OpenAI-compatible args.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", + temperature=0.5, + top_p=0.9, + max_tokens_to_sample=2048, + stream=False, + ) + args = provider._get_base_args(gen_config) + assert args["model"] == "minimax/MiniMax-M2.7" + assert args["temperature"] == 0.5 + assert args["top_p"] == 0.9 + assert args["max_tokens"] == 2048 + assert args["stream"] is False + + def test_minimax_response_format_passed(self): + """response_format should be passed through for MiniMax models.""" + provider = self._create_provider_with_minimax() + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", + response_format={"type": "json_object"}, + ) + args = provider._get_base_args(gen_config) + assert args["response_format"] == {"type": "json_object"} + + def test_minimax_tools_passed(self): + """Tools should be passed through for MiniMax models.""" + provider = self._create_provider_with_minimax() + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object"}, + }, + } + ] + gen_config = GenerationConfig( + model="minimax/MiniMax-M2.7", tools=tools + ) + args = provider._get_base_args(gen_config) + assert args["tools"] == tools + + +class TestR2RRouterMiniMax: + """Test that the R2R router correctly dispatches minimax/ prefix.""" + + @patch.dict( + os.environ, + { + "MINIMAX_API_KEY": "test-key", + "OPENAI_API_KEY": "test-key", + "ANTHROPIC_API_KEY": "test-key", + "AZURE_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "AZURE_FOUNDRY_API_KEY": "", + }, + clear=False, + ) + @patch("core.providers.llm.openai.OpenAI") + @patch("core.providers.llm.openai.AsyncOpenAI") + @patch("core.providers.llm.anthropic.Anthropic") + @patch("core.providers.llm.anthropic.AsyncAnthropic") + def test_r2r_routes_minimax_to_openai_provider( + self, + mock_async_anthropic, + mock_anthropic, + mock_async_openai, + mock_openai, + ): + """minimax/ prefix should be routed to the OpenAI sub-provider.""" + from core.providers.llm.r2r_llm import R2RCompletionProvider + + config = CompletionConfig(provider="r2r") + provider = R2RCompletionProvider(config) + + sub = provider._choose_subprovider_by_model("minimax/MiniMax-M2.7") + assert sub is provider._openai_provider + + +class TestMiniMaxConfigFile: + """Test the MiniMax config TOML file.""" + + def test_minimax_config_exists(self): + """The minimax.toml config file should exist.""" + config_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "core", + "configs", + "minimax.toml", + ) + assert os.path.exists(config_path), f"minimax.toml not found at {config_path}" + + def test_minimax_config_valid_toml(self): + """The minimax.toml config should be valid TOML.""" + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + + config_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "core", + "configs", + "minimax.toml", + ) + with open(config_path, "rb") as f: + data = tomllib.load(f) + + assert data["app"]["quality_llm"] == "minimax/MiniMax-M2.7" + assert ( + data["app"]["fast_llm"] == "minimax/MiniMax-M2.5-highspeed" + ) + assert data["completion"]["provider"] == "openai"