feat: add MiniMax as LLM text completion provider

- Add MiniMax chat model provider using OpenAI-compatible API
- Support MiniMax-M2.7 and MiniMax-M2.7-highspeed models
- Temperature clamping to MiniMax valid range (0.0, 1.0]
- Streaming support with token usage tracking
- MINIMAX_API_KEY environment variable support
- Add text-completion-minimax entry point
- Add 15 unit tests and 3 integration tests
- Update README with MiniMax in LLM APIs list
This commit is contained in:
PR Bot 2026-03-24 18:19:00 +08:00
parent d30857b5c3
commit 50f24b8e2a
8 changed files with 960 additions and 4 deletions

View file

@ -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<br>
- Google AI Studio<br>
- Google VertexAI<br>
- MiniMax<br>
- Mistral<br>
- OpenAI<br>

View file

@ -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

View file

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

View file

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

View file

@ -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"

View file

@ -0,0 +1,2 @@
from . llm import *

View file

@ -0,0 +1,6 @@
#!/usr/bin/env python3
from . llm import run
if __name__ == '__main__':
run()

View file

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