From f1faf8a4bedd7d669b237a40324331f18f5675b2 Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 22 Mar 2026 17:00:26 +0800 Subject: [PATCH 1/3] feat: add MiniMax as a model provider Add MiniMax (https://www.minimaxi.com/) as a first-class model provider alongside SiliconFlow, DashScope, and TokenPony. MiniMax offers LLM models (M2.7 with 1M context, M2.5/M2.5-highspeed with 204K context) and the embo-01 embedding model. Backend: - Add MiniMaxModelProvider with model type classification (LLM, embedding, TTS, STT, reranker, VLM) and known context window sizes - Register provider in factory, enum, and base URL constants - Wire into model_management_service for batch create flow Frontend: - Add useMinimaxModelList hook for batch import - Add MiniMax option in ModelAddDialog provider dropdown - Add provider constants (icon, hint, link) and i18n translations (en/zh) Tests: - Add 16 unit tests covering all model types, error handling, context windows, and auth header verification --- backend/consts/provider.py | 5 + backend/services/model_management_service.py | 3 + backend/services/model_provider_service.py | 4 + backend/services/providers/__init__.py | 2 + .../services/providers/minimax_provider.py | 113 +++ .../components/model/ModelAddDialog.tsx | 12 + frontend/const/modelConfig.ts | 13 +- frontend/hooks/model/useMinimaxModelList.ts | 133 ++++ frontend/public/locales/en/common.json | 3 + frontend/public/locales/zh/common.json | 3 + frontend/public/minimax.png | Bin 0 -> 345 bytes .../providers/test_minimax_provider.py | 681 ++++++++++++++++++ 12 files changed, 968 insertions(+), 4 deletions(-) create mode 100644 backend/services/providers/minimax_provider.py create mode 100644 frontend/hooks/model/useMinimaxModelList.ts create mode 100644 frontend/public/minimax.png create mode 100644 test/backend/services/providers/test_minimax_provider.py diff --git a/backend/consts/provider.py b/backend/consts/provider.py index fe49332b7..ec2df070e 100644 --- a/backend/consts/provider.py +++ b/backend/consts/provider.py @@ -8,6 +8,7 @@ class ProviderEnum(str, Enum): MODELENGINE = "modelengine" DASHSCOPE = "dashscope" TOKENPONY = "tokenpony" + MINIMAX = "minimax" # Silicon Flow @@ -24,5 +25,9 @@ class ProviderEnum(str, Enum): TOKENPONY_BASE_URL = "https://api.tokenpony.cn/v1/" TOKENPONY_GET_URL = "https://api.tokenpony.cn/v1/models" +# MiniMax +MINIMAX_BASE_URL = "https://api.minimax.io/v1/" +MINIMAX_GET_URL = "https://api.minimax.io/v1/models" + # ModelEngine # Base URL and API key are loaded from environment variables at runtime diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 1511a9301..a407f7359 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -9,6 +9,7 @@ DASHSCOPE_BASE_URL, DASHSCOPE_REALTIME_BASE_URL, TOKENPONY_BASE_URL, + MINIMAX_BASE_URL, ) from database.model_management_db import ( @@ -193,6 +194,8 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay model_url = DASHSCOPE_REALTIME_BASE_URL if model_type in ("stt", "tts") else DASHSCOPE_BASE_URL elif provider == ProviderEnum.TOKENPONY.value: model_url = TOKENPONY_BASE_URL + elif provider == ProviderEnum.MINIMAX.value: + model_url = MINIMAX_BASE_URL else: model_url = "" diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 1aa89fa3b..58e62b270 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -14,6 +14,7 @@ from services.providers.tokenpony_provider import TokenPonyModelProvider from services.providers.dashscope_provider import DashScopeModelProvider from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url, MODEL_ENGINE_NORTH_PREFIX +from services.providers.minimax_provider import MiniMaxModelProvider from utils.model_name_utils import split_repo_name, add_repo_to_name logger = logging.getLogger("model_provider") @@ -48,6 +49,9 @@ async def get_provider_models(model_data: dict) -> List[dict]: elif model_data["provider"] == ProviderEnum.TOKENPONY.value: provider = TokenPonyModelProvider() model_list = await provider.get_models(model_data) + elif model_data["provider"] == ProviderEnum.MINIMAX.value: + provider = MiniMaxModelProvider() + model_list = await provider.get_models(model_data) return model_list diff --git a/backend/services/providers/__init__.py b/backend/services/providers/__init__.py index 9478043c2..e5524baea 100644 --- a/backend/services/providers/__init__.py +++ b/backend/services/providers/__init__.py @@ -2,10 +2,12 @@ from services.providers.base import AbstractModelProvider from services.providers.silicon_provider import SiliconModelProvider from services.providers.modelengine_provider import ModelEngineProvider, get_model_engine_raw_url +from services.providers.minimax_provider import MiniMaxModelProvider __all__ = [ "AbstractModelProvider", "SiliconModelProvider", "ModelEngineProvider", + "MiniMaxModelProvider", "get_model_engine_raw_url", ] diff --git a/backend/services/providers/minimax_provider.py b/backend/services/providers/minimax_provider.py new file mode 100644 index 000000000..c68a39141 --- /dev/null +++ b/backend/services/providers/minimax_provider.py @@ -0,0 +1,113 @@ +import httpx +from typing import Dict, List + +from consts.const import DEFAULT_LLM_MAX_TOKENS +from consts.provider import MINIMAX_GET_URL +from services.providers.base import AbstractModelProvider, _classify_provider_error + + +# MiniMax known model context windows (tokens) +MINIMAX_MODEL_CONTEXT = { + "MiniMax-M2.7": 1000000, + "MiniMax-M2.5": 204800, + "MiniMax-M2.5-highspeed": 204800, +} + + +class MiniMaxModelProvider(AbstractModelProvider): + """Concrete implementation for MiniMax provider.""" + + async def get_models(self, provider_config: Dict) -> List[Dict]: + """ + Fetch models from MiniMax API, categorize them based on model ID, + and return the requested model type. + + Args: + provider_config: Configuration dict containing model_type and api_key + + Returns: + List of models with canonical fields. Returns error dict if API call fails. + """ + try: + target_model_type: str = provider_config["model_type"] + model_api_key: str = provider_config["api_key"] + + headers = {"Authorization": f"Bearer {model_api_key}"} + url = MINIMAX_GET_URL + + async with httpx.AsyncClient(verify=False) as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + all_models: List[Dict] = response.json().get("data", []) + + # Initialize containers for the main categories + categorized_models = { + "chat": [], # Maps to "llm" + "vlm": [], # Maps to "vlm" + "embedding": [], # Maps to "embedding" / "multi_embedding" + "reranker": [], # Maps to "reranker" + "tts": [], # Maps to "tts" + "stt": [] # Maps to "stt" + } + + for model_obj in all_models: + m_id = model_obj.get("id", "") + m_id_lower = m_id.lower() + model_obj.setdefault("object", "model") + model_obj.setdefault("owned_by", "minimax") + + # Use known context window or default + max_tokens = MINIMAX_MODEL_CONTEXT.get(m_id, DEFAULT_LLM_MAX_TOKENS) + + cleaned_model = { + "id": m_id, + "object": model_obj.get("object"), + "created": model_obj.get("created", 0), + "owned_by": model_obj.get("owned_by"), + "model_tag": "", + "model_type": "", + "max_tokens": max_tokens, + } + + # 1. Embedding + if "embo" in m_id_lower or "embedding" in m_id_lower: + cleaned_model.update({"model_tag": "embedding", "model_type": "embedding"}) + categorized_models["embedding"].append(cleaned_model) + + # 2. TTS (speech models) + elif "speech" in m_id_lower or "tts" in m_id_lower: + cleaned_model.update({"model_tag": "tts", "model_type": "tts"}) + categorized_models["tts"].append(cleaned_model) + + # 3. STT + elif "stt" in m_id_lower or "whisper" in m_id_lower: + cleaned_model.update({"model_tag": "stt", "model_type": "stt"}) + categorized_models["stt"].append(cleaned_model) + + # 4. Reranker + elif "rerank" in m_id_lower: + cleaned_model.update({"model_tag": "reranker", "model_type": "reranker"}) + categorized_models["reranker"].append(cleaned_model) + + # 5. VLM + elif any(kw in m_id_lower for kw in ["-vl", "vl-", "vision"]): + cleaned_model.update({"model_tag": "chat", "model_type": "vlm"}) + categorized_models["vlm"].append(cleaned_model) + + # 6. Chat / LLM (default fallback) + else: + cleaned_model.update({"model_tag": "chat", "model_type": "llm"}) + categorized_models["chat"].append(cleaned_model) + + # Return the specific list based on the requested target_model_type + if target_model_type == "llm": + return categorized_models["chat"] + elif target_model_type in ("embedding", "multi_embedding"): + return categorized_models["embedding"] + elif target_model_type in categorized_models: + return categorized_models[target_model_type] + else: + return [] + + except (httpx.HTTPStatusError, httpx.ConnectTimeout, httpx.ConnectError, Exception) as e: + return _classify_provider_error("MiniMax", exception=e) diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 6a1313ba7..1aa16fd2d 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -23,6 +23,7 @@ import { MODEL_TYPES, PROVIDER_LINKS } from "@/const/modelConfig"; import { useSiliconModelList } from "@/hooks/model/useSiliconModelList"; import { useDashscopeModelList } from "@/hooks/model/useDashscopeModelList"; import { useTokenPonyModelList } from "@/hooks/model/useTokenponyModelList"; +import { useMinimaxModelList } from "@/hooks/model/useMinimaxModelList"; import log from "@/lib/logger"; import { ModelChunkSizeSlider, @@ -325,6 +326,14 @@ export const ModelAddDialog = ({ setLoadingModelList, tenantId, }); + const minimaxHook = useMinimaxModelList({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList, + tenantId, + }); let getModelList; let getProviderSelectedModalList; @@ -335,6 +344,8 @@ export const ModelAddDialog = ({ ({ getModelList, getProviderSelectedModalList } = dashscopeHook); } else if (form.provider === "tokenpony") { ({ getModelList, getProviderSelectedModalList } = tokenponyHook); + } else if (form.provider === "minimax") { + ({ getModelList, getProviderSelectedModalList } = minimaxHook); } // Reset form to default state const resetForm = useCallback(() => { @@ -1082,6 +1093,7 @@ export const ModelAddDialog = ({ + {/* ModelEngine URL input (only when provider is ModelEngine) */} {form.provider === "modelengine" && ( diff --git a/frontend/const/modelConfig.ts b/frontend/const/modelConfig.ts index c85b0b2c6..6d2fda3fe 100644 --- a/frontend/const/modelConfig.ts +++ b/frontend/const/modelConfig.ts @@ -21,6 +21,7 @@ export const MODEL_SOURCES = { DASHSCOPE: "dashscope", TOKENPONY: "tokenpony", VOLCENGINE: "volcengine", + MINIMAX: "minimax", } as const; // Model status constants @@ -47,7 +48,8 @@ export const MODEL_PROVIDER_KEYS = [ "aliyuncs", "tokenpony", "dashscope", - "volcengine" + "volcengine", + "minimax", ] as const; export type ModelProviderKey = (typeof MODEL_PROVIDER_KEYS)[number]; @@ -62,7 +64,8 @@ export const PROVIDER_HINTS: Record = { aliyuncs: "aliyuncs", tokenpony: "tokenpony", dashscope: "dashscope", - volcengine:"bytedance" + volcengine: "bytedance", + minimax: "minimax", }; // Icon filenames for providers @@ -75,7 +78,8 @@ export const PROVIDER_ICON_MAP: Record = { aliyuncs: "/aliyuncs.png", dashscope:"/aliyuncs.png", tokenpony: "/tokenpony.png", - volcengine: "/volcengine.png" + volcengine: "/volcengine.png", + minimax: "/minimax.png", }; export const OFFICIAL_PROVIDER_ICON = "/modelengine-logo.png"; @@ -93,7 +97,8 @@ export const PROVIDER_LINKS: Record = { baai: "https://www.baai.ac.cn/", dashscope: "https://dashscope.aliyun.com/", tokenpony: "https://www.tokenpony.cn/", - volcengine:"https://www.volcengine.com/" + volcengine: "https://www.volcengine.com/", + minimax: "https://www.minimaxi.com/", }; // User role constants diff --git a/frontend/hooks/model/useMinimaxModelList.ts b/frontend/hooks/model/useMinimaxModelList.ts new file mode 100644 index 000000000..0b29c71fc --- /dev/null +++ b/frontend/hooks/model/useMinimaxModelList.ts @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { message } from "antd"; +import { useTranslation } from "react-i18next"; +import { modelService } from "@/services/modelService"; +import { ModelType } from "@/types/modelConfig"; +import { processProviderResponse } from "@/lib/providerError"; +import log from "@/lib/logger"; + +interface UseMinimaxModelListProps { + form: { + type: ModelType; + isBatchImport: boolean; + apiKey: string; + provider: string; // Expected to be "minimax" + maxTokens: string; + isMultimodal: boolean; + }; + setModelList: (models: any[]) => void; + setSelectedModelIds: (ids: Set) => void; + setShowModelList: (show: boolean) => void; + setLoadingModelList: (loading: boolean) => void; + tenantId?: string; // Optional tenant ID for manage operations +} + +export const useMinimaxModelList = ({ + form, + setModelList, + setSelectedModelIds, + setShowModelList, + setLoadingModelList, + tenantId, +}: UseMinimaxModelListProps) => { + const { t } = useTranslation(); + + const getModelList = async () => { + setShowModelList(true); + setLoadingModelList(true); + + const modelType = + form.type === "embedding" && form.isMultimodal + ? ("multi_embedding" as ModelType) + : form.type; + + try { + // Use manage interface if tenantId is provided (for super admin) + const result = tenantId + ? await modelService.addManageProviderModel({ + tenantId, + provider: form.provider, + type: modelType, + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }) + : await modelService.addProviderModel({ + provider: form.provider, + type: modelType, + apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }); + + // Use centralized error processing + const { models, error } = processProviderResponse( + result, + form.provider, + t + ); + + if (error) { + message.error(error); + setModelList([]); + setSelectedModelIds(new Set()); + setLoadingModelList(false); + return; + } + + // Ensure each model has a default max_tokens value + const modelsWithDefaults = models.map((model: any) => ({ + ...model, + max_tokens: model.max_tokens || parseInt(form.maxTokens) || 4096, + })); + setModelList(modelsWithDefaults); + + const selectedModels = (await getProviderSelectedModalList()) || []; + + // Key logic: Sync previously selected models + if (!selectedModels.length) { + // Select none + setSelectedModelIds(new Set()); + } else { + // Only select selectedModels + setSelectedModelIds(new Set(selectedModels.map((m: any) => m.id))); + } + } catch (error) { + message.error(t("model.dialog.error.addFailed", { error })); + log.error(t("model.dialog.error.addFailedLog"), error); + } finally { + setLoadingModelList(false); + } + }; + + const getProviderSelectedModalList = async () => { + const modelType = + form.type === "embedding" && form.isMultimodal + ? ("multi_embedding" as ModelType) + : form.type; + + // Use manage interface if tenantId is provided (for super admin) + const result = tenantId + ? await modelService.getManageProviderSelectedModalList({ + tenantId, + provider: form.provider, + type: modelType, + }) + : await modelService.getProviderSelectedModalList({ + provider: form.provider, + type: modelType, + api_key: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, + }); + + return result; + }; + + // Auto-fetch model list when batch import is enabled and API key is provided + useEffect(() => { + if (form.isBatchImport && form.apiKey.trim() !== "") { + getModelList(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.type, form.isBatchImport]); + + return { + getModelList, + getProviderSelectedModalList, + }; +}; diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index c3ccbd6c0..d365b34ed 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -830,6 +830,7 @@ "model.provider.silicon": "SiliconFlow", "model.provider.dashscope": "DashScope", "model.provider.tokenpony": "TokenPony", + "model.provider.minimax": "MiniMax", "model.provider.modelengine": "ModelEngine", "model.provider.volcengine": "VolcEngine", "model.dialog.modelList.title": "Show Models", @@ -909,6 +910,7 @@ "model.source.silicon": "Silicon Flow", "model.source.dashscope": "DashScope", "model.source.tokenpony": "TokenPony", + "model.source.minimax": "MiniMax", "model.source.unknown": "Unknown Source", "model.warning.updateNotFound": "Model not found for update: {{displayName}}, type: {{type}}", "model.type.main": "LLM Model", @@ -918,6 +920,7 @@ "model.group.dashscope": "DashScope Models", "model.group.tokenpony": "TokenPony Models", "model.group.volcengine": "VolcEngine Models", + "model.group.minimax": "MiniMax Models", "model.group.custom": "Custom Models", "model.status.tooltip": "Click to verify connectivity", "model.dialog.embeddingConfig.title": "Edit Embedding Model: {{modelName}}", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 09b8bcd4a..66587eacc 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -801,6 +801,7 @@ "model.provider.silicon": "硅基流动", "model.provider.dashscope": "阿里灵积", "model.provider.tokenpony": "小马算力", + "model.provider.minimax": "MiniMax", "model.provider.modelengine": "ModelEngine", "model.provider.volcengine": "火山引擎", "model.dialog.modelList.title": "显示模型", @@ -880,6 +881,7 @@ "model.source.silicon": "硅基流动", "model.source.dashscope": "阿里灵积", "model.source.tokenpony": "小马算力", + "model.source.minimax": "MiniMax", "model.warning.updateNotFound": "未找到要更新的模型: {{displayName}}, 类型: {{type}}", "model.type.main": "大语言模型", "model.select.placeholder": "选择模型", @@ -888,6 +890,7 @@ "model.group.dashscope": "阿里灵积模型", "model.group.tokenpony": "小马算力模型", "model.group.volcengine": "火山引擎模型", + "model.group.minimax": "MiniMax模型", "model.group.custom": "自定义模型", "model.status.tooltip": "点击可验证连通性", "model.dialog.success.updateSuccess": "更新成功", diff --git a/frontend/public/minimax.png b/frontend/public/minimax.png new file mode 100644 index 0000000000000000000000000000000000000000..5948af41ff6798d9c59d744ba29c51f204756926 GIT binary patch literal 345 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU{vsQaSW-L^Y)UV&|w7u=fL?Y z>L)FWV}q7{Ji^Q!^{J{`~nk|*jL!Z>rS$E)Bb*fag*ScXr2TH z<`;~c1dPfg4lsB!WVNi}-PF9lb~dNMuCoj(4P1&va$FO>a=D#1YGCAITBGE0{t)Ap z^$JCOjH{T=D4m%T%;fNuYf-wy0wV{J#BV)D46D~CEW6LZrom^>wc%1cSHd^ZGiA~V z><#x;Tzyx>a4~#CS89VS!@7OpU#780?7ex_k69u?x#6?@s&ktd_DY9+kxVdeIA4Ei z-zA1{^R=&x4)igUTz|{3*Dti6ktd;H7en~oE Date: Sun, 7 Jun 2026 14:00:15 +0800 Subject: [PATCH 2/3] feat: upgrade MiniMax default model to M3 - Add MiniMax-M3 to model context map and set as default - Keep MiniMax-M2.7 and MiniMax-M2.7-highspeed (correct 192K context) - Remove older M2.5/M2.5-highspeed models - Update related unit tests to match the new model list --- .../services/providers/minimax_provider.py | 6 ++--- .../providers/test_minimax_provider.py | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/services/providers/minimax_provider.py b/backend/services/providers/minimax_provider.py index c68a39141..e58d9d47d 100644 --- a/backend/services/providers/minimax_provider.py +++ b/backend/services/providers/minimax_provider.py @@ -8,9 +8,9 @@ # MiniMax known model context windows (tokens) MINIMAX_MODEL_CONTEXT = { - "MiniMax-M2.7": 1000000, - "MiniMax-M2.5": 204800, - "MiniMax-M2.5-highspeed": 204800, + "MiniMax-M3": 512000, + "MiniMax-M2.7": 192000, + "MiniMax-M2.7-highspeed": 192000, } diff --git a/test/backend/services/providers/test_minimax_provider.py b/test/backend/services/providers/test_minimax_provider.py index aa350e84d..8df23a1bc 100644 --- a/test/backend/services/providers/test_minimax_provider.py +++ b/test/backend/services/providers/test_minimax_provider.py @@ -23,17 +23,17 @@ async def test_get_models_llm_success(self, mocker: MockFixture): mock_response.json.return_value = { "data": [ { - "id": "MiniMax-M2.7", + "id": "MiniMax-M3", "object": "model", "owned_by": "minimax" }, { - "id": "MiniMax-M2.5", + "id": "MiniMax-M2.7", "object": "model", "owned_by": "minimax" }, { - "id": "MiniMax-M2.5-highspeed", + "id": "MiniMax-M2.7-highspeed", "object": "model", "owned_by": "minimax" } @@ -70,11 +70,11 @@ async def test_get_models_llm_success(self, mocker: MockFixture): result = await provider.get_models(provider_config) assert len(result) == 3 - assert result[0]["id"] == "MiniMax-M2.7" + assert result[0]["id"] == "MiniMax-M3" assert result[0]["model_type"] == "llm" assert result[0]["model_tag"] == "chat" - # M2.7 has 1M context window - assert result[0]["max_tokens"] == 1000000 + # M3 has 512K context window + assert result[0]["max_tokens"] == 512000 @pytest.mark.asyncio async def test_get_models_llm_known_context_windows(self, mocker: MockFixture): @@ -83,9 +83,9 @@ async def test_get_models_llm_known_context_windows(self, mocker: MockFixture): mock_response.status_code = 200 mock_response.json.return_value = { "data": [ + {"id": "MiniMax-M3", "object": "model", "owned_by": "minimax"}, {"id": "MiniMax-M2.7", "object": "model", "owned_by": "minimax"}, - {"id": "MiniMax-M2.5", "object": "model", "owned_by": "minimax"}, - {"id": "MiniMax-M2.5-highspeed", "object": "model", "owned_by": "minimax"}, + {"id": "MiniMax-M2.7-highspeed", "object": "model", "owned_by": "minimax"}, ] } mock_response.raise_for_status = MagicMock() @@ -112,9 +112,9 @@ async def test_get_models_llm_known_context_windows(self, mocker: MockFixture): result = await provider.get_models(provider_config) model_map = {m["id"]: m for m in result} - assert model_map["MiniMax-M2.7"]["max_tokens"] == 1000000 - assert model_map["MiniMax-M2.5"]["max_tokens"] == 204800 - assert model_map["MiniMax-M2.5-highspeed"]["max_tokens"] == 204800 + assert model_map["MiniMax-M3"]["max_tokens"] == 512000 + assert model_map["MiniMax-M2.7"]["max_tokens"] == 192000 + assert model_map["MiniMax-M2.7-highspeed"]["max_tokens"] == 192000 @pytest.mark.asyncio async def test_get_models_embedding_success(self, mocker: MockFixture): From edf433013ca6bfb242296a67d91f8f35619af6a8 Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 24 Jun 2026 13:15:55 +0800 Subject: [PATCH 3/3] fix: address review comments on MiniMax provider - Make SSL verification configurable via provider_config['ssl_verify'], defaulting to True (no more unconditional verify=False) - Add a 15s request timeout to httpx.AsyncClient to prevent indefinite hangs if the MiniMax API is slow/stalled - Add unit tests covering both behaviors Co-Authored-By: Octopus --- .../services/providers/minimax_provider.py | 4 +- .../providers/test_minimax_provider.py | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/backend/services/providers/minimax_provider.py b/backend/services/providers/minimax_provider.py index e58d9d47d..98206dc87 100644 --- a/backend/services/providers/minimax_provider.py +++ b/backend/services/providers/minimax_provider.py @@ -31,11 +31,13 @@ async def get_models(self, provider_config: Dict) -> List[Dict]: try: target_model_type: str = provider_config["model_type"] model_api_key: str = provider_config["api_key"] + # Default SSL verification to True; can be disabled via provider config. + ssl_verify: bool = provider_config.get("ssl_verify", True) headers = {"Authorization": f"Bearer {model_api_key}"} url = MINIMAX_GET_URL - async with httpx.AsyncClient(verify=False) as client: + async with httpx.AsyncClient(verify=ssl_verify, timeout=15.0) as client: response = await client.get(url, headers=headers) response.raise_for_status() all_models: List[Dict] = response.json().get("data", []) diff --git a/test/backend/services/providers/test_minimax_provider.py b/test/backend/services/providers/test_minimax_provider.py index 8df23a1bc..c8be46aaa 100644 --- a/test/backend/services/providers/test_minimax_provider.py +++ b/test/backend/services/providers/test_minimax_provider.py @@ -679,3 +679,70 @@ async def test_get_models_preserves_original_id(self, mocker: MockFixture): # ID should be preserved as-is (not lowercased) assert result[0]["id"] == "MiniMax-M2.7" + + @pytest.mark.asyncio + async def test_get_models_ssl_verify_defaults_to_true(self, mocker: MockFixture): + """Test that ssl_verify defaults to True and timeout is configured.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mock_async_client = mocker.patch( + "backend.services.providers.minimax_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.minimax_provider.MINIMAX_GET_URL", + "https://api.minimax.io/v1/models" + ) + + provider = MiniMaxModelProvider() + # No ssl_verify in config -> should default to True + await provider.get_models({"model_type": "llm", "api_key": "test"}) + + call_kwargs = mock_async_client.call_args.kwargs + assert call_kwargs.get("verify") is True + assert call_kwargs.get("timeout") == 15.0 + + @pytest.mark.asyncio + async def test_get_models_ssl_verify_can_be_disabled(self, mocker: MockFixture): + """Test that ssl_verify can be disabled via provider config.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": []} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + mock_async_client = mocker.patch( + "backend.services.providers.minimax_provider.httpx.AsyncClient", + return_value=mock_cm + ) + mocker.patch( + "backend.services.providers.minimax_provider.MINIMAX_GET_URL", + "https://api.minimax.io/v1/models" + ) + + provider = MiniMaxModelProvider() + # Explicit ssl_verify=False (e.g., for self-signed cert environments) + await provider.get_models({ + "model_type": "llm", + "api_key": "test", + "ssl_verify": False, + }) + + call_kwargs = mock_async_client.call_args.kwargs + assert call_kwargs.get("verify") is False