diff --git a/.env.template b/.env.template index e70ed3d6b..fa873b0bd 100644 --- a/.env.template +++ b/.env.template @@ -17,6 +17,7 @@ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.co # DASHSCOPE_API_KEY= # DEEPSEEK_API_KEY= # ARK_API_KEY= +# MINIMAX_API_KEY= # MiniMax 大模型 https://platform.minimaxi.com/ # TOGETHER_API_KEY= # # endregion model_provider diff --git a/docs/latest/intro/model-config.md b/docs/latest/intro/model-config.md index fd187ffaa..55fe1bd6c 100644 --- a/docs/latest/intro/model-config.md +++ b/docs/latest/intro/model-config.md @@ -9,6 +9,7 @@ | [硅基流动](https://cloud.siliconflow.cn/i/Eo5yTHGJ) | `SILICONFLOW_API_KEY` | 🆓 免费额度,默认推荐 | | OpenAI | `OPENAI_API_KEY` | GPT 系列模型 | | DeepSeek | `DEEPSEEK_API_KEY` | 国产大模型 | +| [MiniMax](https://platform.minimaxi.com/) | `MINIMAX_API_KEY` | M2.7/M2.5 系列,百万 token 上下文 | | OpenRouter | `OPENROUTER_API_KEY` | 多模型聚合平台 | | 智谱清言 | `ZHIPUAI_API_KEY` | GLM 系列模型 | | 阿里云百炼 | `DASHSCOPE_API_KEY` | 通义千问系列 | diff --git a/src/config/static/models.py b/src/config/static/models.py index 9d4d4b862..885bbc25a 100644 --- a/src/config/static/models.py +++ b/src/config/static/models.py @@ -115,6 +115,19 @@ class RerankerInfo(BaseModel): "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( name="OpenRouter", url="https://openrouter.ai/models", diff --git a/test/test_minimax_provider.py b/test/test_minimax_provider.py new file mode 100644 index 000000000..1cfa8e461 --- /dev/null +++ b/test/test_minimax_provider.py @@ -0,0 +1,280 @@ +""" +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 +import sys +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.""" + from openai import AsyncOpenAI + + 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 diff --git a/web/src/assets/providers/minimax-color.svg b/web/src/assets/providers/minimax-color.svg new file mode 100644 index 000000000..61fcafe04 --- /dev/null +++ b/web/src/assets/providers/minimax-color.svg @@ -0,0 +1 @@ +Minimax diff --git a/web/src/utils/modelIcon.js b/web/src/utils/modelIcon.js index 3f36229f0..afa1705c1 100644 --- a/web/src/utils/modelIcon.js +++ b/web/src/utils/modelIcon.js @@ -8,6 +8,7 @@ import arkIcon from '@/assets/providers/doubao-color.svg' import openrouterIcon from '@/assets/providers/openrouter.svg' import defaultIcon from '@/assets/providers/default.png' import modelscopeIcon from '@/assets/providers/modelscope-color.svg' +import minimaxIcon from '@/assets/providers/minimax-color.svg' export const modelIcons = { openai: openaiIcon, @@ -20,5 +21,6 @@ export const modelIcons = { together: togetherIcon, openrouter: openrouterIcon, modelscope: modelscopeIcon, + minimax: minimaxIcon, default: defaultIcon // 添加默认图标 }