Merge branch 'main' into feat/sandbox-provisioner-shared-storage

This commit is contained in:
supreme0597 2026-03-25 00:01:58 +08:00
commit 4cc795da9c
8 changed files with 314 additions and 3 deletions

View File

@ -49,6 +49,7 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co
# DASHSCOPE_API_KEY= # DASHSCOPE_API_KEY=
# DEEPSEEK_API_KEY= # DEEPSEEK_API_KEY=
# ARK_API_KEY= # ARK_API_KEY=
# MINIMAX_API_KEY= # MiniMax 大模型 https://platform.minimaxi.com/
# TOGETHER_API_KEY= # TOGETHER_API_KEY=
# # endregion model_provider # # endregion model_provider

View File

@ -9,6 +9,7 @@
| [硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) | `SILICONFLOW_API_KEY` | 🆓 免费额度,默认推荐 | | [硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) | `SILICONFLOW_API_KEY` | 🆓 免费额度,默认推荐 |
| OpenAI | `OPENAI_API_KEY` | GPT 系列模型 | | OpenAI | `OPENAI_API_KEY` | GPT 系列模型 |
| DeepSeek | `DEEPSEEK_API_KEY` | 国产大模型 | | DeepSeek | `DEEPSEEK_API_KEY` | 国产大模型 |
| [MiniMax](https://platform.minimaxi.com/) | `MINIMAX_API_KEY` | M2.7/M2.5 系列,百万 token 上下文 |
| OpenRouter | `OPENROUTER_API_KEY` | 多模型聚合平台 | | OpenRouter | `OPENROUTER_API_KEY` | 多模型聚合平台 |
| 智谱清言 | `ZHIPUAI_API_KEY` | GLM 系列模型 | | 智谱清言 | `ZHIPUAI_API_KEY` | GLM 系列模型 |
| 阿里云百炼 | `DASHSCOPE_API_KEY` | 通义千问系列 | | 阿里云百炼 | `DASHSCOPE_API_KEY` | 通义千问系列 |

View File

@ -231,13 +231,14 @@ async def test_mcp_server(
await get_server_or_404(db, name) await get_server_or_404(db, name)
try: try:
tools = await get_all_mcp_tools(name) tools = await get_all_mcp_tools(name, raise_on_error=True)
return { return {
"success": True, "success": True,
"message": f"连接成功,共发现 {len(tools)} 个工具", "message": f"连接成功,共发现 {len(tools)} 个工具",
"tool_count": len(tools), "tool_count": len(tools),
} }
except Exception as test_error: except Exception as test_error:
logger.warning(f"MCP server test failed for '{name}': {test_error}")
raise HTTPException(status_code=500, detail=f"连接失败: {str(test_error)}") raise HTTPException(status_code=500, detail=f"连接失败: {str(test_error)}")
except HTTPException: except HTTPException:
raise raise

View File

@ -115,6 +115,19 @@ DEFAULT_CHAT_MODEL_PROVIDERS: dict[str, ChatModelProvider] = {
"doubao-seed-2-0-mini-260215", "doubao-seed-2-0-mini-260215",
], ],
), ),
"minimax": ChatModelProvider(
name="MiniMax",
url="https://platform.minimaxi.com/document/introduction",
base_url="https://api.minimax.io/v1",
default="MiniMax-M2.7",
env="MINIMAX_API_KEY",
models=[
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
],
),
"openrouter": ChatModelProvider( "openrouter": ChatModelProvider(
name="OpenRouter", name="OpenRouter",
url="https://openrouter.ai/models", url="https://openrouter.ai/models",

View File

@ -210,6 +210,7 @@ async def get_mcp_tools(
disabled_tools: list[str] = None, disabled_tools: list[str] = None,
cache: bool = True, cache: bool = True,
force_refresh: bool = False, force_refresh: bool = False,
raise_on_error: bool = False,
) -> list[Callable[..., Any]]: ) -> list[Callable[..., Any]]:
"""Get MCP tools for a specific server. """Get MCP tools for a specific server.
@ -288,11 +289,15 @@ async def get_mcp_tools(
except AssertionError as e: except AssertionError as e:
logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}") logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}")
if raise_on_error:
raise
return [] return []
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Failed to load tools from MCP server '{server_name}': {e}, traceback: {traceback.format_exc()}" f"Failed to load tools from MCP server '{server_name}': {e}, traceback: {traceback.format_exc()}"
) )
if raise_on_error:
raise
return [] return []
# 3. Filtering (Apply to Return Value Only) # 3. Filtering (Apply to Return Value Only)
@ -602,7 +607,7 @@ async def get_servers_config(names: list[str]) -> dict[str, dict[str, Any]]:
return {name: MCP_SERVERS[name] for name in names if name in MCP_SERVERS} return {name: MCP_SERVERS[name] for name in names if name in MCP_SERVERS}
async def get_all_mcp_tools(server_name: str) -> list: async def get_all_mcp_tools(server_name: str, raise_on_error: bool = False) -> list:
"""Get all tools of an MCP server (no filtering). """Get all tools of an MCP server (no filtering).
For management UI to display tool list, supports viewing all tools and their enabled status. For management UI to display tool list, supports viewing all tools and their enabled status.
@ -610,6 +615,7 @@ async def get_all_mcp_tools(server_name: str) -> list:
Args: Args:
server_name: Server name server_name: Server name
raise_on_error: Whether to raise an exception on error instead of returning empty list
Returns: Returns:
List of all tools (unfiltered) List of all tools (unfiltered)
@ -617,7 +623,15 @@ async def get_all_mcp_tools(server_name: str) -> list:
config = MCP_SERVERS.get(server_name) config = MCP_SERVERS.get(server_name)
if not config: if not config:
logger.warning(f"MCP server '{server_name}' not found in cache") logger.warning(f"MCP server '{server_name}' not found in cache")
if raise_on_error:
raise ValueError(f"MCP server '{server_name}' not found in cache")
return [] return []
# Get all tools (no filtering, force refresh, no cache update) # Get all tools (no filtering, force refresh, no cache update)
return await get_mcp_tools(server_name, disabled_tools=[], cache=False, force_refresh=True) return await get_mcp_tools(
server_name,
disabled_tools=[],
cache=False,
force_refresh=True,
raise_on_error=raise_on_error,
)

View File

@ -0,0 +1,278 @@
"""
Unit tests for MiniMax provider configuration and model selection.
These tests validate MiniMax integration without requiring Docker services.
Run with: uv run python -m pytest test/test_minimax_provider.py -v
"""
from __future__ import annotations
import importlib.util
import os
from pathlib import Path
from unittest.mock import patch
import pytest
# ============================================================
# Helpers: Import static models module directly (bypass src.__init__)
# ============================================================
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _load_static_models():
"""Load src/config/static/models.py directly, bypassing src.__init__.py."""
models_path = _PROJECT_ROOT / "src" / "config" / "static" / "models.py"
spec = importlib.util.spec_from_file_location("_static_models", models_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_models_mod = _load_static_models()
DEFAULT_CHAT_MODEL_PROVIDERS = _models_mod.DEFAULT_CHAT_MODEL_PROVIDERS
ChatModelProvider = _models_mod.ChatModelProvider
# ============================================================
# Unit Tests: Provider Configuration
# ============================================================
class TestMiniMaxProviderConfig:
"""Verify MiniMax is correctly registered in default providers."""
def test_minimax_in_default_providers(self):
assert "minimax" in DEFAULT_CHAT_MODEL_PROVIDERS
def test_minimax_provider_type(self):
assert isinstance(DEFAULT_CHAT_MODEL_PROVIDERS["minimax"], ChatModelProvider)
def test_minimax_name(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].name == "MiniMax"
def test_minimax_base_url(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].base_url == "https://api.minimax.io/v1"
def test_minimax_env_var(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].env == "MINIMAX_API_KEY"
def test_minimax_default_model(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].default == "MiniMax-M2.7"
def test_minimax_models_list(self):
expected = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed"]
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].models == expected
def test_minimax_not_custom(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].custom is False
def test_minimax_has_documentation_url(self):
assert DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].url.startswith("https://")
def test_minimax_all_models_have_minimax_prefix(self):
for model in DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].models:
assert model.startswith("MiniMax-"), f"Model {model} should start with 'MiniMax-'"
# ============================================================
# Unit Tests: Provider Serialization
# ============================================================
class TestMiniMaxProviderSerialization:
"""Test that MiniMax provider config serializes correctly."""
def test_minimax_model_dump(self):
dumped = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].model_dump()
assert dumped["name"] == "MiniMax"
assert dumped["base_url"] == "https://api.minimax.io/v1"
assert dumped["env"] == "MINIMAX_API_KEY"
assert dumped["default"] == "MiniMax-M2.7"
assert len(dumped["models"]) == 4
assert dumped["custom"] is False
def test_minimax_model_dump_round_trip(self):
provider = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]
restored = ChatModelProvider(**provider.model_dump())
assert restored == provider
def test_minimax_model_dump_keys(self):
dumped = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"].model_dump()
assert set(dumped.keys()) == {"name", "url", "base_url", "default", "env", "models", "custom"}
# ============================================================
# Unit Tests: Model Selection (standalone, no Docker deps)
# ============================================================
class TestMiniMaxModelSelection:
"""Test model selection logic with MiniMax provider."""
def _build_select_model(self, providers_dict):
"""Build a standalone select_model function mirroring src/models/chat.py."""
class _Model:
def __init__(self, api_key, base_url, model_name):
self.api_key = api_key
self.base_url = base_url
self.model_name = model_name
def select_model(model_provider=None, model_name=None, model_spec=None):
if model_spec:
parts = model_spec.split("/", 1)
model_provider = model_provider or parts[0]
model_name = model_name or (parts[1] if len(parts) > 1 else "")
assert model_provider, "Model provider not specified"
info = providers_dict.get(model_provider)
if not info:
raise ValueError(f"Unknown model provider: {model_provider}")
model_name = model_name or info.default
return _Model(
api_key=os.environ.get(info.env, info.env),
base_url=info.base_url,
model_name=model_name,
)
return select_model
def test_select_model_minimax_default(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}):
model = select_model("minimax", "MiniMax-M2.7")
assert model.model_name == "MiniMax-M2.7"
assert model.base_url == "https://api.minimax.io/v1"
def test_select_model_minimax_highspeed(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}):
model = select_model("minimax", "MiniMax-M2.7-highspeed")
assert model.model_name == "MiniMax-M2.7-highspeed"
def test_select_model_minimax_uses_default(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}):
model = select_model("minimax")
assert model.model_name == "MiniMax-M2.7"
def test_select_model_minimax_from_spec(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key-123"}):
model = select_model(model_spec="minimax/MiniMax-M2.5")
assert model.model_name == "MiniMax-M2.5"
assert model.base_url == "https://api.minimax.io/v1"
def test_select_model_minimax_api_key_from_env(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with patch.dict(os.environ, {"MINIMAX_API_KEY": "my-secret-key"}):
model = select_model("minimax", "MiniMax-M2.7")
assert model.api_key == "my-secret-key"
def test_select_model_unknown_provider_raises(self):
select_model = self._build_select_model({"minimax": DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]})
with pytest.raises(ValueError, match="Unknown model provider"):
select_model("nonexistent", "some-model")
# ============================================================
# Unit Tests: LangChain OpenAI-compat Loading
# ============================================================
class TestMiniMaxLangChainLoading:
"""Test ChatOpenAI instantiation for MiniMax (OpenAI-compat)."""
def test_langchain_openai_compat_for_minimax(self):
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
minimax = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]
model = ChatOpenAI(
model="MiniMax-M2.7",
api_key=SecretStr("test-key-123"),
base_url=minimax.base_url,
stream_usage=True,
)
assert isinstance(model, ChatOpenAI)
assert model.model_name == "MiniMax-M2.7"
def test_langchain_minimax_highspeed(self):
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
minimax = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]
model = ChatOpenAI(
model="MiniMax-M2.5-highspeed",
api_key=SecretStr("test-key-123"),
base_url=minimax.base_url,
)
assert isinstance(model, ChatOpenAI)
assert model.model_name == "MiniMax-M2.5-highspeed"
def test_langchain_minimax_base_url_correct(self):
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
minimax = DEFAULT_CHAT_MODEL_PROVIDERS["minimax"]
model = ChatOpenAI(
model="MiniMax-M2.7",
api_key=SecretStr("test-key-123"),
base_url=minimax.base_url,
)
assert str(model.openai_api_base) == "https://api.minimax.io/v1"
# ============================================================
# Integration Tests: MiniMax API Connectivity
# ============================================================
@pytest.mark.integration
class TestMiniMaxIntegration:
"""Integration tests that verify MiniMax API connectivity.
Require MINIMAX_API_KEY env var and network access to api.minimax.io.
Run with: uv run python -m pytest test/test_minimax_provider.py -m integration -v
"""
@pytest.fixture(autouse=True)
def skip_without_api_key(self):
if not os.environ.get("MINIMAX_API_KEY"):
pytest.skip("MINIMAX_API_KEY not set")
@pytest.mark.asyncio
async def test_minimax_chat_completion(self):
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=os.environ["MINIMAX_API_KEY"], base_url="https://api.minimax.io/v1")
response = await client.chat.completions.create(
model="MiniMax-M2.7", messages=[{"role": "user", "content": "Say 1."}]
)
assert response.choices[0].message.content is not None
@pytest.mark.asyncio
async def test_minimax_streaming(self):
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=os.environ["MINIMAX_API_KEY"], base_url="https://api.minimax.io/v1")
chunks = []
response = await client.chat.completions.create(
model="MiniMax-M2.7", messages=[{"role": "user", "content": "Say hi."}], stream=True
)
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
chunks.append(chunk.choices[0].delta.content)
assert len(chunks) > 0
@pytest.mark.asyncio
async def test_minimax_highspeed_model(self):
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=os.environ["MINIMAX_API_KEY"], base_url="https://api.minimax.io/v1")
response = await client.chat.completions.create(
model="MiniMax-M2.7-highspeed", messages=[{"role": "user", "content": "Say 1."}]
)
assert response.choices[0].message.content is not None

View File

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-fill" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -8,6 +8,7 @@ import arkIcon from '@/assets/providers/doubao-color.svg'
import openrouterIcon from '@/assets/providers/openrouter.svg' import openrouterIcon from '@/assets/providers/openrouter.svg'
import defaultIcon from '@/assets/providers/default.png' import defaultIcon from '@/assets/providers/default.png'
import modelscopeIcon from '@/assets/providers/modelscope-color.svg' import modelscopeIcon from '@/assets/providers/modelscope-color.svg'
import minimaxIcon from '@/assets/providers/minimax-color.svg'
export const modelIcons = { export const modelIcons = {
openai: openaiIcon, openai: openaiIcon,
@ -20,5 +21,6 @@ export const modelIcons = {
together: togetherIcon, together: togetherIcon,
openrouter: openrouterIcon, openrouter: openrouterIcon,
modelscope: modelscopeIcon, modelscope: modelscopeIcon,
minimax: minimaxIcon,
default: defaultIcon // 添加默认图标 default: defaultIcon // 添加默认图标
} }