diff --git a/.env.example b/.env.example index 930e2e7..16c57ee 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,18 @@ # LLM Provider Selection LLM_PROVIDER=openai -# Options: openai, groq, gemini +# Options: openai, groq, gemini, minimax # LLM Model Configurations OPENAI_MODEL=gpt-4o GROQ_MODEL=llama3-70b-8192 GEMINI_MODEL=gemini-2.5-flash +MINIMAX_MODEL=MiniMax-M2.7 # API Keys (Required based on LLM_PROVIDER) OPENAI_API_KEY=your_openai_api_key_here GROQ_API_KEY=your_groq_api_key_here GEMINI_API_KEY=your_gemini_api_key_here +MINIMAX_API_KEY=your_minimax_api_key_here # Pexels API Key (Always required) PEXELS_API_KEY=your_pexels_api_key_here diff --git a/README.md b/README.md index 42f4037..1d3dcfe 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ https://github.com/user-attachments/assets/1e440ace-8560-4e12-850e-c532740711e7 ## Features - **AI-Powered Script Generation** - Automatically generates engaging scripts from any topic -- **Multiple LLM Providers** - Choose from OpenAI, Groq, or Google Gemini +- **Multiple LLM Providers** - Choose from OpenAI, Groq, Google Gemini, or MiniMax - **Text-to-Speech** - Natural-sounding voiceovers with EdgeTTS (free) or ElevenLabs - **Automatic B-Roll** - Fetches relevant background videos from Pexels - **Customizable Captions** - Full control over font, color, position, and styling @@ -81,13 +81,14 @@ All settings are configured via the `.env` file. Copy `.env.example` to get star | OpenAI | If using OpenAI | [platform.openai.com](https://platform.openai.com/api-keys) | | Groq | If using Groq | [console.groq.com](https://console.groq.com/keys) | | Google Gemini | If using Gemini | [makersuite.google.com](https://makersuite.google.com/app/apikey) | +| MiniMax | If using MiniMax | [platform.minimaxi.com](https://platform.minimaxi.com/user-center/basic-information/interface-key) | | Deepgram | If using Deepgram STT | [console.deepgram.com](https://console.deepgram.com/) | | ElevenLabs | If using ElevenLabs TTS | [elevenlabs.io](https://elevenlabs.io/) | ### Provider Selection ```env -# LLM Provider: openai, groq, or gemini +# LLM Provider: openai, groq, gemini, or minimax LLM_PROVIDER=openai # Text-to-Speech: edgetts (free) or elevenlabs diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 0000000..cbf0cb0 --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,103 @@ +"""Integration tests for MiniMax LLM provider. + +These tests require a valid MINIMAX_API_KEY environment variable. +Skip automatically when the key is not set. +""" + +import os +import json +import re +import unittest + +MINIMAX_API_KEY = os.getenv("MINIMAX_API_KEY") +SKIP_REASON = "MINIMAX_API_KEY not set" + + +def strip_think_tags(text): + """Strip ... tags from MiniMax M2.7 responses.""" + return re.sub(r".*?\s*", "", text, flags=re.DOTALL).strip() + + +def extract_json(text): + """Extract JSON from response, handling think tags and markdown fences.""" + text = strip_think_tags(text) + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip() + json_start = text.find("{") + json_end = text.rfind("}") + if json_start >= 0 and json_end >= 0: + text = text[json_start : json_end + 1] + return json.loads(text) + + +@unittest.skipUnless(MINIMAX_API_KEY, SKIP_REASON) +class TestMiniMaxIntegration(unittest.TestCase): + """Integration tests that hit the real MiniMax API.""" + + def test_chat_completions_basic(self): + """MiniMax should respond to a basic chat completion request.""" + from openai import OpenAI + + client = OpenAI( + api_key=MINIMAX_API_KEY, + base_url="https://api.minimax.io/v1", + ) + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "user", "content": "Say hello in one word."} + ], + max_tokens=200, + ) + self.assertTrue(len(response.choices) > 0) + content = strip_think_tags(response.choices[0].message.content) + self.assertTrue(len(content) > 0) + + def test_json_output(self): + """MiniMax should return valid JSON when prompted.""" + from openai import OpenAI + + client = OpenAI( + api_key=MINIMAX_API_KEY, + base_url="https://api.minimax.io/v1", + ) + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "system", "content": "Output valid JSON only. No markdown. No thinking."}, + {"role": "user", "content": 'Return {"greeting": "hello"}'}, + ], + max_tokens=50, + ) + content = response.choices[0].message.content.strip() + parsed = extract_json(content) + self.assertIn("greeting", parsed) + + def test_script_generation_format(self): + """MiniMax should generate a script in the expected JSON format.""" + from openai import OpenAI + + client = OpenAI( + api_key=MINIMAX_API_KEY, + base_url="https://api.minimax.io/v1", + ) + prompt = ( + "You are a content writer. Generate a very short 2-sentence fact. " + 'Output ONLY a JSON object: {"script": "your text here"}' + ) + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "system", "content": prompt}, + {"role": "user", "content": "space facts"}, + ], + max_tokens=200, + ) + content = response.choices[0].message.content.strip() + parsed = extract_json(content) + self.assertIn("script", parsed) + self.assertTrue(len(parsed["script"]) > 10) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 0000000..533616c --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,291 @@ +"""Unit tests for MiniMax LLM provider integration.""" + +import os +import json +import unittest +from unittest.mock import patch, MagicMock + + +def _base_env(**overrides): + """Return minimal valid env for MiniMax provider.""" + env = { + "LLM_PROVIDER": "minimax", + "MINIMAX_API_KEY": "test-minimax-key", + "MINIMAX_MODEL": "MiniMax-M2.7", + "PEXELS_API_KEY": "test-pexels-key", + "STT_PROVIDER": "whisper", + "TTS_PROVIDER": "edgetts", + "EDGETTS_VOICE": "en-AU-WilliamNeural", + } + env.update(overrides) + return env + + +def _reset_config(): + """Reset Config singleton for clean test.""" + from utility.config import Config + Config._instance = None + + +class TestConfigMiniMaxValidation(unittest.TestCase): + """Test Config validation for MiniMax provider.""" + + def setUp(self): + _reset_config() + + def tearDown(self): + _reset_config() + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_valid_config(self, mock_exists): + """MiniMax config with all required fields should succeed.""" + with patch.dict(os.environ, _base_env(), clear=False): + from utility.config import Config + config = Config() + self.assertEqual(config.get_llm_provider(), "minimax") + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_missing_api_key(self, mock_exists): + """Missing MINIMAX_API_KEY should raise ConfigurationError.""" + from utility.config import ConfigurationError + env = _base_env() + env.pop("MINIMAX_API_KEY") + # Remove from real env too + with patch.dict(os.environ, env, clear=False): + os.environ.pop("MINIMAX_API_KEY", None) + with self.assertRaises(ConfigurationError) as ctx: + from utility.config import Config + _reset_config() + Config() + self.assertIn("MINIMAX_API_KEY", str(ctx.exception)) + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_missing_model(self, mock_exists): + """Missing MINIMAX_MODEL should raise ConfigurationError.""" + from utility.config import ConfigurationError + env = _base_env() + env.pop("MINIMAX_MODEL") + with patch.dict(os.environ, env, clear=False): + os.environ.pop("MINIMAX_MODEL", None) + with self.assertRaises(ConfigurationError): + from utility.config import Config + _reset_config() + Config() + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_default_model(self, mock_exists): + """Default model should be MiniMax-M2.7.""" + with patch.dict(os.environ, _base_env(), clear=False): + from utility.config import Config + config = Config() + self.assertEqual(config.get_llm_model(), "MiniMax-M2.7") + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_custom_model(self, mock_exists): + """Custom model should be returned when set.""" + with patch.dict(os.environ, _base_env(MINIMAX_MODEL="MiniMax-M2.7-highspeed"), clear=False): + from utility.config import Config + config = Config() + self.assertEqual(config.get_llm_model(), "MiniMax-M2.7-highspeed") + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_client_is_openai(self, mock_exists): + """MiniMax client should be an OpenAI instance with custom base_url.""" + from openai import OpenAI + with patch.dict(os.environ, _base_env(), clear=False): + from utility.config import Config + config = Config() + client = config.get_llm_client() + self.assertIsInstance(client, OpenAI) + self.assertIn("minimax", str(client.base_url)) + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_client_cached(self, mock_exists): + """Client should be cached on subsequent calls.""" + with patch.dict(os.environ, _base_env(), clear=False): + from utility.config import Config + config = Config() + client1 = config.get_llm_client() + client2 = config.get_llm_client() + self.assertIs(client1, client2) + + @patch("utility.config.os.path.exists", return_value=True) + def test_minimax_provider_literal(self, mock_exists): + """get_llm_provider should return 'minimax'.""" + with patch.dict(os.environ, _base_env(), clear=False): + from utility.config import Config + config = Config() + self.assertEqual(config.get_llm_provider(), "minimax") + + @patch("utility.config.os.path.exists", return_value=True) + def test_invalid_provider_rejected(self, mock_exists): + """Invalid provider should be rejected.""" + from utility.config import ConfigurationError + with patch.dict(os.environ, _base_env(LLM_PROVIDER="invalid"), clear=False): + with self.assertRaises(ConfigurationError): + from utility.config import Config + _reset_config() + Config() + + @patch("utility.config.os.path.exists", return_value=True) + def test_all_four_providers_accepted(self, mock_exists): + """All four providers should be valid.""" + for provider in ["openai", "groq", "gemini", "minimax"]: + _reset_config() + env = { + "LLM_PROVIDER": provider, + "PEXELS_API_KEY": "test", + "STT_PROVIDER": "whisper", + "TTS_PROVIDER": "edgetts", + "EDGETTS_VOICE": "en-AU-WilliamNeural", + } + if provider == "openai": + env.update({"OPENAI_API_KEY": "test", "OPENAI_MODEL": "gpt-4o"}) + elif provider == "groq": + env.update({"GROQ_API_KEY": "test", "GROQ_MODEL": "llama3-70b-8192"}) + elif provider == "gemini": + env.update({"GEMINI_API_KEY": "test", "GEMINI_MODEL": "gemini-2.5-flash"}) + elif provider == "minimax": + env.update({"MINIMAX_API_KEY": "test", "MINIMAX_MODEL": "MiniMax-M2.7"}) + with patch.dict(os.environ, env, clear=False): + from utility.config import Config + config = Config() + self.assertEqual(config.get_llm_provider(), provider) + + +class TestScriptGeneratorMiniMax(unittest.TestCase): + """Test script_generator works with MiniMax client.""" + + def setUp(self): + _reset_config() + + def tearDown(self): + _reset_config() + + @patch("utility.script.script_generator._call_openai_groq") + @patch("utility.script.script_generator.get_config") + def test_generate_script_calls_openai_groq_for_minimax(self, mock_get_config, mock_call): + """generate_script should call _call_openai_groq for MiniMax provider.""" + mock_config = MagicMock() + mock_config.get_llm_provider.return_value = "minimax" + mock_config.get_llm_model.return_value = "MiniMax-M2.7" + mock_config.get_llm_client.return_value = MagicMock() + mock_get_config.return_value = mock_config + + mock_call.return_value = '{"script": "Test script content"}' + + from utility.script.script_generator import generate_script + result = generate_script("test topic") + + mock_call.assert_called_once() + self.assertEqual(result, "Test script content") + + @patch("utility.script.script_generator._call_gemini") + @patch("utility.script.script_generator._call_openai_groq") + @patch("utility.script.script_generator.get_config") + def test_minimax_does_not_call_gemini(self, mock_get_config, mock_openai, mock_gemini): + """MiniMax should not use the Gemini code path.""" + mock_config = MagicMock() + mock_config.get_llm_provider.return_value = "minimax" + mock_config.get_llm_model.return_value = "MiniMax-M2.7" + mock_config.get_llm_client.return_value = MagicMock() + mock_get_config.return_value = mock_config + + mock_openai.return_value = '{"script": "Hello"}' + + from utility.script.script_generator import generate_script + generate_script("test") + + mock_gemini.assert_not_called() + + @patch("utility.script.script_generator._call_openai_groq") + @patch("utility.script.script_generator.get_config") + def test_minimax_model_passed_to_client(self, mock_get_config, mock_call): + """The MiniMax model name should be passed to the OpenAI-compat call.""" + mock_config = MagicMock() + mock_config.get_llm_provider.return_value = "minimax" + mock_config.get_llm_model.return_value = "MiniMax-M2.7-highspeed" + mock_config.get_llm_client.return_value = MagicMock() + mock_get_config.return_value = mock_config + + mock_call.return_value = '{"script": "Fast script"}' + + from utility.script.script_generator import generate_script + generate_script("speed test") + + call_args = mock_call.call_args + self.assertEqual(call_args[0][1], "MiniMax-M2.7-highspeed") + + +class TestVideoSearchQueryMiniMax(unittest.TestCase): + """Test video_search_query_generator with MiniMax.""" + + @patch("utility.video.video_search_query_generator.get_config") + def test_minimax_uses_openai_chat_completions(self, mock_get_config): + """MiniMax should use client.chat.completions.create (not gemini path).""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices[0].message.content = '[[[0.0, 5.0], ["test keyword", "test scene", "test visual"]]]' + mock_client.chat.completions.create.return_value = mock_response + + mock_config = MagicMock() + mock_config.get_llm_provider.return_value = "minimax" + mock_config.get_llm_model.return_value = "MiniMax-M2.7" + mock_config.get_llm_client.return_value = mock_client + mock_get_config.return_value = mock_config + + from utility.video.video_search_query_generator import call_OpenAI + result = call_OpenAI("test script", [[[0.0, 5.0], "test caption"]]) + + mock_client.chat.completions.create.assert_called_once() + self.assertIn("test keyword", result) + + @patch("utility.video.video_search_query_generator.get_config") + def test_minimax_temperature_within_range(self, mock_get_config): + """Temperature=1 in call_OpenAI should be valid for MiniMax (0, 1].""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices[0].message.content = '[[[0.0, 3.0], ["cat", "dog", "bird"]]]' + mock_client.chat.completions.create.return_value = mock_response + + mock_config = MagicMock() + mock_config.get_llm_provider.return_value = "minimax" + mock_config.get_llm_model.return_value = "MiniMax-M2.7" + mock_config.get_llm_client.return_value = mock_client + mock_get_config.return_value = mock_config + + from utility.video.video_search_query_generator import call_OpenAI + call_OpenAI("animals", [[[0.0, 3.0], "animals"]]) + + call_kwargs = mock_client.chat.completions.create.call_args + self.assertEqual(call_kwargs.kwargs.get("temperature", call_kwargs[1].get("temperature")), 1) + + +class TestEnvExample(unittest.TestCase): + """Test .env.example includes MiniMax configuration.""" + + def test_env_example_has_minimax_key(self): + """Check .env.example contains MINIMAX_API_KEY.""" + env_path = os.path.join(os.path.dirname(__file__), "..", ".env.example") + with open(env_path) as f: + content = f.read() + self.assertIn("MINIMAX_API_KEY", content) + + def test_env_example_has_minimax_model(self): + """Check .env.example contains MINIMAX_MODEL.""" + env_path = os.path.join(os.path.dirname(__file__), "..", ".env.example") + with open(env_path) as f: + content = f.read() + self.assertIn("MINIMAX_MODEL", content) + self.assertIn("MiniMax-M2.7", content) + + def test_env_example_has_minimax_in_options(self): + """Check .env.example lists minimax as a provider option.""" + env_path = os.path.join(os.path.dirname(__file__), "..", ".env.example") + with open(env_path) as f: + content = f.read() + self.assertIn("minimax", content.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/utility/config.py b/utility/config.py index 2e64228..2278ffb 100644 --- a/utility/config.py +++ b/utility/config.py @@ -51,30 +51,36 @@ def _validate_env_file(self) -> None: def _validate_configuration(self) -> None: errors = [] - + llm_provider = os.getenv('LLM_PROVIDER', '').lower() - if llm_provider not in ['openai', 'groq', 'gemini']: + if llm_provider not in ['openai', 'groq', 'gemini', 'minimax']: errors.append( - f"Invalid LLM_PROVIDER: '{llm_provider}'. Must be one of: openai, groq, gemini" + f"Invalid LLM_PROVIDER: '{llm_provider}'. Must be one of: openai, groq, gemini, minimax" ) - + if llm_provider == 'openai': if not os.getenv('OPENAI_API_KEY'): errors.append("Missing required API key: OPENAI_API_KEY (required for LLM_PROVIDER=openai)") if not os.getenv('OPENAI_MODEL'): errors.append("Missing required configuration: OPENAI_MODEL (required for LLM_PROVIDER=openai)") - + elif llm_provider == 'groq': if not os.getenv('GROQ_API_KEY'): errors.append("Missing required API key: GROQ_API_KEY (required for LLM_PROVIDER=groq)") if not os.getenv('GROQ_MODEL'): errors.append("Missing required configuration: GROQ_MODEL (required for LLM_PROVIDER=groq)") - + elif llm_provider == 'gemini': if not os.getenv('GEMINI_API_KEY'): errors.append("Missing required API key: GEMINI_API_KEY (required for LLM_PROVIDER=gemini)") if not os.getenv('GEMINI_MODEL'): errors.append("Missing required configuration: GEMINI_MODEL (required for LLM_PROVIDER=gemini)") + + elif llm_provider == 'minimax': + if not os.getenv('MINIMAX_API_KEY'): + errors.append("Missing required API key: MINIMAX_API_KEY (required for LLM_PROVIDER=minimax)") + if not os.getenv('MINIMAX_MODEL'): + errors.append("Missing required configuration: MINIMAX_MODEL (required for LLM_PROVIDER=minimax)") if not os.getenv('PEXELS_API_KEY'): errors.append("Missing required API key: PEXELS_API_KEY (always required)") @@ -109,9 +115,9 @@ def _validate_configuration(self) -> None: error_message += "\nPlease check your .env file and ensure all required keys are set." raise ConfigurationError(error_message) - def get_llm_provider(self) -> Literal['openai', 'groq', 'gemini']: + def get_llm_provider(self) -> Literal['openai', 'groq', 'gemini', 'minimax']: return os.getenv('LLM_PROVIDER', '').lower() - + def get_llm_model(self) -> str: provider = self.get_llm_provider() if provider == 'openai': @@ -120,14 +126,16 @@ def get_llm_model(self) -> str: return os.getenv('GROQ_MODEL', 'llama3-70b-8192') elif provider == 'gemini': return os.getenv('GEMINI_MODEL', 'gemini-2.5-flash') + elif provider == 'minimax': + return os.getenv('MINIMAX_MODEL', 'MiniMax-M2.7') raise ConfigurationError(f"Unknown LLM provider: {provider}") - + def get_llm_client(self): if self._llm_client is not None: return self._llm_client - + provider = self.get_llm_provider() - + if provider == 'openai': self._llm_client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) elif provider == 'groq': @@ -140,7 +148,12 @@ def get_llm_client(self): genai.configure(api_key=os.getenv('GEMINI_API_KEY')) model_name = os.getenv('GEMINI_MODEL', 'gemini-2.5-flash') self._llm_client = genai.GenerativeModel(model_name) - + elif provider == 'minimax': + self._llm_client = OpenAI( + api_key=os.getenv('MINIMAX_API_KEY'), + base_url="https://api.minimax.io/v1", + ) + return self._llm_client def get_stt_provider(self) -> Literal['whisper', 'deepgram']: