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
29 changes: 29 additions & 0 deletions ccproxy/plugins/minimax/README.md
Original file line number Diff line number Diff line change
@@ -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 = "<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 models
are `MiniMax-M3` and `MiniMax-M2.7`.
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"]
85 changes: 85 additions & 0 deletions ccproxy/plugins/minimax/adapter.py
Original file line number Diff line number Diff line change
@@ -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"),
)
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
78 changes: 78 additions & 0 deletions ccproxy/plugins/minimax/model_defaults.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading