From 060a1427a1da85561be299bbdff470d404f6696e Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:05:06 +0000 Subject: [PATCH 1/4] feat(minimax): add MiniMax provider plugin Add a first-class MiniMax provider plugin following the existing provider plugin pattern (config, HTTP adapter, routes, model mappings, model cards). - Static API-key (Bearer) authentication, no OAuth dependency - Region selection (global_en / cn_zh) that resolves the OpenAI and Anthropic base URLs - OpenAI-compatible /v1/chat/completions plus Anthropic-compatible /v1/messages via the core format chain, and /v1/models - Default model cards and aliases for MiniMax-M3 and MiniMax-M2.7 - Register the plugin entry point and add unit tests --- ccproxy/plugins/minimax/README.md | 29 +++++ ccproxy/plugins/minimax/__init__.py | 11 ++ ccproxy/plugins/minimax/adapter.py | 85 ++++++++++++++ ccproxy/plugins/minimax/config.py | 111 ++++++++++++++++++ ccproxy/plugins/minimax/model_defaults.py | 60 ++++++++++ ccproxy/plugins/minimax/plugin.py | 110 +++++++++++++++++ ccproxy/plugins/minimax/py.typed | 0 ccproxy/plugins/minimax/routes.py | 94 +++++++++++++++ config.example.toml | 26 ++++ pyproject.toml | 2 + tests/plugins/minimax/__init__.py | 0 tests/plugins/minimax/integration/__init__.py | 0 tests/plugins/minimax/unit/__init__.py | 0 tests/plugins/minimax/unit/test_adapter.py | 77 ++++++++++++ tests/plugins/minimax/unit/test_config.py | 54 +++++++++ tests/plugins/minimax/unit/test_manifest.py | 35 ++++++ 16 files changed, 694 insertions(+) create mode 100644 ccproxy/plugins/minimax/README.md create mode 100644 ccproxy/plugins/minimax/__init__.py create mode 100644 ccproxy/plugins/minimax/adapter.py create mode 100644 ccproxy/plugins/minimax/config.py create mode 100644 ccproxy/plugins/minimax/model_defaults.py create mode 100644 ccproxy/plugins/minimax/plugin.py create mode 100644 ccproxy/plugins/minimax/py.typed create mode 100644 ccproxy/plugins/minimax/routes.py create mode 100644 tests/plugins/minimax/__init__.py create mode 100644 tests/plugins/minimax/integration/__init__.py create mode 100644 tests/plugins/minimax/unit/__init__.py create mode 100644 tests/plugins/minimax/unit/test_adapter.py create mode 100644 tests/plugins/minimax/unit/test_config.py create mode 100644 tests/plugins/minimax/unit/test_manifest.py 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..c8da2b93 --- /dev/null +++ b/ccproxy/plugins/minimax/model_defaults.py @@ -0,0 +1,60 @@ +"""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, + ), + ModelCard( + id="MiniMax-M2.7", + created=1735689600, + owned_by="minimax", + permission=[], + root="MiniMax-M2.7", + parent=None, + ), +] + + +# 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..aa393c45 --- /dev/null +++ b/tests/plugins/minimax/unit/test_config.py @@ -0,0 +1,54 @@ +"""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() + ids = {card.id for card in config.models_endpoint} + assert ids == {"MiniMax-M3", "MiniMax-M2.7"} + assert {card.id for card in DEFAULT_MINIMAX_MODEL_CARDS} == ids + assert len(DEFAULT_MINIMAX_MODEL_MAPPINGS) >= 1 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 == [] From bc663e556d0879acd5eb579ec57869effb8ad491 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:03:29 +0800 Subject: [PATCH 2/4] fix(minimax): complete model metadata --- ccproxy/plugins/minimax/model_defaults.py | 18 +++++++++++++++ tests/plugins/minimax/unit/test_config.py | 28 ++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/ccproxy/plugins/minimax/model_defaults.py b/ccproxy/plugins/minimax/model_defaults.py index c8da2b93..c300b90a 100644 --- a/ccproxy/plugins/minimax/model_defaults.py +++ b/ccproxy/plugins/minimax/model_defaults.py @@ -16,6 +16,15 @@ 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", @@ -24,6 +33,15 @@ 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"], ), ] diff --git a/tests/plugins/minimax/unit/test_config.py b/tests/plugins/minimax/unit/test_config.py index aa393c45..3938361a 100644 --- a/tests/plugins/minimax/unit/test_config.py +++ b/tests/plugins/minimax/unit/test_config.py @@ -48,7 +48,29 @@ def test_explicit_base_url_overrides_region() -> None: @pytest.mark.unit def test_default_models_present() -> None: config = MiniMaxConfig() - ids = {card.id for card in config.models_endpoint} - assert ids == {"MiniMax-M3", "MiniMax-M2.7"} - assert {card.id for card in DEFAULT_MINIMAX_MODEL_CARDS} == ids + 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"] From 511cb0956e96f2d7c5cbb4da621e5fee41ce4bb1 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:21:24 +0800 Subject: [PATCH 3/4] fix(minimax): satisfy model metadata type checks --- ccproxy/plugins/minimax/model_defaults.py | 68 ++++++++++++----------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/ccproxy/plugins/minimax/model_defaults.py b/ccproxy/plugins/minimax/model_defaults.py index c300b90a..c4281be9 100644 --- a/ccproxy/plugins/minimax/model_defaults.py +++ b/ccproxy/plugins/minimax/model_defaults.py @@ -9,39 +9,43 @@ # 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.model_validate( + { + "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"], + ModelCard.model_validate( + { + "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"], + } ), ] From 348ab3b05b1a8433862dd74a2bb16f2cbe32ebc1 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:19:37 +0800 Subject: [PATCH 4/4] feat(minimax): prefer Flash and preserve native message history --- ccproxy/plugins/minimax/README.md | 28 +- ccproxy/plugins/minimax/adapter.py | 5 + ccproxy/plugins/minimax/model_defaults.py | 60 ++- ccproxy/plugins/minimax/models.py | 27 ++ ccproxy/plugins/minimax/routes.py | 23 +- config.example.toml | 22 +- .../minimax/integration/test_transport.py | 448 ++++++++++++++++++ tests/plugins/minimax/unit/test_config.py | 49 +- 8 files changed, 599 insertions(+), 63 deletions(-) create mode 100644 ccproxy/plugins/minimax/models.py create mode 100644 tests/plugins/minimax/integration/test_transport.py diff --git a/ccproxy/plugins/minimax/README.md b/ccproxy/plugins/minimax/README.md index 296293db..cad56f31 100644 --- a/ccproxy/plugins/minimax/README.md +++ b/ccproxy/plugins/minimax/README.md @@ -10,8 +10,10 @@ authentication. 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. +- `POST /minimax/v1/messages` — native Anthropic-compatible messages for + `MiniMax-M3.1-Flash` and `MiniMax-M3`. Other explicitly requested models retain + the existing conversion to the OpenAI chat protocol. An overridden OpenAI + `base_url` also retains conversion unless a custom `anthropic_base_url` is set. - `GET /minimax/v1/models` — configured model metadata. ## Configuration @@ -25,5 +27,23 @@ 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`. +documentation root unless they are overridden explicitly. The default catalog +lists `MiniMax-M3.1-Flash` first, followed by `MiniMax-M3`. The `minimax` alias +resolves to Flash. Explicit legacy/custom model IDs and configured mappings or +model cards are preserved; removing a legacy model from the default catalog does +not rewrite requests for that model. Requests must still specify a model. + +Flash always uses adaptive thinking. Omit thinking/effort to use upstream defaults, +or set `reasoning_effort` for Chat Completions and `output_config.effort` for +Messages to `low`, `medium`, `high`, `xhigh`, or `max` (default). Flash does not +support disabled thinking, `none` effort, `service_tier`, or `reasoning_split=false`. +The proxy does not inject these options or silently change explicit settings; +upstream validation applies. M3 supports adaptive/disabled thinking; its default +is adaptive for Chat Completions and disabled for native Messages. + +Native Messages preserves the entire assistant response, including thinking and +tool blocks, when clients send it back in later turns. Image/video blocks and +custom native fields are forwarded unchanged. See the +[MiniMax Messages contract](https://platform.minimax.io/docs/api-reference/text-anthropic-api) +and [pricing](https://platform.minimax.io/docs/pricing/overview). Region selection +configures transport URLs; account/model availability is determined by MiniMax. diff --git a/ccproxy/plugins/minimax/adapter.py b/ccproxy/plugins/minimax/adapter.py index ac659bee..834f6de0 100644 --- a/ccproxy/plugins/minimax/adapter.py +++ b/ccproxy/plugins/minimax/adapter.py @@ -31,6 +31,9 @@ def __init__( self.base_url = self.config.base_url.rstrip("/") async def get_target_url(self, endpoint: str) -> str: + if endpoint == "/v1/messages": + base_url = str(self.config.anthropic_base_url).rstrip("/") + return f"{base_url}{endpoint}" return f"{self.base_url}/{endpoint.lstrip('/')}" def _resolve_api_key(self) -> str: @@ -63,6 +66,8 @@ async def prepare_provider_request( provider_headers["x-request-id"] = str(uuid.uuid4()) final_headers = {**filtered_headers, **provider_headers} + if endpoint == "/v1/messages": + final_headers.setdefault("anthropic-version", "2023-06-01") logger.debug("minimax_request_prepared", header_count=len(final_headers)) diff --git a/ccproxy/plugins/minimax/model_defaults.py b/ccproxy/plugins/minimax/model_defaults.py index c4281be9..b497993e 100644 --- a/ccproxy/plugins/minimax/model_defaults.py +++ b/ccproxy/plugins/minimax/model_defaults.py @@ -5,55 +5,71 @@ 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. +# Timestamps are rounded placeholders, matching the other provider plugins. +# Direct API metadata: https://platform.minimax.io/docs/pricing/overview DEFAULT_MINIMAX_MODEL_CARDS: list[ModelCard] = [ ModelCard.model_validate( { - "id": "MiniMax-M3", + "id": "MiniMax-M3.1-Flash", "created": 1735689600, "owned_by": "minimax", "permission": [], - "root": "MiniMax-M3", + "root": "MiniMax-M3.1-Flash", "parent": None, "context_window": 1_000_000, + "max_output_tokens": 524_288, "pricing_usd_per_million_tokens": { - "input": 0.6, - "output": 2.4, - "cache_read": 0.12, + "input": 0.15, + "output": 0.60, + "cache_read": 0.015, "cache_write": None, }, "input_modalities": ["text", "image", "video"], - "thinking": ["adaptive", "disabled"], + "thinking": ["adaptive"], + "reasoning_efforts": ["low", "medium", "high", "xhigh", "max"], + "default_reasoning_effort": "max", } ), ModelCard.model_validate( { - "id": "MiniMax-M2.7", + "id": "MiniMax-M3", "created": 1735689600, "owned_by": "minimax", "permission": [], - "root": "MiniMax-M2.7", + "root": "MiniMax-M3", "parent": None, - "context_window": 204_800, + "context_window": 1_000_000, "pricing_usd_per_million_tokens": { - "input": 0.3, - "output": 1.2, + "input": 0.30, + "output": 1.20, "cache_read": 0.06, - "cache_write": 0.375, + "cache_write": None, }, - "input_modalities": ["text"], - "thinking": ["always_on"], + "pricing_context_tiers": [ + { + "context": "<=512k", + "input": 0.30, + "output": 1.20, + "cache_read": 0.06, + }, + {"context": ">512k", "input": 0.60, "output": 2.40, "cache_read": 0.12}, + ], + "priority_price_multiplier": 1.5, + "input_modalities": ["text", "image", "video"], + "thinking": ["adaptive", "disabled"], } ), ] -# Convenience aliases so short, case-insensitive client identifiers resolve to -# the canonical upstream model names. Unmatched identifiers pass through -# unchanged. +# Unmatched and explicitly requested legacy identifiers pass through unchanged. DEFAULT_MINIMAX_MODEL_MAPPINGS: list[ModelMappingRule] = [ + ModelMappingRule( + match=r"^minimax-m3\.1-flash$", + target="MiniMax-M3.1-Flash", + kind="regex", + flags=["IGNORECASE"], + ), ModelMappingRule( match=r"^minimax-m3$", target="MiniMax-M3", @@ -68,10 +84,10 @@ ), ModelMappingRule( match=r"^minimax$", - target="MiniMax-M3", + target="MiniMax-M3.1-Flash", kind="regex", flags=["IGNORECASE"], - notes="Default MiniMax alias resolves to the flagship model.", + notes="Default MiniMax alias resolves to MiniMax-M3.1-Flash.", ), ] diff --git a/ccproxy/plugins/minimax/models.py b/ccproxy/plugins/minimax/models.py new file mode 100644 index 00000000..de8b14cb --- /dev/null +++ b/ccproxy/plugins/minimax/models.py @@ -0,0 +1,27 @@ +"""MiniMax's extensible native Messages request envelope.""" + +from typing import Any, Literal + +from pydantic import Field + +from ccproxy.llms.formatters import LlmBaseModel + + +class MiniMaxMessage(LlmBaseModel): + """Preserve native thinking, tool, image and video blocks without conversion.""" + + role: Literal["user", "assistant"] + content: str | list[dict[str, Any]] + + +class MiniMaxMessageRequest(LlmBaseModel): + """Validate the envelope; MiniMax validates protocol-specific parameters. + + The adapter forwards the original body, including output_config and complete + assistant history. The shared schema excludes native thinking/video blocks. + """ + + model: str + messages: list[MiniMaxMessage] + max_tokens: int = Field(gt=0) + stream: bool = False diff --git a/ccproxy/plugins/minimax/routes.py b/ccproxy/plugins/minimax/routes.py index f25ab47e..44fb06e9 100644 --- a/ccproxy/plugins/minimax/routes.py +++ b/ccproxy/plugins/minimax/routes.py @@ -1,7 +1,5 @@ """API routes for the MiniMax provider plugin.""" -from __future__ import annotations - from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, Request @@ -22,8 +20,10 @@ from ccproxy.llms.models import anthropic as anthropic_models from ccproxy.llms.models import openai as openai_models from ccproxy.streaming import DeferredStreaming +from ccproxy.utils.model_mapper import ModelMapper -from .config import MiniMaxConfig +from .config import MINIMAX_REGION_ENDPOINTS, MiniMaxConfig +from .models import MiniMaxMessageRequest logger = get_plugin_logger() @@ -75,11 +75,24 @@ async def create_openai_chat_completion( ) async def create_anthropic_message( request: Request, - _: anthropic_models.CreateMessageRequest, + payload: MiniMaxMessageRequest, auth: ConditionalAuthDep, adapter: MiniMaxAdapterDep, ) -> APIResponse: - """Create a message using MiniMax with the native Anthropic format.""" + """Preserve native M3 history and retain legacy model conversion.""" + mapper = adapter.model_mapper or ModelMapper(adapter.config.model_mappings) + endpoints = MINIMAX_REGION_ENDPOINTS[adapter.config.region] + native_endpoint = ( + adapter.config.base_url.rstrip("/") == endpoints["openai_base_url"] + or adapter.config.anthropic_base_url.rstrip("/") + != endpoints["anthropic_base_url"] + ) + if native_endpoint and mapper.map(payload.model).mapped in { + "MiniMax-M3.1-Flash", + "MiniMax-M3", + }: + request.state.context.format_chain = [FORMAT_ANTHROPIC_MESSAGES] + request.state.context.metadata["endpoint"] = "/v1/messages" return await _handle_adapter_request(request, adapter) diff --git a/config.example.toml b/config.example.toml index b10c898e..950305dc 100644 --- a/config.example.toml +++ b/config.example.toml @@ -172,25 +172,9 @@ permission = [] 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 = [] +# Built-in models: MiniMax-M3.1-Flash (first/default alias), MiniMax-M3. +# Omit model_mappings/models_endpoint to use the built-in aliases and metadata. +# Explicit custom model mappings and model cards are preserved. # DuckDB storage plugin configuration (replaces observability.duckdb_path/backends) [plugins.duckdb_storage] diff --git a/tests/plugins/minimax/integration/test_transport.py b/tests/plugins/minimax/integration/test_transport.py new file mode 100644 index 00000000..e01223b2 --- /dev/null +++ b/tests/plugins/minimax/integration/test_transport.py @@ -0,0 +1,448 @@ +"""Exercise MiniMax routes through the real adapter and mocked HTTP transport.""" + +import json +import time +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any, get_args +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI, Request + +from ccproxy.api.bootstrap import create_service_container +from ccproxy.auth.dependencies import get_conditional_auth_manager +from ccproxy.core.logging import get_logger +from ccproxy.core.plugins.hooks import HookManager, HookRegistry +from ccproxy.core.request_context import RequestContext +from ccproxy.models.provider import ModelCard, ModelMappingRule +from ccproxy.plugins.minimax.adapter import MiniMaxAdapter +from ccproxy.plugins.minimax.config import MiniMaxConfig +from ccproxy.plugins.minimax.routes import MiniMaxAdapterDep, MiniMaxConfigDep, router +from ccproxy.services.adapters.format_registry import FormatRegistry +from ccproxy.streaming.handler import StreamingHandler + + +pytestmark = [pytest.mark.minimax, pytest.mark.integration, pytest.mark.asyncio] + + +@asynccontextmanager +async def proxy( + config: MiniMaxConfig, handler: Callable[[httpx.Request], httpx.Response] +) -> AsyncIterator[httpx.AsyncClient]: + """Only the external HTTP service is faked; route and adapter code are real.""" + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as upstream: + pool = AsyncMock() + pool.get_client.return_value = upstream + container = create_service_container() + adapter = MiniMaxAdapter( + config=config, + auth_manager=None, + http_pool_manager=pool, + format_registry=container.get_service(FormatRegistry), + streaming_handler=StreamingHandler(HookManager(HookRegistry())), + ) + app = FastAPI() + app.include_router(router, prefix="/minimax") + app.dependency_overrides[get_args(MiniMaxAdapterDep)[1].dependency] = ( + lambda: adapter + ) + app.dependency_overrides[get_args(MiniMaxConfigDep)[1].dependency] = ( + lambda: config + ) + app.dependency_overrides[get_conditional_auth_manager] = lambda: None + + @app.middleware("http") + async def request_context(request: Request, call_next: Any) -> Any: + request.state.context = RequestContext( + request_id="minimax-test", + start_time=time.perf_counter(), + logger=get_logger(__name__), + ) + return await call_next(request) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://proxy.test" + ) as client: + yield client + + +def reply(request: httpx.Request) -> httpx.Response: + model = json.loads(request.content)["model"] + if request.url.path.endswith("/anthropic/v1/messages"): + return httpx.Response( + 200, + json={ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": model, + "content": [ + { + "type": "thinking", + "thinking": "Consider tools", + "signature": "opaque", + }, + { + "type": "tool_use", + "id": "call_test", + "name": "lookup", + "input": {}, + }, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + return httpx.Response( + 200, + json={ + "id": "chat_test", + "object": "chat.completion", + "created": 1, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "done"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }, + ) + + +@pytest.mark.parametrize( + "region,host", [("global_en", "api.minimax.io"), ("cn_zh", "api.minimaxi.com")] +) +@pytest.mark.parametrize( + "model,expected", + [ + ("minimax", "MiniMax-M3.1-Flash"), + ("MINIMAX-M3.1-FLASH", "MiniMax-M3.1-Flash"), + ("MiniMax-M3", "MiniMax-M3"), + ("MiniMax-M2.7", "MiniMax-M2.7"), + ("saved-custom-model", "saved-custom-model"), + ("speech-02-hd", "speech-02-hd"), + ], +) +async def test_chat_model_and_history_passthrough( + region: str, host: str, model: str, expected: str +) -> None: + captured = [] + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(request) + return reply(request) + + payload = { + "model": model, + "reasoning_effort": "max", + "thinking": {"type": "adaptive"}, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png"}, + }, + {"type": "text", "text": "Read this"}, + ], + }, + { + "role": "assistant", + "content": None, + "reasoning_content": "Look it up", + "tool_calls": [ + { + "id": "call_test", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_test", "content": "result"}, + ], + "tools": [ + { + "type": "function", + "function": {"name": "lookup", "parameters": {"type": "object"}}, + } + ], + } + config = MiniMaxConfig.model_validate({"api_key": "test-key", "region": region}) + async with proxy(config, upstream) as client: + result = await client.post("/minimax/v1/chat/completions", json=payload) + assert result.status_code == 200, result.text + assert len(captured) == 1 + assert str(captured[0].url) == f"https://{host}/v1/chat/completions" + assert json.loads(captured[0].content) == {**payload, "model": expected} + assert captured[0].headers["authorization"] == "Bearer test-key" + + +@pytest.mark.parametrize( + "region,host", [("global_en", "api.minimax.io"), ("cn_zh", "api.minimaxi.com")] +) +@pytest.mark.parametrize("model", ["minimax", "MiniMax-M3"]) +async def test_native_messages_preserve_full_tool_history( + region: str, host: str, model: str +) -> None: + captured = [] + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(request) + return reply(request) + + config = MiniMaxConfig.model_validate({"api_key": "test-key", "region": region}) + payload: dict[str, Any] = { + "model": model, + "max_tokens": 4096, + "output_config": {"effort": "xhigh"}, + "thinking": {"type": "adaptive"}, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Inspect this"}, + { + "type": "image", + "source": { + "type": "url", + "url": "https://example.com/image.png", + }, + }, + { + "type": "video", + "source": { + "type": "url", + "url": "https://example.com/video.mp4", + }, + }, + ], + } + ], + "tools": [ + {"name": "lookup", "input_schema": {"type": "object", "properties": {}}} + ], + } + async with proxy(config, upstream) as client: + first = await client.post("/minimax/v1/messages", json=payload) + assert first.status_code == 200, first.text + payload["messages"].extend( + [ + {"role": "assistant", "content": first.json()["content"]}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_test", + "content": "result", + } + ], + }, + ] + ) + second = await client.post("/minimax/v1/messages", json=payload) + assert second.status_code == 200, second.text + assert len(captured) == 2 + expected = "MiniMax-M3.1-Flash" if model == "minimax" else model + assert str(captured[-1].url) == f"https://{host}/anthropic/v1/messages" + assert json.loads(captured[-1].content) == {**payload, "model": expected} + assert captured[-1].headers["authorization"] == "Bearer test-key" + assert captured[-1].headers["anthropic-version"] == "2023-06-01" + + +@pytest.mark.parametrize("path", ["chat/completions", "messages"]) +@pytest.mark.parametrize("effort", [None, "low", "medium", "high", "xhigh", "max"]) +async def test_flash_effort_and_defaults(path: str, effort: str | None) -> None: + captured = [] + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(json.loads(request.content)) + return reply(request) + + payload: dict[str, Any] = { + "model": "minimax", + "max_tokens": 4096, + "messages": [{"role": "user", "content": "Hello"}], + } + if effort is not None: + if path == "messages": + payload["output_config"] = {"effort": effort} + else: + payload["reasoning_effort"] = effort + async with proxy(MiniMaxConfig(api_key="test-key"), upstream) as client: + response = await client.post(f"/minimax/v1/{path}", json=payload) + assert response.status_code == 200, response.text + assert captured == [{**payload, "model": "MiniMax-M3.1-Flash"}] + + +@pytest.mark.parametrize("path", ["chat/completions", "messages"]) +@pytest.mark.parametrize("thinking", [None, "adaptive", "disabled"]) +async def test_m3_thinking_and_defaults_preserved( + path: str, thinking: str | None +) -> None: + captured = [] + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(json.loads(request.content)) + return reply(request) + + payload: dict[str, Any] = { + "model": "MiniMax-M3", + "max_tokens": 4096, + "messages": [{"role": "user", "content": "Hello"}], + } + if thinking is not None: + payload["thinking"] = {"type": thinking} + async with proxy(MiniMaxConfig(api_key="test-key"), upstream) as client: + response = await client.post(f"/minimax/v1/{path}", json=payload) + assert response.status_code == 200 + assert captured == [payload] + + +@pytest.mark.parametrize("model", ["MiniMax-M3", "MiniMax-M2.7", "saved-custom-model"]) +async def test_saved_mapping_keeps_legacy_messages_route(model: str) -> None: + captured = [] + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(request) + return reply(request) + + config = MiniMaxConfig( + api_key="test-key", + base_url="https://custom.example/v1", + model_mappings=[ModelMappingRule(match="minimax", target=model)], + ) + async with proxy(config, upstream) as client: + response = await client.post( + "/minimax/v1/messages", + json={ + "model": "minimax", + "max_tokens": 4096, + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + assert response.status_code == 200, response.text + assert str(captured[0].url) == "https://custom.example/v1/chat/completions" + assert json.loads(captured[0].content)["model"] == model + + +async def test_catalog_and_custom_cards() -> None: + async with proxy(MiniMaxConfig(api_key="test-key"), reply) as client: + response = await client.get("/minimax/v1/models") + assert [card["id"] for card in response.json()["data"]] == [ + "MiniMax-M3.1-Flash", + "MiniMax-M3", + ] + card = ModelCard(id="MiniMax-M2.7", created=1, owned_by="minimax") + async with proxy( + MiniMaxConfig(api_key="test-key", models_endpoint=[card]), reply + ) as client: + response = await client.get("/minimax/v1/models") + assert [card["id"] for card in response.json()["data"]] == ["MiniMax-M2.7"] + + +@pytest.mark.parametrize("path", ["chat/completions", "messages"]) +async def test_streaming_transport_and_custom_native_endpoint(path: str) -> None: + captured = [] + events = [ + { + "type": "message_start", + "message": {"id": "msg_stream", "role": "assistant", "content": []}, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "Preserved reasoning"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "call_test", + "name": "lookup", + "input": {}, + }, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": "{}"}, + }, + {"type": "content_block_stop", "index": 1}, + {"type": "message_stop"}, + ] + stream = "".join( + f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events + ).encode() + if path == "chat/completions": + stream = b'data: {"choices":[{"index":0,"delta":{"reasoning_content":"Preserved reasoning","tool_calls":[{"index":0,"id":"call_test","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}]}\n\ndata: [DONE]\n\n' + + def upstream(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, content=stream, headers={"content-type": "text/event-stream"} + ) + + config = MiniMaxConfig( + api_key="test-key", anthropic_base_url="https://custom.example/anthropic" + ) + payload = { + "model": "minimax", + "max_tokens": 4096, + "stream": True, + "messages": [{"role": "user", "content": "Hello"}], + } + async with proxy(config, upstream) as client: + response = await client.post(f"/minimax/v1/{path}", json=payload) + assert response.status_code == 200, response.text + assert json.loads(captured[0].content) == {**payload, "model": "MiniMax-M3.1-Flash"} + if path == "messages": + assert str(captured[0].url) == "https://custom.example/anthropic/v1/messages" + # The shared SSE handler normalizes JSON whitespace and appends [DONE]. + received = [ + json.loads(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + assert received == events + else: + assert response.content == stream + + +async def test_native_upstream_validation_error_is_preserved() -> None: + error = { + "error": { + "type": "invalid_request_error", + "message": "Flash requires adaptive thinking", + } + } + + def upstream(request: httpx.Request) -> httpx.Response: + assert json.loads(request.content)["thinking"] == {"type": "disabled"} + return httpx.Response(400, json=error) + + async with proxy(MiniMaxConfig(api_key="test-key"), upstream) as client: + response = await client.post( + "/minimax/v1/messages", + json={ + "model": "minimax", + "max_tokens": 4096, + "thinking": {"type": "disabled"}, + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + assert response.status_code == 400 + assert response.json() == error diff --git a/tests/plugins/minimax/unit/test_config.py b/tests/plugins/minimax/unit/test_config.py index 3938361a..0311c365 100644 --- a/tests/plugins/minimax/unit/test_config.py +++ b/tests/plugins/minimax/unit/test_config.py @@ -49,28 +49,51 @@ def test_explicit_base_url_overrides_region() -> None: 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 list(models) == ["MiniMax-M3.1-Flash", "MiniMax-M3"] 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, + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, "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 m3["pricing_context_tiers"][1] == { + "context": ">512k", + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, } - assert m27["input_modalities"] == ["text"] - assert m27["thinking"] == ["always_on"] + flash = models["MiniMax-M3.1-Flash"].model_dump() + assert flash["context_window"] == 1_000_000 + assert flash["max_output_tokens"] == 524_288 + assert flash["pricing_usd_per_million_tokens"] == { + "input": 0.15, + "output": 0.60, + "cache_read": 0.015, + "cache_write": None, + } + assert flash["input_modalities"] == ["text", "image", "video"] + assert flash["thinking"] == ["adaptive"] + assert flash["reasoning_efforts"] == ["low", "medium", "high", "xhigh", "max"] + assert flash["default_reasoning_effort"] == "max" + + +@pytest.mark.minimax +@pytest.mark.unit +def test_explicit_model_configuration_preserved() -> None: + from ccproxy.models.provider import ModelCard, ModelMappingRule + from ccproxy.utils.model_mapper import ModelMapper + + card = ModelCard(id="MiniMax-M2.7", created=1, owned_by="minimax") + rules = [ModelMappingRule(match="minimax", target="MiniMax-M2.7")] + config = MiniMaxConfig(models_endpoint=[card], model_mappings=rules) + assert config.models_endpoint == [card] + assert config.model_mappings == rules + assert ModelMapper(config.model_mappings).map("minimax").mapped == "MiniMax-M2.7"