diff --git a/.env.example b/.env.example index 1854e492..da7d48bb 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,20 @@ VIDU_API_KEY=your_vidu_api_key_here # 默认使用 DashScope (通过 OpenAI 兼容接口调用,只需上方 DASHSCOPE_API_KEY 即可) # LLM_PROVIDER=dashscope # +# MiniMax OpenAI-compatible configuration: +# LLM_PROVIDER=minimax +# MINIMAX_API_KEY=your_minimax_api_key_here +# MINIMAX_MODEL=MiniMax-M3 +# Supported models: MiniMax-M3, MiniMax-M2.7 +# Global endpoint: +# MINIMAX_BASE_URL=https://api.minimax.io/v1 +# China endpoint: +# MINIMAX_BASE_URL=https://api.minimaxi.com/v1 +# +# MiniMax Anthropic-compatible clients can use the matching regional endpoint: +# Global: ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic +# China: ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic +# # 如需切换到第三方 OpenAI 兼容 API (OpenAI/DeepSeek/Ollama 等): # LLM_PROVIDER=openai # OPENAI_API_KEY=your_openai_api_key_here @@ -59,5 +73,6 @@ API_PORT=8000 # =============================== # DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com # KLING_BASE_URL=https://api.klingai.com/v1 +# MINIMAX_BASE_URL=https://api.minimax.io/v1 # VIDU_BASE_URL=https://api.vidu.cn/ent/v2 # MULEROUTER_BASE_URL=https://api.mulerouter.ai diff --git a/src/apps/comic_gen/llm_adapter.py b/src/apps/comic_gen/llm_adapter.py index 96162226..43de2547 100644 --- a/src/apps/comic_gen/llm_adapter.py +++ b/src/apps/comic_gen/llm_adapter.py @@ -1,9 +1,10 @@ """ -LLM Adapter - Unified interface for DashScope and OpenAI-compatible APIs. +LLM Adapter - Unified interface for provider-specific, OpenAI-compatible APIs. -Supports two providers: +Supported providers: - dashscope (default): Alibaba Cloud DashScope via OpenAI-compatible endpoint - openai: Any OpenAI-compatible API (OpenAI, DeepSeek, Ollama, etc.) + - minimax: MiniMax via its OpenAI-compatible endpoint Configuration via environment variables: LLM_PROVIDER=dashscope|openai @@ -11,10 +12,15 @@ OPENAI_API_KEY=... OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_MODEL=gpt-4o + LLM_PROVIDER=minimax + MINIMAX_API_KEY=... + MINIMAX_BASE_URL=https://api.minimax.io/v1 + MINIMAX_MODEL=MiniMax-M3 """ -import os + import logging -from typing import Dict, List, Optional, Any +import os +from typing import Any, Dict, List, Optional from ...utils.endpoints import get_provider_base_url @@ -22,7 +28,7 @@ class LLMAdapter: - """Unified LLM call interface supporting DashScope and OpenAI-compatible APIs.""" + """Unified LLM call interface with provider-specific configuration.""" def __init__(self): self.provider = os.getenv("LLM_PROVIDER", "dashscope").lower() @@ -31,6 +37,8 @@ def __init__(self): @property def is_configured(self) -> bool: + if self.provider == "minimax": + return bool(os.getenv("MINIMAX_API_KEY")) if self.provider == "openai": return bool(os.getenv("OPENAI_API_KEY")) return bool(os.getenv("DASHSCOPE_API_KEY")) @@ -41,9 +49,14 @@ def _get_client(self): try: from openai import OpenAI except ImportError: - raise RuntimeError( - "openai package not installed. Run: pip install openai>=1.0.0" + raise RuntimeError("openai package not installed. Run: pip install openai>=1.0.0") + + if self.provider == "minimax": + self._client = OpenAI( + api_key=os.getenv("MINIMAX_API_KEY"), + base_url=get_provider_base_url("MINIMAX"), ) + return self._client if self.provider == "openai": self._client = OpenAI( @@ -64,6 +77,8 @@ def _get_client(self): _DASHSCOPE_MODEL_FALLBACK_CHAIN = ["qwen3.7-plus", "qwen3.6-plus", "qwen-plus"] def _get_default_model(self) -> str: + if self.provider == "minimax": + return os.getenv("MINIMAX_MODEL", "MiniMax-M3") if self.provider == "openai": return os.getenv("OPENAI_MODEL", "gpt-4o") return self._DASHSCOPE_MODEL_FALLBACK_CHAIN[0] @@ -94,6 +109,9 @@ def chat( if model: return self._chat_once(client, model, messages, response_format) + if self.provider == "minimax": + return self._chat_once(client, self._get_default_model(), messages, response_format) + # Provider 默认路径:DashScope 走 fallback chain,OpenAI 单次尝试。 if self.provider == "openai": return self._chat_once(client, self._get_default_model(), messages, response_format) @@ -106,16 +124,26 @@ def chat( # 仅在 "模型不存在 / 不可用" 类错误时回退;其他错误(鉴权、限流、网络) # 直接抛,不浪费第二次重试。判定关键字宽松匹配 DashScope 文案。 msg = str(e).lower() - is_model_unavailable = any(k in msg for k in ( - "model not found", "invalidmodel", "model_not_found", - "no such model", "not supported", "modelnotfound", "404", - )) + is_model_unavailable = any( + k in msg + for k in ( + "model not found", + "invalidmodel", + "model_not_found", + "no such model", + "not supported", + "modelnotfound", + "404", + ) + ) last_err = e if is_model_unavailable and idx < len(self._DASHSCOPE_MODEL_FALLBACK_CHAIN) - 1: next_candidate = self._DASHSCOPE_MODEL_FALLBACK_CHAIN[idx + 1] logger.warning( "DashScope model %s unavailable (%s); falling back to %s", - candidate, e, next_candidate, + candidate, + e, + next_candidate, ) continue raise @@ -140,5 +168,7 @@ def _chat_once( response = client.chat.completions.create(**kwargs) return response.choices[0].message.content except Exception as e: + if self.provider == "minimax": + raise RuntimeError(f"MiniMax API error: {e}") from e provider_label = "DashScope" if self.provider != "openai" else "OpenAI" raise RuntimeError(f"{provider_label} API error: {e}") from e diff --git a/src/utils/endpoints.py b/src/utils/endpoints.py index 9098b489..07a93729 100644 --- a/src/utils/endpoints.py +++ b/src/utils/endpoints.py @@ -4,6 +4,7 @@ PROVIDER_DEFAULTS = { "DASHSCOPE": "https://dashscope.aliyuncs.com", "KLING": "https://api-beijing.klingai.com/v1", + "MINIMAX": "https://api.minimax.io/v1", "VIDU": "https://api.vidu.cn/ent/v2", "MULEROUTER": "https://api.mulerouter.ai", } diff --git a/tests/test_llm_adapter.py b/tests/test_llm_adapter.py new file mode 100644 index 00000000..8f1e2539 --- /dev/null +++ b/tests/test_llm_adapter.py @@ -0,0 +1,87 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from src.apps.comic_gen.llm_adapter import LLMAdapter + + +@pytest.fixture +def minimax_client(monkeypatch): + captured = {} + create = Mock( + return_value=SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="MiniMax response"))] + ) + ) + client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + def build_client(**kwargs): + captured["kwargs"] = kwargs + captured["client"] = client + return client + + monkeypatch.setitem(sys.modules, "openai", SimpleNamespace(OpenAI=build_client)) + return captured + + +def test_minimax_configuration_requires_its_api_key(monkeypatch): + monkeypatch.setenv("LLM_PROVIDER", "minimax") + monkeypatch.delenv("MINIMAX_API_KEY", raising=False) + + adapter = LLMAdapter() + + assert adapter.is_configured is False + + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + assert adapter.is_configured is True + + +def test_minimax_uses_global_defaults(monkeypatch, minimax_client): + monkeypatch.setenv("LLM_PROVIDER", "minimax") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.delenv("MINIMAX_BASE_URL", raising=False) + monkeypatch.delenv("MINIMAX_MODEL", raising=False) + messages = [{"role": "user", "content": "Hello"}] + + result = LLMAdapter().chat(messages) + + assert result == "MiniMax response" + assert minimax_client["kwargs"] == { + "api_key": "test-key", + "base_url": "https://api.minimax.io/v1", + } + minimax_client["client"].chat.completions.create.assert_called_once_with( + model="MiniMax-M3", + messages=messages, + ) + + +def test_minimax_supports_china_endpoint_and_secondary_model(monkeypatch, minimax_client): + monkeypatch.setenv("LLM_PROVIDER", "minimax") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1") + monkeypatch.setenv("MINIMAX_MODEL", "MiniMax-M2.7") + messages = [{"role": "user", "content": "Hello"}] + + LLMAdapter().chat(messages, response_format={"type": "json_object"}) + + assert minimax_client["kwargs"]["base_url"] == "https://api.minimaxi.com/v1" + minimax_client["client"].chat.completions.create.assert_called_once_with( + model="MiniMax-M2.7", + messages=messages, + response_format={"type": "json_object"}, + ) + + +def test_minimax_documents_regional_protocol_endpoints(): + env_example = (Path(__file__).parents[1] / ".env.example").read_text() + + assert "https://api.minimax.io/v1" in env_example + assert "https://api.minimaxi.com/v1" in env_example + assert "https://api.minimax.io/anthropic" in env_example + assert "https://api.minimaxi.com/anthropic" in env_example + assert "MiniMax-M3" in env_example + assert "MiniMax-M2.7" in env_example