From b683c69a97b0b497bcefaad6a6fbb2061ad876e5 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Tue, 24 Mar 2026 16:43:02 +0800 Subject: [PATCH] Add MiniMax as first-class LLM provider Add support for MiniMax M2.7 and M2.7-highspeed models via the convenient minimax/ model prefix. The integration auto-detects MiniMax models, rewrites the model string for litellm's OpenAI- compatible provider, and configures the API base URL and key automatically from environment variables. Changes: - llm.py: Add api_base/api_key constructor params, MiniMax auto- detection, temperature clamping [0,1], extra params in all request paths - utils.py: Add is_minimax_model(), resolve_minimax_model() helpers and MINIMAX_API_BASE/MINIMAX_MODELS constants - global_models.py: Add get_minimax_model() and get_minimax_highspeed_model() helpers - README.md: Add MiniMax to supported providers list with usage example - tests/test_minimax.py: 29 unit tests + 3 integration tests --- README.md | 24 +++ extract_thinker/global_models.py | 12 +- extract_thinker/llm.py | 62 ++++++-- extract_thinker/utils.py | 40 +++++ tests/test_minimax.py | 259 +++++++++++++++++++++++++++++++ 5 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 tests/test_minimax.py diff --git a/README.md b/README.md index dfb3bbd..b58d8c7 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,29 @@ print("Invoice Number:", result.invoice_number) print("Invoice Date:", result.invoice_date) ``` +### MiniMax Integration Example + +ExtractThinker supports [MiniMax](https://www.minimax.io/) models via the `minimax/` prefix. Set your API key and use MiniMax models for document extraction: + +```python +import os +from extract_thinker import Extractor, DocumentLoaderPyPdf, Contract + +os.environ["MINIMAX_API_KEY"] = "your-minimax-api-key" + +class InvoiceContract(Contract): + invoice_number: str + invoice_date: str + +extractor = Extractor() +extractor.load_document_loader(DocumentLoaderPyPdf()) +extractor.load_llm("minimax/MiniMax-M2.7") # or "minimax/MiniMax-M2.7-highspeed" + +result = extractor.extract("invoice.pdf", InvoiceContract) +print("Invoice Number:", result.invoice_number) +print("Invoice Date:", result.invoice_date) +``` + ## 📚 Documentation and Resources - **Examples**: Check out the examples directory for Jupyter notebooks and scripts demonstrating various use cases. @@ -271,6 +294,7 @@ ExtractThinker supports integration with multiple LLM providers: - **Anthropic**: Integrate with Claude models. - **Cohere**: Utilize Cohere's language models. - **Azure OpenAI**: Connect with Azure's OpenAI services. +- **MiniMax**: Use MiniMax M2.7 / M2.5 models with up to 1M context. - **Local Models**: Ollama compatible models. ## ⚙️ How It Works diff --git a/extract_thinker/global_models.py b/extract_thinker/global_models.py index a5d1219..74a8990 100644 --- a/extract_thinker/global_models.py +++ b/extract_thinker/global_models.py @@ -18,4 +18,14 @@ def get_gpt_mini_model(): def get_gpt_o4_model(): """Return the GPT-4o model.""" - return "gpt-4o" \ No newline at end of file + return "gpt-4o" + + +def get_minimax_model(): + """Return the MiniMax M2.7 model (1M context).""" + return "minimax/MiniMax-M2.7" + + +def get_minimax_highspeed_model(): + """Return the MiniMax M2.7-highspeed model (1M context, faster).""" + return "minimax/MiniMax-M2.7-highspeed" \ No newline at end of file diff --git a/extract_thinker/llm.py b/extract_thinker/llm.py index 119f01e..337bc3e 100644 --- a/extract_thinker/llm.py +++ b/extract_thinker/llm.py @@ -1,10 +1,17 @@ import asyncio +import os from typing import List, Dict, Any, Optional import instructor import litellm from litellm import Router from extract_thinker.llm_engine import LLMEngine -from extract_thinker.utils import add_classification_structure, extract_thinking_json +from extract_thinker.utils import ( + add_classification_structure, + extract_thinking_json, + is_minimax_model, + resolve_minimax_model, + MINIMAX_API_BASE, +) # Helper to build the dynamic prompt used when `is_dynamic=True`. # We expose it as a standalone function so that callers (or subclasses) @@ -56,16 +63,30 @@ def __init__( self, model: str, token_limit: int = None, - backend: LLMEngine = LLMEngine.DEFAULT + backend: LLMEngine = LLMEngine.DEFAULT, + api_base: Optional[str] = None, + api_key: Optional[str] = None, ): """Initialize LLM with specified backend. - + Args: - model: The model name (e.g. "gpt-4", "claude-3") + model: The model name (e.g. "gpt-4", "claude-3", "minimax/MiniMax-M2.7") token_limit: Optional maximum tokens backend: LLMBackend enum (default: LITELLM) + api_base: Optional custom API base URL (auto-detected for MiniMax) + api_key: Optional API key (auto-detected from env for MiniMax) """ - self.model = model + # Auto-detect MiniMax models and configure accordingly + self._is_minimax = is_minimax_model(model) + if self._is_minimax: + self.model = resolve_minimax_model(model) + self.api_base = api_base or MINIMAX_API_BASE + self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") + else: + self.model = model + self.api_base = api_base + self.api_key = api_key + self.token_limit = token_limit self.router = None self.is_dynamic = False @@ -124,9 +145,25 @@ def load_router(self, router: Router) -> None: raise ValueError("Router is only supported with LITELLM backend") self.router = router + def _get_extra_params(self) -> Dict[str, Any]: + """Return provider-specific extra parameters (api_base, api_key).""" + params: Dict[str, Any] = {} + if self.api_base: + params["api_base"] = self.api_base + if self.api_key: + params["api_key"] = self.api_key + return params + + def _effective_temperature(self) -> float: + """Return the temperature value, clamped for MiniMax models.""" + temp = self.temperature + if self._is_minimax: + temp = max(0.0, min(temp, 1.0)) + return temp + def set_temperature(self, temperature: float) -> None: """Set the temperature for LLM requests. - + Args: temperature (float): Temperature value between 0 and 1 """ @@ -242,14 +279,15 @@ def _request_with_router(self, messages: List[Dict[str, str]], response_model: O max_tokens = min(self.token_limit, max_tokens) elif self.is_thinking: max_tokens = min(self.thinking_token_limit, max_tokens) if self.thinking_token_limit else max_tokens - + params = { "model": self.model, "messages": messages, "response_model": response_model, - "temperature": self.temperature, + "temperature": self._effective_temperature(), "timeout": self.TIMEOUT, "max_completion_tokens": max_tokens, + **self._get_extra_params(), } if self.is_thinking: if litellm.supports_reasoning(self.model): @@ -275,13 +313,14 @@ def _request_direct(self, messages: List[Dict[str, str]], response_model: Option base_params = { "model": self.model, "messages": messages, - "temperature": self.temperature, + "temperature": self._effective_temperature(), "response_model": response_model, "max_retries": 1, "max_completion_tokens": max_tokens, "timeout": self.TIMEOUT, + **self._get_extra_params(), } - + if self.is_thinking: if litellm.supports_reasoning(self.model): # Try with thinking parameter @@ -321,6 +360,7 @@ def raw_completion(self, messages: List[Dict[str, str]]) -> str: "model": self.model, "messages": messages, "max_completion_tokens": max_tokens, + **self._get_extra_params(), } if self.is_thinking: @@ -333,7 +373,7 @@ def raw_completion(self, messages: List[Dict[str, str]]) -> str: params["thinking"] = thinking_param else: print(f"Warning: Model {self.model} doesn't support thinking parameter, proceeding without it.") - + if self.router: raw_response = self.router.completion(**params) else: diff --git a/extract_thinker/utils.py b/extract_thinker/utils.py index ec67485..f32776f 100644 --- a/extract_thinker/utils.py +++ b/extract_thinker/utils.py @@ -539,6 +539,46 @@ def extract_thinking_json(thinking_text: str, response_model: type[BaseModel]) - except Exception as e: raise ValueError(f"Failed to parse thinking output: {str(e)}\nInput text was: {thinking_text[:200]}...") +MINIMAX_API_BASE = "https://api.minimax.io/v1" +MINIMAX_MODELS = [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", + "MiniMax-M2.5-highspeed", +] + + +def is_minimax_model(model: str) -> bool: + """Check if a model string refers to a MiniMax model. + + Recognises both the ``minimax/`` convenience prefix and bare model names + that belong to the MiniMax family (e.g. ``MiniMax-M2.7``). + """ + model_lower = model.lower() + if model_lower.startswith("minimax/"): + return True + # Also match openai/MiniMax-* (user may have already rewritten) + for m in MINIMAX_MODELS: + if m.lower() in model_lower: + return True + return False + + +def resolve_minimax_model(model: str) -> str: + """Rewrite a ``minimax/...`` model string to ``openai/...`` for litellm. + + If the model already uses the ``openai/`` prefix or is bare, it is + normalised to ``openai/`` so that litellm routes the request + through its OpenAI-compatible provider. + """ + if model.lower().startswith("minimax/"): + return "openai/" + model[len("minimax/"):] + if model.lower().startswith("openai/"): + return model + # Bare model name + return "openai/" + model + + def is_vision_error(error: Exception) -> bool: if isinstance(error.args[0], litellm.BadRequestError): return True diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 0000000..eb7ce28 --- /dev/null +++ b/tests/test_minimax.py @@ -0,0 +1,259 @@ +"""Tests for MiniMax LLM provider integration. + +Unit tests validate model detection, temperature clamping, and parameter +wiring without making any network calls. Integration tests (marked with +``pytest.mark.integration``) require a valid ``MINIMAX_API_KEY`` environment +variable and perform real API calls. +""" + +import os +import sys +from unittest.mock import patch + +import pytest + +# Import utility helpers directly (no heavy transitive deps) +from extract_thinker.utils import ( + is_minimax_model, + resolve_minimax_model, + MINIMAX_API_BASE, +) +from extract_thinker.global_models import get_minimax_model, get_minimax_highspeed_model + +# LLM import may fail in some environments due to unrelated transitive deps. +# Guard it so the pure-utility tests still run. +_LLM_AVAILABLE = True +try: + from extract_thinker.llm import LLM +except Exception: + _LLM_AVAILABLE = False + +needs_llm = pytest.mark.skipif(not _LLM_AVAILABLE, reason="LLM class not importable (env dep issue)") + + +# --------------------------------------------------------------------------- +# Unit tests – no network calls +# --------------------------------------------------------------------------- + + +class TestIsMiniMaxModel: + """Test the is_minimax_model() helper.""" + + def test_minimax_prefix(self): + assert is_minimax_model("minimax/MiniMax-M2.7") is True + + def test_minimax_prefix_case_insensitive(self): + assert is_minimax_model("MiniMax/MiniMax-M2.5") is True + + def test_openai_prefix_with_minimax_model(self): + assert is_minimax_model("openai/MiniMax-M2.7") is True + + def test_bare_minimax_model_name(self): + assert is_minimax_model("MiniMax-M2.7-highspeed") is True + + def test_non_minimax_model(self): + assert is_minimax_model("gpt-4o") is False + + def test_non_minimax_provider(self): + assert is_minimax_model("anthropic/claude-3") is False + + def test_ollama_model(self): + assert is_minimax_model("ollama/phi4") is False + + +class TestResolveMiniMaxModel: + """Test the resolve_minimax_model() rewriter.""" + + def test_minimax_prefix_to_openai(self): + assert resolve_minimax_model("minimax/MiniMax-M2.7") == "openai/MiniMax-M2.7" + + def test_already_openai_prefix(self): + assert resolve_minimax_model("openai/MiniMax-M2.7") == "openai/MiniMax-M2.7" + + def test_bare_model_name(self): + assert resolve_minimax_model("MiniMax-M2.7") == "openai/MiniMax-M2.7" + + def test_highspeed_variant(self): + assert resolve_minimax_model("minimax/MiniMax-M2.7-highspeed") == "openai/MiniMax-M2.7-highspeed" + + def test_m25_variant(self): + assert resolve_minimax_model("minimax/MiniMax-M2.5") == "openai/MiniMax-M2.5" + + +class TestGlobalModels: + """Test global model helper functions.""" + + def test_get_minimax_model(self): + model = get_minimax_model() + assert model == "minimax/MiniMax-M2.7" + assert is_minimax_model(model) is True + + def test_get_minimax_highspeed_model(self): + model = get_minimax_highspeed_model() + assert model == "minimax/MiniMax-M2.7-highspeed" + assert is_minimax_model(model) is True + + +@needs_llm +class TestLLMInit: + """Test LLM constructor auto-detection for MiniMax.""" + + def test_minimax_auto_detection(self): + llm = LLM("minimax/MiniMax-M2.7") + assert llm._is_minimax is True + assert llm.model == "openai/MiniMax-M2.7" + assert llm.api_base == MINIMAX_API_BASE + + def test_minimax_api_key_from_env(self): + with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}): + llm = LLM("minimax/MiniMax-M2.7") + assert llm.api_key == "test-key-123" + + def test_minimax_explicit_api_key(self): + llm = LLM("minimax/MiniMax-M2.7", api_key="explicit-key") + assert llm.api_key == "explicit-key" + + def test_minimax_explicit_api_base(self): + llm = LLM("minimax/MiniMax-M2.7", api_base="https://custom.api/v1") + assert llm.api_base == "https://custom.api/v1" + + def test_non_minimax_no_auto_config(self): + llm = LLM("gpt-4o") + assert llm._is_minimax is False + assert llm.api_base is None + assert llm.api_key is None + assert llm.model == "gpt-4o" + + def test_non_minimax_with_explicit_api_base(self): + llm = LLM("gpt-4o", api_base="https://custom.openai/v1") + assert llm.api_base == "https://custom.openai/v1" + + def test_highspeed_model_detection(self): + llm = LLM("minimax/MiniMax-M2.7-highspeed") + assert llm._is_minimax is True + assert llm.model == "openai/MiniMax-M2.7-highspeed" + + +@needs_llm +class TestTemperatureClamping: + """Test MiniMax temperature clamping.""" + + def test_default_temperature(self): + llm = LLM("minimax/MiniMax-M2.7") + assert llm._effective_temperature() == 0 + + def test_temperature_in_range(self): + llm = LLM("minimax/MiniMax-M2.7") + llm.set_temperature(0.7) + assert llm._effective_temperature() == 0.7 + + def test_temperature_clamped_high(self): + llm = LLM("minimax/MiniMax-M2.7") + llm.set_temperature(1.5) + assert llm._effective_temperature() == 1.0 + + def test_temperature_clamped_low(self): + llm = LLM("minimax/MiniMax-M2.7") + llm.set_temperature(-0.5) + assert llm._effective_temperature() == 0.0 + + def test_non_minimax_no_clamping(self): + llm = LLM("gpt-4o") + llm.set_temperature(1.5) + assert llm._effective_temperature() == 1.5 + + +@needs_llm +class TestExtraParams: + """Test _get_extra_params() for MiniMax.""" + + def test_minimax_extra_params(self): + with patch.dict(os.environ, {"MINIMAX_API_KEY": "key-abc"}): + llm = LLM("minimax/MiniMax-M2.7") + params = llm._get_extra_params() + assert params["api_base"] == MINIMAX_API_BASE + assert params["api_key"] == "key-abc" + + def test_non_minimax_empty_params(self): + llm = LLM("gpt-4o") + params = llm._get_extra_params() + assert params == {} + + +@needs_llm +class TestLLMDynamic: + """Test MiniMax with dynamic JSON parsing mode.""" + + def test_minimax_dynamic_mode_enabled(self): + llm = LLM("minimax/MiniMax-M2.7") + llm.set_dynamic(True) + assert llm.is_dynamic is True + assert llm._is_minimax is True + + +# --------------------------------------------------------------------------- +# Integration tests – require MINIMAX_API_KEY +# --------------------------------------------------------------------------- +from pydantic import BaseModel, Field +from typing import Optional + + +class InvoiceFields(BaseModel): + """Invoice-like contract for integration tests.""" + invoice_number: str = Field(description="The invoice number") + invoice_date: str = Field(description="The invoice date") + total_amount: Optional[float] = Field(default=None, description="Total amount") + + +@pytest.fixture +def minimax_api_key(): + key = os.environ.get("MINIMAX_API_KEY") + if not key: + pytest.skip("MINIMAX_API_KEY not set") + return key + + +@pytest.mark.integration +@needs_llm +class TestMiniMaxIntegration: + """Integration tests that call the real MiniMax API.""" + + def test_raw_completion(self, minimax_api_key): + """Test raw text completion with MiniMax.""" + llm = LLM("minimax/MiniMax-M2.7") + messages = [ + {"role": "user", "content": "Reply with exactly: Hello World"} + ] + result = llm.raw_completion(messages) + assert result is not None + assert len(result) > 0 + assert "hello" in result.lower() + + def test_extraction_with_extractor(self, minimax_api_key): + """Test structured extraction via Extractor with MiniMax.""" + from extract_thinker import Extractor, DocumentLoaderPyPdf + + cwd = os.getcwd() + test_file = os.path.join(cwd, "tests", "files", "invoice.pdf") + if not os.path.exists(test_file): + pytest.skip("Test invoice.pdf not found") + + extractor = Extractor() + extractor.load_document_loader(DocumentLoaderPyPdf()) + extractor.load_llm("minimax/MiniMax-M2.7") + + result = extractor.extract(test_file, InvoiceFields) + assert result is not None + assert isinstance(result, InvoiceFields) + assert result.invoice_number is not None + assert len(result.invoice_number) > 0 + + def test_highspeed_model(self, minimax_api_key): + """Test the highspeed variant works.""" + llm = LLM("minimax/MiniMax-M2.7-highspeed") + messages = [ + {"role": "user", "content": "What is 2 + 2? Reply with just the number."} + ] + result = llm.raw_completion(messages) + assert result is not None + assert "4" in result