diff --git a/.env.example b/.env.example index 2d29bdbe1..3747ba9c5 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # API Keys - At least one is required # # IMPORTANT: Choose ONE approach: -# - Native APIs (Gemini/OpenAI/XAI) for direct access +# - Native APIs (Gemini/OpenAI/XAI/MiniMax) for direct access # - DIAL for unified enterprise access # - OpenRouter for unified cloud access # Having multiple unified providers creates ambiguity about which serves each model. @@ -29,6 +29,9 @@ AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ # Get your X.AI API key from: https://console.x.ai/ XAI_API_KEY=your_xai_api_key_here +# Get your MiniMax API key from: https://platform.minimaxi.com/ +MINIMAX_API_KEY=your_minimax_api_key_here + # Get your DIAL API key and configure host URL # DIAL provides unified access to multiple AI models through a single API DIAL_API_KEY=your_dial_api_key_here @@ -105,6 +108,20 @@ DEFAULT_THINKING_MODE_THINKDEEP=high # - grok3 (shorthand for grok-3) # - grokfast (shorthand for grok-3-fast) # +# +# Supported MiniMax models: +# - MiniMax-M2.5 (196K context, frontier reasoning) +# - MiniMax-M2.5-highspeed (204K context, fast reasoning) +# - MiniMax-M2.1 (204K context, advanced reasoning) +# - MiniMax-M2.1-highspeed (204K context, fast reasoning) +# - MiniMax-M2 (204K context, capable reasoning) +# - m2.5 (shorthand for MiniMax-M2.5) +# - m2.5-fast (shorthand for MiniMax-M2.5-highspeed) +# - m2.1 (shorthand for MiniMax-M2.1) +# - m2.1-fast (shorthand for MiniMax-M2.1-highspeed) +# - m2 (shorthand for MiniMax-M2) +# - minimax (shorthand for MiniMax-M2.5) +# # Supported DIAL models (when available in your DIAL deployment): # - o3-2025-04-16 (200K context, latest O3 release) # - o4-mini-2025-04-16 (200K context, latest O4 mini) @@ -133,6 +150,8 @@ DEFAULT_THINKING_MODE_THINKDEEP=high # OPENAI_ALLOWED_MODELS=o4-mini # Single model standardization # GOOGLE_ALLOWED_MODELS=flash,pro # Allow both Gemini models # XAI_ALLOWED_MODELS=grok,grok-3-fast # Allow both GROK variants +# MINIMAX_ALLOWED_MODELS=MiniMax-M2.5 # Only allow M2.5 model +# MINIMAX_ALLOWED_MODELS=m2.5,m2.5-fast # Allow M2.5 and M2.5 Highspeed # DIAL_ALLOWED_MODELS=o3,o4-mini # Only allow O3/O4 models via DIAL # DIAL_ALLOWED_MODELS=opus-4.1,sonnet-4.1 # Only Claude 4.1 models (without thinking) # DIAL_ALLOWED_MODELS=opus-4.1-thinking,sonnet-4.1-thinking # Only Claude 4.1 with thinking mode @@ -142,6 +161,7 @@ DEFAULT_THINKING_MODE_THINKDEEP=high # OPENAI_ALLOWED_MODELS= # GOOGLE_ALLOWED_MODELS= # XAI_ALLOWED_MODELS= +# MINIMAX_ALLOWED_MODELS= # DIAL_ALLOWED_MODELS= # Optional: Custom model configuration file path diff --git a/conf/minimax_models.json b/conf/minimax_models.json new file mode 100644 index 000000000..9ed6c31d6 --- /dev/null +++ b/conf/minimax_models.json @@ -0,0 +1,121 @@ +{ + "_README": { + "description": "Model metadata for MiniMax API access.", + "documentation": "https://github.com/BeehiveInnovations/pal-mcp-server/blob/main/docs/custom_models.md", + "usage": "Models listed here are exposed directly through the MiniMax provider. Aliases are case-insensitive.", + "field_notes": "Matches providers/shared/model_capabilities.py.", + "field_descriptions": { + "model_name": "The model identifier (e.g., 'MiniMax-M2.5', 'MiniMax-M2')", + "aliases": "Array of short names users can type instead of the full model name", + "context_window": "Total number of tokens the model can process (input + output combined)", + "max_output_tokens": "Maximum number of tokens the model can generate in a single response", + "supports_extended_thinking": "Whether the model supports extended reasoning tokens (MiniMax uses reasoning_split instead)", + "supports_json_mode": "Whether the model can guarantee valid JSON output", + "supports_function_calling": "Whether the model supports function/tool calling", + "supports_images": "Whether the model can process images/visual input", + "supports_temperature": "Whether the model accepts temperature parameter in API calls", + "description": "Human-readable description of the model", + "intelligence_score": "1-20 human rating used as the primary signal for auto-mode model ordering" + } + }, + "models": [ + { + "model_name": "MiniMax-M2.5", + "friendly_name": "MiniMax (M2.5)", + "aliases": [ + "m2.5", + "minimax", + "minimax-m2.5" + ], + "intelligence_score": 16, + "description": "MiniMax M2.5 (196K context) - Frontier reasoning model with tool calling", + "context_window": 196608, + "max_output_tokens": 196608, + "supports_extended_thinking": false, + "supports_system_prompts": true, + "supports_streaming": true, + "supports_function_calling": true, + "supports_json_mode": true, + "supports_images": false, + "supports_temperature": true + }, + { + "model_name": "MiniMax-M2.5-highspeed", + "friendly_name": "MiniMax (M2.5 Highspeed)", + "aliases": [ + "m2.5-fast", + "m2.5-highspeed", + "minimax-m2.5-fast" + ], + "intelligence_score": 15, + "description": "MiniMax M2.5 Highspeed (204K context) - Fast reasoning model", + "context_window": 204800, + "max_output_tokens": 100000, + "supports_extended_thinking": false, + "supports_system_prompts": true, + "supports_streaming": true, + "supports_function_calling": true, + "supports_json_mode": true, + "supports_images": false, + "supports_temperature": true + }, + { + "model_name": "MiniMax-M2.1", + "friendly_name": "MiniMax (M2.1)", + "aliases": [ + "m2.1", + "minimax-m2.1" + ], + "intelligence_score": 15, + "description": "MiniMax M2.1 (204K context) - Advanced reasoning model with tool calling", + "context_window": 204800, + "max_output_tokens": 100000, + "supports_extended_thinking": false, + "supports_system_prompts": true, + "supports_streaming": true, + "supports_function_calling": true, + "supports_json_mode": true, + "supports_images": false, + "supports_temperature": true + }, + { + "model_name": "MiniMax-M2.1-highspeed", + "friendly_name": "MiniMax (M2.1 Highspeed)", + "aliases": [ + "m2.1-fast", + "m2.1-highspeed", + "minimax-m2.1-fast" + ], + "intelligence_score": 14, + "description": "MiniMax M2.1 Highspeed (204K context) - Fast reasoning model", + "context_window": 204800, + "max_output_tokens": 100000, + "supports_extended_thinking": false, + "supports_system_prompts": true, + "supports_streaming": true, + "supports_function_calling": true, + "supports_json_mode": true, + "supports_images": false, + "supports_temperature": true + }, + { + "model_name": "MiniMax-M2", + "friendly_name": "MiniMax (M2)", + "aliases": [ + "m2", + "minimax-m2" + ], + "intelligence_score": 13, + "description": "MiniMax M2 (204K context) - Capable reasoning model with tool calling", + "context_window": 204800, + "max_output_tokens": 100000, + "supports_extended_thinking": false, + "supports_system_prompts": true, + "supports_streaming": true, + "supports_function_calling": true, + "supports_json_mode": true, + "supports_images": false, + "supports_temperature": true + } + ] +} diff --git a/providers/__init__.py b/providers/__init__.py index 8a499d6dd..067653673 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -3,6 +3,7 @@ from .azure_openai import AzureOpenAIProvider from .base import ModelProvider from .gemini import GeminiModelProvider +from .minimax import MiniMaxModelProvider from .openai import OpenAIModelProvider from .openai_compatible import OpenAICompatibleProvider from .openrouter import OpenRouterProvider @@ -16,6 +17,7 @@ "ModelProviderRegistry", "AzureOpenAIProvider", "GeminiModelProvider", + "MiniMaxModelProvider", "OpenAIModelProvider", "OpenAICompatibleProvider", "OpenRouterProvider", diff --git a/providers/minimax.py b/providers/minimax.py new file mode 100644 index 000000000..a927e5dac --- /dev/null +++ b/providers/minimax.py @@ -0,0 +1,86 @@ +"""MiniMax model provider implementation.""" + +import logging +from typing import TYPE_CHECKING, ClassVar, Optional + +if TYPE_CHECKING: + from tools.models import ToolModelCategory + +from .openai_compatible import OpenAICompatibleProvider +from .registries.minimax import MiniMaxModelRegistry +from .registry_provider_mixin import RegistryBackedProviderMixin +from .shared import ModelCapabilities, ProviderType + +logger = logging.getLogger(__name__) + + +class MiniMaxModelProvider(RegistryBackedProviderMixin, OpenAICompatibleProvider): + """Integration for MiniMax models exposed over an OpenAI-style API. + + Publishes capability metadata for the officially supported deployments and + maps tool-category preferences to the appropriate MiniMax model. + """ + + FRIENDLY_NAME = "MiniMax" + + REGISTRY_CLASS = MiniMaxModelRegistry + MODEL_CAPABILITIES: ClassVar[dict[str, ModelCapabilities]] = {} + + # Canonical model identifiers used for category routing. + PRIMARY_MODEL = "MiniMax-M2.5" + FAST_MODEL = "MiniMax-M2.5-highspeed" + + def __init__(self, api_key: str, **kwargs): + """Initialize MiniMax provider with API key.""" + # Set MiniMax base URL + kwargs.setdefault("base_url", "https://api.minimax.io/v1") + self._ensure_registry() + super().__init__(api_key, **kwargs) + self._invalidate_capability_cache() + + def get_provider_type(self) -> ProviderType: + """Get the provider type.""" + return ProviderType.MINIMAX + + def get_preferred_model(self, category: "ToolModelCategory", allowed_models: list[str]) -> Optional[str]: + """Get MiniMax's preferred model for a given category from allowed models. + + Args: + category: The tool category requiring a model + allowed_models: Pre-filtered list of models allowed by restrictions + + Returns: + Preferred model name or None + """ + from tools.models import ToolModelCategory + + if not allowed_models: + return None + + if category == ToolModelCategory.EXTENDED_REASONING: + # Prefer M2.5 for advanced reasoning tasks + if self.PRIMARY_MODEL in allowed_models: + return self.PRIMARY_MODEL + if self.FAST_MODEL in allowed_models: + return self.FAST_MODEL + return allowed_models[0] + + elif category == ToolModelCategory.FAST_RESPONSE: + # Prefer M2.5-highspeed for speed + if self.FAST_MODEL in allowed_models: + return self.FAST_MODEL + if self.PRIMARY_MODEL in allowed_models: + return self.PRIMARY_MODEL + return allowed_models[0] + + else: # BALANCED or default + # Prefer M2.5 for balanced use + if self.PRIMARY_MODEL in allowed_models: + return self.PRIMARY_MODEL + if self.FAST_MODEL in allowed_models: + return self.FAST_MODEL + return allowed_models[0] + + +# Load registry data at import time +MiniMaxModelProvider._ensure_registry() diff --git a/providers/registries/__init__.py b/providers/registries/__init__.py index 5edbfdf7d..33b1bb41f 100644 --- a/providers/registries/__init__.py +++ b/providers/registries/__init__.py @@ -4,6 +4,7 @@ from .custom import CustomEndpointModelRegistry from .dial import DialModelRegistry from .gemini import GeminiModelRegistry +from .minimax import MiniMaxModelRegistry from .openai import OpenAIModelRegistry from .openrouter import OpenRouterModelRegistry from .xai import XAIModelRegistry @@ -13,6 +14,7 @@ "CustomEndpointModelRegistry", "DialModelRegistry", "GeminiModelRegistry", + "MiniMaxModelRegistry", "OpenAIModelRegistry", "OpenRouterModelRegistry", "XAIModelRegistry", diff --git a/providers/registries/minimax.py b/providers/registries/minimax.py new file mode 100644 index 000000000..00e36f86f --- /dev/null +++ b/providers/registries/minimax.py @@ -0,0 +1,19 @@ +"""Registry loader for MiniMax model capabilities.""" + +from __future__ import annotations + +from ..shared import ProviderType +from .base import CapabilityModelRegistry + + +class MiniMaxModelRegistry(CapabilityModelRegistry): + """Capability registry backed by ``conf/minimax_models.json``.""" + + def __init__(self, config_path: str | None = None) -> None: + super().__init__( + env_var_name="MINIMAX_MODELS_CONFIG_PATH", + default_filename="minimax_models.json", + provider=ProviderType.MINIMAX, + friendly_prefix="MiniMax ({model})", + config_path=config_path, + ) diff --git a/providers/registry.py b/providers/registry.py index cd28c4266..64e61a5c2 100644 --- a/providers/registry.py +++ b/providers/registry.py @@ -40,6 +40,7 @@ class ModelProviderRegistry: ProviderType.OPENAI, # Direct OpenAI access ProviderType.AZURE, # Azure-hosted OpenAI deployments ProviderType.XAI, # Direct X.AI GROK access + ProviderType.MINIMAX, # Direct MiniMax access ProviderType.DIAL, # DIAL unified API access ProviderType.CUSTOM, # Local/self-hosted models ProviderType.OPENROUTER, # Catch-all for cloud models @@ -336,6 +337,7 @@ def _get_api_key_for_provider(cls, provider_type: ProviderType) -> Optional[str] ProviderType.OPENAI: "OPENAI_API_KEY", ProviderType.AZURE: "AZURE_OPENAI_API_KEY", ProviderType.XAI: "XAI_API_KEY", + ProviderType.MINIMAX: "MINIMAX_API_KEY", ProviderType.OPENROUTER: "OPENROUTER_API_KEY", ProviderType.CUSTOM: "CUSTOM_API_KEY", # Can be empty for providers that don't need auth ProviderType.DIAL: "DIAL_API_KEY", diff --git a/providers/shared/provider_type.py b/providers/shared/provider_type.py index a1b31377f..00db15dda 100644 --- a/providers/shared/provider_type.py +++ b/providers/shared/provider_type.py @@ -12,6 +12,7 @@ class ProviderType(Enum): OPENAI = "openai" AZURE = "azure" XAI = "xai" + MINIMAX = "minimax" OPENROUTER = "openrouter" CUSTOM = "custom" DIAL = "dial" diff --git a/server.py b/server.py index 74f7ed83f..373b78540 100644 --- a/server.py +++ b/server.py @@ -387,7 +387,14 @@ def configure_providers(): """ # Log environment variable status for debugging logger.debug("Checking environment variables for API keys...") - api_keys_to_check = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "CUSTOM_API_URL"] + api_keys_to_check = [ + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "XAI_API_KEY", + "MINIMAX_API_KEY", + "CUSTOM_API_URL", + ] for key in api_keys_to_check: value = get_env(key) logger.debug(f" {key}: {'[PRESENT]' if value else '[MISSING]'}") @@ -396,6 +403,7 @@ def configure_providers(): from providers.custom import CustomProvider from providers.dial import DIALModelProvider from providers.gemini import GeminiModelProvider + from providers.minimax import MiniMaxModelProvider from providers.openai import OpenAIModelProvider from providers.openrouter import OpenRouterProvider from providers.shared import ProviderType @@ -455,6 +463,13 @@ def configure_providers(): has_native_apis = True logger.info("X.AI API key found - GROK models available") + # Check for MiniMax API key + minimax_key = get_env("MINIMAX_API_KEY") + if minimax_key and minimax_key != "your_minimax_api_key_here": + valid_providers.append("MiniMax") + has_native_apis = True + logger.info("MiniMax API key found - MiniMax models available") + # Check for DIAL API key dial_key = get_env("DIAL_API_KEY") if dial_key and dial_key != "your_dial_api_key_here": @@ -513,6 +528,10 @@ def configure_providers(): ModelProviderRegistry.register_provider(ProviderType.XAI, XAIModelProvider) registered_providers.append(ProviderType.XAI.value) logger.debug(f"Registered provider: {ProviderType.XAI.value}") + if minimax_key and minimax_key != "your_minimax_api_key_here": + ModelProviderRegistry.register_provider(ProviderType.MINIMAX, MiniMaxModelProvider) + registered_providers.append(ProviderType.MINIMAX.value) + logger.debug(f"Registered provider: {ProviderType.MINIMAX.value}") if dial_key and dial_key != "your_dial_api_key_here": ModelProviderRegistry.register_provider(ProviderType.DIAL, DIALModelProvider) registered_providers.append(ProviderType.DIAL.value) @@ -547,6 +566,7 @@ def custom_provider_factory(api_key=None): "- GEMINI_API_KEY for Gemini models\n" "- OPENAI_API_KEY for OpenAI models\n" "- XAI_API_KEY for X.AI GROK models\n" + "- MINIMAX_API_KEY for MiniMax models\n" "- DIAL_API_KEY for DIAL models\n" "- OPENROUTER_API_KEY for OpenRouter (multiple models)\n" "- CUSTOM_API_URL for local models (Ollama, vLLM, etc.)" @@ -557,7 +577,7 @@ def custom_provider_factory(api_key=None): # Log provider priority priority_info = [] if has_native_apis: - priority_info.append("Native APIs (Gemini, OpenAI)") + priority_info.append("Native APIs (Gemini, OpenAI, MiniMax)") if has_custom: priority_info.append("Custom endpoints") if has_openrouter: @@ -600,7 +620,13 @@ def cleanup_providers(): # Validate restrictions against known models provider_instances = {} - provider_types_to_validate = [ProviderType.GOOGLE, ProviderType.OPENAI, ProviderType.XAI, ProviderType.DIAL] + provider_types_to_validate = [ + ProviderType.GOOGLE, + ProviderType.OPENAI, + ProviderType.XAI, + ProviderType.MINIMAX, + ProviderType.DIAL, + ] for provider_type in provider_types_to_validate: provider = ModelProviderRegistry.get_provider(provider_type) if provider: diff --git a/simulator_tests/test_chat_simple_validation.py b/simulator_tests/test_chat_simple_validation.py index a452d71e9..c6709584d 100644 --- a/simulator_tests/test_chat_simple_validation.py +++ b/simulator_tests/test_chat_simple_validation.py @@ -13,7 +13,6 @@ - Conversation context preservation across turns """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_conversation_chain_validation.py b/simulator_tests/test_conversation_chain_validation.py index 2d70b862b..5ca53338d 100644 --- a/simulator_tests/test_conversation_chain_validation.py +++ b/simulator_tests/test_conversation_chain_validation.py @@ -21,7 +21,6 @@ - Properly traverse parent relationships for history reconstruction """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_cross_tool_comprehensive.py b/simulator_tests/test_cross_tool_comprehensive.py index 8389953ec..6cdd33901 100644 --- a/simulator_tests/test_cross_tool_comprehensive.py +++ b/simulator_tests/test_cross_tool_comprehensive.py @@ -12,7 +12,6 @@ 5. Proper tool chaining with context """ - from .conversation_base_test import ConversationBaseTest diff --git a/simulator_tests/test_ollama_custom_url.py b/simulator_tests/test_ollama_custom_url.py index f23b6ee8d..f40c1e106 100644 --- a/simulator_tests/test_ollama_custom_url.py +++ b/simulator_tests/test_ollama_custom_url.py @@ -9,7 +9,6 @@ - Model alias resolution for local models """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_openrouter_fallback.py b/simulator_tests/test_openrouter_fallback.py index 91fc058ab..74023437f 100644 --- a/simulator_tests/test_openrouter_fallback.py +++ b/simulator_tests/test_openrouter_fallback.py @@ -8,7 +8,6 @@ - Auto mode correctly selects OpenRouter models """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_openrouter_models.py b/simulator_tests/test_openrouter_models.py index bd69806a5..5fb3348bb 100644 --- a/simulator_tests/test_openrouter_models.py +++ b/simulator_tests/test_openrouter_models.py @@ -9,7 +9,6 @@ - Error handling when models are not available """ - from .base_test import BaseSimulatorTest diff --git a/simulator_tests/test_xai_models.py b/simulator_tests/test_xai_models.py index 41c57e3a4..e8d32740a 100644 --- a/simulator_tests/test_xai_models.py +++ b/simulator_tests/test_xai_models.py @@ -9,7 +9,6 @@ - API integration and response validation """ - from .base_test import BaseSimulatorTest diff --git a/tests/test_auto_mode_model_listing.py b/tests/test_auto_mode_model_listing.py index 5f1ae1586..0a61ad490 100644 --- a/tests/test_auto_mode_model_listing.py +++ b/tests/test_auto_mode_model_listing.py @@ -65,6 +65,7 @@ def test_error_listing_respects_env_restrictions(monkeypatch, reset_registry): monkeypatch.setenv("OPENAI_API_KEY", "test-openai") monkeypatch.setenv("OPENROUTER_API_KEY", "test-openrouter") monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) # Ensure Azure provider stays disabled regardless of developer workstation env for azure_var in ( "AZURE_OPENAI_API_KEY", @@ -86,6 +87,7 @@ def test_error_listing_respects_env_restrictions(monkeypatch, reset_registry): monkeypatch.setenv("OPENAI_ALLOWED_MODELS", "gpt-5.2") monkeypatch.setenv("OPENROUTER_ALLOWED_MODELS", "gpt5nano") monkeypatch.setenv("XAI_ALLOWED_MODELS", "") + monkeypatch.setenv("MINIMAX_ALLOWED_MODELS", "") import config @@ -110,7 +112,7 @@ def test_error_listing_respects_env_restrictions(monkeypatch, reset_registry): ): monkeypatch.setenv(key, value) - for var in ("XAI_API_KEY", "CUSTOM_API_URL", "CUSTOM_API_KEY", "DIAL_API_KEY"): + for var in ("XAI_API_KEY", "MINIMAX_API_KEY", "CUSTOM_API_URL", "CUSTOM_API_KEY", "DIAL_API_KEY"): monkeypatch.delenv(var, raising=False) for azure_var in ( "AZURE_OPENAI_API_KEY", @@ -172,6 +174,7 @@ def test_error_listing_without_restrictions_shows_full_catalog(monkeypatch, rese "OPENAI_ALLOWED_MODELS", "OPENROUTER_ALLOWED_MODELS", "XAI_ALLOWED_MODELS", + "MINIMAX_ALLOWED_MODELS", "DIAL_ALLOWED_MODELS", ): monkeypatch.delenv(var, raising=False) @@ -199,7 +202,9 @@ def test_error_listing_without_restrictions_shows_full_catalog(monkeypatch, rese "OPENAI_ALLOWED_MODELS", "OPENROUTER_ALLOWED_MODELS", "XAI_ALLOWED_MODELS", + "MINIMAX_ALLOWED_MODELS", "DIAL_ALLOWED_MODELS", + "MINIMAX_API_KEY", "CUSTOM_API_URL", "CUSTOM_API_KEY", ): diff --git a/tests/test_directory_expansion_tracking.py b/tests/test_directory_expansion_tracking.py index f4e56a019..79ac5adf9 100644 --- a/tests/test_directory_expansion_tracking.py +++ b/tests/test_directory_expansion_tracking.py @@ -37,8 +37,7 @@ def temp_directory_with_files(self, project_path): files = [] for i in range(5): swift_file = temp_path / f"File{i}.swift" - swift_file.write_text( - f""" + swift_file.write_text(f""" import Foundation class TestClass{i} {{ @@ -46,18 +45,15 @@ class TestClass{i} {{ return "test{i}" }} }} -""" - ) +""") files.append(str(swift_file)) # Create a Python file as well python_file = temp_path / "helper.py" - python_file.write_text( - """ + python_file.write_text(""" def helper_function(): return "helper" -""" - ) +""") files.append(str(python_file)) try: diff --git a/tests/test_docker_implementation.py b/tests/test_docker_implementation.py index d93ca9ff4..ad99976e3 100644 --- a/tests/test_docker_implementation.py +++ b/tests/test_docker_implementation.py @@ -310,13 +310,11 @@ def temp_project_dir(): # Create base files (temp_path / "server.py").write_text("# Mock server.py") - (temp_path / "Dockerfile").write_text( - """ + (temp_path / "Dockerfile").write_text(""" FROM python:3.11-slim COPY server.py /app/ CMD ["python", "/app/server.py"] -""" - ) +""") yield temp_path diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 000000000..6188509d8 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,401 @@ +"""Tests for MiniMax provider implementation.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from providers.minimax import MiniMaxModelProvider +from providers.shared import ProviderType + + +class TestMiniMaxProvider: + """Test MiniMax provider functionality.""" + + def setup_method(self): + """Set up clean state before each test.""" + import utils.model_restrictions + + utils.model_restrictions._restriction_service = None + + def teardown_method(self): + """Clean up after each test to avoid singleton issues.""" + import utils.model_restrictions + + utils.model_restrictions._restriction_service = None + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"}) + def test_initialization(self): + """Test provider initialization.""" + provider = MiniMaxModelProvider("test-key") + assert provider.api_key == "test-key" + assert provider.get_provider_type() == ProviderType.MINIMAX + assert provider.base_url == "https://api.minimax.io/v1" + + def test_initialization_with_custom_url(self): + """Test provider initialization with custom base URL.""" + provider = MiniMaxModelProvider("test-key", base_url="https://custom.minimax.io/v1") + assert provider.api_key == "test-key" + assert provider.base_url == "https://custom.minimax.io/v1" + + def test_model_validation(self): + """Test model name validation.""" + provider = MiniMaxModelProvider("test-key") + + # Test valid models (canonical names) + assert provider.validate_model_name("MiniMax-M2.5") is True + assert provider.validate_model_name("MiniMax-M2.5-highspeed") is True + assert provider.validate_model_name("MiniMax-M2.1") is True + assert provider.validate_model_name("MiniMax-M2.1-highspeed") is True + assert provider.validate_model_name("MiniMax-M2") is True + + # Test valid aliases + assert provider.validate_model_name("m2.5") is True + assert provider.validate_model_name("m2.5-fast") is True + assert provider.validate_model_name("m2.1") is True + assert provider.validate_model_name("m2.1-fast") is True + assert provider.validate_model_name("m2") is True + assert provider.validate_model_name("minimax") is True + + # Test invalid models + assert provider.validate_model_name("invalid-model") is False + assert provider.validate_model_name("gpt-4") is False + assert provider.validate_model_name("gemini-pro") is False + assert provider.validate_model_name("grok-4") is False + + def test_resolve_model_name(self): + """Test model name resolution.""" + provider = MiniMaxModelProvider("test-key") + + # Test alias resolution + assert provider._resolve_model_name("m2.5") == "MiniMax-M2.5" + assert provider._resolve_model_name("minimax") == "MiniMax-M2.5" + assert provider._resolve_model_name("m2.5-fast") == "MiniMax-M2.5-highspeed" + assert provider._resolve_model_name("m2.1") == "MiniMax-M2.1" + assert provider._resolve_model_name("m2.1-fast") == "MiniMax-M2.1-highspeed" + assert provider._resolve_model_name("m2") == "MiniMax-M2" + + # Test canonical name passthrough + assert provider._resolve_model_name("MiniMax-M2.5") == "MiniMax-M2.5" + assert provider._resolve_model_name("MiniMax-M2.5-highspeed") == "MiniMax-M2.5-highspeed" + assert provider._resolve_model_name("MiniMax-M2.1") == "MiniMax-M2.1" + assert provider._resolve_model_name("MiniMax-M2") == "MiniMax-M2" + + def test_get_capabilities_m25(self): + """Test getting model capabilities for MiniMax-M2.5.""" + provider = MiniMaxModelProvider("test-key") + + capabilities = provider.get_capabilities("MiniMax-M2.5") + assert capabilities.model_name == "MiniMax-M2.5" + assert capabilities.friendly_name == "MiniMax (M2.5)" + assert capabilities.context_window == 196_608 + assert capabilities.max_output_tokens == 196_608 + assert capabilities.provider == ProviderType.MINIMAX + assert capabilities.supports_extended_thinking is False + assert capabilities.supports_system_prompts is True + assert capabilities.supports_streaming is True + assert capabilities.supports_function_calling is True + assert capabilities.supports_json_mode is True + assert capabilities.supports_images is False + + def test_get_capabilities_m25_highspeed(self): + """Test getting model capabilities for MiniMax-M2.5-highspeed.""" + provider = MiniMaxModelProvider("test-key") + + capabilities = provider.get_capabilities("m2.5-fast") + assert capabilities.model_name == "MiniMax-M2.5-highspeed" + assert capabilities.friendly_name == "MiniMax (M2.5 Highspeed)" + assert capabilities.context_window == 204_800 + assert capabilities.max_output_tokens == 100_000 + assert capabilities.provider == ProviderType.MINIMAX + assert capabilities.supports_function_calling is True + assert capabilities.supports_images is False + + def test_get_capabilities_m21(self): + """Test getting model capabilities for MiniMax-M2.1.""" + provider = MiniMaxModelProvider("test-key") + + capabilities = provider.get_capabilities("m2.1") + assert capabilities.model_name == "MiniMax-M2.1" + assert capabilities.friendly_name == "MiniMax (M2.1)" + assert capabilities.context_window == 204_800 + assert capabilities.provider == ProviderType.MINIMAX + + def test_get_capabilities_m2(self): + """Test getting model capabilities for MiniMax-M2.""" + provider = MiniMaxModelProvider("test-key") + + capabilities = provider.get_capabilities("m2") + assert capabilities.model_name == "MiniMax-M2" + assert capabilities.friendly_name == "MiniMax (M2)" + assert capabilities.context_window == 204_800 + assert capabilities.provider == ProviderType.MINIMAX + + def test_get_capabilities_with_shorthand(self): + """Test getting model capabilities with shorthand.""" + provider = MiniMaxModelProvider("test-key") + + capabilities = provider.get_capabilities("minimax") + assert capabilities.model_name == "MiniMax-M2.5" + assert capabilities.context_window == 196_608 + + capabilities_fast = provider.get_capabilities("m2.5-fast") + assert capabilities_fast.model_name == "MiniMax-M2.5-highspeed" + + def test_unsupported_model_capabilities(self): + """Test error handling for unsupported models.""" + provider = MiniMaxModelProvider("test-key") + + with pytest.raises(ValueError, match="Unsupported model 'invalid-model' for provider minimax"): + provider.get_capabilities("invalid-model") + + def test_no_extended_thinking(self): + """MiniMax models should not expose extended thinking support.""" + provider = MiniMaxModelProvider("test-key") + + all_models = [ + "MiniMax-M2.5", + "m2.5-fast", + "m2.1", + "m2.1-fast", + "m2", + ] + for model in all_models: + assert provider.get_capabilities(model).supports_extended_thinking is False + + def test_no_image_support(self): + """MiniMax models should not support images.""" + provider = MiniMaxModelProvider("test-key") + + all_models = [ + "MiniMax-M2.5", + "m2.5-fast", + "m2.1", + "m2.1-fast", + "m2", + ] + for model in all_models: + assert provider.get_capabilities(model).supports_images is False + + def test_provider_type(self): + """Test provider type identification.""" + provider = MiniMaxModelProvider("test-key") + assert provider.get_provider_type() == ProviderType.MINIMAX + + @patch.dict(os.environ, {"MINIMAX_ALLOWED_MODELS": "MiniMax-M2.5"}) + def test_model_restrictions(self): + """Test model restrictions functionality.""" + import utils.model_restrictions + from providers.registry import ModelProviderRegistry + + utils.model_restrictions._restriction_service = None + ModelProviderRegistry.reset_for_testing() + + provider = MiniMaxModelProvider("test-key") + + # MiniMax-M2.5 should be allowed (including alias) + assert provider.validate_model_name("MiniMax-M2.5") is True + assert provider.validate_model_name("m2.5") is True + + # Other models should be blocked by restrictions + assert provider.validate_model_name("MiniMax-M2.5-highspeed") is False + assert provider.validate_model_name("m2.5-fast") is False + assert provider.validate_model_name("MiniMax-M2") is False + + @patch.dict(os.environ, {"MINIMAX_ALLOWED_MODELS": ""}) + def test_empty_restrictions_allows_all(self): + """Test that empty restrictions allow all models.""" + import utils.model_restrictions + + utils.model_restrictions._restriction_service = None + + provider = MiniMaxModelProvider("test-key") + + assert provider.validate_model_name("MiniMax-M2.5") is True + assert provider.validate_model_name("MiniMax-M2.5-highspeed") is True + assert provider.validate_model_name("MiniMax-M2.1") is True + assert provider.validate_model_name("MiniMax-M2.1-highspeed") is True + assert provider.validate_model_name("MiniMax-M2") is True + assert provider.validate_model_name("m2.5") is True + assert provider.validate_model_name("minimax") is True + + def test_friendly_name(self): + """Test friendly name constant.""" + provider = MiniMaxModelProvider("test-key") + assert provider.FRIENDLY_NAME == "MiniMax" + + capabilities = provider.get_capabilities("MiniMax-M2.5") + assert capabilities.friendly_name == "MiniMax (M2.5)" + + def test_supported_models_structure(self): + """Test that MODEL_CAPABILITIES has the correct structure.""" + provider = MiniMaxModelProvider("test-key") + + # Check that all expected models are present + assert "MiniMax-M2.5" in provider.MODEL_CAPABILITIES + assert "MiniMax-M2.5-highspeed" in provider.MODEL_CAPABILITIES + assert "MiniMax-M2.1" in provider.MODEL_CAPABILITIES + assert "MiniMax-M2.1-highspeed" in provider.MODEL_CAPABILITIES + assert "MiniMax-M2" in provider.MODEL_CAPABILITIES + + # Check model configs have required fields + from providers.shared import ModelCapabilities + + m25_config = provider.MODEL_CAPABILITIES["MiniMax-M2.5"] + assert isinstance(m25_config, ModelCapabilities) + assert hasattr(m25_config, "context_window") + assert hasattr(m25_config, "supports_extended_thinking") + assert hasattr(m25_config, "aliases") + assert m25_config.context_window == 196_608 + assert m25_config.supports_extended_thinking is False + assert m25_config.supports_images is False + + # Check aliases are correctly structured + assert "m2.5" in m25_config.aliases + assert "minimax" in m25_config.aliases + + m25hs_config = provider.MODEL_CAPABILITIES["MiniMax-M2.5-highspeed"] + assert m25hs_config.context_window == 204_800 + assert "m2.5-fast" in m25hs_config.aliases + + def test_preferred_model_extended_reasoning(self): + """Test preferred model for extended reasoning category.""" + from tools.models import ToolModelCategory + + provider = MiniMaxModelProvider("test-key") + all_models = list(provider.MODEL_CAPABILITIES.keys()) + + preferred = provider.get_preferred_model(ToolModelCategory.EXTENDED_REASONING, all_models) + assert preferred == "MiniMax-M2.5" + + def test_preferred_model_fast_response(self): + """Test preferred model for fast response category.""" + from tools.models import ToolModelCategory + + provider = MiniMaxModelProvider("test-key") + all_models = list(provider.MODEL_CAPABILITIES.keys()) + + preferred = provider.get_preferred_model(ToolModelCategory.FAST_RESPONSE, all_models) + assert preferred == "MiniMax-M2.5-highspeed" + + def test_preferred_model_balanced(self): + """Test preferred model for balanced category.""" + from tools.models import ToolModelCategory + + provider = MiniMaxModelProvider("test-key") + all_models = list(provider.MODEL_CAPABILITIES.keys()) + + preferred = provider.get_preferred_model(ToolModelCategory.BALANCED, all_models) + assert preferred == "MiniMax-M2.5" + + def test_preferred_model_empty_list(self): + """Test preferred model with empty allowed models list.""" + from tools.models import ToolModelCategory + + provider = MiniMaxModelProvider("test-key") + + preferred = provider.get_preferred_model(ToolModelCategory.BALANCED, []) + assert preferred is None + + @patch("providers.openai_compatible.OpenAI") + def test_generate_content_resolves_alias_before_api_call(self, mock_openai_class): + """Test that generate_content resolves aliases before making API calls. + + This is the CRITICAL test that ensures aliases like 'minimax' get resolved + to 'MiniMax-M2.5' before being sent to the MiniMax API. + """ + mock_client = MagicMock() + mock_openai_class.return_value = mock_client + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test response" + mock_response.choices[0].finish_reason = "stop" + mock_response.model = "MiniMax-M2.5" + mock_response.id = "test-id" + mock_response.created = 1234567890 + mock_response.usage = MagicMock() + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + mock_response.usage.total_tokens = 15 + + mock_client.chat.completions.create.return_value = mock_response + + provider = MiniMaxModelProvider("test-key") + + result = provider.generate_content( + prompt="Test prompt", + model_name="minimax", + temperature=0.7, + ) + + mock_client.chat.completions.create.assert_called_once() + call_kwargs = mock_client.chat.completions.create.call_args[1] + + # CRITICAL: The API should receive "MiniMax-M2.5", not "minimax" + assert ( + call_kwargs["model"] == "MiniMax-M2.5" + ), f"Expected 'MiniMax-M2.5' but API received '{call_kwargs['model']}'" + + assert call_kwargs["temperature"] == 0.7 + assert len(call_kwargs["messages"]) == 1 + assert call_kwargs["messages"][0]["role"] == "user" + assert call_kwargs["messages"][0]["content"] == "Test prompt" + + assert result.content == "Test response" + assert result.model_name == "MiniMax-M2.5" + + @patch("providers.openai_compatible.OpenAI") + def test_generate_content_other_aliases(self, mock_openai_class): + """Test other alias resolutions in generate_content.""" + mock_client = MagicMock() + mock_openai_class.return_value = mock_client + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test response" + mock_response.choices[0].finish_reason = "stop" + mock_response.usage = MagicMock() + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + mock_response.usage.total_tokens = 15 + mock_client.chat.completions.create.return_value = mock_response + + provider = MiniMaxModelProvider("test-key") + + # Test m2.5 -> MiniMax-M2.5 + mock_response.model = "MiniMax-M2.5" + provider.generate_content(prompt="Test", model_name="m2.5", temperature=0.7) + call_kwargs = mock_client.chat.completions.create.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2.5" + + # Test m2.5-fast -> MiniMax-M2.5-highspeed + mock_response.model = "MiniMax-M2.5-highspeed" + provider.generate_content(prompt="Test", model_name="m2.5-fast", temperature=0.7) + call_kwargs = mock_client.chat.completions.create.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2.5-highspeed" + + # Test m2 -> MiniMax-M2 + mock_response.model = "MiniMax-M2" + provider.generate_content(prompt="Test", model_name="m2", temperature=0.7) + call_kwargs = mock_client.chat.completions.create.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2" + + def test_list_models(self): + """Test that list_models returns all MiniMax models and aliases.""" + provider = MiniMaxModelProvider("test-key") + models = provider.list_models(respect_restrictions=False) + + # Canonical names should be present + assert "MiniMax-M2.5" in models + assert "MiniMax-M2.5-highspeed" in models + assert "MiniMax-M2.1" in models + assert "MiniMax-M2.1-highspeed" in models + assert "MiniMax-M2" in models + + # Aliases should also be present + assert "m2.5" in models + assert "m2.5-fast" in models + assert "m2.1" in models + assert "m2.1-fast" in models + assert "m2" in models + assert "minimax" in models diff --git a/tests/test_prompt_regression.py b/tests/test_prompt_regression.py index bf40164c7..a2bdf45c7 100644 --- a/tests/test_prompt_regression.py +++ b/tests/test_prompt_regression.py @@ -86,16 +86,14 @@ async def test_chat_with_files(self): # Create a temporary Python file for testing with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" def hello_world(): \"\"\"A simple hello world function.\"\"\" return "Hello, World!" if __name__ == "__main__": print(hello_world()) -""" - ) +""") temp_file = f.name try: @@ -155,8 +153,7 @@ async def test_codereview_normal_review(self): # Create a temporary Python file for testing with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" def process_user_input(user_input): # Potentially unsafe code for demonstration query = f"SELECT * FROM users WHERE name = '{user_input}'" @@ -166,8 +163,7 @@ def main(): user_name = input("Enter name: ") result = process_user_input(user_name) print(result) -""" - ) +""") temp_file = f.name try: @@ -241,8 +237,7 @@ async def test_analyze_normal_question(self): # Create a temporary Python file demonstrating MVC pattern with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: - f.write( - """ + f.write(""" # Model class User: def __init__(self, name, email): @@ -262,8 +257,7 @@ def __init__(self, model, view): def get_user_display(self): return self.view.display_user(self.model) -""" - ) +""") temp_file = f.name try: diff --git a/tools/listmodels.py b/tools/listmodels.py index 120afc189..cd3454ab7 100644 --- a/tools/listmodels.py +++ b/tools/listmodels.py @@ -2,7 +2,7 @@ List Models Tool - Display all available models organized by provider This tool provides a comprehensive view of all AI models available in the system, -organized by their provider (Gemini, OpenAI, X.AI, OpenRouter, Custom). +organized by their provider (Gemini, OpenAI, X.AI, MiniMax, OpenRouter, Custom). It shows which providers are configured and what models can be used. """ @@ -102,6 +102,7 @@ async def execute(self, arguments: dict[str, Any]) -> list[TextContent]: ProviderType.OPENAI: {"name": "OpenAI", "env_key": "OPENAI_API_KEY"}, ProviderType.AZURE: {"name": "Azure OpenAI", "env_key": "AZURE_OPENAI_API_KEY"}, ProviderType.XAI: {"name": "X.AI (Grok)", "env_key": "XAI_API_KEY"}, + ProviderType.MINIMAX: {"name": "MiniMax", "env_key": "MINIMAX_API_KEY"}, ProviderType.DIAL: {"name": "AI DIAL", "env_key": "DIAL_API_KEY"}, }