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']: