Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions ccproxy/plugins/minimax/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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` — 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

```toml
[plugins.minimax]
enabled = true
# "global_en" (api.minimax.io) or "cn_zh" (api.minimaxi.com)
region = "global_en"
api_key = "<your-minimax-api-key>"
```

Selecting a `region` resolves the OpenAI and Anthropic base URLs and the
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.
11 changes: 11 additions & 0 deletions ccproxy/plugins/minimax/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
90 changes: 90 additions & 0 deletions ccproxy/plugins/minimax/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""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:
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:
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}
if endpoint == "/v1/messages":
final_headers.setdefault("anthropic-version", "2023-06-01")

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"),
)
111 changes: 111 additions & 0 deletions ccproxy/plugins/minimax/config.py
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions ccproxy/plugins/minimax/model_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Default model metadata and mapping rules for the MiniMax provider."""

from __future__ import annotations

from ccproxy.models.provider import ModelCard, ModelMappingRule


# 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.1-Flash",
"created": 1735689600,
"owned_by": "minimax",
"permission": [],
"root": "MiniMax-M3.1-Flash",
"parent": None,
"context_window": 1_000_000,
"max_output_tokens": 524_288,
"pricing_usd_per_million_tokens": {
"input": 0.15,
"output": 0.60,
"cache_read": 0.015,
"cache_write": None,
},
"input_modalities": ["text", "image", "video"],
"thinking": ["adaptive"],
"reasoning_efforts": ["low", "medium", "high", "xhigh", "max"],
"default_reasoning_effort": "max",
}
),
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.30,
"output": 1.20,
"cache_read": 0.06,
"cache_write": None,
},
"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"],
}
),
]


# 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",
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.1-Flash",
kind="regex",
flags=["IGNORECASE"],
notes="Default MiniMax alias resolves to MiniMax-M3.1-Flash.",
),
]


__all__ = [
"DEFAULT_MINIMAX_MODEL_CARDS",
"DEFAULT_MINIMAX_MODEL_MAPPINGS",
]
27 changes: 27 additions & 0 deletions ccproxy/plugins/minimax/models.py
Original file line number Diff line number Diff line change
@@ -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
Loading