feat: add MiniMax as first-class LLM provider
- 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.
This commit is contained in:
@@ -80,6 +80,16 @@ claude_config = {
|
||||
}
|
||||
```
|
||||
|
||||
```python
|
||||
# MiniMax 使用 OpenAI 兼容格式,变量名含 'oai' 即可
|
||||
# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文
|
||||
oai_minimax_config = {
|
||||
'apikey': 'eyJh...',
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7',
|
||||
}
|
||||
```
|
||||
|
||||
**使用标准工具调用格式(适合较弱模型):**
|
||||
|
||||
```python
|
||||
@@ -105,6 +115,7 @@ native_claude_config = {
|
||||
| `native` + `oai` | OpenAI 标准工具调用 | 较弱模型推荐,工具调用更规范 |
|
||||
|
||||
> 例:用 Claude 模型,但 API 服务提供的是 OpenAI 兼容接口 → 变量名用 `oai_xxx`。
|
||||
> 例:用 MiniMax 模型 → 变量名用 `oai_minimax_config`,MiniMax 走 OpenAI 兼容接口。
|
||||
|
||||
**`apibase` 填写规则**(会自动拼接端点路径):
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Every time GenericAgent solves a new task, it automatically crystallizes the exe
|
||||
- **Self-Evolving**: Automatically crystallizes each task into an skill. Capabilities grow with every use, forming your personal skill tree.
|
||||
- **Minimal Architecture**: ~3,300 lines of core code. Agent Loop is just 92 lines. No complex dependencies, zero deployment overhead.
|
||||
- **Strong Execution**: Injects into a real browser (preserving login sessions). 7 atomic tools take direct control of the system.
|
||||
- **High Compatibility**: Supports Claude / Gemini / Kimi and other major models. Cross-platform.
|
||||
- **High Compatibility**: Supports Claude / Gemini / Kimi / MiniMax and other major models. Cross-platform.
|
||||
|
||||
|
||||
## 🧬 Self-Evolution Mechanism
|
||||
@@ -199,7 +199,7 @@ MIT License — see [LICENSE](LICENSE)
|
||||
- **自我进化**: 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树
|
||||
- **极简架构**: ~3,300 行核心代码,Agent Loop 仅 92 行,无复杂依赖,部署零负担
|
||||
- **强执行力**: 注入真实浏览器(保留登录态),7 个原子工具直接接管系统
|
||||
- **高兼容性**: 支持 Claude / Gemini / Kimi 等主流模型,跨平台运行
|
||||
- **高兼容性**: 支持 Claude / Gemini / Kimi / MiniMax 等主流模型,跨平台运行
|
||||
|
||||
## 🧬 自我进化机制
|
||||
|
||||
|
||||
18
llmcore.py
18
llmcore.py
@@ -20,7 +20,7 @@ def compress_history_tags(messages, keep_recent=10, max_len=800):
|
||||
compress_history_tags._cd = getattr(compress_history_tags, '_cd', 0) + 1
|
||||
if compress_history_tags._cd % 5 != 0: return messages
|
||||
_before = sum(len(json.dumps(m, ensure_ascii=False)) for m in messages)
|
||||
_pats = {tag: re.compile(rf'(<{tag}>)([\s\S]*?)(</{tag}>)') for tag in ('thinking', 'tool_use', 'tool_result')}
|
||||
_pats = {tag: re.compile(rf'(<{tag}>)([\s\S]*?)(</{tag}>)') for tag in ('thinking', 'think', 'tool_use', 'tool_result')}
|
||||
def _trunc(text):
|
||||
for pat in _pats.values(): text = pat.sub(lambda m: m.group(1) + m.group(2)[:max_len] + '...' + m.group(3) if len(m.group(2)) > max_len else m.group(0), text)
|
||||
return text
|
||||
@@ -225,7 +225,9 @@ def _openai_stream(api_base, api_key, messages, model, api_mode='chat_completion
|
||||
temperature=0.5, max_tokens=None, tools=None, reasoning_effort=None,
|
||||
max_retries=0, connect_timeout=10, read_timeout=300, proxies=None):
|
||||
"""Shared OpenAI-compatible streaming request with retry. Yields text chunks, returns list[content_block]."""
|
||||
if 'kimi' in model.lower() or 'moonshot' in model.lower(): temperature = 1.0
|
||||
ml = model.lower()
|
||||
if 'kimi' in ml or 'moonshot' in ml: temperature = 1.0
|
||||
elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1]
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||
if api_mode == "responses":
|
||||
url = auto_make_url(api_base, "responses")
|
||||
@@ -345,7 +347,9 @@ class ClaudeSession:
|
||||
return result[::-1] or raw_msgs[-2:]
|
||||
def raw_ask(self, messages, model=None, temperature=0.5, max_tokens=6144):
|
||||
model = model or self.default_model
|
||||
if 'kimi' in model.lower() or 'moonshot' in model.lower(): temperature = 1.0 # kimi/moonshot only accepts temp 1.0
|
||||
ml = model.lower()
|
||||
if 'kimi' in ml or 'moonshot' in ml: temperature = 1.0 # kimi/moonshot only accepts temp 1.0
|
||||
elif 'minimax' in ml: temperature = max(0.01, min(temperature, 1.0)) # MiniMax requires temp in (0, 1]
|
||||
headers = {"x-api-key": self.api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01", "anthropic-beta": "prompt-caching-2024-07-31"}
|
||||
payload = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, "stream": True}
|
||||
if self.system: payload["system"] = [{"type": "text", "text": self.system, "cache_control": {"type": "persistent"}}]
|
||||
@@ -517,7 +521,7 @@ class NativeOAISession:
|
||||
tool_calls = [MockToolCall(b["name"], b.get("input", {}), id=b.get("id", "")) for b in raw if b.get("type") == "tool_use"]
|
||||
content = content[:idx].strip()
|
||||
except: pass
|
||||
think_pattern = r"<thinking>(.*?)</thinking>"; thinking = ''
|
||||
think_pattern = r"<think(?:ing)?>(.*?)</think(?:ing)?>"; thinking = ''
|
||||
think_match = re.search(think_pattern, content, re.DOTALL)
|
||||
if think_match:
|
||||
thinking = think_match.group(1).strip()
|
||||
@@ -733,7 +737,7 @@ class ToolClient:
|
||||
|
||||
def _parse_mixed_response(self, text):
|
||||
remaining_text = text; thinking = ''
|
||||
think_pattern = r"<thinking>(.*?)</thinking>"
|
||||
think_pattern = r"<think(?:ing)?>(.*?)</think(?:ing)?>"
|
||||
think_match = re.search(think_pattern, text, re.DOTALL)
|
||||
|
||||
if think_match:
|
||||
@@ -875,10 +879,10 @@ class NativeToolClient:
|
||||
if resp:
|
||||
_write_llm_log('Response', resp.raw)
|
||||
text = resp.content
|
||||
think_match = re.search(r'<thinking>(.*?)</thinking>', text, re.DOTALL)
|
||||
think_match = re.search(r'<think(?:ing)?>(.*?)</think(?:ing)?>', text, re.DOTALL)
|
||||
if think_match:
|
||||
resp.thinking = think_match.group(1).strip()
|
||||
text = re.sub(r'<thinking>.*?</thinking>', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'<think(?:ing)?>.*?</think(?:ing)?>', '', text, flags=re.DOTALL)
|
||||
resp.content = text.strip()
|
||||
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls and isinstance(self.backend, NativeClaudeSession):
|
||||
self._pending_tool_ids = [tc.id for tc in resp.tool_calls]
|
||||
|
||||
@@ -68,6 +68,16 @@ native_oai_config = {
|
||||
# key命名含 'sider' 触发 SiderLLMSession(需安装 sider_ai_api 包)
|
||||
#sider_cookie = 'token=Bearer%20eyJhbGciOiJIUz...'
|
||||
|
||||
# ── MiniMax (OpenAI-compatible) ─────────────────────────────────────────────────
|
||||
# MiniMax 使用 OpenAI 兼容接口,key命名含 'oai' 即可
|
||||
# 温度自动修正为 (0, 1],支持 M2.7 / M2.5 全系列,204K 上下文
|
||||
# oai_minimax_config = {
|
||||
# 'apikey': 'eyJh...', # MiniMax API Key
|
||||
# 'apibase': 'https://api.minimax.io/v1',
|
||||
# 'model': 'MiniMax-M2.7', # MiniMax-M2.7-highspeed / MiniMax-M2.5 等
|
||||
# 'context_win': 50000, # M2.7 支持 204K context
|
||||
# }
|
||||
|
||||
|
||||
# If you need them
|
||||
# tg_bot_token = '84102K2gYZ...'
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
17
tests/conftest.py
Normal file
17
tests/conftest.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Conftest to allow importing llmcore without mykey.py/mykey.json."""
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Create a minimal mykey.json so llmcore can be imported in test environments
|
||||
_repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_mykey_path = os.path.join(_repo_dir, 'mykey.json')
|
||||
if not os.path.exists(_mykey_path) and not os.path.exists(os.path.join(_repo_dir, 'mykey.py')):
|
||||
with open(_mykey_path, 'w') as f:
|
||||
json.dump({}, f)
|
||||
import atexit
|
||||
atexit.register(lambda: os.path.exists(_mykey_path) and os.unlink(_mykey_path))
|
||||
|
||||
# Create temp/ dir needed by _write_llm_log
|
||||
_temp_dir = os.path.join(_repo_dir, 'temp')
|
||||
os.makedirs(_temp_dir, exist_ok=True)
|
||||
290
tests/test_minimax.py
Normal file
290
tests/test_minimax.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""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()
|
||||
229
tests/test_minimax_integration.py
Normal file
229
tests/test_minimax_integration.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Integration tests for MiniMax provider support.
|
||||
|
||||
These tests verify end-to-end MiniMax integration by mocking the HTTP layer
|
||||
while exercising the full session → stream → parse pipeline.
|
||||
|
||||
To run against a real MiniMax API, set MINIMAX_API_KEY in your environment.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from io import BytesIO
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _make_sse_response(chunks, finish_reason="stop"):
|
||||
"""Build a mock SSE HTTP response from a list of text chunks."""
|
||||
lines = []
|
||||
for chunk in chunks:
|
||||
data = {
|
||||
"choices": [{"delta": {"content": chunk}}],
|
||||
}
|
||||
lines.append(f"data: {json.dumps(data)}".encode())
|
||||
# Final chunk with usage
|
||||
usage_data = {
|
||||
"choices": [{"delta": {}}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50,
|
||||
"prompt_tokens_details": {"cached_tokens": 0}},
|
||||
}
|
||||
lines.append(f"data: {json.dumps(usage_data)}".encode())
|
||||
lines.append(b"data: [DONE]")
|
||||
return lines
|
||||
|
||||
|
||||
class TestMiniMaxEndToEnd(unittest.TestCase):
|
||||
"""End-to-end integration test: LLMSession + ToolClient + MiniMax streaming."""
|
||||
|
||||
def test_full_pipeline_with_think_tag(self):
|
||||
"""Full pipeline: LLMSession → _openai_stream → ToolClient parse with <think> tag."""
|
||||
from llmcore import LLMSession, ToolClient
|
||||
|
||||
cfg = {
|
||||
'apikey': 'test-integration-key',
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7',
|
||||
'context_win': 50000,
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
client = ToolClient(session)
|
||||
|
||||
sse_lines = _make_sse_response([
|
||||
"<think>Let me analyze this task step by step.\n",
|
||||
"1. First, I need to understand the request.\n",
|
||||
"2. Then, execute the appropriate action.</think>\n\n",
|
||||
"<summary>Analyzing user request</summary>\n\n",
|
||||
"I'll help you with that task.",
|
||||
])
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.iter_lines.return_value = iter(sse_lines)
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch('llmcore.requests.post', return_value=mock_resp):
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Help me read a file."},
|
||||
]
|
||||
gen = client.chat(messages=messages, tools=None)
|
||||
chunks = []
|
||||
try:
|
||||
while True:
|
||||
chunks.append(next(gen))
|
||||
except StopIteration as e:
|
||||
response = e.value
|
||||
|
||||
self.assertIsNotNone(response)
|
||||
self.assertIn("analyze this task", response.thinking)
|
||||
self.assertIn("help you with that task", response.content)
|
||||
# <think> should be stripped from content
|
||||
self.assertNotIn("<think>", response.content)
|
||||
|
||||
def test_full_pipeline_with_tool_call(self):
|
||||
"""Full pipeline: MiniMax response with tool_use block."""
|
||||
from llmcore import LLMSession, ToolClient
|
||||
|
||||
cfg = {
|
||||
'apikey': 'test-key',
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7',
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
client = ToolClient(session)
|
||||
|
||||
sse_lines = _make_sse_response([
|
||||
"<think>I need to read the config file.</think>\n\n",
|
||||
"<summary>Reading config</summary>\n\n",
|
||||
'<tool_use>\n{"name": "file_read", "arguments": {"path": "/etc/config.json"}}\n</tool_use>',
|
||||
])
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.iter_lines.return_value = iter(sse_lines)
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch('llmcore.requests.post', return_value=mock_resp):
|
||||
messages = [{"role": "user", "content": "Read the config file."}]
|
||||
gen = client.chat(messages=messages, tools=None)
|
||||
try:
|
||||
while True:
|
||||
next(gen)
|
||||
except StopIteration as e:
|
||||
response = e.value
|
||||
|
||||
self.assertEqual(response.thinking, "I need to read the config file.")
|
||||
self.assertEqual(len(response.tool_calls), 1)
|
||||
self.assertEqual(response.tool_calls[0].function.name, "file_read")
|
||||
|
||||
def test_temperature_enforced_in_request(self):
|
||||
"""Verify the actual HTTP request has clamped temperature for MiniMax."""
|
||||
from llmcore import LLMSession
|
||||
|
||||
cfg = {
|
||||
'apikey': 'test-key',
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7',
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
captured = {}
|
||||
|
||||
def capture_post(url, headers=None, json=None, stream=None, timeout=None, proxies=None):
|
||||
captured['json'] = 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=capture_post):
|
||||
session.raw_msgs = [{"role": "user", "prompt": "test", "image": None}]
|
||||
gen = session.raw_ask(
|
||||
[{"role": "user", "content": "test"}],
|
||||
model='MiniMax-M2.7',
|
||||
temperature=0.0,
|
||||
)
|
||||
for _ in gen:
|
||||
pass
|
||||
|
||||
self.assertAlmostEqual(captured['json']['temperature'], 0.01)
|
||||
self.assertIn('api.minimax.io', captured['url'])
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
os.environ.get('MINIMAX_API_KEY'),
|
||||
'Set MINIMAX_API_KEY to run live integration tests'
|
||||
)
|
||||
class TestMiniMaxLive(unittest.TestCase):
|
||||
"""Live integration tests against MiniMax API (requires MINIMAX_API_KEY)."""
|
||||
|
||||
def test_live_chat_completion(self):
|
||||
"""Send a real chat completion to MiniMax API."""
|
||||
from llmcore import LLMSession
|
||||
|
||||
cfg = {
|
||||
'apikey': os.environ['MINIMAX_API_KEY'],
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7-highspeed',
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
|
||||
messages = [{"role": "user", "content": "Say 'hello' and nothing else."}]
|
||||
gen = session.raw_ask(messages, temperature=0.1)
|
||||
text = ''
|
||||
for chunk in gen:
|
||||
text += chunk
|
||||
|
||||
self.assertFalse(text.startswith('Error:'), f"API returned error: {text}")
|
||||
self.assertIn('hello', text.lower())
|
||||
|
||||
def test_live_tool_client_pipeline(self):
|
||||
"""Full ToolClient pipeline with real MiniMax API."""
|
||||
from llmcore import LLMSession, ToolClient
|
||||
|
||||
cfg = {
|
||||
'apikey': os.environ['MINIMAX_API_KEY'],
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7-highspeed',
|
||||
'context_win': 50000,
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
client = ToolClient(session)
|
||||
|
||||
messages = [{"role": "user", "content": "What is 2+2? Reply with just the number."}]
|
||||
gen = client.chat(messages=messages, tools=None)
|
||||
try:
|
||||
while True:
|
||||
next(gen)
|
||||
except StopIteration as e:
|
||||
response = e.value
|
||||
|
||||
self.assertIn('4', response.content)
|
||||
|
||||
def test_live_streaming_chunks(self):
|
||||
"""Verify streaming works with MiniMax API."""
|
||||
from llmcore import LLMSession
|
||||
|
||||
cfg = {
|
||||
'apikey': os.environ['MINIMAX_API_KEY'],
|
||||
'apibase': 'https://api.minimax.io/v1',
|
||||
'model': 'MiniMax-M2.7-highspeed',
|
||||
}
|
||||
session = LLMSession(cfg)
|
||||
|
||||
session.raw_msgs.append({"role": "user", "prompt": "Count from 1 to 5.", "image": None})
|
||||
result = session.ask("Count from 1 to 5.", stream=False)
|
||||
self.assertFalse(result.startswith('Error:'), f"API returned error: {result}")
|
||||
# Should contain at least some numbers
|
||||
for n in ['1', '2', '3']:
|
||||
self.assertIn(n, result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user