diff --git a/README.md b/README.md index 52df2720..d801e80b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ The platform: - [x] Deploy locally with Docker - [x] Deploy in cloud with Kubernetes - [x] Support for all major LLMs - - [x] API support for Anthropic, Cohere, Gemini, Mistral, OpenAI, and others + - [x] API support for Anthropic, Cohere, Gemini, MiniMax, Mistral, OpenAI, and others - [x] Model inferencing with vLLM, Ollama, TGI, LM Studio, and Llamafiles - [x] Developer friendly - [x] REST API [Docs](https://docs.trustgraph.ai/reference/apis/rest.html) @@ -50,7 +50,7 @@ The platform: How many times have you cloned a repo and opened the `.env.example` to see the dozens of API keys for 3rd party dependencies needed to make the services work? There are only 3 things in TrustGraph that might need an API key: -- 3rd party LLM services like Anthropic, Cohere, Gemini, Mistral, OpenAI, etc. +- 3rd party LLM services like Anthropic, Cohere, Gemini, MiniMax, Mistral, OpenAI, etc. - 3rd party OCR like Mistral OCR - The API key *you set* for the TrustGraph API gateway @@ -161,6 +161,7 @@ TrustGraph provides component flexibility to optimize agent workflows. - Cohere
- Google AI Studio
- Google VertexAI
+- MiniMax
- Mistral
- OpenAI
diff --git a/tests/integration/test_minimax_integration.py b/tests/integration/test_minimax_integration.py new file mode 100644 index 00000000..0ef91ca4 --- /dev/null +++ b/tests/integration/test_minimax_integration.py @@ -0,0 +1,86 @@ +""" +Integration tests for MiniMax text completion provider. +Tests actual API calls to MiniMax when MINIMAX_API_KEY is available. +""" + +import os +import pytest +from openai import OpenAI + +MINIMAX_API_KEY = os.getenv("MINIMAX_API_KEY") +MINIMAX_BASE_URL = "https://api.minimax.io/v1" + + +@pytest.mark.skipif( + not MINIMAX_API_KEY, + reason="MINIMAX_API_KEY not set" +) +class TestMiniMaxIntegration: + """Integration tests that call the real MiniMax API""" + + def get_client(self): + return OpenAI(api_key=MINIMAX_API_KEY, base_url=MINIMAX_BASE_URL) + + def test_basic_chat_completion(self): + """Test basic chat completion with MiniMax-M2.7""" + client = self.get_client() + + resp = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{ + "role": "user", + "content": 'Say "test passed" and nothing else.' + }], + temperature=1.0, + max_tokens=64, + ) + + assert resp.choices[0].message.content is not None + assert len(resp.choices[0].message.content) > 0 + assert resp.usage.prompt_tokens > 0 + assert resp.usage.completion_tokens > 0 + + def test_highspeed_model(self): + """Test chat completion with MiniMax-M2.7-highspeed""" + client = self.get_client() + + resp = client.chat.completions.create( + model="MiniMax-M2.7-highspeed", + messages=[{ + "role": "user", + "content": 'Say "highspeed test passed" and nothing else.' + }], + temperature=1.0, + max_tokens=64, + ) + + assert resp.choices[0].message.content is not None + assert len(resp.choices[0].message.content) > 0 + + def test_streaming_completion(self): + """Test streaming chat completion""" + client = self.get_client() + + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{ + "role": "user", + "content": 'Say "stream test passed" and nothing else.' + }], + temperature=1.0, + max_tokens=64, + stream=True, + stream_options={"include_usage": True} + ) + + chunks = list(response) + assert len(chunks) > 0 + + # Collect text from chunks + texts = [] + for chunk in chunks: + if chunk.choices and chunk.choices[0].delta.content: + texts.append(chunk.choices[0].delta.content) + + full_text = "".join(texts) + assert len(full_text) > 0 diff --git a/tests/unit/test_text_completion/conftest.py b/tests/unit/test_text_completion/conftest.py index c444ebbb..f96f4fc6 100644 --- a/tests/unit/test_text_completion/conftest.py +++ b/tests/unit/test_text_completion/conftest.py @@ -494,6 +494,45 @@ def mock_llamafile_client(): mock_response.choices[0].message.content = "Test response from LlamaFile" mock_response.usage.prompt_tokens = 14 mock_response.usage.completion_tokens = 8 - + mock_client.chat.completions.create.return_value = mock_response - return mock_client \ No newline at end of file + return mock_client + + +# === MiniMax Specific Fixtures === + +@pytest.fixture +def minimax_processor_config(base_processor_config): + """Default configuration for MiniMax processor""" + config = base_processor_config.copy() + config.update({ + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096 + }) + return config + + +@pytest.fixture +def mock_minimax_client(): + """Mock OpenAI client for MiniMax""" + mock_client = MagicMock() + + # Mock the response structure + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test response from MiniMax" + mock_response.usage.prompt_tokens = 18 + mock_response.usage.completion_tokens = 10 + + mock_client.chat.completions.create.return_value = mock_response + return mock_client + + +@pytest.fixture +def mock_minimax_rate_limit_error(): + """Mock MiniMax (OpenAI) rate limit error""" + from openai import RateLimitError + return RateLimitError("Rate limit exceeded", response=MagicMock(), body=None) \ No newline at end of file diff --git a/tests/unit/test_text_completion/test_minimax_processor.py b/tests/unit/test_text_completion/test_minimax_processor.py new file mode 100644 index 00000000..8c17d5ae --- /dev/null +++ b/tests/unit/test_text_completion/test_minimax_processor.py @@ -0,0 +1,570 @@ +""" +Unit tests for trustgraph.model.text_completion.minimax +Following the same successful pattern as OpenAI and other provider tests +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from unittest import IsolatedAsyncioTestCase + +# Import the service under test +from trustgraph.model.text_completion.minimax.llm import Processor +from trustgraph.base import LlmResult +from trustgraph.exceptions import TooManyRequests + + +class TestMiniMaxProcessorSimple(IsolatedAsyncioTestCase): + """Test MiniMax processor functionality""" + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_processor_initialization_basic(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test basic processor initialization""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act + processor = Processor(**config) + + # Assert + assert processor.default_model == 'MiniMax-M2.7' + assert processor.temperature == 1.0 + assert processor.max_output == 4096 + assert hasattr(processor, 'openai') + mock_openai_class.assert_called_once_with(base_url='https://api.minimax.io/v1', api_key='test-api-key') + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_success(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test successful content generation""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Generated response from MiniMax" + mock_response.usage.prompt_tokens = 20 + mock_response.usage.completion_tokens = 12 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act + result = await processor.generate_content("System prompt", "User prompt") + + # Assert + assert isinstance(result, LlmResult) + assert result.text == "Generated response from MiniMax" + assert result.in_token == 20 + assert result.out_token == 12 + assert result.model == 'MiniMax-M2.7' + + # Verify the API call + mock_openai_client.chat.completions.create.assert_called_once_with( + model='MiniMax-M2.7', + messages=[{ + "role": "user", + "content": [{ + "type": "text", + "text": "System prompt\n\nUser prompt" + }] + }], + temperature=1.0, + max_tokens=4096 + ) + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_rate_limit_error(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test rate limit error handling""" + # Arrange + from openai import RateLimitError + + mock_openai_client = MagicMock() + mock_openai_client.chat.completions.create.side_effect = RateLimitError("Rate limit exceeded", response=MagicMock(), body=None) + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act & Assert + with pytest.raises(TooManyRequests): + await processor.generate_content("System prompt", "User prompt") + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_generic_exception(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test handling of generic exceptions""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_client.chat.completions.create.side_effect = Exception("API connection error") + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act & Assert + with pytest.raises(Exception, match="API connection error"): + await processor.generate_content("System prompt", "User prompt") + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_processor_initialization_without_api_key(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test processor initialization without API key (should fail)""" + # Arrange + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': None, # No API key provided + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act & Assert + with pytest.raises(RuntimeError, match="MiniMax API key not specified"): + processor = Processor(**config) + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_processor_initialization_with_custom_parameters(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test processor initialization with custom parameters""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7-highspeed', + 'api_key': 'custom-api-key', + 'url': 'https://custom-minimax-url.com/v1', + 'temperature': 0.7, + 'max_output': 2048, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act + processor = Processor(**config) + + # Assert + assert processor.default_model == 'MiniMax-M2.7-highspeed' + assert processor.temperature == 0.7 + assert processor.max_output == 2048 + mock_openai_class.assert_called_once_with(base_url='https://custom-minimax-url.com/v1', api_key='custom-api-key') + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_processor_initialization_with_defaults(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test processor initialization with default values""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + # Only provide required fields, should use defaults + config = { + 'api_key': 'test-api-key', + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act + processor = Processor(**config) + + # Assert + assert processor.default_model == 'MiniMax-M2.7' # default_model + assert processor.temperature == 1.0 # default_temperature + assert processor.max_output == 4096 # default_max_output + mock_openai_class.assert_called_once_with(base_url='https://api.minimax.io/v1', api_key='test-api-key') + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_temperature_clamping_zero(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test that temperature 0.0 is clamped to 0.01""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 0.0, # Should be clamped to 0.01 + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act + processor = Processor(**config) + + # Assert + assert processor.temperature == 0.01 # Clamped from 0.0 + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_temperature_clamping_above_one(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test that temperature above 1.0 is clamped to 1.0""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.5, # Should be clamped to 1.0 + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + # Act + processor = Processor(**config) + + # Assert + assert processor.temperature == 1.0 # Clamped from 1.5 + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_temperature_clamping_at_runtime(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test that runtime temperature override is also clamped""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response" + mock_response.usage.prompt_tokens = 10 + mock_response.usage.completion_tokens = 5 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 0.5, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act - Override temperature with 0.0 at runtime + await processor.generate_content("System", "User", temperature=0.0) + + # Assert - temperature should be clamped to 0.01 + call_kwargs = mock_openai_client.chat.completions.create.call_args.kwargs + assert call_kwargs['temperature'] == 0.01 + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_temperature_override(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test temperature parameter override functionality""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response with custom temperature" + mock_response.usage.prompt_tokens = 15 + mock_response.usage.completion_tokens = 10 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act - Override temperature at runtime + result = await processor.generate_content( + "System prompt", + "User prompt", + model=None, + temperature=0.9 + ) + + # Assert + assert isinstance(result, LlmResult) + assert result.text == "Response with custom temperature" + call_kwargs = mock_openai_client.chat.completions.create.call_args.kwargs + assert call_kwargs['temperature'] == 0.9 + assert call_kwargs['model'] == 'MiniMax-M2.7' + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_model_override(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test model parameter override functionality""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response with custom model" + mock_response.usage.prompt_tokens = 15 + mock_response.usage.completion_tokens = 10 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 0.5, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act - Override model at runtime + result = await processor.generate_content( + "System prompt", + "User prompt", + model="MiniMax-M2.7-highspeed", + temperature=None + ) + + # Assert + assert isinstance(result, LlmResult) + assert result.text == "Response with custom model" + call_kwargs = mock_openai_client.chat.completions.create.call_args.kwargs + assert call_kwargs['model'] == 'MiniMax-M2.7-highspeed' + assert call_kwargs['temperature'] == 0.5 + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_supports_streaming(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test that MiniMax provider reports streaming support""" + # Arrange + mock_openai_client = MagicMock() + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Assert + assert processor.supports_streaming() is True + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_empty_prompts(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test content generation with empty prompts""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Default response" + mock_response.usage.prompt_tokens = 2 + mock_response.usage.completion_tokens = 3 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 1.0, + 'max_output': 4096, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act + result = await processor.generate_content("", "") + + # Assert + assert isinstance(result, LlmResult) + assert result.text == "Default response" + call_args = mock_openai_client.chat.completions.create.call_args + expected_prompt = "\n\n" + assert call_args[1]['messages'][0]['content'][0]['text'] == expected_prompt + + @patch('trustgraph.model.text_completion.minimax.llm.OpenAI') + @patch('trustgraph.base.async_processor.AsyncProcessor.__init__') + @patch('trustgraph.base.llm_service.LlmService.__init__') + async def test_generate_content_message_structure(self, mock_llm_init, mock_async_init, mock_openai_class): + """Test that messages are structured correctly""" + # Arrange + mock_openai_client = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Response with proper structure" + mock_response.usage.prompt_tokens = 25 + mock_response.usage.completion_tokens = 15 + + mock_openai_client.chat.completions.create.return_value = mock_response + mock_openai_class.return_value = mock_openai_client + + mock_async_init.return_value = None + mock_llm_init.return_value = None + + config = { + 'model': 'MiniMax-M2.7', + 'api_key': 'test-api-key', + 'url': 'https://api.minimax.io/v1', + 'temperature': 0.5, + 'max_output': 1024, + 'concurrency': 1, + 'taskgroup': AsyncMock(), + 'id': 'test-processor' + } + + processor = Processor(**config) + + # Act + result = await processor.generate_content("You are a helpful assistant", "What is AI?") + + # Assert + assert result.text == "Response with proper structure" + assert result.in_token == 25 + assert result.out_token == 15 + + call_args = mock_openai_client.chat.completions.create.call_args + messages = call_args[1]['messages'] + + assert len(messages) == 1 + assert messages[0]['role'] == 'user' + assert messages[0]['content'][0]['type'] == 'text' + assert messages[0]['content'][0]['text'] == "You are a helpful assistant\n\nWhat is AI?" + + assert call_args[1]['model'] == 'MiniMax-M2.7' + assert call_args[1]['temperature'] == 0.5 + assert call_args[1]['max_tokens'] == 1024 + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/trustgraph-flow/pyproject.toml b/trustgraph-flow/pyproject.toml index dc131a9e..14f9a303 100644 --- a/trustgraph-flow/pyproject.toml +++ b/trustgraph-flow/pyproject.toml @@ -108,6 +108,7 @@ text-completion-claude = "trustgraph.model.text_completion.claude:run" text-completion-cohere = "trustgraph.model.text_completion.cohere:run" text-completion-llamafile = "trustgraph.model.text_completion.llamafile:run" text-completion-lmstudio = "trustgraph.model.text_completion.lmstudio:run" +text-completion-minimax = "trustgraph.model.text_completion.minimax:run" text-completion-mistral = "trustgraph.model.text_completion.mistral:run" text-completion-ollama = "trustgraph.model.text_completion.ollama:run" text-completion-openai = "trustgraph.model.text_completion.openai:run" diff --git a/trustgraph-flow/trustgraph/model/text_completion/minimax/__init__.py b/trustgraph-flow/trustgraph/model/text_completion/minimax/__init__.py new file mode 100644 index 00000000..4498d15d --- /dev/null +++ b/trustgraph-flow/trustgraph/model/text_completion/minimax/__init__.py @@ -0,0 +1,2 @@ + +from . llm import * diff --git a/trustgraph-flow/trustgraph/model/text_completion/minimax/__main__.py b/trustgraph-flow/trustgraph/model/text_completion/minimax/__main__.py new file mode 100644 index 00000000..244e5808 --- /dev/null +++ b/trustgraph-flow/trustgraph/model/text_completion/minimax/__main__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from . llm import run + +if __name__ == '__main__': + run() diff --git a/trustgraph-flow/trustgraph/model/text_completion/minimax/llm.py b/trustgraph-flow/trustgraph/model/text_completion/minimax/llm.py new file mode 100644 index 00000000..3ecefeca --- /dev/null +++ b/trustgraph-flow/trustgraph/model/text_completion/minimax/llm.py @@ -0,0 +1,251 @@ + +""" +Simple LLM service, performs text prompt completion using MiniMax. +Input is prompt, output is response. +""" + +from openai import OpenAI, RateLimitError +import os +import logging + +from .... exceptions import TooManyRequests +from .... base import LlmService, LlmResult, LlmChunk + +# Module logger +logger = logging.getLogger(__name__) + +default_ident = "text-completion" + +default_model = 'MiniMax-M2.7' +default_temperature = 1.0 +default_max_output = 4096 +default_api_key = os.getenv("MINIMAX_API_KEY") +default_base_url = os.getenv("MINIMAX_BASE_URL") + +if default_base_url is None or default_base_url == "": + default_base_url = "https://api.minimax.io/v1" + +class Processor(LlmService): + + def __init__(self, **params): + + model = params.get("model", default_model) + api_key = params.get("api_key", default_api_key) + base_url = params.get("url", default_base_url) + temperature = params.get("temperature", default_temperature) + max_output = params.get("max_output", default_max_output) + + if api_key is None: + raise RuntimeError("MiniMax API key not specified") + + # Clamp temperature to MiniMax's valid range (0.0, 1.0] + if temperature is not None: + if temperature <= 0.0: + temperature = 0.01 + elif temperature > 1.0: + temperature = 1.0 + + super(Processor, self).__init__( + **params | { + "model": model, + "temperature": temperature, + "max_output": max_output, + "base_url": base_url, + } + ) + + self.default_model = model + self.temperature = temperature + self.max_output = max_output + + self.openai = OpenAI(base_url=base_url, api_key=api_key) + + logger.info("MiniMax LLM service initialized") + + def _clamp_temperature(self, temperature): + """Clamp temperature to MiniMax's valid range (0.0, 1.0]""" + if temperature is None: + return self.temperature + if temperature <= 0.0: + return 0.01 + if temperature > 1.0: + return 1.0 + return temperature + + async def generate_content(self, system, prompt, model=None, temperature=None): + + # Use provided model or fall back to default + model_name = model or self.default_model + # Use provided temperature or fall back to default + effective_temperature = self._clamp_temperature(temperature) + + logger.debug(f"Using model: {model_name}") + logger.debug(f"Using temperature: {effective_temperature}") + + prompt = system + "\n\n" + prompt + + try: + + resp = self.openai.chat.completions.create( + model=model_name, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt + } + ] + } + ], + temperature=effective_temperature, + max_tokens=self.max_output, + ) + + inputtokens = resp.usage.prompt_tokens + outputtokens = resp.usage.completion_tokens + logger.debug(f"LLM response: {resp.choices[0].message.content}") + logger.info(f"Input Tokens: {inputtokens}") + logger.info(f"Output Tokens: {outputtokens}") + + resp = LlmResult( + text = resp.choices[0].message.content, + in_token = inputtokens, + out_token = outputtokens, + model = model_name + ) + + return resp + + # FIXME: Wrong exception, don't know what this LLM throws + # for a rate limit + except RateLimitError: + + # Leave rate limit retries to the base handler + raise TooManyRequests() + + except Exception as e: + + # Apart from rate limits, treat all exceptions as unrecoverable + + logger.error(f"MiniMax LLM exception ({type(e).__name__}): {e}", exc_info=True) + raise e + + def supports_streaming(self): + """MiniMax supports streaming""" + return True + + async def generate_content_stream(self, system, prompt, model=None, temperature=None): + """ + Stream content generation from MiniMax. + Yields LlmChunk objects with is_final=True on the last chunk. + """ + # Use provided model or fall back to default + model_name = model or self.default_model + # Use provided temperature or fall back to default + effective_temperature = self._clamp_temperature(temperature) + + logger.debug(f"Using model (streaming): {model_name}") + logger.debug(f"Using temperature: {effective_temperature}") + + prompt = system + "\n\n" + prompt + + try: + response = self.openai.chat.completions.create( + model=model_name, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": prompt + } + ] + } + ], + temperature=effective_temperature, + max_tokens=self.max_output, + stream=True, + stream_options={"include_usage": True} + ) + + total_input_tokens = 0 + total_output_tokens = 0 + + # Stream chunks + for chunk in response: + if chunk.choices and chunk.choices[0].delta.content: + yield LlmChunk( + text=chunk.choices[0].delta.content, + in_token=None, + out_token=None, + model=model_name, + is_final=False + ) + + # Capture usage from final chunk + if chunk.usage: + total_input_tokens = chunk.usage.prompt_tokens + total_output_tokens = chunk.usage.completion_tokens + + # Send final chunk with token counts + yield LlmChunk( + text="", + in_token=total_input_tokens, + out_token=total_output_tokens, + model=model_name, + is_final=True + ) + + logger.debug("Streaming complete") + + except RateLimitError: + logger.warning("Hit rate limit during streaming") + raise TooManyRequests() + + except Exception as e: + logger.error(f"MiniMax streaming exception ({type(e).__name__}): {e}", exc_info=True) + raise e + + @staticmethod + def add_args(parser): + + LlmService.add_args(parser) + + parser.add_argument( + '-m', '--model', + default="MiniMax-M2.7", + help=f'LLM model (default: MiniMax-M2.7)' + ) + + parser.add_argument( + '-k', '--api-key', + default=default_api_key, + help=f'MiniMax API key' + ) + + parser.add_argument( + '-u', '--url', + default=default_base_url, + help=f'MiniMax service base URL' + ) + + parser.add_argument( + '-t', '--temperature', + type=float, + default=default_temperature, + help=f'LLM temperature parameter (default: {default_temperature})' + ) + + parser.add_argument( + '-x', '--max-output', + type=int, + default=default_max_output, + help=f'LLM max output tokens (default: {default_max_output})' + ) + +def run(): + + Processor.launch(default_ident, __doc__)