From afad665836c89502ce2955c13692ff6b848716ac Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sat, 28 Mar 2026 22:39:36 +0800 Subject: [PATCH] feat: add MiniMax as first-class LLM provider Add MiniMax AI (https://www.minimaxi.com/) as a supported LLM provider via their OpenAI-compatible API. Users can now use MiniMax models with either the minimax/ prefix or --model-provider minimax option. Changes: - LLMClient: detect MiniMax provider, auto-configure api_base, resolve MINIMAX_API_KEY env var with oaklib fallback, clamp temperature to (0.0, 1.0] range, route through litellm OpenAI-compatible path - __init__.py: register MiniMax-M2.7 and MiniMax-M2.7-highspeed models (204K context) in the model cost map - CLI: update --model-provider help text to mention MiniMax - README: add MiniMax setup and usage documentation - Tests: 25 unit tests + 3 integration tests covering provider init, API key resolution, temperature clamping, completion calls, and model registry --- README.md | 30 ++ src/ontogpt/__init__.py | 22 ++ src/ontogpt/cli.py | 3 +- src/ontogpt/clients/llm_client.py | 28 ++ .../test_clients/test_minimax_integration.py | 44 +++ .../test_clients/test_minimax_provider.py | 270 ++++++++++++++++++ 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_clients/test_minimax_integration.py create mode 100644 tests/unit/test_clients/test_minimax_provider.py diff --git a/README.md b/README.md index 4764f6427..7acdf3757 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,36 @@ The model may then be used in OntoGPT by prefixing its name with `ollama/`, e.g. Some ollama models may not be listed in `ontogpt list-models` but the full list of downloaded LLMs can be seen with `ollama list` command. +## MiniMax + +[MiniMax](https://www.minimaxi.com/) models may be used through their OpenAI-compatible API. + +1. Set your MiniMax API key: + + ```bash + export MINIMAX_API_KEY="your-minimax-api-key" + ``` + + Or use the `runoak` key manager: + + ```bash + runoak set-apikey -e minimax-key + ``` + +2. Use a MiniMax model with the `minimax/` prefix or the `--model-provider` option: + + ```bash + ontogpt extract -t drug -i example.txt -m minimax/MiniMax-M2.7 + ``` + + Or equivalently: + + ```bash + ontogpt extract -t drug -i example.txt -m MiniMax-M2.7 --model-provider minimax + ``` + +Available MiniMax models include `MiniMax-M2.7` (latest, 204K context) and `MiniMax-M2.7-highspeed` (204K context, optimized for speed). + ## Evaluations OntoGPT's functions have been evaluated on test data. Please see the full documentation for details on these evaluations and how to reproduce them. diff --git a/src/ontogpt/__init__.py b/src/ontogpt/__init__.py index bb8923df4..6830b5599 100644 --- a/src/ontogpt/__init__.py +++ b/src/ontogpt/__init__.py @@ -26,6 +26,28 @@ # This is provided by the litellm package MODELS = get_model_cost_map("") +# Add MiniMax models (OpenAI-compatible API at https://api.minimax.io/v1) +# These may not be in litellm's default cost map yet +MINIMAX_MODELS = { + "minimax/MiniMax-M2.7": { + "max_tokens": 204800, + "max_input_tokens": 204800, + "max_output_tokens": 16384, + "litellm_provider": "minimax", + "mode": "chat", + }, + "minimax/MiniMax-M2.7-highspeed": { + "max_tokens": 204800, + "max_input_tokens": 204800, + "max_output_tokens": 16384, + "litellm_provider": "minimax", + "mode": "chat", + }, +} +for model_name, model_info in MINIMAX_MODELS.items(): + if model_name not in MODELS: + MODELS[model_name] = model_info + try: __version__ = metadata.version(__name__) except metadata.PackageNotFoundError: diff --git a/src/ontogpt/cli.py b/src/ontogpt/cli.py index b6aeb488e..399e57110 100644 --- a/src/ontogpt/cli.py +++ b/src/ontogpt/cli.py @@ -342,7 +342,8 @@ def parse_tabular_input(inputpath: str, selectcols: List[str]) -> str: model_provider_option = click.option( "--model-provider", help="Specify a provider if model is not specified in the model name." - " If using a proxy using the OpenAI API format, this should be set to 'openai'.", + " If using a proxy using the OpenAI API format, this should be set to 'openai'." + " For MiniMax models, set this to 'minimax'.", ) temperature_option = click.option( "-p", diff --git a/src/ontogpt/clients/llm_client.py b/src/ontogpt/clients/llm_client.py index ae326d6ca..0d98722a6 100644 --- a/src/ontogpt/clients/llm_client.py +++ b/src/ontogpt/clients/llm_client.py @@ -1,6 +1,7 @@ """Client for running LLM completion requests through LiteLLM.""" import logging +import os import sys from dataclasses import dataclass, field @@ -18,6 +19,9 @@ # Just get the part before the slash in each model name SERVICES = {model.split("/")[0] for model in MODELS.keys() if len(model.split("/")) > 1} +# MiniMax API base URL (OpenAI-compatible endpoint) +MINIMAX_API_BASE = "https://api.minimax.io/v1" + # Necessary to avoid repeated debug messages litellm.suppress_debug_info = True @@ -57,12 +61,36 @@ def __post_init__(self): else: raise ValueError(f"Model name must be a string, got {type(self.model)}") + # Detect MiniMax provider from model name prefix or explicit provider + is_minimax = ( + self.custom_llm_provider == "minimax" + or self.model.startswith("minimax/") + ) + if self.model.startswith("ollama"): self.api_key = "" # Don't need an API key elif self.model.startswith("fake"): # Just used for testing self.api_key = "" # Don't need an API key logger.info(f"Using mock model: {self.model}") + elif is_minimax: + # MiniMax uses an OpenAI-compatible API + if not self.api_key: + self.api_key = os.environ.get("MINIMAX_API_KEY", "") or get_apikey_value( + "minimax-key" + ) + if self.api_base is None: + self.api_base = MINIMAX_API_BASE + # Strip the minimax/ prefix so litellm sends just the model name + if self.model.startswith("minimax/"): + self.model = self.model[len("minimax/"):] + # Route through litellm's OpenAI-compatible path + self.custom_llm_provider = "openai" + # Clamp temperature: MiniMax requires (0.0, 1.0] + if self.temperature <= 0.0: + self.temperature = 0.01 + elif self.temperature > 1.0: + self.temperature = 1.0 elif not self.api_key and not self.custom_llm_provider: self.api_key = get_apikey_value("openai") elif self.custom_llm_provider == "anthropic": diff --git a/tests/integration/test_clients/test_minimax_integration.py b/tests/integration/test_clients/test_minimax_integration.py new file mode 100644 index 000000000..c609f2593 --- /dev/null +++ b/tests/integration/test_clients/test_minimax_integration.py @@ -0,0 +1,44 @@ +"""Integration tests for MiniMax provider support. + +These tests require a valid MINIMAX_API_KEY environment variable. +They are skipped if the key is not set. +""" + +import os +import unittest + +import pytest + +from ontogpt.clients.llm_client import LLMClient + +MINIMAX_API_KEY = os.environ.get("MINIMAX_API_KEY", "") + + +@pytest.mark.skipif(not MINIMAX_API_KEY, reason="MINIMAX_API_KEY not set") +class TestMiniMaxIntegration(unittest.TestCase): + """Integration tests for MiniMax LLM provider.""" + + def test_minimax_completion_with_prefix(self): + """Test basic completion using minimax/ model prefix.""" + client = LLMClient(model="minimax/MiniMax-M2.7", temperature=0.7) + result = client.complete("Respond with exactly one word: hello") + self.assertIsInstance(result, str) + self.assertGreater(len(result.strip()), 0) + + def test_minimax_completion_with_provider(self): + """Test basic completion using --model-provider minimax.""" + client = LLMClient( + model="MiniMax-M2.7", + custom_llm_provider="minimax", + temperature=0.5, + ) + result = client.complete("What is 2 + 2? Answer with just the number.") + self.assertIsInstance(result, str) + self.assertIn("4", result) + + def test_minimax_highspeed_model(self): + """Test completion with the highspeed variant.""" + client = LLMClient(model="minimax/MiniMax-M2.7-highspeed", temperature=0.7) + result = client.complete("Say the word 'test'.") + self.assertIsInstance(result, str) + self.assertGreater(len(result.strip()), 0) diff --git a/tests/unit/test_clients/test_minimax_provider.py b/tests/unit/test_clients/test_minimax_provider.py new file mode 100644 index 000000000..b32200878 --- /dev/null +++ b/tests/unit/test_clients/test_minimax_provider.py @@ -0,0 +1,270 @@ +"""Tests for MiniMax provider support in LLMClient.""" + +import os +import unittest +import unittest.mock as mock +from types import SimpleNamespace + +import litellm +import pytest + +import ontogpt.clients.llm_client as llm_mod +from ontogpt.clients.llm_client import MINIMAX_API_BASE + + +class FakeCache: + def __init__(self, disk_cache_dir=None, *args, **kwargs): + self.disk_cache_dir = disk_cache_dir + + +@pytest.fixture(autouse=True) +def _restore_litellm_cache(): + original_cache = getattr(litellm, "cache", None) + yield + litellm.cache = original_cache + + +@pytest.fixture +def mock_apikey(): + """Mock get_apikey_value to avoid file system reads.""" + with mock.patch.object(llm_mod, "get_apikey_value", return_value="test-key") as m: + yield m + + +@pytest.fixture +def patch_cache(): + """Patch Cache to avoid disk I/O.""" + with mock.patch.object(llm_mod, "Cache", FakeCache): + yield + + +class TestMiniMaxProviderInit: + """Test MiniMax provider detection and configuration in LLMClient.""" + + def test_minimax_prefix_sets_api_base(self, mock_apikey, patch_cache, monkeypatch): + """minimax/ prefix should auto-configure api_base.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + assert client.api_base == MINIMAX_API_BASE + + def test_minimax_prefix_strips_prefix(self, mock_apikey, patch_cache, monkeypatch): + """minimax/ prefix should be stripped from model name.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + assert client.model == "MiniMax-M2.7" + + def test_minimax_prefix_sets_openai_provider(self, mock_apikey, patch_cache, monkeypatch): + """minimax/ prefix should set custom_llm_provider to openai.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + assert client.custom_llm_provider == "openai" + + def test_minimax_provider_option_sets_api_base(self, mock_apikey, patch_cache, monkeypatch): + """--model-provider minimax should auto-configure api_base.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient( + model="MiniMax-M2.7", custom_llm_provider="minimax" + ) + assert client.api_base == MINIMAX_API_BASE + + def test_minimax_provider_option_sets_openai_provider( + self, mock_apikey, patch_cache, monkeypatch + ): + """--model-provider minimax should be rewritten to openai for litellm.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient( + model="MiniMax-M2.7", custom_llm_provider="minimax" + ) + assert client.custom_llm_provider == "openai" + + def test_minimax_api_key_from_env(self, mock_apikey, patch_cache, monkeypatch): + """MINIMAX_API_KEY env var should be used.""" + monkeypatch.setenv("MINIMAX_API_KEY", "env-minimax-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + assert client.api_key == "env-minimax-key" + + def test_minimax_api_key_fallback_to_oaklib(self, patch_cache, monkeypatch): + """Should fall back to oaklib minimax-key if MINIMAX_API_KEY is not set.""" + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) + with mock.patch.object( + llm_mod, "get_apikey_value", return_value="oaklib-mm-key" + ): + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + assert client.api_key == "oaklib-mm-key" + + def test_minimax_explicit_api_key_preserved(self, mock_apikey, patch_cache, monkeypatch): + """Explicitly provided api_key should not be overwritten.""" + monkeypatch.setenv("MINIMAX_API_KEY", "env-key") + client = llm_mod.LLMClient( + model="minimax/MiniMax-M2.7", api_key="explicit-key" + ) + assert client.api_key == "explicit-key" + + def test_minimax_custom_api_base_preserved(self, mock_apikey, patch_cache, monkeypatch): + """Explicitly provided api_base should not be overwritten.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient( + model="minimax/MiniMax-M2.7", api_base="https://custom.api.example.com/v1" + ) + assert client.api_base == "https://custom.api.example.com/v1" + + def test_minimax_highspeed_model(self, mock_apikey, patch_cache, monkeypatch): + """MiniMax-M2.7-highspeed should also be recognized.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7-highspeed") + assert client.model == "MiniMax-M2.7-highspeed" + assert client.api_base == MINIMAX_API_BASE + assert client.custom_llm_provider == "openai" + + +class TestMiniMaxTemperatureClamping: + """Test MiniMax temperature clamping (must be in (0.0, 1.0]).""" + + def test_temperature_zero_clamped(self, mock_apikey, patch_cache, monkeypatch): + """Temperature 0.0 should be clamped to 0.01.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=0.0) + assert client.temperature == pytest.approx(0.01) + + def test_temperature_negative_clamped(self, mock_apikey, patch_cache, monkeypatch): + """Negative temperature should be clamped to 0.01.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=-0.5) + assert client.temperature == pytest.approx(0.01) + + def test_temperature_above_one_clamped(self, mock_apikey, patch_cache, monkeypatch): + """Temperature > 1.0 should be clamped to 1.0.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=1.5) + assert client.temperature == pytest.approx(1.0) + + def test_temperature_valid_preserved(self, mock_apikey, patch_cache, monkeypatch): + """Valid temperature (0.0 < t <= 1.0) should be preserved.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=0.7) + assert client.temperature == pytest.approx(0.7) + + def test_temperature_one_preserved(self, mock_apikey, patch_cache, monkeypatch): + """Temperature 1.0 is valid for MiniMax.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=1.0) + assert client.temperature == pytest.approx(1.0) + + def test_non_minimax_temperature_not_clamped(self, mock_apikey, patch_cache): + """Non-MiniMax models should not have temperature clamped.""" + client = llm_mod.LLMClient(model="fake/model", temperature=1.5) + assert client.temperature == pytest.approx(1.5) + + +class TestMiniMaxCompletion: + """Test MiniMax completion calls pass correct parameters to litellm.""" + + def test_complete_passes_openai_provider(self, mock_apikey, patch_cache, monkeypatch): + """Completion call should use openai as custom_llm_provider.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="test response"))] + ) + mock_completion = mock.MagicMock(return_value=response) + monkeypatch.setattr(llm_mod, "completion", mock_completion) + + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7") + result = client.complete("Hello") + + call_kwargs = mock_completion.call_args.kwargs + assert call_kwargs["custom_llm_provider"] == "openai" + assert call_kwargs["api_base"] == MINIMAX_API_BASE + assert call_kwargs["model"] == "MiniMax-M2.7" + assert call_kwargs["api_key"] == "mm-test-key" + assert result == "test response" + + def test_complete_with_provider_option(self, mock_apikey, patch_cache, monkeypatch): + """Completion via --model-provider minimax should also work.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + mock_completion = mock.MagicMock(return_value=response) + monkeypatch.setattr(llm_mod, "completion", mock_completion) + + client = llm_mod.LLMClient( + model="MiniMax-M2.7", custom_llm_provider="minimax" + ) + client.complete("test prompt") + + call_kwargs = mock_completion.call_args.kwargs + assert call_kwargs["custom_llm_provider"] == "openai" + assert call_kwargs["api_base"] == MINIMAX_API_BASE + + def test_complete_temperature_clamped_in_request( + self, mock_apikey, patch_cache, monkeypatch + ): + """Temperature in the completion request should reflect clamping.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + mock_completion = mock.MagicMock(return_value=response) + monkeypatch.setattr(llm_mod, "completion", mock_completion) + + client = llm_mod.LLMClient(model="minimax/MiniMax-M2.7", temperature=0.0) + client.complete("test") + + call_kwargs = mock_completion.call_args.kwargs + assert call_kwargs["temperature"] == pytest.approx(0.01) + + def test_complete_with_system_message(self, mock_apikey, patch_cache, monkeypatch): + """System message should be included in MiniMax requests.""" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-test-key") + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok"))] + ) + mock_completion = mock.MagicMock(return_value=response) + monkeypatch.setattr(llm_mod, "completion", mock_completion) + + client = llm_mod.LLMClient( + model="minimax/MiniMax-M2.7", system_message="You are a biomedical expert." + ) + client.complete("Extract entities from this text.") + + call_kwargs = mock_completion.call_args.kwargs + messages = call_kwargs["messages"] + assert len(messages) == 2 + assert messages[0]["role"] == "system" + assert messages[0]["content"] == "You are a biomedical expert." + + +class TestMiniMaxModelsRegistry: + """Test that MiniMax models appear in the model registry.""" + + def test_minimax_m27_in_models(self): + """MiniMax-M2.7 should be in the MODELS dict.""" + from ontogpt import MODELS + + assert "minimax/MiniMax-M2.7" in MODELS + + def test_minimax_m27_highspeed_in_models(self): + """MiniMax-M2.7-highspeed should be in the MODELS dict.""" + from ontogpt import MODELS + + assert "minimax/MiniMax-M2.7-highspeed" in MODELS + + def test_minimax_models_are_chat(self): + """MiniMax models should be listed as chat mode.""" + from ontogpt import MODELS + + assert MODELS["minimax/MiniMax-M2.7"]["mode"] == "chat" + assert MODELS["minimax/MiniMax-M2.7-highspeed"]["mode"] == "chat" + + def test_minimax_models_have_correct_context(self): + """MiniMax models should have 204K context.""" + from ontogpt import MODELS + + assert MODELS["minimax/MiniMax-M2.7"]["max_tokens"] == 204800 + assert MODELS["minimax/MiniMax-M2.7-highspeed"]["max_tokens"] == 204800 + + def test_minimax_in_services(self): + """minimax should appear in the SERVICES set.""" + from ontogpt.clients.llm_client import SERVICES + + assert "minimax" in SERVICES