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..98206dc87 --- /dev/null +++ b/backend/services/providers/minimax_provider.py @@ -0,0 +1,115 @@ +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-M3": 512000, + "MiniMax-M2.7": 192000, + "MiniMax-M2.7-highspeed": 192000, +} + + +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"] + # 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=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", []) + + # 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 000000000..5948af41f Binary files /dev/null and b/frontend/public/minimax.png differ diff --git a/test/backend/services/providers/test_minimax_provider.py b/test/backend/services/providers/test_minimax_provider.py new file mode 100644 index 000000000..c8be46aaa --- /dev/null +++ b/test/backend/services/providers/test_minimax_provider.py @@ -0,0 +1,748 @@ +"""Unit tests for MiniMaxModelProvider module. + +Tests cover model fetching, type classification, and error handling. +""" + +import pytest +from unittest.mock import MagicMock, AsyncMock +from pytest_mock import MockFixture + +import httpx + +from backend.services.providers.minimax_provider import MiniMaxModelProvider + + +class TestMiniMaxModelProvider: + """Tests for MiniMaxModelProvider class.""" + + @pytest.mark.asyncio + async def test_get_models_llm_success(self, mocker: MockFixture): + """Test successful model retrieval for LLM models.""" + mock_response = MagicMock() + 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.7-highspeed", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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" + ) + mocker.patch( + "backend.services.providers.minimax_provider.DEFAULT_LLM_MAX_TOKENS", + 4096 + ) + + provider = MiniMaxModelProvider() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 3 + assert result[0]["id"] == "MiniMax-M3" + assert result[0]["model_type"] == "llm" + assert result[0]["model_tag"] == "chat" + # 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): + """Test that known MiniMax models get their correct context window sizes.""" + mock_response = MagicMock() + 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.7-highspeed", "object": "model", "owned_by": "minimax"}, + ] + } + 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) + + 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() + provider_config = {"model_type": "llm", "api_key": "test-api-key"} + + result = await provider.get_models(provider_config) + + model_map = {m["id"]: m for m in result} + 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): + """Test successful model retrieval for embedding models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "embo-01", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "embedding", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "embo-01" + assert result[0]["model_type"] == "embedding" + assert result[0]["model_tag"] == "embedding" + + @pytest.mark.asyncio + async def test_get_models_tts_success(self, mocker: MockFixture): + """Test successful model retrieval for TTS models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "speech-2.8-hd", + "object": "model", + "owned_by": "minimax" + }, + { + "id": "tts-1", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "tts", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 2 + assert result[0]["id"] == "speech-2.8-hd" + assert result[0]["model_type"] == "tts" + assert result[0]["model_tag"] == "tts" + + @pytest.mark.asyncio + async def test_get_models_stt_success(self, mocker: MockFixture): + """Test successful model retrieval for STT models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "stt-whisper-v1", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "stt", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "stt-whisper-v1" + assert result[0]["model_type"] == "stt" + assert result[0]["model_tag"] == "stt" + + @pytest.mark.asyncio + async def test_get_models_reranker_success(self, mocker: MockFixture): + """Test successful model retrieval for reranker models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "rerank-v1", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "reranker", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "rerank-v1" + assert result[0]["model_type"] == "reranker" + assert result[0]["model_tag"] == "reranker" + + @pytest.mark.asyncio + async def test_get_models_vlm_success(self, mocker: MockFixture): + """Test successful model retrieval for VLM models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "minimax-vl-01", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "vlm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "minimax-vl-01" + assert result[0]["model_type"] == "vlm" + assert result[0]["model_tag"] == "chat" + + @pytest.mark.asyncio + async def test_get_models_multi_embedding_success(self, mocker: MockFixture): + """Test successful model retrieval for multi-embedding models.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "embo-01", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "multi_embedding", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert len(result) == 1 + assert result[0]["id"] == "embo-01" + assert result[0]["model_type"] == "embedding" + + @pytest.mark.asyncio + async def test_get_models_empty_response(self, mocker: MockFixture): + """Test handling of empty model list from API.""" + 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) + + 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() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_models_http_error(self, mocker: MockFixture): + """Test handling of HTTP error.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "Error", + request=MagicMock(), + response=MagicMock(status_code=500) + ) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + 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() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "connection_failed" + + @pytest.mark.asyncio + async def test_get_models_connect_error(self, mocker: MockFixture): + """Test handling of connection error.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.ConnectError("Connection failed") + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + 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() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "connection_failed" + + @pytest.mark.asyncio + async def test_get_models_timeout(self, mocker: MockFixture): + """Test handling of connection timeout.""" + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.ConnectTimeout("Timeout") + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_client) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + 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() + provider_config = { + "model_type": "llm", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["_error"] == "connection_failed" + + @pytest.mark.asyncio + async def test_get_models_authorization_header(self, mocker: MockFixture): + """Test that Authorization header is correctly set.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "MiniMax-M2.7", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "llm", + "api_key": "my-secret-key" + } + + await provider.get_models(provider_config) + + # Verify Authorization header + call_args = mock_client.get.call_args + headers = call_args[1]["headers"] + assert headers["Authorization"] == "Bearer my-secret-key" + + @pytest.mark.asyncio + async def test_get_models_unknown_type_returns_empty(self, mocker: MockFixture): + """Test that unknown model type returns empty list.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + { + "id": "MiniMax-M2.7", + "object": "model", + "owned_by": "minimax" + } + ] + } + 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) + + 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() + provider_config = { + "model_type": "unknown_type", + "api_key": "test-api-key" + } + + result = await provider.get_models(provider_config) + + assert result == [] + + @pytest.mark.asyncio + async def test_get_models_mixed_types_classification(self, mocker: MockFixture): + """Test classification of mixed model types returns only requested type.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "MiniMax-M2.7", "object": "model", "owned_by": "minimax"}, + {"id": "embo-01", "object": "model", "owned_by": "minimax"}, + {"id": "speech-2.8-hd", "object": "model", "owned_by": "minimax"}, + {"id": "rerank-v1", "object": "model", "owned_by": "minimax"}, + ] + } + 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) + + 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() + + # Request LLM - should only return chat models + result = await provider.get_models({"model_type": "llm", "api_key": "test"}) + assert len(result) == 1 + assert result[0]["id"] == "MiniMax-M2.7" + + @pytest.mark.asyncio + async def test_get_models_preserves_original_id(self, mocker: MockFixture): + """Test that original model ID casing is preserved.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "MiniMax-M2.7", "object": "model", "owned_by": "minimax"}, + ] + } + 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) + + 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() + result = await provider.get_models({"model_type": "llm", "api_key": "test"}) + + # 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