diff --git a/README.md b/README.md index 1b7279d..50c703a 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,15 @@ Local [ollama/llama2](https://ollama.ai/library/llama2) model example: llmgraph machine-learning "https://en.wikipedia.org/wiki/Artificial_intelligence" --levels 3 --llm-model ollama/llama2 --llm-base-url http://localhost: ``` +[MiniMax](https://www.minimaxi.com/) models (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed) are supported with the `minimax/` prefix. Set the `MINIMAX_API_KEY` environment variable and use the `minimax/` notation: + +```bash +export MINIMAX_API_KEY=your_api_key_here +llmgraph machine-learning "https://en.wikipedia.org/wiki/Artificial_intelligence" --levels 3 --llm-model minimax/MiniMax-M2.7 +``` + +llmgraph automatically routes `minimax/` models via MiniMax's OpenAI-compatible API endpoint and enforces MiniMax's temperature range `(0.0, 1.0]`. + The `entity_type` sets the LLM prompt used to find related entities to include in the graph. The full list can be seen in [prompts.yaml](https://github.com/dylanhogg/llmgraph/blob/main/llmgraph/prompts.yaml) and include the following entity types: - `automobile` diff --git a/llmgraph/library/consts.py b/llmgraph/library/consts.py index 071ef76..daeaa3e 100644 --- a/llmgraph/library/consts.py +++ b/llmgraph/library/consts.py @@ -5,3 +5,7 @@ default_llm_model = "gpt-5-mini" default_llm_temp = 1.0 # For gpt-5 models only temperature=1 is supported default_output_folder = "./_output/" + +minimax_api_base = "https://api.minimax.io/v1" +minimax_default_model = "minimax/MiniMax-M2.7" +minimax_model_prefix = "minimax/" diff --git a/llmgraph/library/llm.py b/llmgraph/library/llm.py index b4dd176..1c6e950 100644 --- a/llmgraph/library/llm.py +++ b/llmgraph/library/llm.py @@ -7,6 +7,7 @@ from omegaconf import DictConfig from tenacity import retry, retry_if_not_exception_type, stop_after_attempt, wait_exponential +from . import consts from .classes import AppUsageException memory = Memory(".joblib_cache", verbose=0) @@ -16,6 +17,27 @@ # Timeout: Request timed out: HTTPSConnectionPool(host='api.openai.com', port=443): Read timed out. (read timeout=600) +def _is_minimax_model(model: str) -> bool: + """Return True if the model name uses the 'minimax/' provider prefix.""" + return model.startswith(consts.minimax_model_prefix) + + +def _resolve_model_config(model: str, base_url, temperature: float): + """Resolve provider-specific model name, base URL, and temperature. + + For MiniMax models (``minimax/``): + - Routes via LiteLLM's OpenAI-compatible path (``openai/``). + - Sets the MiniMax base URL when none is provided. + - Clamps temperature to (0.0, 1.0] as required by the MiniMax API. + """ + if _is_minimax_model(model): + model_name = model[len(consts.minimax_model_prefix):] + resolved_base_url = base_url if base_url else consts.minimax_api_base + clamped_temp = max(0.01, min(1.0, temperature)) + return f"openai/{model_name}", resolved_base_url, clamped_temp + return model, base_url, temperature + + def log_retry(state): msg = ( f"Tenacity retry {state.fn.__name__}: {state.attempt_number=}, {state.idle_for=}, {state.seconds_since_start=}" @@ -37,12 +59,16 @@ def make_call(entity: str, system: str, prompt: str, llm_config: DictConfig) -> messages = [{"role": "system", "content": system}, {"role": "user", "content": prompt}] start = datetime.now() + model, base_url, temperature = _resolve_model_config( + llm_config.model, llm_config.base_url, llm_config.temperature + ) + try: api_response = completion( - model=llm_config.model, + model=model, messages=messages, - temperature=llm_config.temperature, - base_url=llm_config.base_url, + temperature=temperature, + base_url=base_url, ) except Exception as ex: logger.exception(f"Exception from LLM call: {ex}") diff --git a/tests/test_llm_minimax.py b/tests/test_llm_minimax.py new file mode 100644 index 0000000..24de820 --- /dev/null +++ b/tests/test_llm_minimax.py @@ -0,0 +1,159 @@ +"""Tests for MiniMax provider support in llmgraph.library.llm.""" +from unittest.mock import MagicMock, patch + +import pytest +from omegaconf import OmegaConf + +from llmgraph.library import consts +from llmgraph.library.llm import _is_minimax_model, _resolve_model_config, memory + + +# --------------------------------------------------------------------------- +# Unit tests: _is_minimax_model +# --------------------------------------------------------------------------- + + +class TestIsMiniMaxModel: + def test_minimax_m27_detected(self): + assert _is_minimax_model("minimax/MiniMax-M2.7") is True + + def test_minimax_m27_highspeed_detected(self): + assert _is_minimax_model("minimax/MiniMax-M2.7-highspeed") is True + + def test_minimax_m25_detected(self): + assert _is_minimax_model("minimax/MiniMax-M2.5") is True + + def test_minimax_m25_highspeed_detected(self): + assert _is_minimax_model("minimax/MiniMax-M2.5-highspeed") is True + + def test_openai_model_not_minimax(self): + assert _is_minimax_model("gpt-4o-mini") is False + + def test_openai_prefixed_minimax_model_not_minimax(self): + assert _is_minimax_model("openai/MiniMax-M2.7") is False + + def test_ollama_model_not_minimax(self): + assert _is_minimax_model("ollama/llama2") is False + + def test_empty_string_not_minimax(self): + assert _is_minimax_model("") is False + + def test_claude_not_minimax(self): + assert _is_minimax_model("anthropic/claude-3-opus") is False + + +# --------------------------------------------------------------------------- +# Unit tests: _resolve_model_config +# --------------------------------------------------------------------------- + + +class TestResolveModelConfig: + def test_minimax_m27_routes_to_openai_compat(self): + model, base_url, temp = _resolve_model_config("minimax/MiniMax-M2.7", None, 0.7) + assert model == "openai/MiniMax-M2.7" + + def test_minimax_sets_default_base_url(self): + _, base_url, _ = _resolve_model_config("minimax/MiniMax-M2.7", None, 0.7) + assert base_url == consts.minimax_api_base + + def test_minimax_custom_base_url_preserved(self): + _, base_url, _ = _resolve_model_config("minimax/MiniMax-M2.7", "https://custom.host/v1", 0.7) + assert base_url == "https://custom.host/v1" + + def test_minimax_temp_zero_clamped_to_min(self): + _, _, temp = _resolve_model_config("minimax/MiniMax-M2.7", None, 0.0) + assert temp == 0.01 + + def test_minimax_temp_one_preserved(self): + _, _, temp = _resolve_model_config("minimax/MiniMax-M2.7", None, 1.0) + assert temp == 1.0 + + def test_minimax_temp_above_one_clamped_to_one(self): + _, _, temp = _resolve_model_config("minimax/MiniMax-M2.7", None, 2.0) + assert temp == 1.0 + + def test_minimax_m25_highspeed_routes_correctly(self): + model, _, _ = _resolve_model_config("minimax/MiniMax-M2.5-highspeed", None, 0.5) + assert model == "openai/MiniMax-M2.5-highspeed" + + def test_non_minimax_model_unchanged(self): + model, base_url, temp = _resolve_model_config("gpt-4o-mini", None, 1.0) + assert model == "gpt-4o-mini" + assert base_url is None + assert temp == 1.0 + + def test_ollama_model_unchanged(self): + model, base_url, temp = _resolve_model_config("ollama/llama2", "http://localhost:11434", 0.8) + assert model == "ollama/llama2" + assert base_url == "http://localhost:11434" + assert temp == 0.8 + + def test_minimax_api_base_constant_format(self): + assert consts.minimax_api_base.startswith("https://") + assert "/v1" in consts.minimax_api_base + + +# --------------------------------------------------------------------------- +# Integration tests: make_call routes MiniMax correctly +# --------------------------------------------------------------------------- + + +def _make_mock_response(content="[]", total_tokens=10): + mock = MagicMock() + mock.choices = [MagicMock()] + mock.choices[0].message.content = content + mock.__getitem__ = MagicMock(side_effect=lambda key: {"total_tokens": total_tokens, "usage": {"total_tokens": total_tokens}}[key]) + mock.__class__.__getitem__ = mock.__getitem__ + return mock + + +class TestMakeCallMiniMaxIntegration: + def setup_method(self): + memory.clear(warn=False) + + def test_make_call_routes_minimax_model_to_openai_compat(self): + """make_call should translate minimax/ prefix to openai/ for LiteLLM.""" + from llmgraph.library.llm import make_call + + llm_config = OmegaConf.create( + {"model": "minimax/MiniMax-M2.7", "temperature": 0.7, "base_url": None, "delay": 0} + ) + mock_response = _make_mock_response() + mock_response["usage"] = {"total_tokens": 10} + + with patch("llmgraph.library.llm.completion", return_value=mock_response) as mock_comp: + make_call("integration_test_entity_1", "sys", "prompt", llm_config) + call_kwargs = mock_comp.call_args.kwargs + assert call_kwargs["model"] == "openai/MiniMax-M2.7" + assert call_kwargs["base_url"] == consts.minimax_api_base + + def test_make_call_clamps_zero_temperature_for_minimax(self): + """make_call should clamp temperature=0 to 0.01 for MiniMax.""" + from llmgraph.library.llm import make_call + + llm_config = OmegaConf.create( + {"model": "minimax/MiniMax-M2.7", "temperature": 0.0, "base_url": None, "delay": 0} + ) + mock_response = _make_mock_response() + mock_response["usage"] = {"total_tokens": 10} + + with patch("llmgraph.library.llm.completion", return_value=mock_response) as mock_comp: + make_call("integration_test_entity_2", "sys", "prompt", llm_config) + call_kwargs = mock_comp.call_args.kwargs + assert call_kwargs["temperature"] == 0.01 + + def test_make_call_non_minimax_model_passes_through(self): + """Non-MiniMax models should pass model name and base_url unchanged.""" + from llmgraph.library.llm import make_call + + llm_config = OmegaConf.create( + {"model": "gpt-4o-mini", "temperature": 1.0, "base_url": None, "delay": 0} + ) + mock_response = _make_mock_response() + mock_response["usage"] = {"total_tokens": 10} + + with patch("llmgraph.library.llm.completion", return_value=mock_response) as mock_comp: + make_call("integration_test_entity_3", "sys", "prompt", llm_config) + call_kwargs = mock_comp.call_args.kwargs + assert call_kwargs["model"] == "gpt-4o-mini" + assert call_kwargs["base_url"] is None