From 066f36fa13bc48e85c79b4499bdf58c38f458eae Mon Sep 17 00:00:00 2001 From: Peidong Yang <106003209+ypd666@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:53:49 +0800 Subject: [PATCH] feat: add Moonshot and Kimi Coding Plan as LLM providers (#128) * feat: add Moonshot and Kimi Coding Plan as LLM providers Add two new providers for Moonshot AI: - `moonshot`: OpenAI-compatible direct API (api.moonshot.cn/v1) with kimi-k2.5, kimi-k2-thinking, moonshot-v1-auto/128k/32k/8k models - `kimi-coding`: Anthropic-compatible Kimi Coding Plan endpoint (api.kimi.com/coding/) with User-Agent header for compatibility Both providers disable thinking to avoid multi-turn tool calling errors caused by LangChain dropping reasoning_content from history. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update Moonshot thinking comment and add provider assertions - Add clarifying comment for disabling thinking on all Moonshot models - Add moonshot and kimi-coding assertions to test_entries_has_all_providers * fix: exclude Moonshot and Kimi Coding from content patch Tested and verified both APIs support standard list content format: - Moonshot (OpenAI-compatible): supports list content, no patch needed - Kimi Coding (Anthropic-compatible): supports list content, no patch needed Only apply _patch_openai_compat_content to strict providers like DeepSeek. * fix: set _original_provider in routed provider branches Ensure _original_provider is set before provider is reassigned to 'openai' or 'anthropic', so the no-patch exclusion for Moonshot and Kimi Coding works correctly. * style: translate Moonshot comments to English * style: translate comment to English to fix ruff lint error * merge: resolve conflicts * chore: revert uv.lock and translate Chinese comments to English Revert unrelated uv.lock dependency changes and replace Chinese code comments with English for codebase consistency per review feedback. Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix ruff format for models.py Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: ypd Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Co-authored-by: Xiaohui Yan Co-authored-by: X-iZhang --- .env.example | 2 + EvoScientist/config/onboard.py | 77 +++++++++++++++++++++++++++++++++ EvoScientist/config/settings.py | 8 ++++ EvoScientist/llm/models.py | 33 ++++++++++++-- tests/test_llm.py | 4 ++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 249cac7..2ebf6e4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ MINIMAX_API_KEY= # platform.minimaxi.com (Anthropic-compatible) ZHIPU_API_KEY= # open.bigmodel.cn (智谱) VOLCENGINE_API_KEY= # volcengine.com (火山引擎) DASHSCOPE_API_KEY= # dashscope.aliyuncs.com (阿里云) +MOONSHOT_API_KEY= # platform.moonshot.cn (月之暗面) +KIMI_API_KEY= # kimi.com/code (Kimi 代码计划) # Aggregator platforms (optional) SILICONFLOW_API_KEY= # siliconflow.cn diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index 3095f4c..47f7041 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -479,6 +479,63 @@ def validate_dashscope_key(api_key: str) -> tuple[bool, str]: return False, f"Error: {e}" +def validate_moonshot_key(api_key: str) -> tuple[bool, str]: + """Validate a Moonshot 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.moonshot.cn/v1", + ) + client.models.list() + return True, "Valid" + except Exception as e: + error_str = str(e).lower() + if any( + k in error_str for k in ("401", "unauthorized", "invalid", "authentication") + ): + return False, "Invalid API key" + return False, f"Error: {e}" + + +def validate_kimi_key(api_key: str) -> tuple[bool, str]: + """Validate a Kimi Coding Plan API key by making a test request. + + Uses the Anthropic-compatible endpoint at api.kimi.com/coding/. + + Returns: + Tuple of (is_valid, message). + """ + if not api_key: + return True, "Skipped (no key provided)" + + try: + import anthropic + + client = anthropic.Anthropic( + api_key=api_key, + base_url="https://api.kimi.com/coding/", + default_headers={"User-Agent": "claude-code/0.1.0"}, + ) + client.models.list() + return True, "Valid" + except Exception as e: + error_str = str(e).lower() + if any( + k in error_str for k in ("401", "unauthorized", "invalid", "authentication") + ): + return False, "Invalid API key" + return False, f"Error: {e}" + + def validate_tavily_key(api_key: str) -> tuple[bool, str]: """Validate a Tavily API key by making a test request. @@ -652,6 +709,14 @@ def _step_provider(config: EvoScientistConfig) -> str: title="DeepSeek (DeepSeek-R1, DeepSeek-V3)", value="deepseek", ), + Choice( + title="Moonshot (月之暗面 — Moonshot models)", + value="moonshot", + ), + Choice( + title="Kimi Coding Plan (Kimi 代码计划 — coding-focused)", + value="kimi-coding", + ), # Local Choice(title="Ollama (local models)", value="ollama"), # Third-party / aggregator @@ -751,6 +816,16 @@ def _provider_key_info(config: EvoScientistConfig, provider: str): config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""), validate_dashscope_key, ), + "moonshot": ( + "Moonshot", + config.moonshot_api_key or os.environ.get("MOONSHOT_API_KEY", ""), + validate_moonshot_key, + ), + "kimi-coding": ( + "Kimi Coding Plan", + config.kimi_api_key or os.environ.get("KIMI_API_KEY", ""), + validate_kimi_key, + ), "custom-openai": ( "OpenAI-compatible", config.custom_openai_api_key or os.environ.get("CUSTOM_OPENAI_API_KEY", ""), @@ -2789,6 +2864,8 @@ def run_onboard(skip_validation: bool = False) -> bool: "zhipu-code": "zhipu_api_key", "volcengine": "volcengine_api_key", "dashscope": "dashscope_api_key", + "moonshot": "moonshot_api_key", + "kimi-coding": "kimi_api_key", "custom-openai": "custom_openai_api_key", "custom-anthropic": "custom_anthropic_api_key", } diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 68b368f..dd31fb6 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -73,6 +73,8 @@ class EvoScientistConfig: zhipu_api_key: str = "" volcengine_api_key: str = "" dashscope_api_key: str = "" + moonshot_api_key: str = "" + kimi_api_key: str = "" custom_openai_api_key: str = "" custom_openai_base_url: str = "" custom_anthropic_api_key: str = "" @@ -369,6 +371,8 @@ _ENV_MAPPINGS = { "zhipu_api_key": "ZHIPU_API_KEY", "volcengine_api_key": "VOLCENGINE_API_KEY", "dashscope_api_key": "DASHSCOPE_API_KEY", + "moonshot_api_key": "MOONSHOT_API_KEY", + "kimi_api_key": "KIMI_API_KEY", "custom_openai_api_key": "CUSTOM_OPENAI_API_KEY", "custom_openai_base_url": "CUSTOM_OPENAI_BASE_URL", "custom_anthropic_api_key": "CUSTOM_ANTHROPIC_API_KEY", @@ -460,6 +464,10 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: os.environ["VOLCENGINE_API_KEY"] = config.volcengine_api_key if config.dashscope_api_key and not os.environ.get("DASHSCOPE_API_KEY"): os.environ["DASHSCOPE_API_KEY"] = config.dashscope_api_key + if config.moonshot_api_key and not os.environ.get("MOONSHOT_API_KEY"): + os.environ["MOONSHOT_API_KEY"] = config.moonshot_api_key + if config.kimi_api_key and not os.environ.get("KIMI_API_KEY"): + os.environ["KIMI_API_KEY"] = config.kimi_api_key if config.custom_openai_api_key and not os.environ.get("CUSTOM_OPENAI_API_KEY"): os.environ["CUSTOM_OPENAI_API_KEY"] = config.custom_openai_api_key if config.custom_openai_base_url and not os.environ.get("CUSTOM_OPENAI_BASE_URL"): diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 42473d9..4e1b208 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -29,11 +29,14 @@ _VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" _DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" _DEEPSEEK_BASE_URL = "https://api.deepseek.com" +_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1" +_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/" # Providers routed through the OpenAI provider with a custom base_url. # Maps provider name → (base_url or None, env var for API key). _OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { "deepseek": (_DEEPSEEK_BASE_URL, "DEEPSEEK_API_KEY"), + "moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"), "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"), "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"), "zhipu-code": (_ZHIPU_CODE_BASE_URL, "ZHIPU_API_KEY"), @@ -49,6 +52,7 @@ _OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { # Maps provider name → (base_url or None, env var for API key). _ANTHROPIC_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { "minimax": (_MINIMAX_ANTHROPIC_BASE_URL, "MINIMAX_API_KEY"), + "kimi-coding": (_KIMI_CODING_BASE_URL, "KIMI_API_KEY"), "custom-anthropic": (None, "CUSTOM_ANTHROPIC_API_KEY"), } @@ -160,6 +164,16 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [ # DeepSeek ("deepseek-r1", "deepseek-reasoner", "deepseek"), ("deepseek-v3", "deepseek-chat", "deepseek"), + # Moonshot (OpenAI-compatible) + ("kimi-k2.5", "kimi-k2.5", "moonshot"), + ("kimi-k2-thinking", "kimi-k2-thinking", "moonshot"), + ("kimi-k2-thinking-turbo", "kimi-k2-thinking-turbo", "moonshot"), + ("moonshot-v1-auto", "moonshot-v1-auto", "moonshot"), + ("moonshot-v1-128k", "moonshot-v1-128k", "moonshot"), + ("moonshot-v1-32k", "moonshot-v1-32k", "moonshot"), + ("moonshot-v1-8k", "moonshot-v1-8k", "moonshot"), + # Kimi Coding Plan (Anthropic-compatible) + ("kimi-for-coding", "kimi-for-coding", "kimi-coding"), ] # Public dict for simple lookups (last entry wins for duplicate names). @@ -321,6 +335,7 @@ def get_chat_model( # OpenAI-routed providers → route through OpenAI provider with base_url elif provider in _OPENAI_ROUTED_PROVIDERS: + _original_provider = provider base_url_default, api_key_env = _OPENAI_ROUTED_PROVIDERS[provider] if provider == "custom-openai": base_url = os.environ.get("CUSTOM_OPENAI_BASE_URL", "") @@ -342,6 +357,11 @@ def get_chat_model( # from history, causing error 20015 on multi-turn requests. if provider == "siliconflow": kwargs.setdefault("extra_body", {})["enable_thinking"] = False + # Moonshot: disable thinking for all models to prevent LangChain from dropping + # reasoning_content, which causes multi-turn conversation errors (error 20015). + # Even native thinking models like kimi-k2-thinking operate in non-thinking mode. + if provider == "moonshot": + kwargs.setdefault("extra_body", {})["thinking"] = {"type": "disabled"} provider = "openai" # OpenRouter → native ChatOpenRouter via init_chat_model. @@ -357,6 +377,7 @@ def get_chat_model( # Anthropic-routed providers → route through Anthropic provider with base_url elif provider in _ANTHROPIC_ROUTED_PROVIDERS: + _original_provider = provider base_url_default, api_key_env = _ANTHROPIC_ROUTED_PROVIDERS[provider] if provider == "custom-anthropic": base_url = os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "") @@ -374,7 +395,9 @@ def get_chat_model( api_key = os.environ.get(api_key_env, "") if api_key: kwargs["api_key"] = api_key - _original_provider = provider + # Kimi Coding Plan requires claude-code User-Agent header + if provider == "kimi-coding": + kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0" provider = "anthropic" elif provider == "ollama": @@ -399,10 +422,14 @@ def get_chat_model( chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs) - # Flatten list content to strings for OpenAI-compatible providers + # Flatten list content to strings for strict OpenAI-compatible providers # (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and # native OpenAI through a proxy, to avoid "sequence expected string" errors. - if _is_third_party or _is_openai_proxy: + # Moonshot and Kimi Coding support standard format, no patch needed. + _no_patch_providers = {"moonshot", "kimi-coding"} + if ( + _is_third_party or _is_openai_proxy + ) and _original_provider not in _no_patch_providers: _patch_openai_compat_content(chat_model) return chat_model diff --git a/tests/test_llm.py b/tests/test_llm.py index 37c91b6..aa6b69a 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -39,6 +39,8 @@ class TestModelsRegistry: assert "volcengine" in providers assert "dashscope" in providers assert "deepseek" in providers + assert "moonshot" in providers + assert "kimi-coding" in providers def test_entries_are_valid_tuples(self): """Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples.""" @@ -57,6 +59,8 @@ class TestModelsRegistry: "custom-openai", "custom-anthropic", "deepseek", + "moonshot", + "kimi-coding", } for entry in _MODEL_ENTRIES: assert len(entry) == 3, f"Entry {entry} doesn't have 3 elements"