feat: add MiniMax as LLM provider

Add MiniMax M2.7 as an alternative LLM provider for the Planner & Coder
agents via OpenAI-compatible API.

Changes:
- Add request_minimax() and request_minimax_token() functions in
  gpt_request.py using OpenAI SDK with MiniMax base URL
- Add minimax config entry in api_config.json
- Register minimax in agent.py API mapping and argparse choices
- Mention MiniMax in README.md LLM API section
- Add 16 unit tests and 3 integration tests
This commit is contained in:
Octopus 2026-03-24 02:26:50 -05:00
parent f579f1e527
commit 412289a996
6 changed files with 493 additions and 2 deletions

View file

@ -224,9 +224,10 @@ Here is the [official installation guide](https://docs.manim.community/en/stable
Fill in your **API credentials** in `api_config.json`.
* **LLM API**:
* **LLM API**:
* Required for Planner & Coder.
* Best Manim code quality achieved with **Claude-4-Opus**.
* Also supports [MiniMax](https://www.minimaxi.com/) via OpenAI-compatible API (`MiniMax-M2.7` with 1M context window).
* **VLM API**:
* Required for Planner Critic.
* For layout and aesthetics optimization, provide **Gemini API key**.

View file

@ -815,6 +815,7 @@ def get_api_and_output(API_name):
"gpt-4o": (request_gpt4o_token, "Chatgpt4o"),
"gpt-o4mini": (request_o4mini_token, "Chatgpto4mini"),
"Gemini": (request_gemini_token, "Gemini"),
"minimax": (request_minimax_token, "MiniMax"),
}
try:
return mapping[API_name]
@ -828,7 +829,7 @@ def build_and_parse_args():
parser.add_argument(
"--API",
type=str,
choices=["gpt-41", "claude", "gpt-5", "gpt-4o", "gpt-o4mini", "Gemini"],
choices=["gpt-41", "claude", "gpt-5", "gpt-4o", "gpt-o4mini", "Gemini", "minimax"],
default="gpt-41",
)
parser.add_argument(

View file

@ -33,6 +33,11 @@
"base_url": "...",
"api_key": "..."
},
"minimax": {
"base_url": "https://api.minimax.io/v1",
"api_key": "YOUR_MINIMAX_API_KEY",
"model": "MiniMax-M2.7"
},
"iconfinder": {
"api_key": "YOUR_ICONFINDER_KEY"
}

View file

@ -1035,6 +1035,101 @@ def request_gpt41_img(prompt, image_path=None, log_id=None, max_tokens=1000, max
time.sleep(delay)
def request_minimax(prompt, log_id=None, max_tokens=8000, max_retries=3):
"""
Makes a request to the MiniMax model via OpenAI-compatible API with retry functionality.
Args:
prompt (str): The text prompt to send to the model
log_id (str, optional): The log ID for tracking requests, defaults to tkb+timestamp
max_tokens (int, optional): Maximum tokens for response, default 8000
max_retries (int, optional): Maximum number of retry attempts, default 3
Returns:
str: The model's response content
"""
base_url = cfg("minimax", "base_url")
api_key = cfg("minimax", "api_key")
model_name = cfg("minimax", "model")
client = OpenAI(base_url=base_url, api_key=api_key)
if log_id is None:
log_id = generate_log_id()
retry_count = 0
while retry_count < max_retries:
try:
completion = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return completion.choices[0].message.content.strip()
except Exception as e:
retry_count += 1
if retry_count >= max_retries:
raise Exception(f"Failed after {max_retries} attempts. Last error: {str(e)}")
delay = (2**retry_count) * 0.1 + (random.random() * 0.1)
print(
f"Request failed with error: {str(e)}. Retrying in {delay:.2f} seconds... (Attempt {retry_count}/{max_retries})"
)
time.sleep(delay)
def request_minimax_token(prompt, log_id=None, max_tokens=8000, max_retries=3):
"""
Makes a request to the MiniMax model via OpenAI-compatible API with retry and token tracking.
Args:
prompt (str): The text prompt to send to the model
log_id (str, optional): The log ID for tracking requests, defaults to tkb+timestamp
max_tokens (int, optional): Maximum tokens for response, default 8000
max_retries (int, optional): Maximum number of retry attempts, default 3
Returns:
tuple: (completion, usage_info) where usage_info is a dict with token counts
"""
base_url = cfg("minimax", "base_url")
api_key = cfg("minimax", "api_key")
model_name = cfg("minimax", "model")
client = OpenAI(base_url=base_url, api_key=api_key)
if log_id is None:
log_id = generate_log_id()
usage_info = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
retry_count = 0
while retry_count < max_retries:
try:
completion = client.chat.completions.create(
model=model_name,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
if completion.usage:
usage_info["prompt_tokens"] = completion.usage.prompt_tokens
usage_info["completion_tokens"] = completion.usage.completion_tokens
usage_info["total_tokens"] = completion.usage.total_tokens
return completion, usage_info
except Exception as e:
retry_count += 1
if retry_count >= max_retries:
raise Exception(f"Failed after {max_retries} attempts. Last error: {str(e)}")
delay = (2**retry_count) * 0.1 + (random.random() * 0.1)
print(
f"Request failed with error: {str(e)}. Retrying in {delay:.2f} seconds... (Attempt {retry_count}/{max_retries})"
)
time.sleep(delay)
return None, usage_info
if __name__ == "__main__":
# Gemini

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,389 @@
"""Unit tests for MiniMax provider integration in Code2Video."""
import json
import os
import sys
import unittest
from unittest.mock import MagicMock, patch
# Add src to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
class TestMiniMaxConfig(unittest.TestCase):
"""Tests for MiniMax configuration in api_config.json."""
def setUp(self):
config_path = os.path.join(os.path.dirname(__file__), "..", "src", "api_config.json")
with open(config_path, "r") as f:
self.config = json.load(f)
def test_minimax_entry_exists(self):
"""MiniMax should be present in api_config.json."""
self.assertIn("minimax", self.config)
def test_minimax_base_url(self):
"""MiniMax base_url should point to the official API endpoint."""
self.assertEqual(self.config["minimax"]["base_url"], "https://api.minimax.io/v1")
def test_minimax_model(self):
"""MiniMax model should default to MiniMax-M2.7."""
self.assertEqual(self.config["minimax"]["model"], "MiniMax-M2.7")
def test_minimax_has_api_key_placeholder(self):
"""MiniMax should have an api_key placeholder."""
self.assertIn("api_key", self.config["minimax"])
def test_minimax_config_keys(self):
"""MiniMax config should have base_url, api_key, and model."""
expected_keys = {"base_url", "api_key", "model"}
self.assertEqual(set(self.config["minimax"].keys()), expected_keys)
class TestMiniMaxRequestFunctions(unittest.TestCase):
"""Tests for request_minimax and request_minimax_token functions."""
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_returns_content(self, mock_openai_cls, mock_cfg):
"""request_minimax should return stripped content from the response."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = " Hello from MiniMax "
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax
result = request_minimax("test prompt")
self.assertEqual(result, "Hello from MiniMax")
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_uses_correct_model(self, mock_openai_cls, mock_cfg):
"""request_minimax should pass the correct model name."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "test"
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax
request_minimax("test prompt")
call_kwargs = mock_client.chat.completions.create.call_args
self.assertEqual(call_kwargs.kwargs["model"], "MiniMax-M2.7")
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_uses_openai_client(self, mock_openai_cls, mock_cfg):
"""request_minimax should use OpenAI client (not AzureOpenAI)."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "test"
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax
request_minimax("test prompt")
mock_openai_cls.assert_called_with(base_url="https://api.minimax.io/v1", api_key="test-key")
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_token_returns_tuple(self, mock_openai_cls, mock_cfg):
"""request_minimax_token should return (completion, usage_info) tuple."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.usage.prompt_tokens = 10
mock_response.usage.completion_tokens = 20
mock_response.usage.total_tokens = 30
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax_token
completion, usage_info = request_minimax_token("test prompt")
self.assertIs(completion, mock_response)
self.assertEqual(usage_info["prompt_tokens"], 10)
self.assertEqual(usage_info["completion_tokens"], 20)
self.assertEqual(usage_info["total_tokens"], 30)
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_token_no_usage(self, mock_openai_cls, mock_cfg):
"""request_minimax_token should handle missing usage gracefully."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.usage = None
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax_token
completion, usage_info = request_minimax_token("test prompt")
self.assertEqual(usage_info["prompt_tokens"], 0)
self.assertEqual(usage_info["completion_tokens"], 0)
self.assertEqual(usage_info["total_tokens"], 0)
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_retry_on_failure(self, mock_openai_cls, mock_cfg):
"""request_minimax should retry on failure up to max_retries."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
# First call fails, second succeeds
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "success"
mock_client.chat.completions.create.side_effect = [
Exception("temporary error"),
mock_response,
]
from gpt_request import request_minimax
result = request_minimax("test prompt", max_retries=3)
self.assertEqual(result, "success")
self.assertEqual(mock_client.chat.completions.create.call_count, 2)
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_max_retries_exceeded(self, mock_openai_cls, mock_cfg):
"""request_minimax should raise after max_retries failures."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.chat.completions.create.side_effect = Exception("persistent error")
from gpt_request import request_minimax
with self.assertRaises(Exception) as ctx:
request_minimax("test prompt", max_retries=2)
self.assertIn("Failed after 2 attempts", str(ctx.exception))
@patch("gpt_request.cfg")
@patch("gpt_request.OpenAI")
def test_request_minimax_max_tokens_param(self, mock_openai_cls, mock_cfg):
"""request_minimax should pass max_tokens to the API."""
mock_cfg.side_effect = lambda svc, key, default=None: {
("minimax", "base_url"): "https://api.minimax.io/v1",
("minimax", "api_key"): "test-key",
("minimax", "model"): "MiniMax-M2.7",
}.get((svc, key), default)
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "test"
mock_client.chat.completions.create.return_value = mock_response
from gpt_request import request_minimax
request_minimax("test prompt", max_tokens=16384)
call_kwargs = mock_client.chat.completions.create.call_args
self.assertEqual(call_kwargs.kwargs["max_tokens"], 16384)
class TestMiniMaxAgentIntegration(unittest.TestCase):
"""Tests for MiniMax integration in the agent module."""
def _setup_agent_mocks(self):
"""Set up mocks for agent module import with proper star-import support."""
import types
# Create a proper module (not MagicMock) so 'from gpt_request import *' works
mock_gpt_request = types.ModuleType("gpt_request")
# Define all the function names that agent.py uses via `from gpt_request import *`
func_names = [
"request_gpt41_token", "request_claude_token", "request_gpt5_token",
"request_gpt4o_token", "request_o4mini_token", "request_gemini_token",
"request_minimax_token", "request_gpt41", "request_claude",
"request_gpt5", "request_gpt4o", "request_o4mini", "request_gemini",
"request_minimax", "request_gemini_video_img",
]
for name in func_names:
setattr(mock_gpt_request, name, MagicMock(name=name))
mock_gpt_request.__all__ = func_names
sys.modules["gpt_request"] = mock_gpt_request
# Mock other imports that agent.py needs
for mod_name in ["prompts", "utils", "scope_refine", "manim"]:
mock_mod = types.ModuleType(mod_name)
# agent.py uses `from utils import *` etc., provide empty __all__
mock_mod.__all__ = []
sys.modules[mod_name] = mock_mod
# external_assets needs process_storyboard_with_assets as named import
mock_external = types.ModuleType("external_assets")
mock_external.__all__ = []
mock_external.process_storyboard_with_assets = MagicMock(name="process_storyboard_with_assets")
sys.modules["external_assets"] = mock_external
return mock_gpt_request
def _cleanup_agent_mocks(self):
"""Remove mocked modules."""
for mod in ["agent", "gpt_request", "prompts", "utils", "scope_refine", "external_assets", "manim"]:
sys.modules.pop(mod, None)
def test_agent_mapping_includes_minimax(self):
"""get_api_and_output should include 'minimax' in its mapping."""
mock_gpt_request = self._setup_agent_mocks()
try:
if "agent" in sys.modules:
del sys.modules["agent"]
import agent
api_func, folder_name = agent.get_api_and_output("minimax")
self.assertEqual(folder_name, "MiniMax")
finally:
self._cleanup_agent_mocks()
def test_agent_argparse_accepts_minimax(self):
"""build_and_parse_args should accept 'minimax' as a valid --API choice."""
self._setup_agent_mocks()
try:
if "agent" in sys.modules:
del sys.modules["agent"]
import agent
original_argv = sys.argv
sys.argv = ["agent.py", "--API", "minimax"]
try:
args = agent.build_and_parse_args()
self.assertEqual(args.API, "minimax")
finally:
sys.argv = original_argv
finally:
self._cleanup_agent_mocks()
def test_agent_invalid_api_raises(self):
"""get_api_and_output should raise ValueError for unknown API name."""
self._setup_agent_mocks()
try:
if "agent" in sys.modules:
del sys.modules["agent"]
import agent
with self.assertRaises(ValueError):
agent.get_api_and_output("nonexistent-provider")
finally:
self._cleanup_agent_mocks()
class TestMiniMaxIntegration(unittest.TestCase):
"""Integration tests for MiniMax API (require MINIMAX_API_KEY)."""
def setUp(self):
self.api_key = os.environ.get("MINIMAX_API_KEY")
if not self.api_key:
self.skipTest("MINIMAX_API_KEY not set")
@patch("gpt_request._CFG", {
"minimax": {
"base_url": "https://api.minimax.io/v1",
"api_key": "",
"model": "MiniMax-M2.7",
}
})
def test_live_minimax_request(self):
"""Integration: request_minimax should return a non-empty string."""
os.environ["MINIMAX_API_KEY"] = self.api_key
try:
from gpt_request import request_minimax
result = request_minimax("Say hello in one word.", max_tokens=50, max_retries=2)
self.assertIsInstance(result, str)
self.assertTrue(len(result) > 0)
finally:
pass
@patch("gpt_request._CFG", {
"minimax": {
"base_url": "https://api.minimax.io/v1",
"api_key": "",
"model": "MiniMax-M2.7",
}
})
def test_live_minimax_token_tracking(self):
"""Integration: request_minimax_token should return valid token counts."""
os.environ["MINIMAX_API_KEY"] = self.api_key
try:
from gpt_request import request_minimax_token
completion, usage = request_minimax_token("Say hello.", max_tokens=50, max_retries=2)
self.assertIsNotNone(completion)
self.assertGreater(usage["total_tokens"], 0)
finally:
pass
@patch("gpt_request._CFG", {
"minimax": {
"base_url": "https://api.minimax.io/v1",
"api_key": "",
"model": "MiniMax-M2.7",
}
})
def test_live_minimax_long_response(self):
"""Integration: request_minimax should handle longer responses."""
os.environ["MINIMAX_API_KEY"] = self.api_key
try:
from gpt_request import request_minimax
result = request_minimax(
"Briefly explain the Fourier Transform in 2-3 sentences.",
max_tokens=200,
max_retries=2,
)
self.assertIsInstance(result, str)
self.assertTrue(len(result) > 20)
finally:
pass
if __name__ == "__main__":
unittest.main()