diff --git a/README.md b/README.md index 386c41b10..163b797f0 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,17 @@ For each of these graphs there is the multi version. It allows to make calls of It is possible to use different LLM through APIs, such as **OpenAI**, **Groq**, **Azure**, **Gemini**, **[MiniMax](docs/minimax.md)** and more, or local models using **Ollama**. +For MiniMax, use `{"llm": {"model_provider": "minimax", "api_key": "YOUR_MINIMAX_API_KEY"}}` +with `SmartScraperGraph` to select `MiniMax-M3.1-Flash` by default. The current catalog +also includes `MiniMax-M3`; explicit saved model IDs remain supported. Both current +models have a 1,000,000-token context window. The adapter uses Chat Completions and +preserves reasoning and tool history; pass `reasoning_effort` (`low`, `medium`, +`high`, `xhigh`, or `max`) to tune Flash thinking. Omit it for the API default of +`max`. Flash does not accept disabled thinking, `reasoning_effort="none"`, +`reasoning_split=false`, or `service_tier`. See the +[MiniMax API reference](https://platform.minimax.io/docs/api-reference/text-openai-api). +Explicit `base_url` / `openai_api_base` and model IDs are passed through unchanged. + Remember to have [Ollama](https://ollama.com/) installed and download the models using the **ollama pull** command, if you want to use local models. diff --git a/scrapegraphai/graphs/abstract_graph.py b/scrapegraphai/graphs/abstract_graph.py index 21f90460c..84db142a1 100644 --- a/scrapegraphai/graphs/abstract_graph.py +++ b/scrapegraphai/graphs/abstract_graph.py @@ -13,6 +13,7 @@ from pydantic import BaseModel from ..helpers import models_tokens +from ..helpers.models_tokens import MINIMAX_LEGACY_MODEL_TOKENS from ..models import XAI, CLoD, DeepSeek, MiniMax, Nvidia, OneApi from ..utils.logging import get_logger, set_verbosity_info, set_verbosity_warning @@ -177,6 +178,9 @@ def _create_llm(self, llm_config: dict) -> object: "minimax", } + if llm_params.get("model_provider") == "minimax" and "model" not in llm_params: + llm_params["model"] = next(iter(models_tokens["minimax"])) + if "/" in llm_params["model"]: split_model_provider = llm_params["model"].split("/", 1) llm_params["model_provider"] = split_model_provider[0] @@ -187,6 +191,8 @@ def _create_llm(self, llm_config: dict) -> object: for provider, models_d in models_tokens.items() if llm_params["model"] in models_d ] + if llm_params["model"] in MINIMAX_LEGACY_MODEL_TOKENS: + possible_providers.append("minimax") if len(possible_providers) <= 0: raise ValueError( f"""Provider {llm_params["model_provider"]} is not supported. @@ -209,9 +215,10 @@ def _create_llm(self, llm_config: dict) -> object: if llm_params.get("model_tokens", None) is None: try: - self.model_token = models_tokens[llm_params["model_provider"]][ - llm_params["model"] - ] + token_limits = models_tokens[llm_params["model_provider"]] + if llm_params["model_provider"] == "minimax": + token_limits = {**MINIMAX_LEGACY_MODEL_TOKENS, **token_limits} + self.model_token = token_limits[llm_params["model"]] except KeyError: logger.warning( "Max input tokens for model %s/%s not found, " diff --git a/scrapegraphai/helpers/models_tokens.py b/scrapegraphai/helpers/models_tokens.py index 5a81ecaa7..dffc4b1e0 100644 --- a/scrapegraphai/helpers/models_tokens.py +++ b/scrapegraphai/helpers/models_tokens.py @@ -425,8 +425,13 @@ "grok-beta": 128000, }, "minimax": { + "MiniMax-M3.1-Flash": 1000000, "MiniMax-M3": 1000000, - "MiniMax-M2.7": 204800, - "MiniMax-M2.7-highspeed": 204800, }, } + +# Saved model IDs remain routable without appearing in the current catalog. +MINIMAX_LEGACY_MODEL_TOKENS = { + "MiniMax-M2.7": 204800, + "MiniMax-M2.7-highspeed": 204800, +} diff --git a/scrapegraphai/models/minimax.py b/scrapegraphai/models/minimax.py index 420cc4203..c733a7166 100644 --- a/scrapegraphai/models/minimax.py +++ b/scrapegraphai/models/minimax.py @@ -4,7 +4,6 @@ from langchain_openai import ChatOpenAI - DEFAULT_MINIMAX_OPENAI_BASE_URL = "https://api.minimax.io/v1" @@ -19,9 +18,48 @@ class MiniMax(ChatOpenAI): """ def __init__(self, **llm_config): + if "model" not in llm_config and "model_name" not in llm_config: + llm_config["model"] = "MiniMax-M3.1-Flash" if "api_key" in llm_config: llm_config["openai_api_key"] = llm_config.pop("api_key") if "base_url" not in llm_config and "openai_api_base" not in llm_config: llm_config["openai_api_base"] = DEFAULT_MINIMAX_OPENAI_BASE_URL super().__init__(**llm_config) + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + # ChatOpenAI only serializes standard message fields. MiniMax requires + # its separate reasoning field to be replayed alongside tool calls. + if "messages" in payload: + messages = self._convert_input(input_).to_messages() + for message, serialized in zip(messages, payload["messages"]): + if serialized["role"] == "assistant": + reasoning = message.additional_kwargs.get("reasoning_content") + if reasoning is not None: + serialized["reasoning_content"] = reasoning + return payload + + def _create_chat_result(self, response, generation_info=None): + result = super()._create_chat_result(response, generation_info) + response_dict = ( + response if isinstance(response, dict) else response.model_dump() + ) + for generation, choice in zip(result.generations, response_dict["choices"]): + reasoning = choice["message"].get("reasoning_content") + if reasoning is not None: + generation.message.additional_kwargs["reasoning_content"] = reasoning + return result + + def _convert_chunk_to_generation_chunk( + self, chunk, default_chunk_class, base_generation_info + ): + generation = super()._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, base_generation_info + ) + choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices", []) + if generation is not None and choices: + reasoning = (choices[0].get("delta") or {}).get("reasoning_content") + if reasoning is not None: + generation.message.additional_kwargs["reasoning_content"] = reasoning + return generation diff --git a/scrapegraphai/utils/model_costs.py b/scrapegraphai/utils/model_costs.py index 861d9536b..f9b7ad34a 100644 --- a/scrapegraphai/utils/model_costs.py +++ b/scrapegraphai/utils/model_costs.py @@ -50,6 +50,7 @@ "amazon.titan-text-express-v1": 0.0002, "amazon.titan-text-lite-v1": 0.00015, "amazon.titan-text-premier-v1:0": 0.0005, + "MiniMax-M3.1-Flash": 0.00015, "MiniMax-M2.7": 0.0003, } @@ -103,11 +104,13 @@ "amazon.titan-text-express-v1": 0.0006, "amazon.titan-text-lite-v1": 0.0002, "amazon.titan-text-premier-v1:0": 0.0015, + "MiniMax-M3.1-Flash": 0.0006, "MiniMax-M2.7": 0.0012, } MODEL_CACHE_COST_PER_1K_TOKENS = { + "MiniMax-M3.1-Flash": {"read": 0.000015, "write": None}, "MiniMax-M2.7": {"read": 0.00006, "write": 0.000375}, } diff --git a/tests/test_minimax_models.py b/tests/test_minimax_models.py index 637847eb8..755513659 100644 --- a/tests/test_minimax_models.py +++ b/tests/test_minimax_models.py @@ -1,6 +1,7 @@ """Tests for MiniMax model configuration.""" import importlib.util +import json import os import sys import types @@ -92,17 +93,19 @@ def test_minimax_m3_in_model_list(models_tokens): assert "MiniMax-M3" in minimax_models -def test_minimax_m3_listed_first(models_tokens): - """MiniMax-M3 should be the first (default) model in the minimax dict.""" - minimax_models = list(models_tokens["minimax"].keys()) - assert minimax_models[0] == "MiniMax-M3" +def test_minimax_flash_listed_first(models_tokens): + """Only current models are listed, with Flash first.""" + assert list(models_tokens["minimax"]) == ["MiniMax-M3.1-Flash", "MiniMax-M3"] -def test_minimax_m27_still_available(models_tokens): - """MiniMax-M2.7 and its highspeed variant should remain as legacy options.""" - minimax_models = models_tokens["minimax"] - assert "MiniMax-M2.7" in minimax_models - assert "MiniMax-M2.7-highspeed" in minimax_models +def test_minimax_m27_still_available(): + """Saved legacy models retain token metadata outside the current catalog.""" + from scrapegraphai.helpers.models_tokens import MINIMAX_LEGACY_MODEL_TOKENS + + assert MINIMAX_LEGACY_MODEL_TOKENS == { + "MiniMax-M2.7": 204800, + "MiniMax-M2.7-highspeed": 204800, + } def test_minimax_deprecated_models_removed(models_tokens): @@ -119,8 +122,7 @@ def test_minimax_token_limits(models_tokens): """MiniMax model token limits should match upstream documentation.""" minimax_models = models_tokens["minimax"] assert minimax_models["MiniMax-M3"] == 1000000 - assert minimax_models["MiniMax-M2.7"] == 204800 - assert minimax_models["MiniMax-M2.7-highspeed"] == 204800 + assert minimax_models["MiniMax-M3.1-Flash"] == 1000000 def test_minimax_m27_costs(model_costs): @@ -272,3 +274,260 @@ def test_minimax_defaults_to_global_openai_endpoint(minimax_module): """The adapter should keep the global OpenAI-compatible endpoint as default.""" model = minimax_module.MiniMax(model="MiniMax-M3", api_key="test-key") assert str(model.openai_api_base).rstrip("/") == "https://api.minimax.io/v1" + + +def test_flash_costs(model_costs, custom_callback_module): + assert model_costs.MODEL_COST_PER_1K_TOKENS_INPUT["MiniMax-M3.1-Flash"] == 0.00015 + assert model_costs.MODEL_COST_PER_1K_TOKENS_OUTPUT["MiniMax-M3.1-Flash"] == 0.0006 + assert model_costs.MODEL_CACHE_COST_PER_1K_TOKENS["MiniMax-M3.1-Flash"] == { + "read": 0.000015, + "write": None, + } + assert ( + custom_callback_module.get_token_cost_for_model("MiniMax-M3.1-Flash", 1000) + == 0.00015 + ) + + +@pytest.fixture +def captured_transport(): + requests = [] + tool_call = { + "id": "call_lookup", + "type": "function", + "function": {"name": "lookup", "arguments": '{"query":"page"}'}, + } + + def handle(request): + body = json.loads(request.content) + requests.append((str(request.url), body)) + message = { + "role": "assistant", + "content": "Keep this.", + "reasoning_content": "Inspect the page.", + "tool_calls": [tool_call], + } + response = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": body["model"], + "choices": [ + {"index": 0, "message": message, "finish_reason": "tool_calls"} + ], + } + if body.get("stream"): + chunks = [] + for delta in ( + {"role": "assistant", "reasoning_content": "Inspect "}, + {"reasoning_content": "the page."}, + { + "content": message["content"], + "tool_calls": [{"index": 0, **tool_call}], + }, + ): + chunks.append( + "data: " + + json.dumps( + { + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "created": 0, + "model": body["model"], + "choices": [ + {"index": 0, "delta": delta, "finish_reason": None} + ], + } + ) + + "\n\n" + ) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text="".join(chunks) + "data: [DONE]\n\n", + ) + return httpx.Response(200, json=response) + + return requests, httpx.MockTransport(handle) + + +@pytest.mark.parametrize("base_key", ["base_url", "openai_api_base"]) +@pytest.mark.parametrize( + "base_url", ["https://api.minimax.io/v1", "https://api.minimaxi.com/v1"] +) +@pytest.mark.parametrize( + "config, expected, tokens", + [ + ({"model_provider": "minimax"}, "MiniMax-M3.1-Flash", 1000000), + ({"model": "minimax/MiniMax-M3.1-Flash"}, "MiniMax-M3.1-Flash", 1000000), + ({"model": "MiniMax-M3"}, "MiniMax-M3", 1000000), + ({"model": "MiniMax-M2.7"}, "MiniMax-M2.7", 204800), + ({"model": "minimax/MiniMax-M2.7-highspeed"}, "MiniMax-M2.7-highspeed", 204800), + ( + {"model": "minimax/custom-model", "model_tokens": 12345}, + "custom-model", + 12345, + ), + ], +) +def test_graph_request_models( + captured_transport, base_key, base_url, config, expected, tokens +): + from scrapegraphai.graphs.abstract_graph import AbstractGraph + + requests, transport = captured_transport + graph = types.SimpleNamespace() + original = dict(config) + with httpx.Client(transport=transport) as client: + model = AbstractGraph._create_llm( + graph, + { + **config, + "api_key": "test-key", + base_key: base_url, + "http_client": client, + }, + ) + model.invoke("Test") + assert config == original + assert graph.model_token == tokens + url, body = requests[0] + assert url == base_url + "/chat/completions" + assert body["model"] == expected + assert "service_tier" not in body + assert "model_tokens" not in body + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"]) +def test_flash_tool_history_and_images( + minimax_module, captured_transport, streaming, effort +): + from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage + + requests, transport = captured_transport + image = { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,dGVzdA=="}, + } + video = {"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}} + history = [ + SystemMessage("Extract the page."), + HumanMessage( + [ + {"type": "text", "text": "Read this."}, + image, + video, + ] + ), + ] + tool = { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a page.", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + }, + }, + } + with httpx.Client(transport=transport) as client: + model = minimax_module.MiniMax( + api_key="test-key", + http_client=client, + streaming=streaming, + reasoning_effort=effort, + max_tokens=524288, + extra_body={"thinking": {"type": "adaptive"}}, + ).bind_tools([tool]) + answer = model.invoke(history) + assert answer.additional_kwargs["reasoning_content"] == "Inspect the page." + model.invoke( + history + [answer, ToolMessage("Result", tool_call_id="call_lookup")] + ) + body = requests[1][1] + assert body["model"] == "MiniMax-M3.1-Flash" + assert body["reasoning_effort"] == effort + assert body["max_completion_tokens"] == 524288 + assert body["thinking"] == {"type": "adaptive"} + assert "service_tier" not in body + assert body["tools"] == [tool] + assert body["messages"][0] == {"role": "system", "content": "Extract the page."} + assert body["messages"][1]["content"][1:] == [image, video] + assert body["messages"][2]["reasoning_content"] == "Inspect the page." + assert body["messages"][2]["content"] == "Keep this." + assert body["messages"][2]["tool_calls"][0]["id"] == "call_lookup" + assert body["messages"][3] == { + "role": "tool", + "content": "Result", + "tool_call_id": "call_lookup", + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("streaming", [False, True]) +async def test_async_m3_history_preserves_disabled_thinking( + minimax_module, captured_transport, streaming +): + from langchain_core.messages import ToolMessage + + requests, transport = captured_transport + async with httpx.AsyncClient(transport=transport) as client: + model = minimax_module.MiniMax( + model="MiniMax-M3", + api_key="test-key", + http_async_client=client, + streaming=streaming, + extra_body={"thinking": {"type": "disabled"}}, + ) + answer = await model.ainvoke("Test") + await model.ainvoke( + [ + ("user", "Test"), + answer, + ToolMessage("Result", tool_call_id="call_lookup"), + ] + ) + assert requests[1][1]["model"] == "MiniMax-M3" + assert requests[1][1]["thinking"] == {"type": "disabled"} + assert requests[1][1]["messages"][1]["reasoning_content"] == "Inspect the page." + + +def test_flash_usage_callback(captured_transport): + from scrapegraphai.models.minimax import MiniMax + from scrapegraphai.utils.llm_callback_manager import CustomLLMCallbackManager + + _, transport = captured_transport + with httpx.Client(transport=transport) as client: + model = MiniMax(api_key="test-key", http_client=client) + with CustomLLMCallbackManager().exclusive_get_callback( + model, model.model_name + ) as callback: + callback.on_llm_end( + types.SimpleNamespace( + generations=[[]], + llm_output={ + "token_usage": { + "prompt_tokens": 1000, + "completion_tokens": 1000, + "total_tokens": 2000, + }, + }, + ) + ) + assert callback.total_cost == pytest.approx(0.00075) + + +def test_explicit_model_alias_and_custom_endpoint(minimax_module, captured_transport): + requests, transport = captured_transport + with httpx.Client(transport=transport) as client: + model = minimax_module.MiniMax( + model_name="saved-model", + api_key="test-key", + base_url="https://custom.example/v1", + http_client=client, + ) + model.invoke("Test") + assert requests[0][0] == "https://custom.example/v1/chat/completions" + assert requests[0][1]["model"] == "saved-model"