diff --git a/frontend/public/icons/adapter-icons/MiniMax.png b/frontend/public/icons/adapter-icons/MiniMax.png new file mode 100644 index 0000000000..0039c488ea Binary files /dev/null and b/frontend/public/icons/adapter-icons/MiniMax.png differ diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py index ebd93e3dbd..6cb4d25a10 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -512,7 +512,9 @@ def _validate_branded_openai_compatible( _NVIDIA_BUILD_API_BASE = "https://integrate.api.nvidia.com/v1" _OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" +_MINIMAX_API_BASE = "https://api.minimax.io/v1" _OPENROUTER_PROVIDER_PREFIX = "openrouter/" +_MINIMAX_PROVIDER_PREFIX = "minimax/" class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): @@ -528,6 +530,57 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: ) +class MiniMaxLLMParameters(BaseChatCompletionParameters): + """Adapter for MiniMax's OpenAI-compatible API.""" + + api_key: str + api_base: str = _MINIMAX_API_BASE + temperature: float | None = 1 + thinking: dict[str, str] | None = None + + @staticmethod + def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: + adapter_metadata = dict(adapter_metadata) + api_base = adapter_metadata.get("api_base") + if not (isinstance(api_base, str) and api_base.strip()): + adapter_metadata["api_base"] = _MINIMAX_API_BASE + + adapter_metadata["model"] = MiniMaxLLMParameters.validate_model(adapter_metadata) + model_id = adapter_metadata["model"][len(_MINIMAX_PROVIDER_PREFIX) :] + + if "enable_thinking" in adapter_metadata: + enable_thinking = adapter_metadata.pop("enable_thinking") + if not isinstance(enable_thinking, bool): + raise ValueError("enable_thinking must be a boolean.") + adapter_metadata["thinking"] = { + "type": "adaptive" if enable_thinking else "disabled" + } + + thinking = adapter_metadata.get("thinking") + if thinking is not None: + if not isinstance(thinking, dict) or thinking.get("type") not in { + "adaptive", + "disabled", + }: + raise ValueError("thinking.type must be adaptive or disabled.") + if ( + model_id.lower().startswith("minimax-m2") + and thinking["type"] == "disabled" + ): + raise ValueError(f"{model_id} does not support disabling thinking.") + + return MiniMaxLLMParameters(**adapter_metadata).model_dump() + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + model = str(adapter_metadata.get("model", "")).strip() + if not model: + raise ValueError("model is required for the MiniMax adapter.") + if model.startswith(_MINIMAX_PROVIDER_PREFIX): + return model + return f"{_MINIMAX_PROVIDER_PREFIX}{model}" + + class OpenRouterLLMParameters(BaseChatCompletionParameters): """Adapter for OpenRouter (openrouter.ai). diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py index 2d935e218f..a3a03c7da3 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/__init__.py @@ -6,6 +6,7 @@ from unstract.sdk1.adapters.llm1.anyscale import AnyscaleLLMAdapter from unstract.sdk1.adapters.llm1.azure_openai import AzureOpenAILLMAdapter from unstract.sdk1.adapters.llm1.bedrock import AWSBedrockLLMAdapter +from unstract.sdk1.adapters.llm1.minimax import MiniMaxLLMAdapter from unstract.sdk1.adapters.llm1.nvidia_build import NvidiaBuildLLMAdapter from unstract.sdk1.adapters.llm1.ollama import OllamaLLMAdapter from unstract.sdk1.adapters.llm1.openai import OpenAILLMAdapter @@ -23,6 +24,7 @@ "AnyscaleLLMAdapter", "AWSBedrockLLMAdapter", "AzureOpenAILLMAdapter", + "MiniMaxLLMAdapter", "NvidiaBuildLLMAdapter", "OllamaLLMAdapter", "OpenAILLMAdapter", diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/minimax.py b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/minimax.py new file mode 100644 index 0000000000..18f6ee3702 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/minimax.py @@ -0,0 +1,45 @@ +from typing import Any + +from unstract.sdk1.adapters.base1 import BaseAdapter, MiniMaxLLMParameters +from unstract.sdk1.adapters.enums import AdapterTypes + +DESCRIPTION = ( + "Adapter for MiniMax's OpenAI-compatible API. " + "Supply a model name and your MiniMax API key; the endpoint is preconfigured." +) + + +class MiniMaxLLMAdapter(MiniMaxLLMParameters, BaseAdapter): + @staticmethod + def get_id() -> str: + return "minimax|4f0e4241-2430-4921-81bf-8b2c6040d8d2" + + @staticmethod + def get_metadata() -> dict[str, Any]: + return { + "name": "MiniMax", + "version": "1.0.0", + "adapter": MiniMaxLLMAdapter, + "description": DESCRIPTION, + "is_active": True, + } + + @staticmethod + def get_name() -> str: + return "MiniMax" + + @staticmethod + def get_description() -> str: + return DESCRIPTION + + @staticmethod + def get_provider() -> str: + return "minimax" + + @staticmethod + def get_icon() -> str: + return "/icons/adapter-icons/MiniMax.png" + + @staticmethod + def get_adapter_type() -> AdapterTypes: + return AdapterTypes.LLM diff --git a/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/minimax.json b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/minimax.json new file mode 100644 index 0000000000..2979df8f9b --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/minimax.json @@ -0,0 +1,70 @@ +{ + "title": "MiniMax", + "type": "object", + "required": [ + "adapter_name", + "api_key", + "model" + ], + "properties": { + "adapter_name": { + "type": "string", + "title": "Name", + "default": "", + "description": "Provide a unique name for this adapter instance. Example: minimax-1" + }, + "api_key": { + "type": "string", + "title": "API Key", + "format": "password", + "description": "Your MiniMax API key from [platform.minimax.io](https://platform.minimax.io)." + }, + "model": { + "type": "string", + "title": "Model", + "default": "MiniMax-M3", + "examples": [ + "MiniMax-M3", + "MiniMax-M2.7" + ], + "description": "MiniMax-M3 supports a 1,000,000-token context window and multimodal input. MiniMax-M2.7 supports a 204,800-token context window and text input." + }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "https://api.minimax.io/v1", + "description": "MiniMax OpenAI-compatible endpoint. The default is for global accounts; use https://api.minimaxi.com/v1 for China accounts." + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "default": 4096, + "description": "Maximum number of output tokens to limit LLM replies. Leave it empty to use the provider default." + }, + "max_retries": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Max Retries", + "default": 5, + "description": "The maximum number of times to retry a request if it fails." + }, + "timeout": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Timeout", + "default": 900, + "description": "Timeout in seconds." + }, + "enable_thinking": { + "type": "boolean", + "title": "Enable Thinking", + "default": true, + "description": "Enable adaptive thinking for MiniMax-M3. MiniMax-M2.7 always keeps thinking enabled." + } + } +} diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index c876698847..328ecdf1c6 100644 --- a/unstract/sdk1/tests/test_branded_openai_adapters.py +++ b/unstract/sdk1/tests/test_branded_openai_adapters.py @@ -2,6 +2,7 @@ import pytest from unstract.sdk1.adapters.base1 import ( + MiniMaxLLMParameters, NvidiaBuildEmbeddingParameters, NvidiaBuildLLMParameters, OpenAICompatibleEmbeddingParameters, @@ -14,11 +15,13 @@ OpenAICompatibleEmbeddingAdapter, ) from unstract.sdk1.adapters.llm1 import adapters as llm_adapters +from unstract.sdk1.adapters.llm1.minimax import MiniMaxLLMAdapter from unstract.sdk1.adapters.llm1.nvidia_build import NvidiaBuildLLMAdapter from unstract.sdk1.adapters.llm1.openrouter import OpenRouterLLMAdapter _NVIDIA_BUILD_API_BASE = "https://integrate.api.nvidia.com/v1" _OPENROUTER_API_BASE = "https://openrouter.ai/api/v1" +_MINIMAX_API_BASE = "https://api.minimax.io/v1" # --- Branded LLM adapters ------------------------------------------------- @@ -26,7 +29,7 @@ @pytest.mark.parametrize( "adapter", - [NvidiaBuildLLMAdapter, OpenRouterLLMAdapter], + [MiniMaxLLMAdapter, NvidiaBuildLLMAdapter, OpenRouterLLMAdapter], ) def test_branded_llm_adapter_is_registered(adapter: type) -> None: adapter_id = adapter.get_id() @@ -41,6 +44,55 @@ def test_nvidia_llm_prefixes_model_via_custom_openai() -> None: assert validated["api_base"] == _NVIDIA_BUILD_API_BASE +@pytest.mark.parametrize("model", ["MiniMax-M3", "MiniMax-M2.7"]) +def test_minimax_llm_routes_via_native_provider(model: str) -> None: + from litellm import get_llm_provider + + validated = MiniMaxLLMParameters.validate({"model": model, "api_key": "k"}) + + assert validated["model"] == f"minimax/{model}" + assert validated["api_base"] == _MINIMAX_API_BASE + assert get_llm_provider(validated["model"])[1] == "minimax" + + +def test_minimax_model_prefix_is_idempotent() -> None: + once = MiniMaxLLMParameters.validate({"model": "MiniMax-M3", "api_key": "k"}) + twice = MiniMaxLLMParameters.validate(dict(once)) + + assert twice["model"] == once["model"] == "minimax/MiniMax-M3" + + +def test_minimax_native_routing_resolves_usage_cost() -> None: + from litellm import cost_per_token + + prompt_cost, completion_cost = cost_per_token( + "minimax/MiniMax-M3", prompt_tokens=1, completion_tokens=1 + ) + + assert prompt_cost > 0 + assert completion_cost > 0 + + +def test_minimax_maps_thinking_toggle_to_native_parameter() -> None: + enabled = MiniMaxLLMParameters.validate( + {"model": "MiniMax-M3", "api_key": "k", "enable_thinking": True} + ) + disabled = MiniMaxLLMParameters.validate( + {"model": "MiniMax-M3", "api_key": "k", "enable_thinking": False} + ) + + assert enabled["thinking"] == {"type": "adaptive"} + assert disabled["thinking"] == {"type": "disabled"} + assert "enable_thinking" not in enabled + + +def test_minimax_m2_rejects_disabling_thinking() -> None: + with pytest.raises(ValueError, match="does not support disabling thinking"): + MiniMaxLLMParameters.validate( + {"model": "MiniMax-M2.7", "api_key": "k", "enable_thinking": False} + ) + + def test_openrouter_llm_routes_via_native_openrouter_provider() -> None: from litellm import get_llm_provider @@ -106,6 +158,7 @@ def test_openrouter_reasoning_survives_revalidation() -> None: @pytest.mark.parametrize( ("params", "default_base"), [ + (MiniMaxLLMParameters, _MINIMAX_API_BASE), (NvidiaBuildLLMParameters, _NVIDIA_BUILD_API_BASE), (OpenRouterLLMParameters, _OPENROUTER_API_BASE), ], @@ -120,7 +173,7 @@ def test_branded_llm_blank_api_base_falls_back_to_default( @pytest.mark.parametrize( "params", - [NvidiaBuildLLMParameters, OpenRouterLLMParameters], + [MiniMaxLLMParameters, NvidiaBuildLLMParameters, OpenRouterLLMParameters], ) def test_branded_llm_honours_api_base_override(params: type) -> None: validated = params.validate( @@ -133,6 +186,7 @@ def test_branded_llm_honours_api_base_override(params: type) -> None: @pytest.mark.parametrize( ("adapter", "default_base"), [ + (MiniMaxLLMAdapter, _MINIMAX_API_BASE), (NvidiaBuildLLMAdapter, _NVIDIA_BUILD_API_BASE), (OpenRouterLLMAdapter, _OPENROUTER_API_BASE), ], @@ -147,6 +201,20 @@ def test_branded_llm_schema_exposes_api_base_with_default( assert "model" in schema["required"] +def test_minimax_schema_covers_models_thinking_and_regions() -> None: + schema = json.loads(MiniMaxLLMAdapter.get_json_schema()) + + assert schema["properties"]["model"]["examples"] == [ + "MiniMax-M3", + "MiniMax-M2.7", + ] + assert schema["properties"]["enable_thinking"]["default"] is True + assert ( + "https://api.minimaxi.com/v1" in schema["properties"]["api_base"]["description"] + ) + assert "reasoning_effort" not in json.dumps(schema) + + # --- Branded / generic embedding adapters ---------------------------------