diff --git a/ccproxy/plugins/minimax/README.md b/ccproxy/plugins/minimax/README.md new file mode 100644 index 00000000..296293db --- /dev/null +++ b/ccproxy/plugins/minimax/README.md @@ -0,0 +1,29 @@ +# MiniMax provider plugin + +First-class MiniMax provider for CCProxy. It exposes OpenAI- and +Anthropic-compatible endpoints backed by MiniMax's global (`minimax.io`) and +regional (`minimaxi.com`) deployments, using static API-key (Bearer) +authentication. + +## Endpoints + +Mounted under the `/minimax` prefix: + +- `POST /minimax/v1/chat/completions` — OpenAI-compatible chat completions. +- `POST /minimax/v1/messages` — Anthropic-compatible messages, converted to the + OpenAI chat protocol via the core format chain. +- `GET /minimax/v1/models` — configured model metadata. + +## Configuration + +```toml +[plugins.minimax] +enabled = true +# "global_en" (api.minimax.io) or "cn_zh" (api.minimaxi.com) +region = "global_en" +api_key = "" +``` + +Selecting a `region` resolves the OpenAI and Anthropic base URLs and the +documentation root unless they are overridden explicitly. The default models +are `MiniMax-M3` and `MiniMax-M2.7`. diff --git a/ccproxy/plugins/minimax/__init__.py b/ccproxy/plugins/minimax/__init__.py new file mode 100644 index 00000000..97ebbc22 --- /dev/null +++ b/ccproxy/plugins/minimax/__init__.py @@ -0,0 +1,11 @@ +"""MiniMax provider plugin for CCProxy. + +This plugin adds first-class support for the MiniMax LLM API, exposing +OpenAI- and Anthropic-compatible endpoints backed by MiniMax's global and +regional deployments. +""" + +from .plugin import MiniMaxFactory, MiniMaxRuntime, factory + + +__all__ = ["MiniMaxFactory", "MiniMaxRuntime", "factory"] diff --git a/ccproxy/plugins/minimax/adapter.py b/ccproxy/plugins/minimax/adapter.py new file mode 100644 index 00000000..ac659bee --- /dev/null +++ b/ccproxy/plugins/minimax/adapter.py @@ -0,0 +1,85 @@ +"""HTTP adapter for the MiniMax provider plugin.""" + +from __future__ import annotations + +import uuid +from typing import Any + +import httpx +from starlette.responses import Response + +from ccproxy.core.errors import AuthenticationError +from ccproxy.core.logging import get_plugin_logger +from ccproxy.services.adapters.http_adapter import BaseHTTPAdapter +from ccproxy.utils.headers import extract_response_headers, filter_request_headers + +from .config import MiniMaxConfig + + +logger = get_plugin_logger() + + +class MiniMaxAdapter(BaseHTTPAdapter): + """MiniMax adapter using static API-key (Bearer) authentication.""" + + def __init__( + self, + config: MiniMaxConfig | None = None, + **kwargs: Any, + ) -> None: + super().__init__(config=config or MiniMaxConfig(), **kwargs) + self.base_url = self.config.base_url.rstrip("/") + + async def get_target_url(self, endpoint: str) -> str: + return f"{self.base_url}/{endpoint.lstrip('/')}" + + def _resolve_api_key(self) -> str: + api_key = getattr(self.config, "api_key", None) + if not api_key: + logger.warning( + "minimax_api_key_missing", + category="auth", + ) + raise AuthenticationError( + "MiniMax API key is not configured. Set the 'api_key' option for " + "the minimax plugin." + ) + return str(api_key) + + async def prepare_provider_request( + self, body: bytes, headers: dict[str, str], endpoint: str + ) -> tuple[bytes, dict[str, str]]: + api_key = self._resolve_api_key() + + # Drop any inbound client credentials before adding our own. + filtered_headers = filter_request_headers(headers, preserve_auth=False) + + provider_headers = { + key.lower(): str(value) + for key, value in self.config.api_headers.items() + if value is not None + } + provider_headers["authorization"] = f"Bearer {api_key}" + provider_headers["x-request-id"] = str(uuid.uuid4()) + + final_headers = {**filtered_headers, **provider_headers} + + logger.debug("minimax_request_prepared", header_count=len(final_headers)) + + return body, final_headers + + async def process_provider_response( + self, response: httpx.Response, endpoint: str + ) -> Response: + """Return the upstream response verbatim. + + Streaming detection and format-chain conversion are handled centrally + by ``BaseHTTPAdapter``; non-streaming responses are forwarded as-is. + """ + response_headers = extract_response_headers(response) + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + media_type=response.headers.get("content-type"), + ) diff --git a/ccproxy/plugins/minimax/config.py b/ccproxy/plugins/minimax/config.py new file mode 100644 index 00000000..1c88e194 --- /dev/null +++ b/ccproxy/plugins/minimax/config.py @@ -0,0 +1,111 @@ +"""Configuration for the MiniMax provider plugin.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field, model_validator + +from ccproxy.models.provider import ModelCard, ModelMappingRule, ProviderConfig + +from .model_defaults import ( + DEFAULT_MINIMAX_MODEL_CARDS, + DEFAULT_MINIMAX_MODEL_MAPPINGS, +) + + +MiniMaxRegion = Literal["global_en", "cn_zh"] + + +# Per-region upstream endpoints. Selecting a region populates the OpenAI and +# Anthropic base URLs (and documentation root) unless the caller overrides them +# explicitly. +MINIMAX_REGION_ENDPOINTS: dict[str, dict[str, str]] = { + "global_en": { + "openai_base_url": "https://api.minimax.io/v1", + "anthropic_base_url": "https://api.minimax.io/anthropic", + "docs_root": "https://platform.minimax.io/docs", + }, + "cn_zh": { + "openai_base_url": "https://api.minimaxi.com/v1", + "anthropic_base_url": "https://api.minimaxi.com/anthropic", + "docs_root": "https://platform.minimaxi.com/docs", + }, +} + + +class MiniMaxConfig(ProviderConfig): + """Provider configuration for the MiniMax API.""" + + name: str = "minimax" + region: MiniMaxRegion = Field( + default="global_en", + description=( + "Upstream region: 'global_en' (minimax.io) or 'cn_zh' (minimaxi.com)." + ), + ) + base_url: str = "https://api.minimax.io/v1" + anthropic_base_url: str = Field( + default="https://api.minimax.io/anthropic", + description="Base URL for the MiniMax Anthropic-compatible endpoint.", + ) + docs_root: str = Field( + default="https://platform.minimax.io/docs", + description="Root URL for MiniMax API documentation.", + ) + + supports_streaming: bool = True + requires_auth: bool = True + auth_type: str | None = "api_key" + + enabled: bool = True + priority: int = 5 + default_max_tokens: int = 4096 + + api_key: str | None = Field( + default=None, + description="MiniMax API key sent as a Bearer token on every upstream request.", + ) + request_timeout: int = Field( + default=120, + description="Timeout for API requests in seconds.", + ge=1, + le=600, + ) + + api_headers: dict[str, str] = Field( + default_factory=lambda: {"Content-Type": "application/json"}, + description="Default headers for MiniMax API requests.", + ) + + model_mappings: list[ModelMappingRule] = Field( + default_factory=lambda: [ + rule.model_copy(deep=True) for rule in DEFAULT_MINIMAX_MODEL_MAPPINGS + ], + description=( + "Ordered model translation rules mapping client model identifiers to " + "MiniMax upstream equivalents." + ), + ) + models_endpoint: list[ModelCard] = Field( + default_factory=lambda: [ + card.model_copy(deep=True) for card in DEFAULT_MINIMAX_MODEL_CARDS + ], + description=( + "Fallback metadata served from /models when the MiniMax API listing is " + "unavailable." + ), + ) + + @model_validator(mode="after") + def _apply_region_endpoints(self) -> MiniMaxConfig: + """Resolve base URLs from the selected region unless explicitly overridden.""" + endpoints = MINIMAX_REGION_ENDPOINTS.get(self.region) + if endpoints: + if "base_url" not in self.model_fields_set: + self.base_url = endpoints["openai_base_url"] + if "anthropic_base_url" not in self.model_fields_set: + self.anthropic_base_url = endpoints["anthropic_base_url"] + if "docs_root" not in self.model_fields_set: + self.docs_root = endpoints["docs_root"] + return self diff --git a/ccproxy/plugins/minimax/model_defaults.py b/ccproxy/plugins/minimax/model_defaults.py new file mode 100644 index 00000000..c300b90a --- /dev/null +++ b/ccproxy/plugins/minimax/model_defaults.py @@ -0,0 +1,78 @@ +"""Default model metadata and mapping rules for the MiniMax provider.""" + +from __future__ import annotations + +from ccproxy.models.provider import ModelCard, ModelMappingRule + + +# Fallback metadata served from ``/models`` when the upstream listing is +# unavailable. Timestamps are rounded placeholders, matching the convention +# used by the other provider plugins in this repository. +DEFAULT_MINIMAX_MODEL_CARDS: list[ModelCard] = [ + ModelCard( + id="MiniMax-M3", + created=1735689600, + owned_by="minimax", + permission=[], + root="MiniMax-M3", + parent=None, + context_window=1_000_000, + pricing_usd_per_million_tokens={ + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + }, + input_modalities=["text", "image", "video"], + thinking=["adaptive", "disabled"], + ), + ModelCard( + id="MiniMax-M2.7", + created=1735689600, + owned_by="minimax", + permission=[], + root="MiniMax-M2.7", + parent=None, + 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"], + ), +] + + +# Convenience aliases so short, case-insensitive client identifiers resolve to +# the canonical upstream model names. Unmatched identifiers pass through +# unchanged. +DEFAULT_MINIMAX_MODEL_MAPPINGS: list[ModelMappingRule] = [ + ModelMappingRule( + match=r"^minimax-m3$", + target="MiniMax-M3", + kind="regex", + flags=["IGNORECASE"], + ), + ModelMappingRule( + match=r"^minimax-m2\.7$", + target="MiniMax-M2.7", + kind="regex", + flags=["IGNORECASE"], + ), + ModelMappingRule( + match=r"^minimax$", + target="MiniMax-M3", + kind="regex", + flags=["IGNORECASE"], + notes="Default MiniMax alias resolves to the flagship model.", + ), +] + + +__all__ = [ + "DEFAULT_MINIMAX_MODEL_CARDS", + "DEFAULT_MINIMAX_MODEL_MAPPINGS", +] diff --git a/ccproxy/plugins/minimax/plugin.py b/ccproxy/plugins/minimax/plugin.py new file mode 100644 index 00000000..c076c48f --- /dev/null +++ b/ccproxy/plugins/minimax/plugin.py @@ -0,0 +1,110 @@ +"""MiniMax provider plugin factory and runtime implementation.""" + +from __future__ import annotations + +from typing import Any + +from ccproxy.core.constants import ( + FORMAT_ANTHROPIC_MESSAGES, + FORMAT_OPENAI_CHAT, +) +from ccproxy.core.logging import get_plugin_logger +from ccproxy.core.plugins import ( + BaseProviderPluginFactory, + FormatAdapterSpec, + FormatPair, + PluginManifest, + ProviderPluginRuntime, +) +from ccproxy.core.plugins.declaration import RouterSpec +from ccproxy.llms.streaming.accumulators import OpenAIAccumulator + +from .adapter import MiniMaxAdapter +from .config import MiniMaxConfig +from .routes import router as minimax_router + + +logger = get_plugin_logger() + + +class MiniMaxRuntime(ProviderPluginRuntime): + """Runtime for the MiniMax provider plugin.""" + + def __init__(self, manifest: PluginManifest): + """Initialize runtime.""" + super().__init__(manifest) + self.config: MiniMaxConfig | None = None + + async def _on_initialize(self) -> None: + """Initialize the MiniMax provider plugin.""" + if not self.context: + raise RuntimeError("Context not set") + + try: + config = self.context.get(MiniMaxConfig) + except ValueError: + config = MiniMaxConfig() + logger.debug("minimax_using_default_config") + self.config = config + + # Base runtime wires up the adapter from the factory. + await super()._on_initialize() + + logger.debug( + "minimax_plugin_initialized", + plugin="minimax", + version=self.manifest.version, + region=self.config.region if self.config else None, + has_adapter=self.adapter is not None, + ) + + async def _get_health_details(self) -> dict[str, Any]: + """Get health check details.""" + details = await super()._get_health_details() + if self.config: + details.update( + { + "region": self.config.region, + "base_url": self.config.base_url, + "supports_streaming": self.config.supports_streaming, + "api_key_configured": bool(self.config.api_key), + "models": [card.id for card in self.config.models_endpoint], + } + ) + return details + + +class MiniMaxFactory(BaseProviderPluginFactory): + """Factory for the MiniMax provider plugin.""" + + cli_safe = False # Provider plugin - not safe for CLI use + + plugin_name = "minimax" + plugin_description = ( + "MiniMax provider plugin with API-key authentication and " + "OpenAI/Anthropic format conversion" + ) + runtime_class = MiniMaxRuntime + adapter_class = MiniMaxAdapter + config_class = MiniMaxConfig + + # Static API-key auth: no OAuth credential manager or detection service. + auth_manager_name = None + routers = [ + RouterSpec(router=minimax_router, prefix="/minimax", tags=["minimax-api"]), + ] + dependencies: list[str] = [] + optional_requires = ["pricing"] + + # No plugin-provided format adapters - core supplies the conversions used + # by the MiniMax endpoints. + format_adapters: list[FormatAdapterSpec] = [] + requires_format_adapters: list[FormatPair] = [ + (FORMAT_ANTHROPIC_MESSAGES, FORMAT_OPENAI_CHAT), + (FORMAT_OPENAI_CHAT, FORMAT_ANTHROPIC_MESSAGES), + ] + tool_accumulator_class = OpenAIAccumulator + + +# Export the factory instance +factory = MiniMaxFactory() diff --git a/ccproxy/plugins/minimax/py.typed b/ccproxy/plugins/minimax/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/ccproxy/plugins/minimax/routes.py b/ccproxy/plugins/minimax/routes.py new file mode 100644 index 00000000..f25ab47e --- /dev/null +++ b/ccproxy/plugins/minimax/routes.py @@ -0,0 +1,94 @@ +"""API routes for the MiniMax provider plugin.""" + +from __future__ import annotations + +from typing import Annotated, Any, cast + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import Response, StreamingResponse + +from ccproxy.api.decorators import with_format_chain +from ccproxy.api.dependencies import ( + get_plugin_adapter, + get_provider_config_dependency, +) +from ccproxy.auth.dependencies import ConditionalAuthDep +from ccproxy.core.constants import ( + FORMAT_ANTHROPIC_MESSAGES, + FORMAT_OPENAI_CHAT, + UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS, +) +from ccproxy.core.logging import get_plugin_logger +from ccproxy.llms.models import anthropic as anthropic_models +from ccproxy.llms.models import openai as openai_models +from ccproxy.streaming import DeferredStreaming + +from .config import MiniMaxConfig + + +logger = get_plugin_logger() + +MiniMaxAdapterDep = Annotated[Any, Depends(get_plugin_adapter("minimax"))] +MiniMaxConfigDep = Annotated[ + MiniMaxConfig, + Depends(get_provider_config_dependency("minimax", MiniMaxConfig)), +] + +APIResponse = Response | StreamingResponse | DeferredStreaming + +router = APIRouter() + + +def _cast_result(result: object) -> APIResponse: + return cast(APIResponse, result) + + +async def _handle_adapter_request(request: Request, adapter: Any) -> APIResponse: + result = await adapter.handle_request(request) + return _cast_result(result) + + +@router.post( + "/v1/chat/completions", + response_model=openai_models.ChatCompletionResponse | openai_models.ErrorResponse, +) +async def create_openai_chat_completion( + request: Request, + _: openai_models.ChatCompletionRequest, + auth: ConditionalAuthDep, + adapter: MiniMaxAdapterDep, +) -> APIResponse: + """Create a chat completion using MiniMax with the OpenAI-compatible format.""" + request.state.context.metadata["endpoint"] = ( + UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS + ) + return await _handle_adapter_request(request, adapter) + + +@router.post( + "/v1/messages", + response_model=anthropic_models.MessageResponse | anthropic_models.APIError, +) +@with_format_chain( + [FORMAT_ANTHROPIC_MESSAGES, FORMAT_OPENAI_CHAT], + endpoint=UPSTREAM_ENDPOINT_OPENAI_CHAT_COMPLETIONS, +) +async def create_anthropic_message( + request: Request, + _: anthropic_models.CreateMessageRequest, + auth: ConditionalAuthDep, + adapter: MiniMaxAdapterDep, +) -> APIResponse: + """Create a message using MiniMax with the native Anthropic format.""" + return await _handle_adapter_request(request, adapter) + + +@router.get("/v1/models", response_model=openai_models.ModelList) +async def list_models( + request: Request, + auth: ConditionalAuthDep, + config: MiniMaxConfigDep, +) -> dict[str, Any]: + """List available MiniMax models from configuration.""" + models = [card.model_dump(mode="json") for card in config.models_endpoint] + return {"object": "list", "data": models} diff --git a/config.example.toml b/config.example.toml index 5e9e7903..b10c898e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -166,6 +166,32 @@ owned_by = "openai" root = "gpt-5-nano" permission = [] +# MiniMax provider: select a region (global_en | cn_zh) to resolve the +# OpenAI and Anthropic base URLs, and supply an API key. +[plugins.minimax] +enabled = true +region = "global_en" +# api_key = "" +model_mappings = [ + { match = "^minimax$", target = "MiniMax-M3", kind = "regex", flags = ["IGNORECASE"] }, +] + +[[plugins.minimax.models_endpoint]] +id = "MiniMax-M3" +object = "model" +created = 1735689600 +owned_by = "minimax" +root = "MiniMax-M3" +permission = [] + +[[plugins.minimax.models_endpoint]] +id = "MiniMax-M2.7" +object = "model" +created = 1735689600 +owned_by = "minimax" +root = "MiniMax-M2.7" +permission = [] + # DuckDB storage plugin configuration (replaces observability.duckdb_path/backends) [plugins.duckdb_storage] # database_path = "/var/lib/ccproxy/metrics.duckdb" # Optional override diff --git a/pyproject.toml b/pyproject.toml index 35cd0d14..94491890 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,7 @@ claude_sdk = "ccproxy.plugins.claude_sdk.plugin:factory" codex = "ccproxy.plugins.codex.plugin:factory" command_replay = "ccproxy.plugins.command_replay.plugin:factory" copilot = "ccproxy.plugins.copilot.plugin:factory" +minimax = "ccproxy.plugins.minimax.plugin:factory" dashboard = "ccproxy.plugins.dashboard.plugin:factory" docker = "ccproxy.plugins.docker.plugin:factory" duckdb_storage = "ccproxy.plugins.duckdb_storage.plugin:factory" @@ -335,6 +336,7 @@ markers = [ "metrics: Metrics and monitoring plugin tests", "claude_api: Claude API plugin tests", "codex: Codex plugin tests", + "minimax: MiniMax plugin tests", ] [tool.bandit] diff --git a/tests/plugins/minimax/__init__.py b/tests/plugins/minimax/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/minimax/integration/__init__.py b/tests/plugins/minimax/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/minimax/unit/__init__.py b/tests/plugins/minimax/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/plugins/minimax/unit/test_adapter.py b/tests/plugins/minimax/unit/test_adapter.py new file mode 100644 index 00000000..fd31212f --- /dev/null +++ b/tests/plugins/minimax/unit/test_adapter.py @@ -0,0 +1,77 @@ +"""Unit tests for the MiniMax adapter.""" + +import json +from unittest.mock import Mock + +import httpx +import pytest + +from ccproxy.core.errors import AuthenticationError +from ccproxy.plugins.minimax.adapter import MiniMaxAdapter +from ccproxy.plugins.minimax.config import MiniMaxConfig + + +def _make_adapter(config: MiniMaxConfig) -> MiniMaxAdapter: + return MiniMaxAdapter( + config=config, + auth_manager=None, + http_pool_manager=Mock(), + ) + + +@pytest.mark.minimax +@pytest.mark.unit +@pytest.mark.asyncio +async def test_get_target_url() -> None: + adapter = _make_adapter(MiniMaxConfig(api_key="test-key")) + url = await adapter.get_target_url("/chat/completions") + assert url == "https://api.minimax.io/v1/chat/completions" + + +@pytest.mark.minimax +@pytest.mark.unit +@pytest.mark.asyncio +async def test_prepare_provider_request_sets_bearer_key() -> None: + adapter = _make_adapter(MiniMaxConfig(api_key="secret-value")) + body = json.dumps({"model": "MiniMax-M3", "messages": []}).encode() + headers = { + "content-type": "application/json", + "authorization": "Bearer client-token", # must be replaced + } + + result_body, result_headers = await adapter.prepare_provider_request( + body, headers, "/chat/completions" + ) + + assert result_body == body + assert result_headers["authorization"] == "Bearer secret-value" + assert "x-request-id" in result_headers + + +@pytest.mark.minimax +@pytest.mark.unit +@pytest.mark.asyncio +async def test_prepare_provider_request_requires_api_key() -> None: + adapter = _make_adapter(MiniMaxConfig()) + with pytest.raises(AuthenticationError): + await adapter.prepare_provider_request(b"{}", {}, "/chat/completions") + + +@pytest.mark.minimax +@pytest.mark.unit +@pytest.mark.asyncio +async def test_process_provider_response_passthrough() -> None: + adapter = _make_adapter(MiniMaxConfig(api_key="test-key")) + payload = {"id": "cmpl-1", "choices": []} + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.content = json.dumps(payload).encode() + mock_response.headers = {"content-type": "application/json"} + + result = await adapter.process_provider_response(mock_response, "/chat/completions") + + assert result.status_code == 200 + body = result.body + if isinstance(body, memoryview): + body = bytes(body) + assert json.loads(body.decode()) == payload diff --git a/tests/plugins/minimax/unit/test_config.py b/tests/plugins/minimax/unit/test_config.py new file mode 100644 index 00000000..3938361a --- /dev/null +++ b/tests/plugins/minimax/unit/test_config.py @@ -0,0 +1,76 @@ +"""Configuration tests for the MiniMax provider plugin.""" + +import pytest + +from ccproxy.plugins.minimax.config import MINIMAX_REGION_ENDPOINTS, MiniMaxConfig +from ccproxy.plugins.minimax.model_defaults import ( + DEFAULT_MINIMAX_MODEL_CARDS, + DEFAULT_MINIMAX_MODEL_MAPPINGS, +) + + +@pytest.mark.minimax +@pytest.mark.unit +def test_default_region_is_global() -> None: + config = MiniMaxConfig() + assert config.name == "minimax" + assert config.region == "global_en" + assert config.base_url == "https://api.minimax.io/v1" + assert config.anthropic_base_url == "https://api.minimax.io/anthropic" + assert config.auth_type == "api_key" + + +@pytest.mark.minimax +@pytest.mark.unit +def test_cn_region_resolves_regional_endpoints() -> None: + config = MiniMaxConfig(region="cn_zh") + assert config.base_url == MINIMAX_REGION_ENDPOINTS["cn_zh"]["openai_base_url"] + assert ( + config.anthropic_base_url + == MINIMAX_REGION_ENDPOINTS["cn_zh"]["anthropic_base_url"] + ) + assert config.docs_root == MINIMAX_REGION_ENDPOINTS["cn_zh"]["docs_root"] + + +@pytest.mark.minimax +@pytest.mark.unit +def test_explicit_base_url_overrides_region() -> None: + config = MiniMaxConfig(region="cn_zh", base_url="https://proxy.example/v1") + assert config.base_url == "https://proxy.example/v1" + # Unset fields still follow the region. + assert ( + config.anthropic_base_url + == MINIMAX_REGION_ENDPOINTS["cn_zh"]["anthropic_base_url"] + ) + + +@pytest.mark.minimax +@pytest.mark.unit +def test_default_models_present() -> None: + config = MiniMaxConfig() + models = {card.id: card for card in config.models_endpoint} + assert set(models) == {"MiniMax-M3", "MiniMax-M2.7"} + assert {card.id for card in DEFAULT_MINIMAX_MODEL_CARDS} == set(models) + assert len(DEFAULT_MINIMAX_MODEL_MAPPINGS) >= 1 + + m3 = models["MiniMax-M3"].model_dump() + assert m3["context_window"] == 1_000_000 + assert m3["pricing_usd_per_million_tokens"] == { + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + } + assert m3["input_modalities"] == ["text", "image", "video"] + assert m3["thinking"] == ["adaptive", "disabled"] + + m27 = models["MiniMax-M2.7"].model_dump() + assert m27["context_window"] == 204_800 + assert m27["pricing_usd_per_million_tokens"] == { + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375, + } + assert m27["input_modalities"] == ["text"] + assert m27["thinking"] == ["always_on"] diff --git a/tests/plugins/minimax/unit/test_manifest.py b/tests/plugins/minimax/unit/test_manifest.py new file mode 100644 index 00000000..e2d1716f --- /dev/null +++ b/tests/plugins/minimax/unit/test_manifest.py @@ -0,0 +1,35 @@ +"""Manifest and factory tests for the MiniMax provider plugin.""" + +import pytest + + +@pytest.mark.minimax +@pytest.mark.unit +def test_minimax_manifest_name_and_config() -> None: + from ccproxy.plugins.minimax.plugin import factory + + manifest = factory.get_manifest() + assert manifest.name == "minimax" + assert manifest.version + assert manifest.is_provider is True + assert manifest.config_class is not None + + +@pytest.mark.minimax +@pytest.mark.unit +def test_minimax_factory_creates_runtime() -> None: + from ccproxy.plugins.minimax.plugin import factory + + runtime = factory.create_runtime() + assert runtime is not None + assert not runtime.initialized + + +@pytest.mark.minimax +@pytest.mark.unit +def test_minimax_factory_has_no_auth_manager_dependency() -> None: + """MiniMax uses static API keys, so it must not depend on an OAuth manager.""" + from ccproxy.plugins.minimax.plugin import factory + + assert factory.auth_manager_name is None + assert factory.get_manifest().dependencies == []