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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
54 changes: 42 additions & 12 deletions src/apps/comic_gen/llm_adapter.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
"""
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
DASHSCOPE_API_KEY=...
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

logger = logging.getLogger(__name__)


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()
Expand All @@ -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"))
Expand All @@ -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(
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions src/utils/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
87 changes: 87 additions & 0 deletions tests/test_llm_adapter.py
Original file line number Diff line number Diff line change
@@ -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