diff --git a/.env.example b/.env.example index 1ffe6a72..c67f12ab 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,19 @@ ANTHROPIC_API_KEY= # Google Gemini — set this if using Google as your primary provider. GOOGLE_API_KEY= +# MiniMax — set this if using MiniMax as your primary provider. +# Get your key at https://platform.minimax.io (global) or https://platform.minimaxi.com (CN). +MINIMAX_API_KEY= + +# MiniMax region — selects the endpoint set for both the OpenAI-compatible and +# Anthropic-compatible routes. Use "global" (default) or "cn". +# global: https://api.minimax.io/v1 https://api.minimax.io/anthropic +# cn: https://api.minimaxi.com/v1 https://api.minimaxi.com/anthropic +MINIMAX_REGION=global +# Optional explicit endpoint overrides (otherwise derived from MINIMAX_REGION): +# MINIMAX_API_BASE= +# MINIMAX_ANTHROPIC_BASE= + # ── Model selection ─────────────────────────── @@ -24,6 +37,9 @@ GOOGLE_API_KEY= # OpenAI example: DEFAULT_MODEL=gpt-5.4 # Anthropic example: DEFAULT_MODEL=litellm/claude-sonnet-4-6 # Google example: DEFAULT_MODEL=litellm/gemini/gemini-3-flash +# MiniMax (OpenAI-compatible): DEFAULT_MODEL=minimax/MiniMax-M3 +# MiniMax (OpenAI-compatible): DEFAULT_MODEL=minimax/MiniMax-M2.7 +# MiniMax (Anthropic-compatible): DEFAULT_MODEL=anthropic/MiniMax-M3 DEFAULT_MODEL= diff --git a/README.md b/README.md index 2cd5a345..addf52c1 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ The setup wizard walks you through everything, but you'll need at least one of t - `OPENAI_API_KEY` - For GPT 5.5 and Sora video generation - `ANTHROPIC_API_KEY` - For Claude models +- `MINIMAX_API_KEY` - For MiniMax models (MiniMax-M3, MiniMax-M2.7); set `MINIMAX_REGION` to `global` or `cn` **Optional superpowers:** diff --git a/config.py b/config.py index 7054ba0d..48b3418f 100644 --- a/config.py +++ b/config.py @@ -2,6 +2,21 @@ import os +# MiniMax exposes matching OpenAI-compatible and Anthropic-compatible endpoints +# on both a global and a mainland-China region. Selecting a region swaps the +# whole endpoint set so the same model strings work from either location. +MINIMAX_ENDPOINTS = { + "global": { + "openai_base_url": "https://api.minimax.io/v1", + "anthropic_base_url": "https://api.minimax.io/anthropic", + }, + "cn": { + "openai_base_url": "https://api.minimaxi.com/v1", + "anthropic_base_url": "https://api.minimaxi.com/anthropic", + }, +} + + def get_default_model(fallback: str = "gpt-5.4"): """Return the configured default model for standard agents.""" model = os.getenv("DEFAULT_MODEL", fallback) @@ -13,22 +28,64 @@ def is_openai_provider() -> bool: OpenAI model IDs never contain a slash (e.g. 'gpt-5.4', 'o3'). Any 'provider/model' string (e.g. 'anthropic/claude-sonnet-4-6', - 'litellm/gemini/gemini-3-flash') is treated as a LiteLLM-routed model. + 'litellm/gemini/gemini-3-flash', 'minimax/MiniMax-M3') is treated as a + LiteLLM-routed model. """ return "/" not in os.getenv("DEFAULT_MODEL", "") +def is_minimax_model(model: str) -> bool: + """Return True when ``model`` targets a MiniMax model on either routing style. + + Covers the OpenAI-compatible form ('minimax/MiniMax-M3') and the + Anthropic-compatible form ('anthropic/MiniMax-M3'), regardless of case. + """ + return "minimax" in model.lower() + + +def minimax_region() -> str: + """Return the selected MiniMax region ('global' or 'cn').""" + region = os.getenv("MINIMAX_REGION", "global").strip().lower() + return "cn" if region in {"cn", "cn_zh", "china"} else "global" + + +def minimax_base_url(model: str) -> str: + """Return the MiniMax base URL for ``model`` in the configured region. + + Uses the Anthropic-compatible endpoint when the model is routed through the + 'anthropic/' provider, otherwise the OpenAI-compatible endpoint. Explicit + ``MINIMAX_ANTHROPIC_BASE`` / ``MINIMAX_API_BASE`` overrides win when set. + """ + endpoints = MINIMAX_ENDPOINTS[minimax_region()] + if model.startswith("anthropic/"): + return os.getenv("MINIMAX_ANTHROPIC_BASE") or endpoints["anthropic_base_url"] + return os.getenv("MINIMAX_API_BASE") or endpoints["openai_base_url"] + + +def _minimax_kwargs(model: str) -> dict: + """Build LitellmModel keyword arguments for a MiniMax model.""" + kwargs = {"api_base": minimax_base_url(model)} + api_key = os.getenv("MINIMAX_API_KEY") + if api_key: + kwargs["api_key"] = api_key + return kwargs + + def _resolve(model: str): """Route 'provider/model' strings through LitellmModel. Handles both explicit 'litellm/' and bare 'provider/model' forms. - OpenAI model IDs contain no slash, so they pass through unchanged. + OpenAI model IDs contain no slash, so they pass through unchanged. MiniMax + models additionally receive the region-selected base URL so both the + OpenAI-compatible and Anthropic-compatible endpoints route correctly. """ if "/" not in model: return model bare = model[len("litellm/"):] if model.startswith("litellm/") else model try: from agency_swarm import LitellmModel # noqa: PLC0415 - return LitellmModel(model=bare) except ImportError: return model + if is_minimax_model(bare): + return LitellmModel(model=bare, **_minimax_kwargs(bare)) + return LitellmModel(model=bare) diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 00000000..0e819d2f --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,158 @@ +"""Unit tests for MiniMax provider configuration in config.py. + +These tests avoid any network calls and do not require ``agency_swarm`` to be +installed: ``config._resolve`` falls back to returning the raw model string when +the dependency is absent, so the region/endpoint selection logic is exercised +directly through the public helpers. +""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path +from unittest import mock + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import config # noqa: E402 + + +@pytest.fixture(autouse=True) +def _reload_config(): + """Reload config with a clean MiniMax environment for each test.""" + for key in ("DEFAULT_MODEL", "MINIMAX_REGION", "MINIMAX_API_BASE", + "MINIMAX_ANTHROPIC_BASE", "MINIMAX_API_KEY"): + os.environ.pop(key, None) + importlib.reload(config) + yield + importlib.reload(config) + + +class TestMiniMaxDetection: + def test_openai_compatible_form_detected(self): + assert config.is_minimax_model("minimax/MiniMax-M3") is True + + def test_anthropic_compatible_form_detected(self): + assert config.is_minimax_model("anthropic/MiniMax-M2.7") is True + + def test_case_insensitive(self): + assert config.is_minimax_model("MINIMAX/MINIMAX-M3") is True + + def test_other_providers_not_detected(self): + assert config.is_minimax_model("anthropic/claude-sonnet-4-6") is False + assert config.is_minimax_model("litellm/gemini/gemini-3-flash") is False + + +class TestMiniMaxRegion: + def test_default_region_is_global(self): + assert config.minimax_region() == "global" + + def test_cn_region_selected(self): + with mock.patch.dict(os.environ, {"MINIMAX_REGION": "cn"}): + assert config.minimax_region() == "cn" + + def test_cn_zh_alias(self): + with mock.patch.dict(os.environ, {"MINIMAX_REGION": "cn_zh"}): + assert config.minimax_region() == "cn" + + +class TestMiniMaxBaseUrl: + def test_global_openai_endpoint(self): + assert config.minimax_base_url("minimax/MiniMax-M3") == "https://api.minimax.io/v1" + + def test_global_anthropic_endpoint(self): + assert config.minimax_base_url("anthropic/MiniMax-M3") == "https://api.minimax.io/anthropic" + + def test_cn_openai_endpoint(self): + with mock.patch.dict(os.environ, {"MINIMAX_REGION": "cn"}): + assert config.minimax_base_url("minimax/MiniMax-M2.7") == "https://api.minimaxi.com/v1" + + def test_cn_anthropic_endpoint(self): + with mock.patch.dict(os.environ, {"MINIMAX_REGION": "cn"}): + assert config.minimax_base_url("anthropic/MiniMax-M3") == "https://api.minimaxi.com/anthropic" + + def test_explicit_openai_override_wins(self): + with mock.patch.dict(os.environ, {"MINIMAX_API_BASE": "https://proxy.example/v1"}): + assert config.minimax_base_url("minimax/MiniMax-M3") == "https://proxy.example/v1" + + def test_explicit_anthropic_override_wins(self): + with mock.patch.dict(os.environ, {"MINIMAX_ANTHROPIC_BASE": "https://proxy.example/anthropic"}): + assert config.minimax_base_url("anthropic/MiniMax-M3") == "https://proxy.example/anthropic" + + +class TestMiniMaxKwargs: + def test_api_base_always_present(self): + kwargs = config._minimax_kwargs("minimax/MiniMax-M3") + assert kwargs["api_base"] == "https://api.minimax.io/v1" + assert "api_key" not in kwargs + + def test_api_key_included_when_set(self): + with mock.patch.dict(os.environ, {"MINIMAX_API_KEY": "unit-test-placeholder"}): + kwargs = config._minimax_kwargs("anthropic/MiniMax-M3") + assert kwargs["api_base"] == "https://api.minimax.io/anthropic" + assert kwargs["api_key"] == "unit-test-placeholder" + + +class TestResolveAndDefaultModel: + def test_minimax_openai_route_resolves(self): + result = config._resolve("minimax/MiniMax-M3") + try: + from agency_swarm import LitellmModel + assert isinstance(result, LitellmModel) + except ImportError: + assert result == "minimax/MiniMax-M3" + + def test_minimax_anthropic_route_resolves(self): + result = config._resolve("anthropic/MiniMax-M2.7") + try: + from agency_swarm import LitellmModel + assert isinstance(result, LitellmModel) + except ImportError: + assert result == "anthropic/MiniMax-M2.7" + + def test_minimax_model_is_not_openai_provider(self): + with mock.patch.dict(os.environ, {"DEFAULT_MODEL": "minimax/MiniMax-M3"}): + importlib.reload(config) + assert config.is_openai_provider() is False + + def test_get_default_model_with_minimax(self): + with mock.patch.dict(os.environ, {"DEFAULT_MODEL": "minimax/MiniMax-M2.7"}): + importlib.reload(config) + result = config.get_default_model() + try: + from agency_swarm import LitellmModel + assert isinstance(result, LitellmModel) + except ImportError: + assert result == "minimax/MiniMax-M2.7" + + +class TestEnvExample: + def _content(self): + return (ROOT / ".env.example").read_text(encoding="utf-8") + + def test_api_key_documented(self): + assert "MINIMAX_API_KEY" in self._content() + + def test_region_documented(self): + assert "MINIMAX_REGION" in self._content() + + def test_both_models_documented(self): + content = self._content() + assert "MiniMax-M3" in content + assert "MiniMax-M2.7" in content + + def test_both_routes_documented(self): + content = self._content() + assert "minimax/MiniMax-M3" in content + assert "anthropic/MiniMax-M3" in content + + def test_both_regions_documented(self): + content = self._content() + assert "api.minimax.io" in content + assert "api.minimaxi.com" in content