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..875a457708 100644 --- a/unstract/sdk1/src/unstract/sdk1/adapters/base1.py +++ b/unstract/sdk1/src/unstract/sdk1/adapters/base1.py @@ -530,7 +530,35 @@ 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/" +_MINIMAX_CONTEXT_WINDOWS = { + "MiniMax-M3": 1_000_000, +} +# All M2.x variants share one window. Kept local because LiteLLM overstates it. +# REF: https://platform.minimax.io/docs/api-reference/text-openai-api +_MINIMAX_M2_CONTEXT_WINDOW = 204_800 + + +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 + + +def _minimax_context_window(model_id: str) -> int | None: + if context_window := _MINIMAX_CONTEXT_WINDOWS.get(model_id): + return context_window + if _is_minimax_m2_model(model_id): + return _MINIMAX_M2_CONTEXT_WINDOW + return None class NvidiaBuildLLMParameters(OpenAICompatibleLLMParameters): @@ -546,6 +574,74 @@ 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}" + if context_window := _minimax_context_window(model_id): + validated["context_window"] = context_window + 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 +980,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..3075271e97 --- /dev/null +++ b/unstract/sdk1/src/unstract/sdk1/adapters/llm1/static/minimax.json @@ -0,0 +1,79 @@ +{ + "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": "The model name as listed in [MiniMax's model docs](https://platform.minimax.io/docs/api-reference/text-openai-api). Examples: MiniMax-M3, MiniMax-M2.7. See [MiniMax pricing](https://platform.minimax.io/docs/guides/pricing-paygo) for rates and context windows." + }, + "api_base": { + "type": "string", + "format": "url", + "title": "API Base", + "default": "https://api.minimax.io/v1", + "description": "MiniMax endpoint. Pre-filled with the global OpenAI-compatible base. A base whose path ends in `/anthropic` is sent using the Anthropic-compatible protocol; any other base uses the OpenAI-compatible protocol. China accounts use the `api.minimaxi.com` host. See [MiniMax API docs](https://platform.minimax.io/docs/api-reference/text-openai-api)." + }, + "service_tier": { + "type": "string", + "title": "Service Tier", + "enum": [ + "standard", + "priority" + ], + "default": "standard", + "description": "Priority requests receive faster admission at a higher price. See [MiniMax pricing](https://platform.minimax.io/docs/guides/pricing-paygo). Note that cost tracking records standard-tier rates." + }, + "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", + "description": "Override the protocol default for MiniMax-M3: OpenAI-compatible requests default to adaptive thinking, while Anthropic-compatible requests default to disabled thinking. MiniMax-M2.x models always keep thinking enabled. See [MiniMax API docs](https://platform.minimax.io/docs/api-reference/text-openai-api)." + } + } +} diff --git a/unstract/sdk1/src/unstract/sdk1/llm.py b/unstract/sdk1/src/unstract/sdk1/llm.py index b45b856239..b1180a03da 100644 --- a/unstract/sdk1/src/unstract/sdk1/llm.py +++ b/unstract/sdk1/src/unstract/sdk1/llm.py @@ -224,6 +224,7 @@ def __init__( # noqa: C901 self.kwargs = self.adapter.validate(self._adapter_metadata) self._cost_model = self.kwargs.pop("cost_model", None) + self.kwargs.pop("context_window", None) # REF: https://docs.litellm.ai/docs/completion/input#translated-openai-params # supported = get_supported_openai_params(model=self.kwargs["model"], @@ -328,6 +329,7 @@ def complete(self, prompt: str, **kwargs: object) -> dict[str, object]: completion_kwargs = self.adapter.validate({**self.kwargs, **kwargs}) completion_kwargs.pop("cost_model", None) + completion_kwargs.pop("context_window", None) # if hasattr(self, "model") and self.model not in O1_MODELS: # completion_kwargs["temperature"] = 0.003 @@ -445,12 +447,12 @@ def complete_vision( litellm.drop_params = True logger.debug( - f"[sdk1][LLM]Invoking {self.adapter.get_provider()} " - f"vision completion API" + f"[sdk1][LLM]Invoking {self.adapter.get_provider()} vision completion API" ) completion_kwargs = self.adapter.validate({**self.kwargs, **kwargs}) completion_kwargs.pop("cost_model", None) + completion_kwargs.pop("context_window", None) response: dict[str, object] = litellm.completion( messages=messages, @@ -518,6 +520,7 @@ def stream_complete( completion_kwargs = self.adapter.validate({**self.kwargs, **kwargs}) completion_kwargs.pop("cost_model", None) + completion_kwargs.pop("context_window", None) max_retries = pop_litellm_retry_kwargs( completion_kwargs, self._get_adapter_info() @@ -589,6 +592,7 @@ async def acomplete(self, prompt: str, **kwargs: object) -> dict[str, object]: completion_kwargs = self.adapter.validate({**self.kwargs, **kwargs}) completion_kwargs.pop("cost_model", None) + completion_kwargs.pop("context_window", None) max_retries = pop_litellm_retry_kwargs( completion_kwargs, self._get_adapter_info() @@ -652,8 +656,21 @@ def get_context_window_size( ) -> int: """Returns the context window size of the LLM.""" try: - model = adapters[adapter_id][Common.MODULE].validate_model(adapter_metadata) - return get_max_tokens(model) + validated = adapters[adapter_id][Common.MODULE].validate( + dict(adapter_metadata) + ) + context_window = validated.get("context_window") + if isinstance(context_window, int): + return context_window + model = cast("str", validated.get("cost_model") or validated["model"]) + model_info = litellm.get_model_info(model) + context_window = model_info.get("max_input_tokens") + if isinstance(context_window, int): + return context_window + fallback = get_max_tokens(model) + if isinstance(fallback, int): + return fallback + raise ValueError(f"Context window is unavailable for model {model}.") except Exception as e: logger.warning(f"Failed to get context window size for {adapter_id}: {e}") return cls.MAX_TOKENS @@ -667,10 +684,10 @@ def get_max_tokens( llm_config = PlatformHelper.get_adapter_config(tool, adapter_instance_id) adapter_id = llm_config[Common.ADAPTER_ID] adapter_metadata = llm_config[Common.ADAPTER_METADATA] - - model = adapters[adapter_id][Common.MODULE].validate_model(adapter_metadata) - - return get_max_tokens(model) - reserved_for_output + return ( + cls.get_context_window_size(adapter_id, adapter_metadata) + - reserved_for_output + ) except Exception as e: logger.warning( f"Failed to get context window size for {adapter_instance_id}: {e}" diff --git a/unstract/sdk1/tests/test_branded_openai_adapters.py b/unstract/sdk1/tests/test_branded_openai_adapters.py index c876698847..c1556a3dd7 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,72 @@ 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 "default" not in schema["properties"]["enable_thinking"] + assert schema["properties"]["api_base"]["default"] == _MINIMAX_API_BASE + assert schema["properties"]["service_tier"]["enum"] == [ + "standard", + "priority", + ] + assert "reasoning_effort" not in json.dumps(schema) + + +def test_minimax_schema_descriptions_link_out_instead_of_quoting_provider_facts() -> None: + """Provider-owned facts go stale, so descriptions link instead of copying.""" + schema = json.loads(MiniMaxLLMAdapter.get_json_schema()) + descriptions = { + name: prop["description"] + for name, prop in schema["properties"].items() + if "description" in prop + } + + for name, description in descriptions.items(): + assert "$" not in description, f"{name} quotes a price that will go stale" + + for name in ("model", "service_tier"): + assert "platform.minimax.io" in descriptions[name] + + # Protocol selection is adapter behaviour, not a MiniMax fact. + assert "/anthropic" in descriptions["api_base"] + + +@pytest.mark.parametrize( + ("model", "api_base", "context_window"), + [ + ("MiniMax-M3", _MINIMAX_API_BASE, 1_000_000), + ("MiniMax-M2.7", _MINIMAX_ANTHROPIC_API_BASE, 204_800), + # LiteLLM overstates the M2.x window; the adapter rule must win. + ("MiniMax-M2.5", _MINIMAX_API_BASE, 204_800), + ("MiniMax-M2.1", _MINIMAX_API_BASE, 204_800), + ("MiniMax-M2", _MINIMAX_API_BASE, 204_800), + ("MiniMax-M2.7-highspeed", _MINIMAX_API_BASE, 204_800), + ], +) +def test_minimax_context_window_uses_adapter_metadata( + model: str, api_base: str, context_window: int +) -> None: + import sys + from importlib import import_module + from types import ModuleType + + sys.modules.setdefault("magic", ModuleType("magic")) + llm_class = import_module("unstract.sdk1.llm").LLM + + assert ( + llm_class.get_context_window_size( + MiniMaxLLMAdapter.get_id(), + {"model": model, "api_key": "k", "api_base": api_base}, + ) + == context_window + ) + + # --- Branded / generic embedding adapters ---------------------------------