diff --git a/.env.example b/.env.example index 51ff42ea5..1c591b7ee 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,9 @@ # --- Chutes --- #CHUTES_API_KEY_1="YOUR_CHUTES_API_KEY" +# --- MiniMax --- +#MINIMAX_API_KEY_1="YOUR_MINIMAX_API_KEY" + # ------------------------------------------------------------------------------ # | [OAUTH] Provider OAuth 2.0 Credentials | # ------------------------------------------------------------------------------ @@ -86,6 +89,20 @@ # cannot automatically determine your Google Cloud Project ID. #GEMINI_CLI_PROJECT_ID="" +# --- MiniMax Endpoint Selection --- +# Select the upstream region and compatibility protocol used for MiniMax calls. +# Supported regions: global_en, cn_zh +# Supported protocols: openai, anthropic +#MINIMAX_API_REGION="global_en" +#MINIMAX_API_PROTOCOL="openai" +# +# Anthropic-compatible base URLs must end with /anthropic. The client adapter +# appends /v1/messages when sending a Messages API request. +#MINIMAX_GLOBAL_OPENAI_BASE_URL="https://api.minimax.io/v1" +#MINIMAX_GLOBAL_ANTHROPIC_BASE_URL="https://api.minimax.io/anthropic" +#MINIMAX_CN_OPENAI_BASE_URL="https://api.minimaxi.com/v1" +#MINIMAX_CN_ANTHROPIC_BASE_URL="https://api.minimaxi.com/anthropic" + # --- Model Ignore Lists --- # Specify a comma-separated list of model names to exclude from a provider's # available models. This is useful for filtering out models you don't want to use. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d8825ff62..4deb655c3 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -1145,7 +1145,31 @@ TIMEOUT_POOL=120 The library handles provider idiosyncrasies through specialized "Provider" classes in `src/rotator_library/providers/`. -### 3.1. Gemini CLI (`gemini_cli_provider.py`) +### 3.1. MiniMax (`minimax_provider.py`) + +The MiniMax provider exposes the built-in `MiniMax-M3` and `MiniMax-M2.7` +models while preserving additional models returned by the provider's model +discovery endpoint. Native model metadata includes context limits, pricing, +input modalities, tool use, interleaved thinking, and tiered pricing for the +`/v1/models` and cost APIs. + +Configure the upstream region and protocol with these environment variables: + +```env +MINIMAX_API_REGION="global_en" # or cn_zh +MINIMAX_API_PROTOCOL="openai" # or anthropic + +MINIMAX_GLOBAL_OPENAI_BASE_URL="https://api.minimax.io/v1" +MINIMAX_GLOBAL_ANTHROPIC_BASE_URL="https://api.minimax.io/anthropic" +MINIMAX_CN_OPENAI_BASE_URL="https://api.minimaxi.com/v1" +MINIMAX_CN_ANTHROPIC_BASE_URL="https://api.minimaxi.com/anthropic" +``` + +Anthropic-compatible base URLs must end with `/anthropic`. The adapter passes +that base to the compatibility client, which appends `/v1/messages` for the +Messages API request. + +### 3.2. Gemini CLI (`gemini_cli_provider.py`) The `GeminiCliProvider` is the most complex implementation, mimicking the Google Cloud Code extension. @@ -1492,4 +1516,3 @@ The GUI modifies the same environment variables that the `RotatingClient` reads: 3. **Proxy applies rules** → `get_available_models()` filters based on rules **Note**: The proxy must be restarted to pick up rule changes made via the GUI (or use the Launcher TUI's reload functionality if available). - diff --git a/README.md b/README.md index 72ecfa0c7..33ecf8225 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ openai/gpt-4o ← OpenAI API anthropic/claude-3-5-sonnet ← Anthropic API openrouter/anthropic/claude-3-opus ← OpenRouter gemini_cli/gemini-2.5-pro ← Gemini CLI (OAuth) +minimax/MiniMax-M3 (MiniMax OpenAI-compatible route) +minimax/MiniMax-M2.7 (MiniMax OpenAI-compatible route) ``` ### Usage Examples @@ -278,6 +280,7 @@ GEMINI_API_KEY_1="your-gemini-key" GEMINI_API_KEY_2="another-gemini-key" OPENAI_API_KEY_1="your-openai-key" ANTHROPIC_API_KEY_1="your-anthropic-key" +MINIMAX_API_KEY_1="your-minimax-key" ``` > Copy `.env.example` to `.env` as a starting point. @@ -454,6 +457,25 @@ The proxy includes a powerful text-based UI for configuration and management. | `IGNORE_MODELS_` | Blacklist (comma-separated, supports `*`) | `IGNORE_MODELS_OPENAI=*-preview*` | | `WHITELIST_MODELS_` | Whitelist (overrides blacklist) | `WHITELIST_MODELS_GEMINI=gemini-2.5-pro` | +### MiniMax Endpoint Selection + +MiniMax supports both configured regions and both compatibility protocols. Set +the region and protocol in `.env`; the provider plugin then routes requests +through the selected base URL. + +```env +MINIMAX_API_REGION="global_en" # or cn_zh +MINIMAX_API_PROTOCOL="openai" # or anthropic + +MINIMAX_GLOBAL_OPENAI_BASE_URL="https://api.minimax.io/v1" +MINIMAX_GLOBAL_ANTHROPIC_BASE_URL="https://api.minimax.io/anthropic" +MINIMAX_CN_OPENAI_BASE_URL="https://api.minimaxi.com/v1" +MINIMAX_CN_ANTHROPIC_BASE_URL="https://api.minimaxi.com/anthropic" +``` + +Anthropic-compatible base URLs must end with `/anthropic`. The adapter uses +the provider's `/v1/messages` request path without exposing a derived base URL. + ### Advanced Features | Variable | Description | diff --git a/src/rotator_library/minimax_config.py b/src/rotator_library/minimax_config.py new file mode 100644 index 000000000..9c3d1c77d --- /dev/null +++ b/src/rotator_library/minimax_config.py @@ -0,0 +1,148 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# Copyright (c) 2026 Mirrowel + +"""MiniMax endpoint and model configuration.""" + +from __future__ import annotations + +import os +import logging +from typing import Any, Dict, Optional + + +lib_logger = logging.getLogger("rotator_library") + + +GLOBAL_EN = "global_en" +CN_ZH = "cn_zh" +OPENAI_PROTOCOL = "openai" +ANTHROPIC_PROTOCOL = "anthropic" + +MINIMAX_ENDPOINTS: Dict[str, Dict[str, str]] = { + GLOBAL_EN: { + OPENAI_PROTOCOL: "https://api.minimax.io/v1", + ANTHROPIC_PROTOCOL: "https://api.minimax.io/anthropic", + }, + CN_ZH: { + OPENAI_PROTOCOL: "https://api.minimaxi.com/v1", + ANTHROPIC_PROTOCOL: "https://api.minimaxi.com/anthropic", + }, +} + +MINIMAX_MODEL_DEFINITIONS: Dict[str, Dict[str, Any]] = { + "MiniMax-M3": { + "context_window": 1_000_000, + "pricing_usd_per_million_tokens": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": None, + }, + "pricing_tiers_usd_per_million_tokens": [ + { + "service_tier": "standard", + "input_tokens_lte": 512_000, + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": None, + }, + { + "service_tier": "standard", + "input_tokens_gt": 512_000, + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + }, + { + "service_tier": "priority", + "input_tokens_lte": 512_000, + "input": 0.45, + "output": 1.8, + "cache_read": 0.09, + "cache_write": None, + }, + { + "service_tier": "priority", + "input_tokens_gt": 512_000, + "input": 0.9, + "output": 3.6, + "cache_read": 0.18, + "cache_write": None, + }, + ], + "input_modalities": ["text", "image", "video"], + "thinking": ["adaptive", "disabled"], + "interleaved": True, + }, + "MiniMax-M2.7": { + "context_window": 204_800, + "pricing_usd_per_million_tokens": { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375, + }, + "input_modalities": ["text"], + "thinking": ["always_on"], + }, +} + +MINIMAX_DEFAULT_MODELS = tuple(MINIMAX_MODEL_DEFINITIONS) + +_REGION_ENV_VARS = { + GLOBAL_EN: { + OPENAI_PROTOCOL: "MINIMAX_GLOBAL_OPENAI_BASE_URL", + ANTHROPIC_PROTOCOL: "MINIMAX_GLOBAL_ANTHROPIC_BASE_URL", + }, + CN_ZH: { + OPENAI_PROTOCOL: "MINIMAX_CN_OPENAI_BASE_URL", + ANTHROPIC_PROTOCOL: "MINIMAX_CN_ANTHROPIC_BASE_URL", + }, +} + + +def get_minimax_region() -> str: + """Return the configured endpoint region, defaulting to the global service.""" + region = os.getenv("MINIMAX_API_REGION", GLOBAL_EN).strip().lower() + return region if region in MINIMAX_ENDPOINTS else GLOBAL_EN + + +def get_minimax_protocol() -> str: + """Return the configured upstream protocol.""" + protocol = os.getenv("MINIMAX_API_PROTOCOL", OPENAI_PROTOCOL).strip().lower() + return ( + protocol + if protocol in (OPENAI_PROTOCOL, ANTHROPIC_PROTOCOL) + else OPENAI_PROTOCOL + ) + + +def get_minimax_endpoint( + region: Optional[str] = None, + protocol: Optional[str] = None, +) -> str: + """Resolve a user-configured or default MiniMax endpoint.""" + selected_region = region or get_minimax_region() + selected_protocol = protocol or get_minimax_protocol() + + if selected_region not in MINIMAX_ENDPOINTS: + selected_region = GLOBAL_EN + if selected_protocol not in (OPENAI_PROTOCOL, ANTHROPIC_PROTOCOL): + selected_protocol = OPENAI_PROTOCOL + + env_var = _REGION_ENV_VARS[selected_region][selected_protocol] + override = os.getenv(env_var, "").strip() + if not override: + if selected_protocol == OPENAI_PROTOCOL: + override = os.getenv("MINIMAX_API_BASE", "").strip() + + endpoint = override or MINIMAX_ENDPOINTS[selected_region][selected_protocol] + endpoint = endpoint.rstrip("/") + if selected_protocol == ANTHROPIC_PROTOCOL and not endpoint.endswith("/anthropic"): + lib_logger.warning( + "Invalid MiniMax Anthropic base URL; using the selected default endpoint" + ) + endpoint = MINIMAX_ENDPOINTS[selected_region][selected_protocol] + return endpoint diff --git a/src/rotator_library/model_info_service.py b/src/rotator_library/model_info_service.py index e9c86b153..b5aa38630 100644 --- a/src/rotator_library/model_info_service.py +++ b/src/rotator_library/model_info_service.py @@ -20,6 +20,8 @@ from urllib.request import Request, urlopen from urllib.error import URLError +from .minimax_config import MINIMAX_MODEL_DEFINITIONS + logger = logging.getLogger(__name__) @@ -45,6 +47,7 @@ "meta-llama", "nvidia", "moonshotai", # Used in nvidia_nim/moonshotai/model format + "minimax", # These are aggregators/proxies - lower priority "openrouter", "azure", @@ -86,6 +89,74 @@ } +def _build_minimax_model_catalog() -> Dict[str, Dict[str, Any]]: + """Build native metadata records for the supported MiniMax models.""" + + def normalize_tiers(definition: Dict[str, Any]) -> List[Dict[str, Any]]: + tiers = [] + for source in definition.get("pricing_tiers_usd_per_million_tokens", []): + tier = { + key: source[key] + for key in ("service_tier", "input_tokens_lte", "input_tokens_gt") + if key in source + } + for source_key, target_key in ( + ("input", "prompt"), + ("output", "completion"), + ("cache_read", "cached_input"), + ("cache_write", "cache_write"), + ): + if source_key in source: + value = source[source_key] + tier[target_key] = value / 1_000_000 if value is not None else None + tiers.append(tier) + return tiers + + catalog = {} + for model_id, definition in MINIMAX_MODEL_DEFINITIONS.items(): + pricing = definition["pricing_usd_per_million_tokens"] + input_types = definition["input_modalities"] + thinking_modes = definition["thinking"] + catalog[f"minimax/{model_id}"] = { + "name": model_id, + "original_id": model_id, + "provider": "minimax", + "source": "native", + "category": "chat", + "prompt_cost": pricing["input"] / 1_000_000, + "completion_cost": pricing["output"] / 1_000_000, + "cache_read_cost": pricing["cache_read"] / 1_000_000, + "cache_write_cost": ( + pricing["cache_write"] / 1_000_000 + if pricing["cache_write"] is not None + else None + ), + "pricing_tiers": normalize_tiers(definition), + "context": definition["context_window"], + "max_out": 0, + "inputs": input_types, + "outputs": ["text"], + "has_tools": model_id == "MiniMax-M3", + "has_functions": model_id == "MiniMax-M3", + "has_reasoning": bool(definition["thinking"]), + "has_vision": "image" in input_types, + "has_structured_output": False, + "has_temperature": True, + "has_attachments": "file" in input_types, + "has_interleaved": definition.get("interleaved", False), + "thinking_modes": thinking_modes, + "supported_parameters": ( + ["tools", "tool_choice", "thinking"] + if model_id == "MiniMax-M3" + else [] + ), + } + return catalog + + +MINIMAX_MODEL_CATALOG = _build_minimax_model_catalog() + + def _get_provider_priority(provider: str) -> int: """ Get priority score for a provider (lower = better). @@ -132,6 +203,7 @@ class ModelPricing: completion: Optional[float] = None cached_input: Optional[float] = None cache_write: Optional[float] = None + tiers: List[Dict[str, Any]] = field(default_factory=list) @dataclass @@ -158,6 +230,7 @@ class ModelCapabilities: temperature: bool = True # Most models support temperature attachments: bool = False # File/document attachments interleaved: bool = False # Interleaved content support + thinking_modes: List[str] = field(default_factory=list) @dataclass @@ -228,6 +301,8 @@ def as_api_response(self) -> Dict[str, Any]: response["pricing"]["cached_input"] = self.pricing.cached_input if self.pricing.cache_write is not None: response["pricing"]["cache_write"] = self.pricing.cache_write + if self.pricing.tiers: + response["pricing"]["tiers"] = self.pricing.tiers # === Architecture/modalities (OpenRouter-style) === response["architecture"] = { @@ -251,6 +326,8 @@ def as_api_response(self) -> Dict[str, Any]: "attachments": self.capabilities.attachments, "interleaved": self.capabilities.interleaved, } + if self.capabilities.thinking_modes: + response["capabilities"]["thinking_modes"] = self.capabilities.thinking_modes # === Supported parameters (if available) === if self.supported_parameters: @@ -777,6 +854,7 @@ def create_metadata( completion=best_record.get("completion_cost"), cached_input=best_record.get("cache_read_cost"), cache_write=best_record.get("cache_write_cost"), + tiers=best_record.get("pricing_tiers", []), ), limits=ModelLimits( context_window=best_record.get("context") or None, @@ -792,6 +870,7 @@ def create_metadata( temperature=best_record.get("has_temperature", True), attachments=best_record.get("has_attachments", False), interleaved=best_record.get("has_interleaved", False), + thinking_modes=best_record.get("thinking_modes", []), ), info=ModelInfo( family=best_record.get("family", ""), @@ -922,6 +1001,7 @@ def __init__( # Raw data stores self._openrouter_store: Dict[str, Dict] = {} self._modelsdev_store: Dict[str, Dict] = {} + self._native_store: Dict[str, Dict] = MINIMAX_MODEL_CATALOG.copy() # Lookup infrastructure self._index = ModelIndex() @@ -1022,6 +1102,9 @@ def _rebuild_index(self): for model_id in self._modelsdev_store: self._index.add(model_id) + for model_id in self._native_store: + self._index.add(model_id) + # ---------- Query API ---------- def lookup(self, model_id: str) -> Optional[ModelMetadata]: @@ -1064,6 +1147,13 @@ def _resolve_model(self, model_id: str) -> Optional[ModelMetadata]: ) quality = "exact" + if model_id in self._native_store: + records.insert( + 0, + (self._native_store[model_id], f"native:exact:{model_id}"), + ) + quality = "exact" + # Step 2: Try provider alias substitution for direct match # This handles cases like nvidia_nim/org/model -> nvidia/org/model if not records: @@ -1169,6 +1259,27 @@ def compute_cost( if not pricing: return None + metadata = self.lookup(model_id) + if metadata and metadata.pricing.tiers: + standard_tiers = [ + tier + for tier in metadata.pricing.tiers + if tier.get("service_tier") == "standard" + ] + for tier in standard_tiers: + if tier.get("input_tokens_lte") is not None: + matches = input_tokens <= tier["input_tokens_lte"] + else: + matches = input_tokens > tier.get("input_tokens_gt", -1) + if matches: + pricing = { + "input_cost_per_token": tier.get("prompt"), + "output_cost_per_token": tier.get("completion"), + "cache_read_input_token_cost": tier.get("cached_input"), + "cache_creation_input_token_cost": tier.get("cache_write"), + } + break + in_rate = pricing.get("input_cost_per_token") out_rate = pricing.get("output_cost_per_token") @@ -1215,6 +1326,7 @@ def all_raw_models(self) -> Dict[str, Dict]: combined = {} combined.update(self._openrouter_store) combined.update(self._modelsdev_store) + combined.update(self._native_store) return combined def diagnostics(self) -> Dict[str, Any]: @@ -1224,6 +1336,7 @@ def diagnostics(self) -> Dict[str, Any]: "last_refresh": self._last_refresh, "openrouter_count": len(self._openrouter_store), "modelsdev_count": len(self._modelsdev_store), + "native_count": len(self._native_store), "cached_lookups": len(self._result_cache), "index_entries": self._index.entry_count(), "refresh_interval": self._refresh_interval, diff --git a/src/rotator_library/provider_config.py b/src/rotator_library/provider_config.py index 51d40043b..bb22f4f36 100644 --- a/src/rotator_library/provider_config.py +++ b/src/rotator_library/provider_config.py @@ -21,6 +21,13 @@ get_provider_api_key_var, get_provider_display_name, ) +from .minimax_config import ( + ANTHROPIC_PROTOCOL, + CN_ZH, + GLOBAL_EN, + MINIMAX_ENDPOINTS, + OPENAI_PROTOCOL, +) lib_logger = logging.getLogger("rotator_library") @@ -93,8 +100,38 @@ }, "minimax": { "category": "popular", + "note": ( + "Set MINIMAX_API_REGION to global_en or cn_zh and " + "MINIMAX_API_PROTOCOL to openai or anthropic." + ), "extra_vars": [ - ("MINIMAX_API_BASE", "API Base URL (optional)", None), + ("MINIMAX_API_REGION", "Endpoint region", GLOBAL_EN), + ("MINIMAX_API_PROTOCOL", "Upstream protocol", OPENAI_PROTOCOL), + ( + "MINIMAX_API_BASE", + "OpenAI-compatible base override (optional)", + None, + ), + ( + "MINIMAX_GLOBAL_OPENAI_BASE_URL", + "Global OpenAI-compatible base URL", + MINIMAX_ENDPOINTS[GLOBAL_EN][OPENAI_PROTOCOL], + ), + ( + "MINIMAX_GLOBAL_ANTHROPIC_BASE_URL", + "Global Anthropic-compatible base URL", + MINIMAX_ENDPOINTS[GLOBAL_EN][ANTHROPIC_PROTOCOL], + ), + ( + "MINIMAX_CN_OPENAI_BASE_URL", + "China OpenAI-compatible base URL", + MINIMAX_ENDPOINTS[CN_ZH][OPENAI_PROTOCOL], + ), + ( + "MINIMAX_CN_ANTHROPIC_BASE_URL", + "China Anthropic-compatible base URL", + MINIMAX_ENDPOINTS[CN_ZH][ANTHROPIC_PROTOCOL], + ), ], }, "xiaomi_mimo": { @@ -712,6 +749,11 @@ def convert_for_litellm(self, **kwargs) -> Dict[str, Any]: if not model: return kwargs + # Provider hooks may select a protocol-specific endpoint. Preserve it + # instead of replacing it with a global provider override. + if kwargs.get("api_base") and kwargs.get("custom_llm_provider"): + return kwargs + # Extract provider from model string (e.g., "openai/gpt-4" → "openai") provider = model.split("/")[0].lower() api_base = self._api_bases.get(provider) diff --git a/src/rotator_library/providers/minimax_provider.py b/src/rotator_library/providers/minimax_provider.py new file mode 100644 index 000000000..a49ff72f2 --- /dev/null +++ b/src/rotator_library/providers/minimax_provider.py @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# Copyright (c) 2026 Mirrowel + +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +import httpx + +from ..minimax_config import ( + MINIMAX_DEFAULT_MODELS, + OPENAI_PROTOCOL, + get_minimax_endpoint, + get_minimax_protocol, + get_minimax_region, +) +from ..model_definitions import ModelDefinitions +from .provider_interface import ProviderInterface + +lib_logger = logging.getLogger("rotator_library") + + +class MinimaxProvider(ProviderInterface): + """MiniMax provider using the configured OpenAI or Anthropic protocol.""" + + provider_env_name = "minimax" + + def __init__(self): + self.model_definitions = ModelDefinitions() + + async def get_models(self, api_key: str, client: httpx.AsyncClient) -> List[str]: + """Return configured fallback models and any models exposed by the API.""" + models: List[str] = [] + seen: set[str] = set() + + configured_models = self.model_definitions.get_all_provider_models("minimax") + for model in configured_models: + if model not in seen: + models.append(model) + seen.add(model) + + for model_id in MINIMAX_DEFAULT_MODELS: + model = f"minimax/{model_id}" + if model not in seen: + models.append(model) + seen.add(model) + + try: + models_url = f"{get_minimax_endpoint(protocol=OPENAI_PROTOCOL)}/models" + response = await client.get( + models_url, + headers={"Authorization": f"Bearer {api_key}"}, + ) + response.raise_for_status() + for model_data in response.json().get("data", []): + model_id = model_data.get("id") + model = f"minimax/{model_id}" if model_id else "" + if model and model not in seen: + models.append(model) + seen.add(model) + except Exception as exc: + lib_logger.debug("MiniMax model discovery failed: %s", exc) + + return models + + def get_model_options(self, model_name: str) -> Dict[str, Any]: + """Return user-defined options for a MiniMax model.""" + model_name = model_name.rsplit("/", 1)[-1] + return self.model_definitions.get_model_options("minimax", model_name) + + async def transform_request( + self, + kwargs: Dict[str, Any], + model: str, + credential: str, + ) -> List[str]: + """Route the normalized request through the selected compatibility API.""" + protocol = get_minimax_protocol() + model_name = model.rsplit("/", 1)[-1] + kwargs["model"] = f"{protocol}/{model_name}" + kwargs["api_base"] = get_minimax_endpoint(protocol=protocol) + kwargs["custom_llm_provider"] = protocol + return [ + f"minimax: selected {get_minimax_region()} {protocol}-compatible endpoint" + ] + + def has_custom_logic(self) -> bool: + """Use LiteLLM after selecting the configured compatibility adapter.""" + return False + + async def get_auth_header(self, credential_identifier: str) -> Dict[str, str]: + """Return the standard API key authorization header.""" + return {"Authorization": f"Bearer {credential_identifier}"} diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 000000000..d97a51948 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,172 @@ +import json +import os +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +import httpx +import litellm + +from rotator_library.minimax_config import ( + ANTHROPIC_PROTOCOL, + CN_ZH, + GLOBAL_EN, + OPENAI_PROTOCOL, + get_minimax_endpoint, +) +from rotator_library.client.transforms import ProviderTransforms +from rotator_library.model_info_service import ModelRegistry +from rotator_library.provider_config import ProviderConfig +from rotator_library.providers.minimax_provider import MinimaxProvider + + +class _CaptureHandler(BaseHTTPRequestHandler): + captured_paths = [] + + def do_POST(self): # noqa: N802 + self.__class__.captured_paths.append(self.path) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write( + json.dumps( + { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "MiniMax-M3", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + ).encode() + ) + + def log_message(self, format, *args): # noqa: A002 + return + + +class MiniMaxProviderTests(unittest.IsolatedAsyncioTestCase): + async def test_fallback_models_preserve_dynamic_models(self): + async def handler(request): + return httpx.Response( + 200, + json={"data": [{"id": "MiniMax-Extra"}, {"id": "MiniMax-M3"}]}, + ) + + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as client: + models = await MinimaxProvider().get_models("test-key", client) + + self.assertEqual( + models[:2], ["minimax/MiniMax-M3", "minimax/MiniMax-M2.7"] + ) + self.assertIn("minimax/MiniMax-Extra", models) + self.assertEqual(models.count("minimax/MiniMax-M3"), 1) + + async def test_anthropic_request_capture_appends_v1_messages(self): + _CaptureHandler.captured_paths = [] + server = ThreadingHTTPServer(("127.0.0.1", 0), _CaptureHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base_url = f"http://127.0.0.1:{server.server_port}/anthropic" + with patch.dict( + os.environ, + { + "MINIMAX_API_PROTOCOL": ANTHROPIC_PROTOCOL, + "MINIMAX_API_REGION": GLOBAL_EN, + "MINIMAX_GLOBAL_ANTHROPIC_BASE_URL": base_url, + }, + ): + kwargs = { + "model": "minimax/MiniMax-M3", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 1, + } + await MinimaxProvider().transform_request( + kwargs, kwargs["model"], "test-key" + ) + kwargs["api_key"] = "test-key" + await litellm.acompletion(**kwargs) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + self.assertEqual(_CaptureHandler.captured_paths, ["/anthropic/v1/messages"]) + + async def test_native_model_metadata_and_endpoint_regions(self): + registry = ModelRegistry() + metadata = registry.lookup("minimax/MiniMax-M3") + + self.assertIsNotNone(metadata) + self.assertEqual(metadata.limits.context_window, 1_000_000) + self.assertEqual(metadata.pricing.prompt, 0.3 / 1_000_000) + self.assertEqual(metadata.input_types, ["text", "image", "video"]) + self.assertTrue(metadata.capabilities.tools) + self.assertTrue(metadata.capabilities.functions) + self.assertTrue(metadata.capabilities.interleaved) + self.assertEqual( + metadata.capabilities.thinking_modes, ["adaptive", "disabled"] + ) + self.assertEqual(len(metadata.pricing.tiers), 4) + self.assertEqual(metadata.pricing.tiers[1]["prompt"], 0.6 / 1_000_000) + + secondary = registry.lookup("minimax/MiniMax-M2.7") + self.assertIsNotNone(secondary) + self.assertEqual(secondary.limits.context_window, 204_800) + self.assertEqual(secondary.pricing.cache_write, 0.375 / 1_000_000) + self.assertEqual(secondary.input_types, ["text"]) + self.assertEqual(secondary.capabilities.thinking_modes, ["always_on"]) + self.assertFalse(secondary.supported_parameters) + self.assertEqual( + get_minimax_endpoint(GLOBAL_EN, OPENAI_PROTOCOL), + "https://api.minimax.io/v1", + ) + self.assertEqual( + get_minimax_endpoint(CN_ZH, ANTHROPIC_PROTOCOL), + "https://api.minimaxi.com/anthropic", + ) + + async def test_provider_hook_endpoint_survives_global_override(self): + with patch.dict( + os.environ, + { + "OPENAI_API_BASE": "https://global.example/v1", + "MINIMAX_API_REGION": GLOBAL_EN, + "MINIMAX_API_PROTOCOL": OPENAI_PROTOCOL, + }, + ): + transforms = ProviderTransforms( + {"minimax": MinimaxProvider}, ProviderConfig() + ) + result = await transforms.apply( + "minimax", + "minimax/MiniMax-M3", + "test-key", + {"model": "minimax/MiniMax-M3"}, + ) + + self.assertEqual(result["api_base"], "https://api.minimax.io/v1") + self.assertEqual(result["custom_llm_provider"], "openai") + + async def test_invalid_anthropic_override_uses_selected_default(self): + with patch.dict( + os.environ, + { + "MINIMAX_API_REGION": GLOBAL_EN, + "MINIMAX_API_PROTOCOL": ANTHROPIC_PROTOCOL, + "MINIMAX_GLOBAL_ANTHROPIC_BASE_URL": "https://invalid.example/v1", + }, + ): + endpoint = get_minimax_endpoint(protocol=ANTHROPIC_PROTOCOL) + + self.assertEqual(endpoint, "https://api.minimax.io/anthropic") + + async def test_long_context_uses_standard_pricing_tier(self): + registry = ModelRegistry() + cost = registry.compute_cost("minimax/MiniMax-M3", 512_001, 1) + + self.assertAlmostEqual(cost, (512_001 * 0.6 + 2.4) / 1_000_000)