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__)