- Temperature auto-clamping for MiniMax models: (0, 1] range enforcement - <think> tag handling for MiniMax M2.7 reasoning output (alongside existing <thinking> support) - MiniMax configuration example in mykey_template.py - Updated README.md and GETTING_STARTED.md with MiniMax provider docs - 19 unit tests + 6 integration tests (3 live tests with MINIMAX_API_KEY) MiniMax models (M2.7, M2.7-highspeed, M2.5, M2.5-highspeed) are accessed via the standard OpenAI-compatible interface at https://api.minimax.io/v1, using the existing LLMSession with an 'oai'-prefixed config key.
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""Unit tests for MiniMax provider support in llmcore.py."""
|
|
import json
|
|
import re
|
|
import sys
|
|
import os
|
|
import unittest
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
class TestMiniMaxTemperatureClamping(unittest.TestCase):
|
|
"""Test MiniMax temperature clamping in _openai_stream."""
|
|
|
|
def _make_stream_call(self, model, temperature):
|
|
"""Capture the payload sent by _openai_stream."""
|
|
from llmcore import _openai_stream
|
|
|
|
captured = {}
|
|
|
|
def fake_post(url, headers=None, json=None, stream=None, timeout=None, proxies=None):
|
|
captured['payload'] = json
|
|
captured['url'] = url
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.iter_lines.return_value = iter([b'data: [DONE]'])
|
|
resp.__enter__ = lambda s: s
|
|
resp.__exit__ = MagicMock(return_value=False)
|
|
return resp
|
|
|
|
with patch('llmcore.requests.post', side_effect=fake_post):
|
|
gen = _openai_stream(
|
|
'https://api.minimax.io/v1', 'test-key', [{"role": "user", "content": "hi"}],
|
|
model, temperature=temperature
|
|
)
|
|
# Drain the generator
|
|
for _ in gen:
|
|
pass
|
|
|
|
return captured.get('payload', {})
|
|
|
|
def test_minimax_temp_zero_clamped(self):
|
|
"""MiniMax rejects temperature=0, should be clamped to 0.01."""
|
|
payload = self._make_stream_call('MiniMax-M2.7', 0.0)
|
|
self.assertAlmostEqual(payload['temperature'], 0.01)
|
|
|
|
def test_minimax_temp_negative_clamped(self):
|
|
"""Negative temperature should be clamped to 0.01."""
|
|
payload = self._make_stream_call('MiniMax-M2.5', -0.5)
|
|
self.assertAlmostEqual(payload['temperature'], 0.01)
|
|
|
|
def test_minimax_temp_normal_preserved(self):
|
|
"""Normal temperature (0 < t <= 1) should be preserved."""
|
|
payload = self._make_stream_call('MiniMax-M2.7', 0.5)
|
|
self.assertAlmostEqual(payload['temperature'], 0.5)
|
|
|
|
def test_minimax_temp_one_preserved(self):
|
|
"""Temperature=1.0 should be preserved."""
|
|
payload = self._make_stream_call('MiniMax-M2.7-highspeed', 1.0)
|
|
self.assertAlmostEqual(payload['temperature'], 1.0)
|
|
|
|
def test_minimax_temp_above_one_clamped(self):
|
|
"""Temperature > 1.0 should be clamped to 1.0."""
|
|
payload = self._make_stream_call('MiniMax-M2.7', 1.5)
|
|
self.assertAlmostEqual(payload['temperature'], 1.0)
|
|
|
|
def test_minimax_case_insensitive(self):
|
|
"""Model name matching should be case-insensitive."""
|
|
payload = self._make_stream_call('minimax-m2.7', 0.0)
|
|
self.assertAlmostEqual(payload['temperature'], 0.01)
|
|
|
|
def test_non_minimax_temp_zero_unchanged(self):
|
|
"""Non-MiniMax models should not have temperature clamped."""
|
|
payload = self._make_stream_call('gpt-4o', 0.0)
|
|
self.assertAlmostEqual(payload['temperature'], 0.0)
|
|
|
|
def test_kimi_temp_still_forced(self):
|
|
"""Kimi/Moonshot temp override should still work."""
|
|
payload = self._make_stream_call('kimi-2.0', 0.5)
|
|
self.assertAlmostEqual(payload['temperature'], 1.0)
|
|
|
|
|
|
class TestMiniMaxThinkTagHandling(unittest.TestCase):
|
|
"""Test <think>...</think> tag stripping for MiniMax M2.7 responses."""
|
|
|
|
def test_think_tag_stripped_from_response(self):
|
|
"""<think> tags (used by MiniMax M2.7) should be stripped from content."""
|
|
from llmcore import ToolClient, LLMSession
|
|
|
|
mock_cfg = {
|
|
'apikey': 'test', 'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7',
|
|
}
|
|
with patch('llmcore._load_mykeys', return_value={}):
|
|
session = LLMSession(mock_cfg)
|
|
|
|
client = ToolClient(session)
|
|
text = '<think>Let me reason about this task.</think>\n\nHere is the answer.'
|
|
result = client._parse_mixed_response(text)
|
|
self.assertEqual(result.thinking, 'Let me reason about this task.')
|
|
self.assertEqual(result.content, 'Here is the answer.')
|
|
|
|
def test_thinking_tag_still_works(self):
|
|
"""<thinking> tags (used by Claude) should still work."""
|
|
from llmcore import ToolClient, LLMSession
|
|
|
|
mock_cfg = {
|
|
'apikey': 'test', 'apibase': 'https://api.anthropic.com',
|
|
'model': 'claude-sonnet-4-20250514',
|
|
}
|
|
with patch('llmcore._load_mykeys', return_value={}):
|
|
session = LLMSession(mock_cfg)
|
|
|
|
client = ToolClient(session)
|
|
text = '<thinking>Let me analyze this.</thinking>\n\nThe result is 42.'
|
|
result = client._parse_mixed_response(text)
|
|
self.assertEqual(result.thinking, 'Let me analyze this.')
|
|
self.assertEqual(result.content, 'The result is 42.')
|
|
|
|
def test_think_tag_with_tool_use(self):
|
|
"""<think> tags should be separated from tool_use blocks."""
|
|
from llmcore import ToolClient, LLMSession
|
|
|
|
mock_cfg = {
|
|
'apikey': 'test', 'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7',
|
|
}
|
|
with patch('llmcore._load_mykeys', return_value={}):
|
|
session = LLMSession(mock_cfg)
|
|
|
|
client = ToolClient(session)
|
|
text = '<think>I need to read the file first.</think>\n\n<summary>Reading config</summary>\n\n<tool_use>\n{"name": "file_read", "arguments": {"path": "/tmp/test.txt"}}\n</tool_use>'
|
|
result = client._parse_mixed_response(text)
|
|
self.assertEqual(result.thinking, 'I need to read the file first.')
|
|
self.assertTrue(len(result.tool_calls) > 0)
|
|
self.assertEqual(result.tool_calls[0].function.name, 'file_read')
|
|
|
|
|
|
class TestMiniMaxCompressHistoryTags(unittest.TestCase):
|
|
"""Test that <think> tags are compressed in history like <thinking> tags."""
|
|
|
|
def test_think_tag_compressed_in_old_messages(self):
|
|
"""<think> tags in old messages should be truncated."""
|
|
from llmcore import compress_history_tags
|
|
|
|
long_think = "A" * 2000
|
|
messages = [
|
|
{"role": "assistant", "prompt": f"<think>{long_think}</think>\nShort answer."},
|
|
{"role": "user", "prompt": "Follow up"},
|
|
] + [{"role": "user", "prompt": f"msg{i}"} for i in range(12)]
|
|
|
|
# Force compression (counter divisible by 5)
|
|
compress_history_tags._cd = 4
|
|
result = compress_history_tags(messages, keep_recent=10, max_len=800)
|
|
# The first message's <think> content should be truncated
|
|
first_content = result[0]["prompt"]
|
|
self.assertIn("<think>", first_content)
|
|
self.assertIn("...", first_content)
|
|
self.assertLess(len(first_content), len(f"<think>{long_think}</think>\nShort answer."))
|
|
|
|
|
|
class TestMiniMaxAutoMakeUrl(unittest.TestCase):
|
|
"""Test URL construction for MiniMax API base."""
|
|
|
|
def test_minimax_base_url(self):
|
|
from llmcore import auto_make_url
|
|
url = auto_make_url('https://api.minimax.io/v1', 'chat/completions')
|
|
self.assertEqual(url, 'https://api.minimax.io/v1/chat/completions')
|
|
|
|
def test_minimax_base_url_no_version(self):
|
|
from llmcore import auto_make_url
|
|
url = auto_make_url('https://api.minimax.io', 'chat/completions')
|
|
self.assertEqual(url, 'https://api.minimax.io/v1/chat/completions')
|
|
|
|
def test_minimax_full_url_preserved(self):
|
|
from llmcore import auto_make_url
|
|
url = auto_make_url('https://api.minimax.io/v1/chat/completions$', 'chat/completions')
|
|
self.assertEqual(url, 'https://api.minimax.io/v1/chat/completions')
|
|
|
|
|
|
class TestMiniMaxNativeOAISessionThinkTag(unittest.TestCase):
|
|
"""Test <think> tag handling in NativeOAISession."""
|
|
|
|
def test_think_tag_extracted_in_native_oai(self):
|
|
"""NativeOAISession.ask should extract <think> tags from MiniMax M2.7 responses."""
|
|
from llmcore import NativeOAISession
|
|
|
|
cfg = {
|
|
'apikey': 'test-key',
|
|
'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7',
|
|
}
|
|
session = NativeOAISession(cfg)
|
|
|
|
# Mock the raw_ask to return content with <think> tag via generator
|
|
def mock_raw_ask(messages, tools=None, system=None, model=None, temperature=0.5, max_tokens=6144, **kw):
|
|
content_text = "<think>Planning the approach.</think>\n\nHere is the result."
|
|
yield content_text
|
|
return [{"type": "text", "text": content_text}]
|
|
|
|
session.raw_ask = mock_raw_ask
|
|
|
|
msg = {"role": "user", "content": [{"type": "text", "text": "test"}]}
|
|
gen = session.ask(msg)
|
|
# Drain generator
|
|
try:
|
|
while True:
|
|
next(gen)
|
|
except StopIteration as e:
|
|
resp = e.value
|
|
|
|
self.assertEqual(resp.thinking, 'Planning the approach.')
|
|
self.assertNotIn('<think>', resp.content)
|
|
self.assertIn('Here is the result.', resp.content)
|
|
|
|
|
|
class TestMiniMaxLLMSessionConfig(unittest.TestCase):
|
|
"""Test LLMSession configuration with MiniMax settings."""
|
|
|
|
def test_llm_session_init_with_minimax(self):
|
|
"""LLMSession should initialize correctly with MiniMax config."""
|
|
from llmcore import LLMSession
|
|
|
|
cfg = {
|
|
'apikey': 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9',
|
|
'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7',
|
|
'context_win': 50000,
|
|
'max_retries': 2,
|
|
'connect_timeout': 10,
|
|
'read_timeout': 120,
|
|
}
|
|
session = LLMSession(cfg)
|
|
self.assertEqual(session.default_model, 'MiniMax-M2.7')
|
|
self.assertEqual(session.api_base, 'https://api.minimax.io/v1')
|
|
self.assertEqual(session.context_win, 50000)
|
|
self.assertEqual(session.max_retries, 2)
|
|
|
|
def test_llm_session_minimax_highspeed(self):
|
|
"""LLMSession should work with MiniMax-M2.7-highspeed model."""
|
|
from llmcore import LLMSession
|
|
|
|
cfg = {
|
|
'apikey': 'test-key',
|
|
'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7-highspeed',
|
|
}
|
|
session = LLMSession(cfg)
|
|
self.assertEqual(session.default_model, 'MiniMax-M2.7-highspeed')
|
|
|
|
|
|
class TestMiniMaxNativeToolClientThinkTag(unittest.TestCase):
|
|
"""Test <think> tag handling in NativeToolClient.chat."""
|
|
|
|
def test_native_tool_client_think_tag(self):
|
|
"""NativeToolClient should extract <think> tags from MiniMax responses."""
|
|
from llmcore import NativeToolClient, NativeOAISession, MockResponse
|
|
|
|
cfg = {
|
|
'apikey': 'test-key',
|
|
'apibase': 'https://api.minimax.io/v1',
|
|
'model': 'MiniMax-M2.7',
|
|
}
|
|
session = NativeOAISession(cfg)
|
|
client = NativeToolClient(session)
|
|
|
|
# Mock the backend.ask to yield chunks and return a MockResponse with think tags
|
|
def mock_ask(msg, tools=None, model=None):
|
|
text = "<think>Analyzing the request.</think>\n\nResult: success"
|
|
yield text
|
|
return MockResponse('', text, [], text)
|
|
|
|
session.ask = mock_ask
|
|
|
|
messages = [{"role": "user", "content": "test query"}]
|
|
gen = client.chat(messages)
|
|
resp = None
|
|
try:
|
|
while True:
|
|
next(gen)
|
|
except StopIteration as e:
|
|
resp = e.value
|
|
|
|
self.assertIsNotNone(resp)
|
|
self.assertEqual(resp.thinking, 'Analyzing the request.')
|
|
self.assertEqual(resp.content, 'Result: success')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|