From b6f697d40f360ea790e21740a65e08d8d8d63585 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 29 Mar 2026 07:09:46 +0800 Subject: [PATCH] feat: add MiniMax as first-class LLM provider - Temperature auto-clamping for MiniMax models: (0, 1] range enforcement - tag handling for MiniMax M2.7 reasoning output (alongside existing 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. --- GETTING_STARTED.md | 11 ++ README.md | 4 +- llmcore.py | 18 +- mykey_template.py | 10 ++ tests/__init__.py | 0 tests/conftest.py | 17 ++ tests/test_minimax.py | 290 ++++++++++++++++++++++++++++++ tests/test_minimax_integration.py | 229 +++++++++++++++++++++++ 8 files changed, 570 insertions(+), 9 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_minimax.py create mode 100644 tests/test_minimax_integration.py 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]*?)()') for tag in ('thinking', 'tool_use', 'tool_result')} + _pats = {tag: re.compile(rf'(<{tag}>)([\s\S]*?)()') 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()