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 3c9aee1d60..bb05278bfd 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -530,7 +530,21 @@ 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/" +_MINIMAX_ANTHROPIC_PROVIDER_PREFIX = "anthropic/" + + +def _minimax_provider_prefix(api_base: str) -> str: + path = urlparse(api_base).path.rstrip("/").lower() + if path.endswith("/anthropic"): + return _MINIMAX_ANTHROPIC_PROVIDER_PREFIX + return _MINIMAX_PROVIDER_PREFIX + + +def _is_minimax_m2_model(model_id: str) -> bool: + return re.match(r"^minimax-m2(?:$|[.-])", model_id, re.IGNORECASE) is not None class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): @@ -546,6 +560,72 @@ def validate(adapter_metadata: dict[str, "Any"]) -> dict[str, "Any"]: ) +class MiniMaxLLMParameters(BaseChatCompletionParameters): + """Adapter for MiniMax's OpenAI- and Anthropic-compatible APIs.""" + + api_key: str + api_base: str = _MINIMAX_API_BASE + temperature: float | None = Field(default=1, ge=0, le=2) + thinking: dict[str, str] | None = None + service_tier: 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"].split("/", 1)[-1] + + service_tier = adapter_metadata.get("service_tier") + if service_tier not in {None, "standard", "priority"}: + raise ValueError("service_tier must be standard or priority.") + + 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 None and _is_minimax_m2_model(model_id): + thinking = {"type": "adaptive"} + adapter_metadata["thinking"] = 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 _is_minimax_m2_model(model_id) and thinking["type"] == "disabled": + raise ValueError(f"{model_id} does not support disabling thinking.") + + validated = MiniMaxLLMParameters(**adapter_metadata).model_dump() + validated["cost_model"] = f"{_MINIMAX_PROVIDER_PREFIX}{model_id}" + validated["allowed_openai_params"] = ["service_tier", "thinking"] + return validated + + @staticmethod + def validate_model(adapter_metadata: dict[str, "Any"]) -> str: + raw_model = adapter_metadata.get("model") + model = str(raw_model).strip() if raw_model is not None else "" + if not model: + raise ValueError("model is required for the MiniMax adapter.") + for prefix in ( + _MINIMAX_PROVIDER_PREFIX, + _MINIMAX_ANTHROPIC_PROVIDER_PREFIX, + ): + if model.startswith(prefix): + model = model[len(prefix) :] + break + api_base = str(adapter_metadata.get("api_base") or _MINIMAX_API_BASE) + return f"{_minimax_provider_prefix(api_base)}{model}" + + class OpenRouterLLMParameters(BaseChatCompletionParameters): """Adapter for OpenRouter (openrouter.ai). @@ -884,8 +964,7 @@ def _translate_bedrock_bearer_token(validated: dict[str, "Any"]) -> None: token = validated.pop(_BEDROCK_BEARER_TOKEN_FIELD, None) if not isinstance(token, str) or not token.strip(): raise ValueError( - f"{_BEDROCK_BEARER_TOKEN_FIELD} is required when " - "auth_type is 'bearer_token'." + f"{_BEDROCK_BEARER_TOKEN_FIELD} is required when auth_type is 'bearer_token'." ) validated[_BEDROCK_LITELLM_BEARER_KWARG] = token.strip() 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..fbde816b5f --- /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- and Anthropic-compatible APIs. " + "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..8ac78c483d --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/minimax.json @@ -0,0 +1,80 @@ +{ + "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 with text, image, and video input. MiniMax-M2.7 supports a 204,800-token context window with text input." + }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "https://api.minimax.io/v1", + "description": "The default https://api.minimax.io/v1 is the global OpenAI-compatible endpoint. Other supported bases are https://api.minimax.io/anthropic for global Anthropic-compatible requests, https://api.minimaxi.com/v1 for China OpenAI-compatible requests, and https://api.minimaxi.com/anthropic for China Anthropic-compatible requests." + }, + "service_tier": { + "type": "string", + "title": "Service Tier", + "enum": [ + "standard", + "priority" + ], + "default": "standard", + "description": "Priority requests receive priority admission and use the provider's priority pricing." + }, + "max_tokens": { + "type": "number", + "minimum": 0, + "multipleOf": 1, + "title": "Maximum Output Tokens", + "default": 4096, + "description": "Maximum output length, subject to the selected model's total context window." + }, + "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..5771f353f7 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,16 @@ 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" +_MINIMAX_ANTHROPIC_API_BASE = "https://api.minimax.io/anthropic" +_MINIMAX_CN_API_BASE = "https://api.minimaxi.com/v1" +_MINIMAX_CN_ANTHROPIC_API_BASE = "https://api.minimaxi.com/anthropic" # --- Branded LLM adapters ------------------------------------------------- @@ -26,7 +32,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 +47,159 @@ 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"]) +@pytest.mark.parametrize( + ("api_base", "provider"), + [ + (_MINIMAX_API_BASE, "minimax"), + (_MINIMAX_CN_API_BASE, "minimax"), + (_MINIMAX_ANTHROPIC_API_BASE, "anthropic"), + (_MINIMAX_CN_ANTHROPIC_API_BASE, "anthropic"), + ], +) +def test_minimax_llm_routes_by_api_protocol( + model: str, api_base: str, provider: str +) -> None: + from litellm import get_llm_provider + + validated = MiniMaxLLMParameters.validate( + {"model": model, "api_key": "k", "api_base": api_base} + ) + + assert validated["model"] == f"{provider}/{model}" + assert validated["api_base"] == api_base + assert validated["cost_model"] == f"minimax/{model}" + assert get_llm_provider(validated["model"])[1] == provider + assert validated["allowed_openai_params"] == ["service_tier", "thinking"] + + +@pytest.mark.parametrize("api_base", [_MINIMAX_API_BASE, _MINIMAX_ANTHROPIC_API_BASE]) +def test_minimax_model_prefix_is_idempotent(api_base: str) -> None: + once = MiniMaxLLMParameters.validate( + {"model": "MiniMax-M3", "api_key": "k", "api_base": api_base} + ) + twice = MiniMaxLLMParameters.validate(dict(once)) + + assert twice["model"] == once["model"] + + +def test_minimax_model_prefix_follows_changed_protocol() -> None: + openai = MiniMaxLLMParameters.validate({"model": "MiniMax-M3", "api_key": "k"}) + anthropic = MiniMaxLLMParameters.validate( + {**openai, "api_base": _MINIMAX_ANTHROPIC_API_BASE} + ) + + assert anthropic["model"] == "anthropic/MiniMax-M3" + + +def test_minimax_m3_standard_cost_path_handles_long_context() -> None: + from litellm import cost_per_token + + base_prompt_cost, base_completion_cost = cost_per_token( + "minimax/MiniMax-M3", prompt_tokens=512_000, completion_tokens=1 + ) + long_prompt_cost, long_completion_cost = cost_per_token( + "minimax/MiniMax-M3", prompt_tokens=512_001, completion_tokens=1 + ) + + assert base_prompt_cost == pytest.approx(512_000 * 0.3e-6) + assert base_completion_cost == pytest.approx(1.2e-6) + assert long_prompt_cost == pytest.approx(512_001 * 0.6e-6) + assert long_completion_cost == pytest.approx(2.4e-6) + + +def test_minimax_temperature_uses_official_default_and_range() -> None: + validated = MiniMaxLLMParameters.validate({"model": "MiniMax-M3", "api_key": "k"}) + + assert validated["temperature"] == pytest.approx(1) + for temperature in (-0.1, 2.1): + with pytest.raises(ValueError): + MiniMaxLLMParameters.validate( + { + "model": "MiniMax-M3", + "api_key": "k", + "temperature": temperature, + } + ) + + +def test_minimax_anthropic_temperature_uses_official_range() -> None: + validated = MiniMaxLLMParameters.validate( + { + "model": "MiniMax-M3", + "api_key": "k", + "api_base": _MINIMAX_ANTHROPIC_API_BASE, + "temperature": 2, + } + ) + + assert validated["temperature"] == pytest.approx(2) + + +@pytest.mark.parametrize("model", [None, "", " "]) +def test_minimax_rejects_missing_model(model: str | None) -> None: + with pytest.raises(ValueError, match="model is required"): + MiniMaxLLMParameters.validate({"model": model, "api_key": "k"}) + + +@pytest.mark.parametrize("service_tier", ["standard", "priority"]) +def test_minimax_forwards_supported_service_tiers(service_tier: str) -> None: + validated = MiniMaxLLMParameters.validate( + { + "model": "MiniMax-M3", + "api_key": "k", + "service_tier": service_tier, + } + ) + + assert validated["service_tier"] == service_tier + + +def test_minimax_rejects_unknown_service_tier() -> None: + with pytest.raises(ValueError, match="service_tier"): + MiniMaxLLMParameters.validate( + { + "model": "MiniMax-M3", + "api_key": "k", + "service_tier": "unsupported", + } + ) + + +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_minimax_m2_defaults_to_adaptive_thinking() -> None: + validated = MiniMaxLLMParameters.validate({"model": "MiniMax-M2.7", "api_key": "k"}) + + assert validated["thinking"] == {"type": "adaptive"} + + +def test_minimax_m2_thinking_rules_require_model_family_boundary() -> None: + validated = MiniMaxLLMParameters.validate( + {"model": "MiniMax-M20", "api_key": "k", "enable_thinking": False} + ) + + assert validated["thinking"] == {"type": "disabled"} + + def test_openrouter_llm_routes_via_native_openrouter_provider() -> None: from litellm import get_llm_provider @@ -106,6 +265,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 +280,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 +293,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 +308,29 @@ 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 + endpoint_description = schema["properties"]["api_base"]["description"] + for endpoint in ( + _MINIMAX_API_BASE, + _MINIMAX_ANTHROPIC_API_BASE, + _MINIMAX_CN_API_BASE, + _MINIMAX_CN_ANTHROPIC_API_BASE, + ): + assert endpoint in endpoint_description + assert schema["properties"]["service_tier"]["enum"] == [ + "standard", + "priority", + ] + assert "reasoning_effort" not in json.dumps(schema) + + # --- Branded / generic embedding adapters ---------------------------------