diff --git a/.env.example b/.env.example index 1ffe6a72..88988a60 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,10 @@ 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 +# MiniMax (Anthropic-compatible): DEFAULT_MODEL=anthropic/MiniMax-M2.7 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/openswarm.config.mjs b/openswarm.config.mjs index 785f5523..19795332 100644 --- a/openswarm.config.mjs +++ b/openswarm.config.mjs @@ -127,6 +127,7 @@ export const productAddons = [ { id: "anthropic", title: "Anthropic Claude", keys: ["ANTHROPIC_API_KEY"], excludeProviders: ["anthropic"] }, { id: "composio", title: "Composio", keys: ["COMPOSIO_API_KEY", "COMPOSIO_USER_ID"] }, { id: "google", title: "Google Gemini", keys: ["GOOGLE_API_KEY"], excludeProviders: ["google"] }, + { id: "minimax", title: "MiniMax", keys: ["MINIMAX_API_KEY"], excludeProviders: ["minimax"] }, { id: "fal", title: "Fal.ai", keys: ["FAL_KEY"] }, { id: "pexels", title: "Pexels", keys: ["PEXELS_API_KEY"] }, { id: "pixabay", title: "Pixabay", keys: ["PIXABAY_API_KEY"] }, diff --git a/openswarm.product-env.json b/openswarm.product-env.json index 55a4f396..65ae8b74 100644 --- a/openswarm.product-env.json +++ b/openswarm.product-env.json @@ -17,7 +17,7 @@ "AGENTSWARM_PRODUCT_WORDMARK_LINES": "[\"\",\" ██████╗ ██████╗ ███████╗███╗ ██╗ ███████╗██╗ ██╗ █████╗ ██████╗ ███╗ ███╗\",\"██╔═══██╗██╔══██╗██╔════╝████╗ ██║ ██╔════╝██║ ██║██╔══██╗██╔══██╗████╗ ████║\",\"██║ ██║██████╔╝█████╗ ██╔██╗ ██║ ███████╗██║ █╗ ██║███████║██████╔╝██╔████╔██║\",\"██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║ ╚════██║██║███╗██║██╔══██╗██╔══██╗██║╚██╔╝██║\",\"╚██████╔╝██║ ███████╗██║ ╚████║ ███████║╚███╔███╔╝██║ ██║██║ ██║██║ ╚═╝ ██║\",\" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝\"]", "AGENTSWARM_PRODUCT_PYTHON_ENVIRONMENT": "standalone", "AGENTSWARM_PRODUCT_ENABLE_ADDONS": "true", - "AGENTSWARM_PRODUCT_ADDONS": "[{\"id\":\"search\",\"title\":\"Web Search\",\"keys\":[\"SEARCH_API_KEY\"]},{\"id\":\"anthropic\",\"title\":\"Anthropic Claude\",\"keys\":[\"ANTHROPIC_API_KEY\"],\"excludeProviders\":[\"anthropic\"]},{\"id\":\"composio\",\"title\":\"Composio\",\"keys\":[\"COMPOSIO_API_KEY\",\"COMPOSIO_USER_ID\"]},{\"id\":\"google\",\"title\":\"Google Gemini\",\"keys\":[\"GOOGLE_API_KEY\"],\"excludeProviders\":[\"google\"]},{\"id\":\"fal\",\"title\":\"Fal.ai\",\"keys\":[\"FAL_KEY\"]},{\"id\":\"pexels\",\"title\":\"Pexels\",\"keys\":[\"PEXELS_API_KEY\"]},{\"id\":\"pixabay\",\"title\":\"Pixabay\",\"keys\":[\"PIXABAY_API_KEY\"]},{\"id\":\"unsplash\",\"title\":\"Unsplash\",\"keys\":[\"UNSPLASH_ACCESS_KEY\"]}]", + "AGENTSWARM_PRODUCT_ADDONS": "[{\"id\":\"search\",\"title\":\"Web Search\",\"keys\":[\"SEARCH_API_KEY\"]},{\"id\":\"anthropic\",\"title\":\"Anthropic Claude\",\"keys\":[\"ANTHROPIC_API_KEY\"],\"excludeProviders\":[\"anthropic\"]},{\"id\":\"composio\",\"title\":\"Composio\",\"keys\":[\"COMPOSIO_API_KEY\",\"COMPOSIO_USER_ID\"]},{\"id\":\"google\",\"title\":\"Google Gemini\",\"keys\":[\"GOOGLE_API_KEY\"],\"excludeProviders\":[\"google\"]},{\"id\":\"minimax\",\"title\":\"MiniMax\",\"keys\":[\"MINIMAX_API_KEY\"],\"excludeProviders\":[\"minimax\"]},{\"id\":\"fal\",\"title\":\"Fal.ai\",\"keys\":[\"FAL_KEY\"]},{\"id\":\"pexels\",\"title\":\"Pexels\",\"keys\":[\"PEXELS_API_KEY\"]},{\"id\":\"pixabay\",\"title\":\"Pixabay\",\"keys\":[\"PIXABAY_API_KEY\"]},{\"id\":\"unsplash\",\"title\":\"Unsplash\",\"keys\":[\"UNSPLASH_ACCESS_KEY\"]}]", "AGENTSWARM_MARKETPLACE_SWARM_ID": "VRSEN/OpenSwarm", "AGENTSWARM_MARKETPLACE_SWARM_ORIGIN": "original" } diff --git a/scripts/smoke-openswarm-launcher.js b/scripts/smoke-openswarm-launcher.js index 2e01113c..51d44be7 100644 --- a/scripts/smoke-openswarm-launcher.js +++ b/scripts/smoke-openswarm-launcher.js @@ -84,6 +84,7 @@ function assertProductAddons(env) { { id: 'anthropic', title: 'Anthropic Claude', keys: ['ANTHROPIC_API_KEY'], excludeProviders: ['anthropic'] }, { id: 'composio', title: 'Composio', keys: ['COMPOSIO_API_KEY', 'COMPOSIO_USER_ID'] }, { id: 'google', title: 'Google Gemini', keys: ['GOOGLE_API_KEY'], excludeProviders: ['google'] }, + { id: 'minimax', title: 'MiniMax', keys: ['MINIMAX_API_KEY'], excludeProviders: ['minimax'] }, { id: 'fal', title: 'Fal.ai', keys: ['FAL_KEY'] }, { id: 'pexels', title: 'Pexels', keys: ['PEXELS_API_KEY'] }, { id: 'pixabay', title: 'Pixabay', keys: ['PIXABAY_API_KEY'] }, diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 00000000..50bfe6b8 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,160 @@ +"""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() + + @pytest.mark.parametrize( + "model", + [ + "minimax/MiniMax-M3", + "minimax/MiniMax-M2.7", + "anthropic/MiniMax-M3", + "anthropic/MiniMax-M2.7", + ], + ) + def test_models_documented_for_both_routes(self, model): + assert model in self._content() + + def test_both_regions_documented(self): + content = self._content() + assert "api.minimax.io" in content + assert "api.minimaxi.com" in content