diff --git a/README.md b/README.md index 637c422e..992762d3 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,28 @@ Plexe uses LLMs via [LiteLLM](https://docs.litellm.ai/docs/providers), so you ca hypothesiser_llm: "openai/gpt-5-mini" feature_processor_llm: "anthropic/claude-sonnet-4-5-20250929" model_definer_llm: "ollama/llama3" +planner_llm: "minimax/MiniMax-M2.7" ``` +#### MiniMax + +[MiniMax](https://www.minimaxi.com) models are supported as a first-class provider via the `minimax/` prefix. +Set your API key and use MiniMax models directly: + +```bash +export MINIMAX_API_KEY= +``` + +```yaml +# config.yaml +hypothesiser_llm: "minimax/MiniMax-M2.7" +planner_llm: "minimax/MiniMax-M2.7" +model_definer_llm: "minimax/MiniMax-M2.5-highspeed" +litellm_drop_params: true +``` + +Available models: `MiniMax-M2.7`, `MiniMax-M2.7-highspeed` (1M context), `MiniMax-M2.5`, `MiniMax-M2.5-highspeed` (204K context). + > [!NOTE] > Plexe *should* work with most LiteLLM providers, but we actively test only with `openai/*` and `anthropic/*` > models. If you encounter issues with other providers, please let us know. @@ -198,6 +218,7 @@ Requires Python >= 3.10, < 3.13. ```bash export OPENAI_API_KEY= export ANTHROPIC_API_KEY= +export MINIMAX_API_KEY= # Optional: for MiniMax models ``` See [LiteLLM providers](https://docs.litellm.ai/docs/providers) for all supported providers. diff --git a/config.yaml.template b/config.yaml.template index 7bded73f..3e015804 100644 --- a/config.yaml.template +++ b/config.yaml.template @@ -290,6 +290,28 @@ # models: # anthropic/claude-sonnet-4-5-20250929: my-proxy +# ============================================================ +# Example: Using MiniMax as LLM Provider +# ============================================================ +# +# MiniMax models are supported via the minimax/ prefix. +# Set MINIMAX_API_KEY in your environment, then use: +# +# hypothesiser_llm: "minimax/MiniMax-M2.7" +# planner_llm: "minimax/MiniMax-M2.7" +# model_definer_llm: "minimax/MiniMax-M2.5-highspeed" +# +# Available MiniMax models: +# minimax/MiniMax-M2.7 (1M context) +# minimax/MiniMax-M2.7-highspeed (1M context, faster) +# minimax/MiniMax-M2.5 (204K context) +# minimax/MiniMax-M2.5-highspeed (204K context, faster) +# +# Recommended: enable litellm_drop_params when using MiniMax +# to silently ignore any unsupported parameters: +# +# litellm_drop_params: true + # ============================================================ # Example: Minimal Config for Quick Testing # ============================================================ diff --git a/plexe/config.py b/plexe/config.py index db287eb2..476cbe21 100644 --- a/plexe/config.py +++ b/plexe/config.py @@ -169,6 +169,25 @@ class StandardMetric(str, Enum): ] +# ============================================ +# MiniMax Provider Support +# ============================================ + +MINIMAX_API_BASE = "https://api.minimax.io/v1" + +MINIMAX_MODELS = { + "MiniMax-M2.7": {"context_window": 1_000_000}, + "MiniMax-M2.7-highspeed": {"context_window": 1_000_000}, + "MiniMax-M2.5": {"context_window": 204_000}, + "MiniMax-M2.5-highspeed": {"context_window": 204_000}, +} + + +def _is_minimax_model(model_id: str) -> bool: + """Check if a model ID uses the ``minimax/`` provider prefix.""" + return model_id.startswith("minimax/") + + # ============================================ # Configuration Helpers # ============================================ @@ -512,8 +531,9 @@ def get_routing_for_model(config: RoutingConfig | None, model_id: str) -> tuple[ Lookup order: 1. Check if model_id is in 'models' mapping → use that provider's config - 2. Else use 'default' config if present - 3. Else return (None, {}) for LiteLLM's default routing + 2. If model_id uses ``minimax/`` prefix → auto-route to MiniMax API + 3. Else use 'default' config if present + 4. Else return (None, {}) for LiteLLM's default routing Args: config: Routing configuration (or None if no config loaded) @@ -524,29 +544,34 @@ def get_routing_for_model(config: RoutingConfig | None, model_id: str) -> tuple[ - api_base: Base URL for API requests (None = use LiteLLM default) - headers: Dict of HTTP headers to include in requests """ - # If no config provided, use LiteLLM defaults - if config is None: - return None, {} - - # Check if model has explicit provider mapping - if model_id in config.models: + # Check if model has explicit provider mapping (highest priority) + if config is not None and model_id in config.models: provider_name = config.models[model_id] if provider_name not in config.providers: - # This should have been caught by validation, but handle gracefully logging.getLogger(__name__).warning( f"Model '{model_id}' references non-existent provider '{provider_name}'. Using default routing." ) - provider_config = config.default else: provider_config = config.providers[provider_name] logging.getLogger(__name__).debug(f"Model '{model_id}' → provider '{provider_name}'") - else: - # No explicit mapping, use default - provider_config = config.default - logging.getLogger(__name__).debug(f"Model '{model_id}' → default routing") + return provider_config.api_base, provider_config.headers + + # Auto-route minimax/ prefix to MiniMax API + if _is_minimax_model(model_id): + api_key = os.getenv("MINIMAX_API_KEY", "") + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + logging.getLogger(__name__).debug(f"Model '{model_id}' → MiniMax auto-routing") + return MINIMAX_API_BASE, headers + + # If no config provided, use LiteLLM defaults + if config is None: + return None, {} + + # Use default routing config + provider_config = config.default + logging.getLogger(__name__).debug(f"Model '{model_id}' → default routing") - # If no applicable config found, use LiteLLM defaults if provider_config is None: return None, {} diff --git a/plexe/utils/litellm_wrapper.py b/plexe/utils/litellm_wrapper.py index ede267bf..8dec3ad6 100644 --- a/plexe/utils/litellm_wrapper.py +++ b/plexe/utils/litellm_wrapper.py @@ -15,6 +15,8 @@ from smolagents import LiteLLMModel from tenacity import retry, stop_after_attempt, wait_exponential, wait_random, retry_if_exception_type +from plexe.config import _is_minimax_model + logger = logging.getLogger(__name__) @@ -70,6 +72,13 @@ def __init__( on_llm_call: Callable[[str, Any, int], None] | None = None, **kwargs, ): + # Rewrite minimax/ prefix to openai/ for LiteLLM compatibility + if _is_minimax_model(model_id): + model_id = "openai/" + model_id[len("minimax/"):] + # Clamp temperature to MiniMax's supported range [0, 1.0] + if "temperature" in kwargs: + kwargs["temperature"] = max(0.0, min(1.0, kwargs["temperature"])) + super().__init__(model_id=model_id, **kwargs) self.extra_headers = extra_headers or {} self.on_llm_call = on_llm_call diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e8e0d04f..4431d6c9 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -5,7 +5,16 @@ import pytest import yaml -from plexe.config import Config, RoutingConfig, RoutingProviderConfig, get_routing_for_model, setup_logging +from plexe.config import ( + Config, + MINIMAX_API_BASE, + MINIMAX_MODELS, + RoutingConfig, + RoutingProviderConfig, + _is_minimax_model, + get_routing_for_model, + setup_logging, +) def test_get_routing_for_model_mapping_and_default(): @@ -83,3 +92,125 @@ def test_setup_logging_disables_propagation(): assert logger.name == "plexe" assert logger.propagate is False assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + + +# ============================================ +# MiniMax Provider Tests +# ============================================ + + +def test_is_minimax_model(): + """minimax/ prefix is correctly detected.""" + assert _is_minimax_model("minimax/MiniMax-M2.7") is True + assert _is_minimax_model("minimax/MiniMax-M2.5-highspeed") is True + assert _is_minimax_model("openai/gpt-4") is False + assert _is_minimax_model("anthropic/claude-sonnet-4-5-20250929") is False + + +def test_minimax_models_constant(): + """MINIMAX_MODELS should contain expected model entries.""" + assert "MiniMax-M2.7" in MINIMAX_MODELS + assert "MiniMax-M2.7-highspeed" in MINIMAX_MODELS + assert "MiniMax-M2.5" in MINIMAX_MODELS + assert "MiniMax-M2.5-highspeed" in MINIMAX_MODELS + assert MINIMAX_MODELS["MiniMax-M2.7"]["context_window"] == 1_000_000 + + +def test_minimax_auto_routing_no_config(monkeypatch): + """minimax/ models auto-route even without routing_config.""" + monkeypatch.setenv("MINIMAX_API_KEY", "test-key-123") + + api_base, headers = get_routing_for_model(None, "minimax/MiniMax-M2.7") + + assert api_base == MINIMAX_API_BASE + assert headers == {"Authorization": "Bearer test-key-123"} + + +def test_minimax_auto_routing_with_config(monkeypatch): + """minimax/ models auto-route when not explicitly mapped in config.""" + monkeypatch.setenv("MINIMAX_API_KEY", "test-key-456") + config = RoutingConfig( + default=RoutingProviderConfig(api_base="https://default", headers={"x": "1"}), + ) + + api_base, headers = get_routing_for_model(config, "minimax/MiniMax-M2.7") + + assert api_base == MINIMAX_API_BASE + assert headers == {"Authorization": "Bearer test-key-456"} + + +def test_minimax_explicit_mapping_overrides_auto_routing(monkeypatch): + """Explicit routing_config mapping takes priority over minimax auto-routing.""" + monkeypatch.setenv("MINIMAX_API_KEY", "should-not-be-used") + config = RoutingConfig( + providers={ + "my-proxy": RoutingProviderConfig(api_base="https://proxy.example.com/v1", headers={"auth": "proxy-key"}), + }, + models={"minimax/MiniMax-M2.7": "my-proxy"}, + ) + + api_base, headers = get_routing_for_model(config, "minimax/MiniMax-M2.7") + + assert api_base == "https://proxy.example.com/v1" + assert headers == {"auth": "proxy-key"} + + +def test_minimax_auto_routing_without_api_key(monkeypatch): + """minimax/ models auto-route without API key (headers empty).""" + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) + + api_base, headers = get_routing_for_model(None, "minimax/MiniMax-M2.5-highspeed") + + assert api_base == MINIMAX_API_BASE + assert headers == {} + + +def test_minimax_llm_from_yaml(tmp_path, monkeypatch): + """MiniMax model IDs can be loaded from YAML config.""" + monkeypatch.delenv("HYPOTHESISER_LLM", raising=False) + monkeypatch.delenv("PLANNER_LLM", raising=False) + + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "hypothesiser_llm": "minimax/MiniMax-M2.7", + "planner_llm": "minimax/MiniMax-M2.5-highspeed", + } + ) + ) + monkeypatch.setenv("CONFIG_FILE", str(config_path)) + + config = Config() + + assert config.hypothesiser_llm == "minimax/MiniMax-M2.7" + assert config.planner_llm == "minimax/MiniMax-M2.5-highspeed" + + +def test_minimax_llm_from_env(monkeypatch): + """MiniMax model IDs can be set via environment variables.""" + monkeypatch.setenv("HYPOTHESISER_LLM", "minimax/MiniMax-M2.7") + + config = Config() + + assert config.hypothesiser_llm == "minimax/MiniMax-M2.7" + + +def test_non_minimax_routing_unchanged(): + """Non-minimax models still use default routing behavior.""" + config = RoutingConfig( + default=RoutingProviderConfig(api_base="https://default", headers={"x": "1"}), + ) + + api_base, headers = get_routing_for_model(config, "openai/gpt-4") + + assert api_base == "https://default" + assert headers == {"x": "1"} + + +def test_non_minimax_no_config_returns_none(): + """Non-minimax models without config return (None, {}).""" + api_base, headers = get_routing_for_model(None, "openai/gpt-4") + + assert api_base is None + assert headers == {} diff --git a/tests/unit/test_minimax_integration.py b/tests/unit/test_minimax_integration.py new file mode 100644 index 00000000..b59720a9 --- /dev/null +++ b/tests/unit/test_minimax_integration.py @@ -0,0 +1,86 @@ +"""Integration tests for MiniMax provider support. + +These tests verify end-to-end MiniMax configuration and routing. +They require a MINIMAX_API_KEY environment variable to run API calls. +""" + +import os + +import pytest + +from plexe.config import Config, MINIMAX_API_BASE, get_routing_for_model + + +@pytest.fixture +def minimax_api_key(): + """Skip if MINIMAX_API_KEY is not set.""" + key = os.getenv("MINIMAX_API_KEY") + if not key: + pytest.skip("MINIMAX_API_KEY not set") + return key + + +def test_minimax_config_yaml_roundtrip(tmp_path, monkeypatch): + """Full config loading with MiniMax model IDs from YAML.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + "hypothesiser_llm: minimax/MiniMax-M2.7\n" + "planner_llm: minimax/MiniMax-M2.5-highspeed\n" + "litellm_drop_params: true\n" + ) + monkeypatch.setenv("CONFIG_FILE", str(config_path)) + monkeypatch.setenv("MINIMAX_API_KEY", "test-integration-key") + + config = Config() + + assert config.hypothesiser_llm == "minimax/MiniMax-M2.7" + assert config.planner_llm == "minimax/MiniMax-M2.5-highspeed" + assert config.litellm_drop_params is True + + # Verify routing resolves correctly + api_base, headers = get_routing_for_model(config.routing_config, config.hypothesiser_llm) + assert api_base == MINIMAX_API_BASE + assert headers["Authorization"] == "Bearer test-integration-key" + + +def test_minimax_mixed_provider_config(tmp_path, monkeypatch): + """Config with mixed providers (MiniMax + Anthropic) works correctly.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + "hypothesiser_llm: minimax/MiniMax-M2.7\n" + "planner_llm: anthropic/claude-sonnet-4-5-20250929\n" + "model_definer_llm: minimax/MiniMax-M2.5-highspeed\n" + ) + monkeypatch.setenv("CONFIG_FILE", str(config_path)) + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + + config = Config() + + # MiniMax models auto-route to MiniMax API + api_base, headers = get_routing_for_model(config.routing_config, config.hypothesiser_llm) + assert api_base == MINIMAX_API_BASE + + # Anthropic models use default (None) routing + api_base, headers = get_routing_for_model(config.routing_config, config.planner_llm) + assert api_base is None + assert headers == {} + + +def test_minimax_api_completion(minimax_api_key): + """Verify MiniMax API responds to a basic completion request via LiteLLM.""" + import litellm + + response = litellm.completion( + model="openai/MiniMax-M2.5-highspeed", + messages=[{"role": "user", "content": "Say hello in one word."}], + api_base=MINIMAX_API_BASE, + api_key=minimax_api_key, + temperature=0.2, + max_tokens=256, + ) + + message = response.choices[0].message + # MiniMax models may include reasoning_content alongside content + has_content = message.content and len(message.content.strip()) > 0 + has_reasoning = getattr(message, "reasoning_content", None) and len(message.reasoning_content.strip()) > 0 + assert has_content or has_reasoning, f"Expected non-empty response, got content={message.content!r}" diff --git a/tests/unit/utils/test_litellm_wrapper.py b/tests/unit/utils/test_litellm_wrapper.py new file mode 100644 index 00000000..04a882aa --- /dev/null +++ b/tests/unit/utils/test_litellm_wrapper.py @@ -0,0 +1,103 @@ +"""Unit tests for PlexeLiteLLMModel MiniMax support.""" + +from unittest.mock import patch, MagicMock + +import pytest + +from plexe.utils.litellm_wrapper import PlexeLiteLLMModel + + +@pytest.fixture(autouse=True) +def _patch_litellm_model(): + """Patch LiteLLMModel.__init__ so tests don't need real LiteLLM credentials.""" + with patch("plexe.utils.litellm_wrapper.LiteLLMModel.__init__", return_value=None): + yield + + +class TestMiniMaxModelIdRewriting: + """Tests for minimax/ prefix → openai/ rewriting in PlexeLiteLLMModel.""" + + def test_minimax_prefix_rewritten_to_openai(self): + """minimax/MiniMax-M2.7 should become openai/MiniMax-M2.7.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.7", temperature=0.2) + # The super().__init__ was patched, so check the call args + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.7", temperature=0.2) + + def test_minimax_highspeed_rewritten(self): + """minimax/MiniMax-M2.5-highspeed should become openai/MiniMax-M2.5-highspeed.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.5-highspeed", temperature=0.5) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.5-highspeed", temperature=0.5) + + def test_non_minimax_model_unchanged(self): + """openai/gpt-4 should not be rewritten.""" + model = PlexeLiteLLMModel(model_id="openai/gpt-4", temperature=0.7) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/gpt-4", temperature=0.7) + + def test_anthropic_model_unchanged(self): + """anthropic/ models should not be rewritten.""" + model = PlexeLiteLLMModel(model_id="anthropic/claude-sonnet-4-5-20250929", temperature=0.2) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with( + model_id="anthropic/claude-sonnet-4-5-20250929", temperature=0.2 + ) + + +class TestMiniMaxTemperatureClamping: + """Tests for MiniMax temperature clamping in PlexeLiteLLMModel.""" + + def test_minimax_temperature_clamped_high(self): + """Temperature > 1.0 should be clamped to 1.0 for MiniMax models.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.7", temperature=1.5) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.7", temperature=1.0) + + def test_minimax_temperature_clamped_low(self): + """Temperature < 0.0 should be clamped to 0.0 for MiniMax models.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.7", temperature=-0.1) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.7", temperature=0.0) + + def test_minimax_temperature_within_range_unchanged(self): + """Temperature within [0, 1.0] should not be modified for MiniMax models.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.7", temperature=0.7) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.7", temperature=0.7) + + def test_non_minimax_temperature_not_clamped(self): + """Temperature > 1.0 should NOT be clamped for non-MiniMax models.""" + model = PlexeLiteLLMModel(model_id="openai/gpt-4", temperature=1.5) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/gpt-4", temperature=1.5) + + def test_minimax_no_temperature_kwarg(self): + """When no temperature is provided, no clamping should occur.""" + model = PlexeLiteLLMModel(model_id="minimax/MiniMax-M2.7") + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with(model_id="openai/MiniMax-M2.7") + + def test_minimax_api_base_passthrough(self): + """api_base kwarg should be passed through to LiteLLMModel.""" + model = PlexeLiteLLMModel( + model_id="minimax/MiniMax-M2.7", + temperature=0.2, + api_base="https://api.minimax.io/v1", + ) + from plexe.utils.litellm_wrapper import LiteLLMModel + + LiteLLMModel.__init__.assert_called_once_with( + model_id="openai/MiniMax-M2.7", + temperature=0.2, + api_base="https://api.minimax.io/v1", + )