Merge pull request #52 from octo-patch/feature/add-minimax-provider
feat: add MiniMax as first-class LLM provider
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
|
```python
|
||||||
@@ -105,6 +115,7 @@ native_claude_config = {
|
|||||||
| `native` + `oai` | OpenAI 标准工具调用 | 较弱模型推荐,工具调用更规范 |
|
| `native` + `oai` | OpenAI 标准工具调用 | 较弱模型推荐,工具调用更规范 |
|
||||||
|
|
||||||
> 例:用 Claude 模型,但 API 服务提供的是 OpenAI 兼容接口 → 变量名用 `oai_xxx`。
|
> 例:用 Claude 模型,但 API 服务提供的是 OpenAI 兼容接口 → 变量名用 `oai_xxx`。
|
||||||
|
> 例:用 MiniMax 模型 → 变量名用 `oai_minimax_config`,MiniMax 走 OpenAI 兼容接口。
|
||||||
|
|
||||||
**`apibase` 填写规则**(会自动拼接端点路径):
|
**`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.
|
- **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.
|
- **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.
|
- **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
|
## 🧬 Self-Evolution Mechanism
|
||||||
@@ -199,7 +199,7 @@ MIT License — see [LICENSE](LICENSE)
|
|||||||
- **自我进化**: 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树
|
- **自我进化**: 每次任务自动沉淀 Skill,能力随使用持续增长,形成专属技能树
|
||||||
- **极简架构**: ~3,300 行核心代码,Agent Loop 仅 92 行,无复杂依赖,部署零负担
|
- **极简架构**: ~3,300 行核心代码,Agent Loop 仅 92 行,无复杂依赖,部署零负担
|
||||||
- **强执行力**: 注入真实浏览器(保留登录态),7 个原子工具直接接管系统
|
- **强执行力**: 注入真实浏览器(保留登录态),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
|
compress_history_tags._cd = getattr(compress_history_tags, '_cd', 0) + 1
|
||||||
if compress_history_tags._cd % 5 != 0: return messages
|
if compress_history_tags._cd % 5 != 0: return messages
|
||||||
_before = sum(len(json.dumps(m, ensure_ascii=False)) for m in 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):
|
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)
|
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
|
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,
|
temperature=0.5, max_tokens=None, tools=None, reasoning_effort=None,
|
||||||
max_retries=0, connect_timeout=10, read_timeout=300, proxies=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]."""
|
"""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"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"}
|
||||||
if api_mode == "responses":
|
if api_mode == "responses":
|
||||||
url = auto_make_url(api_base, "responses")
|
url = auto_make_url(api_base, "responses")
|
||||||
@@ -345,7 +347,9 @@ class ClaudeSession:
|
|||||||
return result[::-1] or raw_msgs[-2:]
|
return result[::-1] or raw_msgs[-2:]
|
||||||
def raw_ask(self, messages, model=None, temperature=0.5, max_tokens=6144):
|
def raw_ask(self, messages, model=None, temperature=0.5, max_tokens=6144):
|
||||||
model = model or self.default_model
|
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"}
|
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}
|
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"}}]
|
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"]
|
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()
|
content = content[:idx].strip()
|
||||||
except: pass
|
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)
|
think_match = re.search(think_pattern, content, re.DOTALL)
|
||||||
if think_match:
|
if think_match:
|
||||||
thinking = think_match.group(1).strip()
|
thinking = think_match.group(1).strip()
|
||||||
@@ -733,7 +737,7 @@ class ToolClient:
|
|||||||
|
|
||||||
def _parse_mixed_response(self, text):
|
def _parse_mixed_response(self, text):
|
||||||
remaining_text = text; thinking = ''
|
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)
|
think_match = re.search(think_pattern, text, re.DOTALL)
|
||||||
|
|
||||||
if think_match:
|
if think_match:
|
||||||
@@ -884,10 +888,10 @@ class NativeToolClient:
|
|||||||
if resp:
|
if resp:
|
||||||
_write_llm_log('Response', resp.raw)
|
_write_llm_log('Response', resp.raw)
|
||||||
text = resp.content
|
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:
|
if think_match:
|
||||||
resp.thinking = think_match.group(1).strip()
|
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()
|
resp.content = text.strip()
|
||||||
if resp and hasattr(resp, 'tool_calls') and resp.tool_calls and isinstance(self.backend, NativeClaudeSession):
|
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]
|
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 包)
|
# key命名含 'sider' 触发 SiderLLMSession(需安装 sider_ai_api 包)
|
||||||
#sider_cookie = 'token=Bearer%20eyJhbGciOiJIUz...'
|
#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
|
# If you need them
|
||||||
# tg_bot_token = '84102K2gYZ...'
|
# 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