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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * style: fix ruff format for models.py Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ypd <ypd@ypddeMac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Co-authored-by: Xiaohui Yan <xhcloud@gmail.com> Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user