diff --git a/.env.example b/.env.example index 2f42db7..d3b13a3 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ OPENAI_API_KEY= # platform.openai.com GOOGLE_API_KEY= # aistudio.google.com/api-keys NVIDIA_API_KEY= # build.nvidia.com +# MiniMax (optional) +MINIMAX_API_KEY= # platform.minimaxi.com + # Third-party providers (optional) SILICONFLOW_API_KEY= # siliconflow.cn OPENROUTER_API_KEY= # openrouter.ai diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index a41d8f8..4523bc1 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -276,6 +276,35 @@ def validate_google_key(api_key: str) -> tuple[bool, str]: return False, f"Error: {e}" +def validate_minimax_key(api_key: str) -> tuple[bool, str]: + """Validate a MiniMax API key by making a test request. + + Returns: + Tuple of (is_valid, message). + """ + if not api_key: + return True, "Skipped (no key provided)" + + try: + import openai + + client = openai.OpenAI( + api_key=api_key, base_url="https://api.minimax.io/v1" + ) + client.models.list() + return True, "Valid" + except Exception as e: + error_str = str(e).lower() + if ( + "401" in error_str + or "unauthorized" in error_str + or "invalid" in error_str + or "authentication" in error_str + ): + return False, "Invalid API key" + return False, f"Error: {e}" + + def validate_siliconflow_key(api_key: str) -> tuple[bool, str]: """Validate a SiliconFlow API key by making a test request. @@ -576,6 +605,7 @@ def _step_provider(config: EvoScientistConfig) -> str: Choice(title="Anthropic (Claude models — API / OAuth)", value="anthropic"), Choice(title="OpenAI (GPT models — API / OAuth)", value="openai"), Choice(title="Google GenAI (Gemini models)", value="google-genai"), + Choice(title="MiniMax (M2.5 models — 204K context)", value="minimax"), Choice(title="NVIDIA (third party — limited free requests)", value="nvidia"), Choice( title="SiliconFlow (third party — GLM, Kimi, MiniMax, etc.)", @@ -636,6 +666,11 @@ def _provider_key_info(config: EvoScientistConfig, provider: str): config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", ""), validate_anthropic_key, ), + "minimax": ( + "MiniMax", + config.minimax_api_key or os.environ.get("MINIMAX_API_KEY", ""), + validate_minimax_key, + ), "nvidia": ( "NVIDIA", config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", ""), @@ -2356,6 +2391,7 @@ def run_onboard(skip_validation: bool = False) -> bool: # and for Anthropic/OpenAI pure OAuth — key provided by ccproxy) _PROVIDER_KEY_ATTR = { "anthropic": "anthropic_api_key", + "minimax": "minimax_api_key", "nvidia": "nvidia_api_key", "google-genai": "google_api_key", "siliconflow": "siliconflow_api_key", diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 5e070c1..f73a824 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -67,6 +67,7 @@ class EvoScientistConfig: openai_auth_mode: str = "api_key" # "api_key" | "oauth" nvidia_api_key: str = "" google_api_key: str = "" + minimax_api_key: str = "" siliconflow_api_key: str = "" openrouter_api_key: str = "" zhipu_api_key: str = "" @@ -346,6 +347,7 @@ _ENV_MAPPINGS = { "openai_auth_mode": "EVOSCIENTIST_OPENAI_AUTH_MODE", "nvidia_api_key": "NVIDIA_API_KEY", "google_api_key": "GOOGLE_API_KEY", + "minimax_api_key": "MINIMAX_API_KEY", "siliconflow_api_key": "SILICONFLOW_API_KEY", "openrouter_api_key": "OPENROUTER_API_KEY", "zhipu_api_key": "ZHIPU_API_KEY", @@ -427,6 +429,8 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: os.environ["NVIDIA_API_KEY"] = config.nvidia_api_key if config.google_api_key and not os.environ.get("GOOGLE_API_KEY"): os.environ["GOOGLE_API_KEY"] = config.google_api_key + if config.minimax_api_key and not os.environ.get("MINIMAX_API_KEY"): + os.environ["MINIMAX_API_KEY"] = config.minimax_api_key if config.siliconflow_api_key and not os.environ.get("SILICONFLOW_API_KEY"): os.environ["SILICONFLOW_API_KEY"] = config.siliconflow_api_key if config.openrouter_api_key and not os.environ.get("OPENROUTER_API_KEY"): diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 50e32ac..a48b16e 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -1,9 +1,9 @@ """LLM model configuration based on LangChain init_chat_model. This module provides a unified interface for creating chat model instances -with support for multiple providers (Anthropic, OpenAI, Google GenAI, NVIDIA, -SiliconFlow, OpenRouter, ZhipuAI, Volcengine, DashScope, Ollama, and custom -OpenAI-compatible endpoints) and convenient short names for common models. +with support for multiple providers (Anthropic, OpenAI, Google GenAI, MiniMax, +NVIDIA, SiliconFlow, OpenRouter, ZhipuAI, Volcengine, DashScope, Ollama, and +custom OpenAI-compatible endpoints) and convenient short names for common models. """ from __future__ import annotations @@ -63,6 +63,7 @@ def strip_thinking_tags(content: str) -> str: return _THINKING_TAG_RE.sub("", content) +_MINIMAX_BASE_URL = "https://api.minimax.io/v1" _SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1" _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" _ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4" @@ -73,6 +74,7 @@ _DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" # Third-party providers routed through the OpenAI provider with a custom base_url. # Maps provider name → (base_url or None, env var for API key). _THIRD_PARTY_PROVIDERS: dict[str, tuple[str | None, str]] = { + "minimax": (_MINIMAX_BASE_URL, "MINIMAX_API_KEY"), "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"), "openrouter": (_OPENROUTER_BASE_URL, "OPENROUTER_API_KEY"), "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"), @@ -125,6 +127,9 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [ ("gemini-2.5-flash", "gemini-2.5-flash", "google-genai"), ("gemini-2.5-flash-lite", "gemini-2.5-flash-lite", "google-genai"), ("gemini-2.5-pro", "gemini-2.5-pro", "google-genai"), + # MiniMax (direct API — api.minimax.io) + ("minimax-m2.5", "MiniMax-M2.5", "minimax"), + ("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed", "minimax"), # NVIDIA ("nemotron-super", "nvidia/nemotron-3-super-120b-a12b", "nvidia"), ("nemotron-nano", "nvidia/nemotron-3-nano-30b-a3b", "nvidia"), diff --git a/README.md b/README.md index 2c6dcf3..89abfea 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human ## ✨ Features - **🤖 Multi-Agent Team** — 6 sub-agents (plan, research, code, debug, analyze, write) working in concert. - **🧠 Persistent Memory** — Context, preferences, and findings survive across sessions. -- **🌐 Multi-Provider** — Anthropic, OpenAI, Google, NVIDIA — one config to switch. +- **🌐 Multi-Provider** — Anthropic, OpenAI, Google, MiniMax, NVIDIA — one config to switch. - **📱 Multi-Channel** — CLI as the hub; Telegram, Slack, Feishu, WeChat, and more — one agent session. - **🔬 Scientific Workflow** — Intake → plan → execute → evaluate → write → verify. - **🔌 MCP & Skills** — Plug in MCP servers or install skills from GitHub on the fly. @@ -227,10 +227,11 @@ Set at least one LLM provider key and (optionally) a search key: ```bash # Pick one LLM provider -export ANTHROPIC_API_KEY="sk-..." # Claude — console.anthropic.com -export OPENAI_API_KEY="sk-..." # GPT — platform.openai.com -export GOOGLE_API_KEY="AI..." # Gemini — aistudio.google.com/api-keys -export NVIDIA_API_KEY="nvapi-..." # NIM — build.nvidia.com +export ANTHROPIC_API_KEY="sk-..." # Claude — console.anthropic.com +export OPENAI_API_KEY="sk-..." # GPT — platform.openai.com +export GOOGLE_API_KEY="AI..." # Gemini — aistudio.google.com/api-keys +export MINIMAX_API_KEY="sk-..." # MiniMax — platform.minimaxi.com +export NVIDIA_API_KEY="nvapi-..." # NIM — build.nvidia.com # Web search (optional) export TAVILY_API_KEY="tvly-..." # app.tavily.com diff --git a/README.zh-CN.md b/README.zh-CN.md index 2cfa8d1..0f26096 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -96,7 +96,7 @@ EvoScientist 超越了传统的人在回路(Human-in-the-Loop)模式,采 - **🤖 多智能体协作** — 6 个子智能体(规划、调研、编码、调试、分析、写作)协同工作。 - **🧠 持久化记忆** — 上下文、偏好和研究发现跨会话保持。 -- **🌐 多模型供应商** — Anthropic、OpenAI、Google、NVIDIA——一处配置,随时切换。 +- **🌐 多模型供应商** — Anthropic、OpenAI、Google、MiniMax、NVIDIA——一处配置,随时切换。 - **📱 多渠道接入** — CLI 为中心;Telegram、Slack、飞书、微信等——共享同一智能体会话。 - **🔬 科学工作流** — 需求采集 → 规划 → 执行 → 评估 → 撰写 → 验证。 - **🔌 MCP 与 Skills** — 即插即用 MCP 服务器,或从 GitHub 一键安装技能包。 @@ -236,10 +236,11 @@ EvoSci onboard ```bash # 选择一个 LLM 供应商 -export ANTHROPIC_API_KEY="sk-..." # Claude — console.anthropic.com -export OPENAI_API_KEY="sk-..." # GPT — platform.openai.com -export GOOGLE_API_KEY="AI..." # Gemini — aistudio.google.com/api-keys -export NVIDIA_API_KEY="nvapi-..." # NIM — build.nvidia.com +export ANTHROPIC_API_KEY="sk-..." # Claude — console.anthropic.com +export OPENAI_API_KEY="sk-..." # GPT — platform.openai.com +export GOOGLE_API_KEY="AI..." # Gemini — aistudio.google.com/api-keys +export MINIMAX_API_KEY="sk-..." # MiniMax — platform.minimaxi.com +export NVIDIA_API_KEY="nvapi-..." # NIM — build.nvidia.com # 网络搜索(可选) export TAVILY_API_KEY="tvly-..." # app.tavily.com diff --git a/tests/test_llm.py b/tests/test_llm.py index e07854a..907f236 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -29,6 +29,7 @@ class TestModelsRegistry: assert "anthropic" in providers assert "openai" in providers assert "google-genai" in providers + assert "minimax" in providers assert "nvidia" in providers assert "siliconflow" in providers assert "openrouter" in providers @@ -43,6 +44,7 @@ class TestModelsRegistry: "anthropic", "openai", "google-genai", + "minimax", "nvidia", "siliconflow", "openrouter", @@ -478,6 +480,94 @@ class TestThirdPartyRouting: ) assert call_kwargs["api_key"] == "ds-key-456" + @patch("EvoScientist.llm.models.init_chat_model") + def test_minimax_routes_through_openai(self, mock_init, monkeypatch): + """MiniMax provider should route through OpenAI with correct base_url.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key-123") + + get_chat_model("MiniMax-M2.5", provider="minimax") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model_provider"] == "openai" + assert call_kwargs["base_url"] == "https://api.minimax.io/v1" + assert call_kwargs["api_key"] == "mm-key-123" + + @patch("EvoScientist.llm.models.init_chat_model") + def test_minimax_no_reasoning(self, mock_init, monkeypatch): + """MiniMax provider should NOT get auto-reasoning (routed via OpenAI).""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") + + get_chat_model("MiniMax-M2.5", provider="minimax") + + call_kwargs = mock_init.call_args[1] + assert "reasoning" not in call_kwargs + + @patch("EvoScientist.llm.models.init_chat_model") + def test_minimax_short_name_resolution(self, mock_init, monkeypatch): + """MiniMax short names should resolve to correct model IDs.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") + + get_chat_model("minimax-m2.5", provider="minimax") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2.5" + assert call_kwargs["model_provider"] == "openai" + + @patch("EvoScientist.llm.models.init_chat_model") + def test_minimax_highspeed_model(self, mock_init, monkeypatch): + """MiniMax M2.5-highspeed model should resolve correctly.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") + + get_chat_model("minimax-m2.5-highspeed", provider="minimax") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model"] == "MiniMax-M2.5-highspeed" + assert call_kwargs["model_provider"] == "openai" + assert call_kwargs["base_url"] == "https://api.minimax.io/v1" + + +# ============================================================================= +# Test MiniMax provider +# ============================================================================= + + +class TestMiniMaxProvider: + def test_minimax_in_third_party_providers(self): + """MiniMax should be registered in _THIRD_PARTY_PROVIDERS.""" + from EvoScientist.llm.models import _THIRD_PARTY_PROVIDERS + + assert "minimax" in _THIRD_PARTY_PROVIDERS + base_url, api_key_env = _THIRD_PARTY_PROVIDERS["minimax"] + assert base_url == "https://api.minimax.io/v1" + assert api_key_env == "MINIMAX_API_KEY" + + def test_minimax_models_registered(self): + """MiniMax should have direct model entries in _MODEL_ENTRIES.""" + minimax_models = get_models_for_provider("minimax") + assert len(minimax_models) >= 2 + model_names = {name for name, _ in minimax_models} + assert "minimax-m2.5" in model_names + assert "minimax-m2.5-highspeed" in model_names + + def test_minimax_model_ids_correct(self): + """MiniMax model IDs should match the official API model names.""" + minimax_models = get_models_for_provider("minimax") + model_dict = {name: mid for name, mid in minimax_models} + assert model_dict["minimax-m2.5"] == "MiniMax-M2.5" + assert model_dict["minimax-m2.5-highspeed"] == "MiniMax-M2.5-highspeed" + + def test_minimax_short_name_in_models_dict(self): + """MiniMax short names should be accessible via the MODELS dict.""" + # Note: MODELS dict uses last-entry-wins, so direct minimax entries + # may be overridden by nvidia/siliconflow/openrouter entries. + # Use get_models_for_provider() for provider-specific lookups. + minimax_models = get_models_for_provider("minimax") + assert len(minimax_models) > 0 + # ============================================================================= # Test _apply_auto_config diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 0000000..f961d89 --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,44 @@ +"""Integration tests for MiniMax direct provider. + +These tests validate that the MiniMax provider can connect to the real +MiniMax API (api.minimax.io/v1) and produce chat completions. + +Requires MINIMAX_API_KEY environment variable to be set. +""" + +import os + +import pytest + +from EvoScientist.llm import get_chat_model + + +pytestmark = pytest.mark.skipif( + not os.environ.get("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set", +) + + +class TestMiniMaxIntegration: + def test_minimax_m25_chat_completion(self): + """Test that MiniMax M2.5 can produce a chat completion.""" + model = get_chat_model("minimax-m2.5", provider="minimax", temperature=0) + response = model.invoke("Reply with exactly: hello") + assert response.content + assert len(response.content) > 0 + + def test_minimax_m25_highspeed_chat_completion(self): + """Test that MiniMax M2.5-highspeed can produce a chat completion.""" + model = get_chat_model( + "minimax-m2.5-highspeed", provider="minimax", temperature=0 + ) + response = model.invoke("Reply with exactly: world") + assert response.content + assert len(response.content) > 0 + + def test_minimax_with_full_model_id(self): + """Test using the full model ID directly.""" + model = get_chat_model("MiniMax-M2.5", provider="minimax", temperature=0) + response = model.invoke("What is 2+2? Answer with just the number.") + assert response.content + assert "4" in response.content