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"