diff --git a/README.md b/README.md index 1129079..38955a1 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/src/agent.py b/src/agent.py index 5af7742..5af5716 100644 --- a/src/agent.py +++ b/src/agent.py @@ -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( diff --git a/src/api_config.json b/src/api_config.json index 4a958bc..93c7639 100644 --- a/src/api_config.json +++ b/src/api_config.json @@ -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" } diff --git a/src/gpt_request.py b/src/gpt_request.py index 1251784..0d69fb9 100644 --- a/src/gpt_request.py +++ b/src/gpt_request.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 0000000..9245fdd --- /dev/null +++ b/tests/test_minimax_provider.py @@ -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()