diff --git a/.env.example b/.env.example index 000f11c4d..ba4f69a10 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= ANTHROPIC_ENDPOINT=https://api.anthropic.com +MINIMAX_API_KEY= +MINIMAX_OPENAI_ENDPOINT=https://api.minimax.io/v1 +MINIMAX_CN_OPENAI_ENDPOINT=https://api.minimaxi.com/v1 +MINIMAX_ANTHROPIC_ENDPOINT=https://api.minimax.io/anthropic +MINIMAX_CN_ANTHROPIC_ENDPOINT=https://api.minimaxi.com/anthropic + GOOGLE_API_KEY= AZURE_OPENAI_ENDPOINT= diff --git a/docker-compose.yml b/docker-compose.yml index 97fdd2c43..2b6612267 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,11 @@ services: - OPENAI_API_KEY=${OPENAI_API_KEY:-} - ANTHROPIC_ENDPOINT=${ANTHROPIC_ENDPOINT:-https://api.anthropic.com} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - MINIMAX_API_KEY=${MINIMAX_API_KEY:-} + - MINIMAX_OPENAI_ENDPOINT=${MINIMAX_OPENAI_ENDPOINT:-https://api.minimax.io/v1} + - MINIMAX_CN_OPENAI_ENDPOINT=${MINIMAX_CN_OPENAI_ENDPOINT:-https://api.minimaxi.com/v1} + - MINIMAX_ANTHROPIC_ENDPOINT=${MINIMAX_ANTHROPIC_ENDPOINT:-https://api.minimax.io/anthropic} + - MINIMAX_CN_ANTHROPIC_ENDPOINT=${MINIMAX_CN_ANTHROPIC_ENDPOINT:-https://api.minimaxi.com/anthropic} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT:-} - AZURE_OPENAI_API_KEY=${AZURE_OPENAI_API_KEY:-} diff --git a/src/utils/config.py b/src/utils/config.py index de82bb9e9..36c718cdf 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,3 +1,28 @@ +MINIMAX_MODELS = ["MiniMax-M3", "MiniMax-M2.7"] + +MINIMAX_PROVIDER_CONFIGS = { + "minimax": { + "protocol": "openai", + "endpoint_env": "MINIMAX_OPENAI_ENDPOINT", + "base_url": "https://api.minimax.io/v1", + }, + "minimax_cn": { + "protocol": "openai", + "endpoint_env": "MINIMAX_CN_OPENAI_ENDPOINT", + "base_url": "https://api.minimaxi.com/v1", + }, + "minimax_anthropic": { + "protocol": "anthropic", + "endpoint_env": "MINIMAX_ANTHROPIC_ENDPOINT", + "base_url": "https://api.minimax.io/anthropic", + }, + "minimax_anthropic_cn": { + "protocol": "anthropic", + "endpoint_env": "MINIMAX_CN_ANTHROPIC_ENDPOINT", + "base_url": "https://api.minimaxi.com/anthropic", + }, +} + PROVIDER_DISPLAY_NAMES = { "openai": "OpenAI", "azure_openai": "Azure OpenAI", @@ -9,12 +34,20 @@ "unbound": "Unbound AI", "ibm": "IBM", "grok": "Grok", + "minimax": "MiniMax (Global, OpenAI-compatible)", + "minimax_cn": "MiniMax (China, OpenAI-compatible)", + "minimax_anthropic": "MiniMax (Global, Anthropic-compatible)", + "minimax_anthropic_cn": "MiniMax (China, Anthropic-compatible)", } # Predefined model names for common providers model_names = { "anthropic": ["claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20240620", "claude-3-opus-20240229"], "openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo", "o3-mini"], + "minimax": MINIMAX_MODELS, + "minimax_cn": MINIMAX_MODELS, + "minimax_anthropic": MINIMAX_MODELS, + "minimax_anthropic_cn": MINIMAX_MODELS, "deepseek": ["deepseek-chat", "deepseek-reasoner"], "google": ["gemini-2.0-flash", "gemini-2.0-flash-thinking-exp", "gemini-1.5-flash-latest", "gemini-1.5-flash-8b-latest", "gemini-2.0-flash-thinking-exp-01-21", "gemini-2.0-pro-exp-02-05", diff --git a/src/utils/llm_provider.py b/src/utils/llm_provider.py index 2ef3d6382..75d11c8df 100644 --- a/src/utils/llm_provider.py +++ b/src/utils/llm_provider.py @@ -156,8 +156,10 @@ def get_llm_model(provider: str, **kwargs): :param kwargs: :return: """ + provider_config = config.MINIMAX_PROVIDER_CONFIGS.get(provider) + if provider not in ["ollama", "bedrock"]: - env_var = f"{provider.upper()}_API_KEY" + env_var = "MINIMAX_API_KEY" if provider_config else f"{provider.upper()}_API_KEY" api_key = kwargs.get("api_key", "") or os.getenv(env_var, "") if not api_key: provider_display = config.PROVIDER_DISPLAY_NAMES.get(provider, provider.upper()) @@ -165,7 +167,27 @@ def get_llm_model(provider: str, **kwargs): raise ValueError(error_msg) kwargs["api_key"] = api_key - if provider == "anthropic": + if provider_config: + base_url = kwargs.get("base_url", "") or os.getenv( + provider_config["endpoint_env"], provider_config["base_url"] + ) + model_name = kwargs.get("model_name", config.MINIMAX_MODELS[0]) + + if provider_config["protocol"] == "anthropic": + return ChatAnthropic( + model=model_name, + temperature=kwargs.get("temperature", 0.0), + base_url=base_url, + api_key=api_key, + ) + + return ChatOpenAI( + model=model_name, + temperature=kwargs.get("temperature", 0.0), + base_url=base_url, + api_key=api_key, + ) + elif provider == "anthropic": if not kwargs.get("base_url", ""): base_url = "https://api.anthropic.com" else: diff --git a/tests/test_minimax_provider.py b/tests/test_minimax_provider.py new file mode 100644 index 000000000..70ddba8e5 --- /dev/null +++ b/tests/test_minimax_provider.py @@ -0,0 +1,107 @@ +import httpx +import pytest + +from src.utils import config, llm_provider + + +EXPECTED_MODELS = ["MiniMax-M3", "MiniMax-M2.7"] + + +@pytest.mark.parametrize("provider", config.MINIMAX_PROVIDER_CONFIGS) +def test_minimax_models_are_registered(provider): + assert config.model_names[provider] == EXPECTED_MODELS + + +@pytest.mark.parametrize( + ("provider", "expected_adapter", "expected_base_url"), + [ + ("minimax", "openai", "https://api.minimax.io/v1"), + ("minimax_cn", "openai", "https://api.minimaxi.com/v1"), + ("minimax_anthropic", "anthropic", "https://api.minimax.io/anthropic"), + ("minimax_anthropic_cn", "anthropic", "https://api.minimaxi.com/anthropic"), + ], +) +def test_minimax_provider_uses_configured_adapter_and_endpoint( + monkeypatch, provider, expected_adapter, expected_base_url +): + created = {} + + def fake_openai(**kwargs): + created.update(adapter="openai", kwargs=kwargs) + return object() + + def fake_anthropic(**kwargs): + created.update(adapter="anthropic", kwargs=kwargs) + return object() + + provider_config = config.MINIMAX_PROVIDER_CONFIGS[provider] + monkeypatch.setattr(llm_provider, "ChatOpenAI", fake_openai) + monkeypatch.setattr(llm_provider, "ChatAnthropic", fake_anthropic) + monkeypatch.setenv("MINIMAX_API_KEY", "unit-test-key") + monkeypatch.delenv(provider_config["endpoint_env"], raising=False) + + llm_provider.get_llm_model(provider=provider, model_name=EXPECTED_MODELS[0]) + + assert created["adapter"] == expected_adapter + assert created["kwargs"]["model"] == EXPECTED_MODELS[0] + assert created["kwargs"]["base_url"] == expected_base_url + assert created["kwargs"]["api_key"] == "unit-test-key" + + +def test_minimax_clients_append_expected_request_paths(monkeypatch): + captured_urls = [] + + def fake_send(self, request, *args, **kwargs): + captured_urls.append(str(request.url)) + if request.url.path.endswith("/chat/completions"): + payload = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": EXPECTED_MODELS[0], + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + else: + payload = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": EXPECTED_MODELS[0], + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + return httpx.Response(200, json=payload, request=request) + + monkeypatch.setattr(httpx.Client, "send", fake_send) + expected_urls = { + "minimax": "https://api.minimax.io/v1/chat/completions", + "minimax_cn": "https://api.minimaxi.com/v1/chat/completions", + "minimax_anthropic": "https://api.minimax.io/anthropic/v1/messages", + "minimax_anthropic_cn": "https://api.minimaxi.com/anthropic/v1/messages", + } + + for provider, expected_url in expected_urls.items(): + captured_urls.clear() + model = llm_provider.get_llm_model( + provider=provider, + model_name=EXPECTED_MODELS[0], + api_key="unit-test-key", + temperature=0.0, + ) + + model.invoke("hello") + + assert captured_urls == [expected_url]