diff --git a/README.md b/README.md index 64cd91b5..a6b00eac 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ https://github.com/user-attachments/assets/33e0e7b4-9eb2-4a26-8274-f96c2c1c3a48 Experience a sleek, chat-based interface with built-in video playback and intuitive controls. It’s like having a personal assistant for your media. ### 🥣 A mixing bowl of your GenAI APIs -Connect seamlessly with powerful AI tools like LLMs, databases, and GenAI APIs, while VideoDB ensures your video infrastructure is reliable and scalable for cloud storage, indexing and streaming your content effortlessly. +Connect seamlessly with powerful AI tools like LLMs (OpenAI, Anthropic, Google Gemini, MiniMax), databases, and GenAI APIs, while VideoDB ensures your video infrastructure is reliable and scalable for cloud storage, indexing and streaming your content effortlessly. ![Integration-Updated](https://github.com/user-attachments/assets/d06e3b57-1135-4c3b-9f3a-d427d4142b42) ### 🧩 Customizable and Flexible diff --git a/backend/.env.sample b/backend/.env.sample index 1b356257..c10a9e5b 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -19,6 +19,7 @@ SQLITE_DB_PATH= OPENAI_API_KEY= ANTHROPIC_API_KEY= GOOGLEAI_API_KEY= +MINIMAX_API_KEY= # Tools REPLICATE_API_TOKEN= diff --git a/backend/director/constants.py b/backend/director/constants.py index a398d134..cd188b75 100644 --- a/backend/director/constants.py +++ b/backend/director/constants.py @@ -20,6 +20,7 @@ class LLMType(str, Enum): OPENAI = "openai" ANTHROPIC = "anthropic" GOOGLEAI = "googleai" + MINIMAX = "minimax" VIDEODB_PROXY = "videodb_proxy" @@ -29,5 +30,6 @@ class EnvPrefix(str, Enum): OPENAI_ = "OPENAI_" ANTHROPIC_ = "ANTHROPIC_" GOOGLEAI_ = "GOOGLEAI_" + MINIMAX_ = "MINIMAX_" DOWNLOADS_PATH="director/downloads" diff --git a/backend/director/llm/__init__.py b/backend/director/llm/__init__.py index 71e79c4c..daee9109 100644 --- a/backend/director/llm/__init__.py +++ b/backend/director/llm/__init__.py @@ -5,6 +5,7 @@ from director.llm.openai import OpenAI from director.llm.anthropic import AnthropicAI from director.llm.googleai import GoogleAI +from director.llm.minimax import MiniMax from director.llm.videodb_proxy import VideoDBProxy @@ -14,6 +15,7 @@ def get_default_llm(): openai = True if os.getenv("OPENAI_API_KEY") else False anthropic = True if os.getenv("ANTHROPIC_API_KEY") else False googleai = True if os.getenv("GOOGLEAI_API_KEY") else False + minimax = True if os.getenv("MINIMAX_API_KEY") else False default_llm = os.getenv("DEFAULT_LLM") @@ -23,5 +25,7 @@ def get_default_llm(): return AnthropicAI() elif googleai or default_llm == LLMType.GOOGLEAI: return GoogleAI() + elif minimax or default_llm == LLMType.MINIMAX: + return MiniMax() else: return VideoDBProxy() diff --git a/backend/director/llm/minimax.py b/backend/director/llm/minimax.py new file mode 100644 index 00000000..4b6fe378 --- /dev/null +++ b/backend/director/llm/minimax.py @@ -0,0 +1,200 @@ +import json +import re +from enum import Enum + +from pydantic import Field, field_validator, FieldValidationInfo +from pydantic_settings import SettingsConfigDict + + +from director.llm.base import BaseLLM, BaseLLMConfig, LLMResponse, LLMResponseStatus +from director.constants import ( + LLMType, + EnvPrefix, +) + + +class MiniMaxChatModel(str, Enum): + """Enum for MiniMax Chat models""" + + MINIMAX_M2_7 = "MiniMax-M2.7" + MINIMAX_M2_5 = "MiniMax-M2.5" + MINIMAX_M2_5_HIGHSPEED = "MiniMax-M2.5-highspeed" + + +class MiniMaxConfig(BaseLLMConfig): + """MiniMax Config""" + + model_config = SettingsConfigDict( + env_prefix=EnvPrefix.MINIMAX_, + extra="ignore", + ) + + llm_type: str = LLMType.MINIMAX + api_key: str = "" + api_base: str = "https://api.minimax.io/v1" + chat_model: str = Field(default=MiniMaxChatModel.MINIMAX_M2_7) + max_tokens: int = 4096 + temperature: float = 0.9 + + @field_validator("api_key") + @classmethod + def validate_non_empty(cls, v, info: FieldValidationInfo): + if not v: + raise ValueError( + f"{info.field_name} must not be empty. Please set {EnvPrefix.MINIMAX_.value}{info.field_name.upper()} environment variable." + ) + return v + + @field_validator("temperature") + @classmethod + def clamp_temperature(cls, v): + """Clamp temperature to MiniMax's accepted range [0, 1].""" + return max(0.0, min(1.0, v)) + + +class MiniMax(BaseLLM): + def __init__(self, config: MiniMaxConfig = None): + """ + :param config: MiniMax Config + """ + if config is None: + config = MiniMaxConfig() + super().__init__(config=config) + try: + import openai + except ImportError: + raise ImportError("Please install OpenAI python library.") + + self.client = openai.OpenAI( + api_key=self.api_key, base_url=self.api_base + ) + + def _format_messages(self, messages: list): + """Format the messages to the format that MiniMax expects via OpenAI-compatible API.""" + formatted_messages = [] + + for message in messages: + if message["role"] == "assistant" and message.get("tool_calls"): + formatted_messages.append( + { + "role": message["role"], + "content": message["content"], + "tool_calls": [ + { + "id": tool_call["id"], + "function": { + "name": tool_call["tool"]["name"], + "arguments": json.dumps( + tool_call["tool"]["arguments"] + ), + }, + "type": tool_call["type"], + } + for tool_call in message["tool_calls"] + ], + } + ) + else: + formatted_messages.append(message) + + return formatted_messages + + def _format_tools(self, tools: list): + """Format the tools to the format that MiniMax expects. + + **Example**:: + + [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather in a given location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state" + } + }, + "required": ["location"] + } + } + } + ] + """ + return [ + { + "type": "function", + "function": { + "name": tool.get("name", ""), + "description": tool.get("description", ""), + "parameters": tool.get("parameters", {}), + }, + } + for tool in tools + if tool.get("name") + ] + + @staticmethod + def _strip_think_tags(content: str) -> str: + """Strip ... tags from MiniMax M2.7 reasoning output.""" + if not content: + return content + return re.sub(r".*?\s*", "", content, flags=re.DOTALL).strip() + + def chat_completions( + self, messages: list, tools: list = [], stop=None, response_format=None + ): + """Get chat completions using MiniMax. + + MiniMax provides an OpenAI-compatible API at https://api.minimax.io/v1. + docs: https://platform.minimaxi.com/document/ChatCompletion%20v2 + """ + params = { + "model": self.chat_model, + "messages": self._format_messages(messages), + "temperature": self.temperature, + "max_tokens": self.max_tokens, + "top_p": self.top_p, + "timeout": self.timeout, + } + + if tools: + params["tools"] = self._format_tools(tools) + params["tool_choice"] = "auto" + + if response_format: + params["response_format"] = response_format + + try: + response = self.client.chat.completions.create(**params) + except Exception as e: + print(f"Error: {e}") + return LLMResponse(content=f"Error: {e}") + + content = response.choices[0].message.content or "" + content = self._strip_think_tags(content) + + return LLMResponse( + content=content, + tool_calls=[ + { + "id": tool_call.id, + "tool": { + "name": tool_call.function.name, + "arguments": json.loads(tool_call.function.arguments), + }, + "type": tool_call.type, + } + for tool_call in response.choices[0].message.tool_calls + ] + if response.choices[0].message.tool_calls + else [], + finish_reason=response.choices[0].finish_reason, + send_tokens=response.usage.prompt_tokens, + recv_tokens=response.usage.completion_tokens, + total_tokens=response.usage.total_tokens, + status=LLMResponseStatus.SUCCESS, + ) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_minimax.py b/backend/tests/test_minimax.py new file mode 100644 index 00000000..b76d1cfa --- /dev/null +++ b/backend/tests/test_minimax.py @@ -0,0 +1,373 @@ +"""Unit tests for MiniMax LLM provider.""" + +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from director.constants import LLMType, EnvPrefix +from director.llm.minimax import MiniMax, MiniMaxConfig, MiniMaxChatModel +from director.llm.base import LLMResponseStatus + + +class TestMiniMaxChatModel: + """Tests for MiniMaxChatModel enum.""" + + def test_model_values(self): + assert MiniMaxChatModel.MINIMAX_M2_7 == "MiniMax-M2.7" + assert MiniMaxChatModel.MINIMAX_M2_5 == "MiniMax-M2.5" + assert MiniMaxChatModel.MINIMAX_M2_5_HIGHSPEED == "MiniMax-M2.5-highspeed" + + def test_model_count(self): + assert len(MiniMaxChatModel) == 3 + + +class TestMiniMaxConfig: + """Tests for MiniMaxConfig.""" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}) + def test_config_from_env(self): + config = MiniMaxConfig() + assert config.api_key == "test-key-123" + assert config.llm_type == LLMType.MINIMAX + assert config.api_base == "https://api.minimax.io/v1" + assert config.chat_model == MiniMaxChatModel.MINIMAX_M2_7 + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_CHAT_MODEL": "MiniMax-M2.5"}) + def test_config_custom_model(self): + config = MiniMaxConfig() + assert config.chat_model == "MiniMax-M2.5" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_API_BASE": "https://custom.api.io/v1"}) + def test_config_custom_api_base(self): + config = MiniMaxConfig() + assert config.api_base == "https://custom.api.io/v1" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_TEMPERATURE": "0.5"}) + def test_config_custom_temperature(self): + config = MiniMaxConfig() + assert config.temperature == 0.5 + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_MAX_TOKENS": "8192"}) + def test_config_custom_max_tokens(self): + config = MiniMaxConfig() + assert config.max_tokens == 8192 + + def test_config_missing_api_key(self): + env = {k: v for k, v in os.environ.items() if not k.startswith("MINIMAX_")} + with patch.dict(os.environ, env, clear=True): + with pytest.raises(ValueError, match="must not be empty"): + MiniMaxConfig() + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_TEMPERATURE": "1.5"}) + def test_temperature_clamped_high(self): + config = MiniMaxConfig() + assert config.temperature == 1.0 + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_TEMPERATURE": "-0.5"}) + def test_temperature_clamped_low(self): + config = MiniMaxConfig() + assert config.temperature == 0.0 + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "key", "MINIMAX_TEMPERATURE": "0"}) + def test_temperature_zero_accepted(self): + config = MiniMaxConfig() + assert config.temperature == 0.0 + + def test_env_prefix(self): + assert EnvPrefix.MINIMAX_ == "MINIMAX_" + + +class TestMiniMaxThinkTagStripping: + """Tests for _strip_think_tags static method.""" + + def test_strip_think_tags(self): + content = "Let me reason about this...\nHello world" + assert MiniMax._strip_think_tags(content) == "Hello world" + + def test_strip_multiline_think_tags(self): + content = "\nStep 1: analyze\nStep 2: respond\n\nThe answer is 42." + assert MiniMax._strip_think_tags(content) == "The answer is 42." + + def test_no_think_tags(self): + content = "Just a normal response." + assert MiniMax._strip_think_tags(content) == "Just a normal response." + + def test_empty_content(self): + assert MiniMax._strip_think_tags("") == "" + + def test_none_content(self): + assert MiniMax._strip_think_tags(None) is None + + def test_multiple_think_tags(self): + content = "firstHello secondworld" + assert MiniMax._strip_think_tags(content) == "Hello world" + + +@pytest.fixture +def minimax_llm(): + """Create a MiniMax LLM instance with mocked OpenAI client.""" + with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"}): + with patch("openai.OpenAI") as mock_openai: + mock_client = MagicMock() + mock_openai.return_value = mock_client + llm = MiniMax() + llm._mock_client = mock_client + return llm + + +class TestMiniMaxFormatMessages: + """Tests for _format_messages method.""" + + def test_format_simple_messages(self, minimax_llm): + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + ] + formatted = minimax_llm._format_messages(messages) + assert formatted == messages + + def test_format_tool_call_messages(self, minimax_llm): + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "tool": { + "name": "search_video", + "arguments": {"query": "funny cat"}, + }, + "type": "function", + } + ], + } + ] + formatted = minimax_llm._format_messages(messages) + assert formatted[0]["tool_calls"][0]["function"]["name"] == "search_video" + assert formatted[0]["tool_calls"][0]["function"]["arguments"] == json.dumps( + {"query": "funny cat"} + ) + + def test_format_system_message(self, minimax_llm): + messages = [ + {"role": "system", "content": "You are a video assistant."}, + {"role": "user", "content": "Summarize this video."}, + ] + formatted = minimax_llm._format_messages(messages) + assert len(formatted) == 2 + assert formatted[0]["role"] == "system" + + +class TestMiniMaxFormatTools: + """Tests for _format_tools method.""" + + def test_format_tools(self, minimax_llm): + tools = [ + { + "name": "search_video", + "description": "Search for videos", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"], + }, + } + ] + formatted = minimax_llm._format_tools(tools) + assert len(formatted) == 1 + assert formatted[0]["type"] == "function" + assert formatted[0]["function"]["name"] == "search_video" + # No strict mode for MiniMax (unlike OpenAI) + assert "strict" not in formatted[0] + + def test_format_empty_tools(self, minimax_llm): + assert minimax_llm._format_tools([]) == [] + + def test_skip_tools_without_name(self, minimax_llm): + tools = [ + {"description": "No name tool", "parameters": {}}, + {"name": "valid_tool", "description": "Valid", "parameters": {}}, + ] + formatted = minimax_llm._format_tools(tools) + assert len(formatted) == 1 + assert formatted[0]["function"]["name"] == "valid_tool" + + +class TestMiniMaxChatCompletions: + """Tests for chat_completions method.""" + + def _make_mock_response(self, content="OK", tool_calls=None, finish_reason="stop", + prompt_tokens=10, completion_tokens=20, total_tokens=30): + mock_message = MagicMock() + mock_message.content = content + mock_message.tool_calls = tool_calls + + mock_choice = MagicMock() + mock_choice.message = mock_message + mock_choice.finish_reason = finish_reason + + mock_usage = MagicMock() + mock_usage.prompt_tokens = prompt_tokens + mock_usage.completion_tokens = completion_tokens + mock_usage.total_tokens = total_tokens + + mock_response = MagicMock() + mock_response.choices = [mock_choice] + mock_response.usage = mock_usage + return mock_response + + def test_chat_completions_success(self, minimax_llm): + minimax_llm._mock_client.chat.completions.create.return_value = ( + self._make_mock_response("This is a video summary.") + ) + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Summarize the video"}] + ) + + assert result.status == LLMResponseStatus.SUCCESS + assert result.content == "This is a video summary." + assert result.tool_calls == [] + assert result.send_tokens == 10 + assert result.recv_tokens == 20 + assert result.total_tokens == 30 + + def test_chat_completions_with_think_tags(self, minimax_llm): + minimax_llm._mock_client.chat.completions.create.return_value = ( + self._make_mock_response("Let me think...\nThe answer is 42.") + ) + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "What is the meaning of life?"}] + ) + + assert result.content == "The answer is 42." + + def test_chat_completions_with_tool_calls(self, minimax_llm): + mock_tool_call = MagicMock() + mock_tool_call.id = "call_abc123" + mock_tool_call.function.name = "search_video" + mock_tool_call.function.arguments = json.dumps({"query": "cat"}) + mock_tool_call.type = "function" + + minimax_llm._mock_client.chat.completions.create.return_value = ( + self._make_mock_response( + content="", + tool_calls=[mock_tool_call], + finish_reason="tool_calls", + prompt_tokens=15, completion_tokens=25, total_tokens=40, + ) + ) + + tools = [ + { + "name": "search_video", + "description": "Search for videos", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + } + ] + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Find cat videos"}], + tools=tools, + ) + + assert result.status == LLMResponseStatus.SUCCESS + assert len(result.tool_calls) == 1 + assert result.tool_calls[0]["tool"]["name"] == "search_video" + assert result.tool_calls[0]["tool"]["arguments"] == {"query": "cat"} + + def test_chat_completions_with_response_format(self, minimax_llm): + minimax_llm._mock_client.chat.completions.create.return_value = ( + self._make_mock_response('{"clips": [{"start": 0, "end": 10}]}') + ) + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Find the highlights"}], + response_format={"type": "json_object"}, + ) + + assert result.status == LLMResponseStatus.SUCCESS + call_kwargs = minimax_llm._mock_client.chat.completions.create.call_args[1] + assert call_kwargs["response_format"] == {"type": "json_object"} + + def test_chat_completions_error(self, minimax_llm): + minimax_llm._mock_client.chat.completions.create.side_effect = Exception( + "API rate limit exceeded" + ) + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Hello"}] + ) + + assert result.status == LLMResponseStatus.ERROR + assert "API rate limit exceeded" in result.content + + def test_chat_completions_params(self, minimax_llm): + minimax_llm._mock_client.chat.completions.create.return_value = ( + self._make_mock_response() + ) + + minimax_llm.chat_completions( + [{"role": "user", "content": "Hi"}] + ) + + call_kwargs = minimax_llm._mock_client.chat.completions.create.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2.7" + assert call_kwargs["temperature"] == 0.9 + assert call_kwargs["max_tokens"] == 4096 + + +class TestGetDefaultLLM: + """Tests for get_default_llm with MiniMax support.""" + + @patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"}, clear=True) + def test_minimax_auto_detect(self): + with patch("director.llm.minimax.MiniMax.__init__", return_value=None): + from director.llm import get_default_llm + llm = get_default_llm() + assert isinstance(llm, MiniMax) + + @patch.dict(os.environ, {"DEFAULT_LLM": "minimax", "MINIMAX_API_KEY": "key"}, clear=True) + def test_minimax_default_llm_env(self): + with patch("director.llm.minimax.MiniMax.__init__", return_value=None): + from director.llm import get_default_llm + llm = get_default_llm() + assert isinstance(llm, MiniMax) + + @patch.dict( + os.environ, + {"OPENAI_API_KEY": "oai-key", "MINIMAX_API_KEY": "mm-key"}, + clear=True, + ) + def test_openai_takes_priority_over_minimax(self): + from director.llm import get_default_llm + from director.llm.openai import OpenAI + + with patch("director.llm.openai.OpenAI.__init__", return_value=None): + llm = get_default_llm() + assert isinstance(llm, OpenAI) + + +class TestLLMTypeEnum: + """Tests for LLMType enum with MiniMax.""" + + def test_minimax_in_llm_type(self): + assert LLMType.MINIMAX == "minimax" + + def test_all_providers_present(self): + providers = [e.value for e in LLMType] + assert "openai" in providers + assert "anthropic" in providers + assert "googleai" in providers + assert "minimax" in providers + assert "videodb_proxy" in providers diff --git a/backend/tests/test_minimax_integration.py b/backend/tests/test_minimax_integration.py new file mode 100644 index 00000000..ddb62f65 --- /dev/null +++ b/backend/tests/test_minimax_integration.py @@ -0,0 +1,85 @@ +"""Integration tests for MiniMax LLM provider. + +These tests call the real MiniMax API and require MINIMAX_API_KEY to be set. +Skip with: pytest -m "not integration" +""" + +import json +import os + +import pytest + +pytestmark = pytest.mark.skipif( + not os.getenv("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set", +) + + +@pytest.fixture +def minimax_llm(): + """Create a MiniMax LLM instance with real API credentials.""" + from director.llm.minimax import MiniMax, MiniMaxConfig + + config = MiniMaxConfig() + return MiniMax(config=config) + + +class TestMiniMaxIntegration: + """Integration tests for MiniMax LLM provider against real API.""" + + def test_simple_chat(self, minimax_llm): + """Test a simple chat completion.""" + from director.llm.base import LLMResponseStatus + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Reply with exactly: Hello Director"}] + ) + assert result.status == LLMResponseStatus.SUCCESS + assert len(result.content) > 0 + assert result.total_tokens > 0 + + def test_json_response_format(self, minimax_llm): + """Test chat completion with JSON response format.""" + from director.llm.base import LLMResponseStatus + + result = minimax_llm.chat_completions( + [ + { + "role": "user", + "content": 'Return a JSON object with key "status" and value "ok". No other text.', + } + ], + response_format={"type": "json_object"}, + ) + assert result.status == LLMResponseStatus.SUCCESS + parsed = json.loads(result.content) + assert "status" in parsed + + def test_tool_calling(self, minimax_llm): + """Test function/tool calling capabilities.""" + from director.llm.base import LLMResponseStatus + + tools = [ + { + "name": "search_video", + "description": "Search for a video by query string", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query", + } + }, + "required": ["query"], + }, + } + ] + + result = minimax_llm.chat_completions( + [{"role": "user", "content": "Search for cat videos"}], + tools=tools, + ) + assert result.status == LLMResponseStatus.SUCCESS + assert len(result.tool_calls) > 0 + assert result.tool_calls[0]["tool"]["name"] == "search_video"