diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md
index 514c11d..0b95e1d 100644
--- a/GETTING_STARTED.md
+++ b/GETTING_STARTED.md
@@ -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` 填写规则**(会自动拼接端点路径):
diff --git a/README.md b/README.md
index 5063e06..c2ef6d2 100644
--- a/README.md
+++ b/README.md
@@ -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 等主流模型,跨平台运行
## 🧬 自我进化机制
diff --git a/llmcore.py b/llmcore.py
index b280d74..8faa964 100644
--- a/llmcore.py
+++ b/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 = ''
+ think_pattern = r"(.*?)"; 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"(.*?)"
+ think_pattern = r"(.*?)"
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'(.*?)', text, re.DOTALL)
+ think_match = re.search(r'(.*?)', text, re.DOTALL)
if think_match:
resp.thinking = think_match.group(1).strip()
- text = re.sub(r'.*?', '', text, flags=re.DOTALL)
+ text = re.sub(r'.*?', '', 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]
diff --git a/mykey_template.py b/mykey_template.py
index 1a5a674..193ef66 100644
--- a/mykey_template.py
+++ b/mykey_template.py
@@ -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...'
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..382b6c4
--- /dev/null
+++ b/tests/conftest.py
@@ -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)
diff --git a/tests/test_minimax.py b/tests/test_minimax.py
new file mode 100644
index 0000000..67544de
--- /dev/null
+++ b/tests/test_minimax.py
@@ -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 ... tag stripping for MiniMax M2.7 responses."""
+
+ def test_think_tag_stripped_from_response(self):
+ """ 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 = 'Let me reason about this task.\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):
+ """ 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 = 'Let me analyze this.\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):
+ """ 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 = 'I need to read the file first.\n\nReading config\n\n\n{"name": "file_read", "arguments": {"path": "/tmp/test.txt"}}\n'
+ 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 tags are compressed in history like tags."""
+
+ def test_think_tag_compressed_in_old_messages(self):
+ """ tags in old messages should be truncated."""
+ from llmcore import compress_history_tags
+
+ long_think = "A" * 2000
+ messages = [
+ {"role": "assistant", "prompt": f"{long_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 content should be truncated
+ first_content = result[0]["prompt"]
+ self.assertIn("", first_content)
+ self.assertIn("...", first_content)
+ self.assertLess(len(first_content), len(f"{long_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 tag handling in NativeOAISession."""
+
+ def test_think_tag_extracted_in_native_oai(self):
+ """NativeOAISession.ask should extract 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 tag via generator
+ def mock_raw_ask(messages, tools=None, system=None, model=None, temperature=0.5, max_tokens=6144, **kw):
+ content_text = "Planning the approach.\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('', 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 tag handling in NativeToolClient.chat."""
+
+ def test_native_tool_client_think_tag(self):
+ """NativeToolClient should extract 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 = "Analyzing the request.\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()
diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py
new file mode 100644
index 0000000..ae7d63e
--- /dev/null
+++ b/tests/test_minimax_integration.py
@@ -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 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([
+ "Let me analyze this task step by step.\n",
+ "1. First, I need to understand the request.\n",
+ "2. Then, execute the appropriate action.\n\n",
+ "Analyzing user request\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)
+ # should be stripped from content
+ self.assertNotIn("", 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([
+ "I need to read the config file.\n\n",
+ "Reading config\n\n",
+ '\n{"name": "file_read", "arguments": {"path": "/etc/config.json"}}\n',
+ ])
+
+ 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()