Skip to content
Closed
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
5 changes: 4 additions & 1 deletion backend/api/v1/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def validate_memory(cls, v):

class ApiKeys(BaseModel):
"""API密钥设置"""
aimlapi: str = Field(default="", description="AI/ML API 密钥")
dashscope: str = Field(default="", description="通义千问API密钥")
openai: str = Field(default="", description="OpenAI API密钥")
gemini: str = Field(default="", description="Gemini API密钥")
Expand All @@ -66,7 +67,7 @@ class ApiKeys(BaseModel):
class ApiSettings(BaseModel):
"""API设置"""
api_keys: ApiKeys = Field(default_factory=ApiKeys, description="API密钥")
api_provider: str = Field(default="dashscope", description="当前 LLM 提供商(dashscope / openai / gemini / deepseek / seed / kimi / glm / grok,或本地预设 ollama / lmstudio)")
api_provider: str = Field(default="dashscope", description="当前 LLM 提供商(dashscope / openai / gemini / aimlapi / deepseek / seed / kimi / glm / grok,或本地预设 ollama / lmstudio)")
api_base_url: str = Field(default="", description="OpenAI 兼容接口地址;provider=openai 时空为官方地址,本地预设为空时用预设默认地址")
api_model: str = Field(default="qwen-plus", description="默认模型")
api_max_tokens: int = Field(default=4096, description="最大Token数")
Expand Down Expand Up @@ -250,6 +251,7 @@ async def get_settings():
),
api=ApiSettings(
api_keys=ApiKeys(
aimlapi="",
dashscope=config.dashscope_api_key,
openai=config.openai_api_key,
gemini=config.gemini_api_key,
Expand Down Expand Up @@ -684,6 +686,7 @@ async def list_backups():
def _saved_provider_api_key(settings: DesktopSettings, provider: str) -> str:
keys = settings.api.api_keys
return {
"aimlapi": keys.aimlapi,
"dashscope": keys.dashscope,
"openai": keys.openai,
"gemini": keys.gemini,
Expand Down
1 change: 1 addition & 0 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def cmd_providers(args: argparse.Namespace) -> int:
from backend.core.llm_manager import get_llm_manager

rows = [
("aimlapi", "AI/ML API", "需要 key;聚合网关,一个 key 300+ 对话模型,型号按 厂商/型号 命名"),
("dashscope", "阿里通义千问", "需要 key;国内直连,qwen-plus 性价比高"),
("openai", "OpenAI / 兼容接口", "需要 key;--base-url 可指向 DeepSeek / 智谱 / OpenRouter / vLLM"),
("gemini", "Google Gemini", "需要 key"),
Expand Down
54 changes: 54 additions & 0 deletions backend/core/aimlapi_attribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
AI/ML API 归因请求头。

网关只有在请求带上这几个头时才会把调用计到 AutoClip 名下,所以它们要跟着每一次
打到 api.aimlapi.com 的请求走 —— 也只跟着这些请求走:同一个 `OpenAIProvider`
还服务 OpenRouter、Ollama、vLLM,把 partner id 发给第三方既不对也没用。

判定按「最终目标地址的主机名」做,只认 `api.aimlapi.com` 本身,不做后缀匹配
(`api.aimlapi.com.evil.tld` 不是我们)。
"""
from __future__ import annotations

from typing import Dict, Optional
from urllib.parse import urlparse

AIMLAPI_HOST = "api.aimlapi.com"
AIMLAPI_BASE_URL = "https://api.aimlapi.com/v1"

# 归因来源标识;格式 `agent/<slug>`,与 AI/ML API 侧登记的一致。
AIMLAPI_SOURCE = "agent/autoclip"
# 合作伙伴 ID 由 AI/ML API 侧随机生成后登记。留空表示「还没有」——
# 留空时不发这个头,而不是发一个不存在的值(未知 id 会被静默忽略)。
AIMLAPI_PARTNER_ID = "part_qVR4oZesx5sRaPUBvUjDQf7p"

_REFERER = "https://github.com/zhouxiaoka/autoclip"
_TITLE = "AutoClip"


def is_aimlapi_url(base_url: Optional[str]) -> bool:
"""base_url 是否指向 AI/ML API 网关。"""
url = (base_url or "").strip()
if not url:
return False
if "://" not in url:
url = f"https://{url}"
try:
host = (urlparse(url).hostname or "").lower()
except ValueError:
return False
return host == AIMLAPI_HOST


def aimlapi_headers(base_url: Optional[str]) -> Dict[str, str]:
"""打 AI/ML API 时返回归因请求头;其它地址返回空字典。"""
if not is_aimlapi_url(base_url):
return {}
headers = {
"HTTP-Referer": _REFERER,
"X-Title": _TITLE,
"X-AIMLAPI-Source": AIMLAPI_SOURCE,
}
if AIMLAPI_PARTNER_ID:
headers["X-AIMLAPI-Partner-ID"] = AIMLAPI_PARTNER_ID
return headers
14 changes: 13 additions & 1 deletion backend/core/cloud_presets.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
云端 OpenAI 兼容预设:DeepSeek / Seed / Kimi / GLM / Grok。
云端 OpenAI 兼容预设:AI/ML API / DeepSeek / Seed / Kimi / GLM / Grok。

底层仍是 openai + 官方 base_url,和 Ollama 预设同一套路,但必须带各家自己的 key,
不能复用用户的 OpenAI key。设置页、CLI、MCP 都用 `--provider deepseek` 这种名字。
Expand All @@ -22,6 +22,15 @@ class CloudPreset:


CLOUD_PRESETS: Dict[str, CloudPreset] = {
"aimlapi": CloudPreset(
key="aimlapi",
display_name="AI/ML API",
base_url="https://api.aimlapi.com/v1",
default_model="openai/gpt-6-luna",
api_key_setting="aimlapi_api_key",
docs_url="https://aimlapi.com/app/keys",
hint="AI/ML API 聚合网关。一个 key 打通 GPT / Claude / Gemini / Qwen / DeepSeek 等 300+ 对话模型。",
),
"deepseek": CloudPreset(
key="deepseek",
display_name="DeepSeek",
Expand Down Expand Up @@ -70,6 +79,9 @@ class CloudPreset:
}

_ALIASES = {
"aiml": "aimlapi",
"aiml-api": "aimlapi",
"aimlapi.com": "aimlapi",
"deepseek-ai": "deepseek",
"moonshot": "kimi",
"moonshotai": "kimi",
Expand Down
3 changes: 3 additions & 0 deletions backend/core/llm_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def _load_settings(self) -> Dict[str, Any]:
"dashscope_base_url": os.getenv("DASHSCOPE_BASE_URL", ""),
"gemini_api_key": "",
"siliconflow_api_key": "",
"aimlapi_api_key": "",
"deepseek_api_key": "",
"kimi_api_key": "",
"glm_api_key": "",
Expand All @@ -123,6 +124,7 @@ def _load_settings(self) -> Dict[str, Any]:
"openai_api_key": api_keys.get("openai", ""),
"gemini_api_key": api_keys.get("gemini", ""),
"siliconflow_api_key": api_keys.get("siliconflow", ""),
"aimlapi_api_key": api_keys.get("aimlapi", ""),
"deepseek_api_key": api_keys.get("deepseek", ""),
"kimi_api_key": api_keys.get("kimi", ""),
"glm_api_key": api_keys.get("glm", ""),
Expand Down Expand Up @@ -193,6 +195,7 @@ def _apply_cloud_preset(self, settings: Dict[str, Any]) -> None:
# Docker / 本地脚本模式没有设置页可用,只能靠环境变量(env.example 里也是这么写的),
# 但此前这里只读 settings.json,导致 API_DASHSCOPE_API_KEY 等变量形同虚设。
_ENV_KEY_FALLBACKS = {
"aimlapi_api_key": ("API_AIMLAPI_API_KEY", "AIMLAPI_API_KEY"),
"dashscope_api_key": ("API_DASHSCOPE_API_KEY", "DASHSCOPE_API_KEY"),
"openai_api_key": ("API_OPENAI_API_KEY", "OPENAI_API_KEY"),
"gemini_api_key": ("API_GEMINI_API_KEY", "GEMINI_API_KEY"),
Expand Down
15 changes: 14 additions & 1 deletion backend/core/llm_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from dataclasses import dataclass
from pathlib import Path

from backend.core.aimlapi_attribution import aimlapi_headers

logger = logging.getLogger(__name__)

class ProviderType(Enum):
Expand Down Expand Up @@ -171,6 +173,7 @@ def call(self, prompt: str, input_data: Any = None, **kwargs) -> LLMResponse:
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
headers.update(aimlapi_headers(self.base_url))
payload = {
"model": self.model_name,
"messages": [{"role": "user", "content": full_input}],
Expand Down Expand Up @@ -231,6 +234,12 @@ def get_available_models(self) -> List[ModelInfo]:
return _catalog_model_infos(ProviderType.DASHSCOPE)

OPENAI_OFFICIAL_BASE_URL = "https://api.openai.com/v1"
# 连通性测试的输出预算。以前是 1,推理型模型(gpt-6-luna、o 系列等)光思考就超了,
# 服务端直接 400 "Could not finish the message because max_tokens ... was reached",
# 于是 key 明明是好的,设置页却报连接失败。实测 2026-09-23:gpt-6-luna 给 1 个 token 必 400,
# 给 16 就能返回;claude-sonnet-5 / gemini-3.8-flash / deepseek-v4-pro 给 1 也能过。
# 留 256 有富余;探针只发两个字,用不到的额度不计费。
CONNECTION_PROBE_MAX_TOKENS = 256
# 本地/自建 OpenAI 兼容服务(Ollama、vLLM、LM Studio 等)通常不校验 key,但 SDK 要求非空
OPENAI_COMPATIBLE_PLACEHOLDER_KEY = "EMPTY"
# 通义千问 OpenAI 兼容接口:中国站 / 国际站(alibabacloud.com 开通的 key 只能打国际站,#45)
Expand Down Expand Up @@ -298,6 +307,10 @@ def __init__(self, api_key: str, model_name: str = "gpt-4o-mini", **kwargs):
http_client = make_openai_http_client(self.base_url)
if http_client is not None:
client_kwargs["http_client"] = http_client
# 归因请求头只在目标就是 AI/ML API 时附带,别的兼容接口一律不发
attribution = aimlapi_headers(self.base_url)
if attribution:
client_kwargs["default_headers"] = attribution
self.client = openai.OpenAI(**client_kwargs)
except ImportError:
raise ImportError("请安装openai: pip install openai")
Expand Down Expand Up @@ -343,7 +356,7 @@ def test_connection(self) -> bool:
logger.warning(f"OpenAI API Key格式可能不正确,期望以'sk-'开头,实际: {self.api_key[:10]}...")

# 使用最简单的测试
response = self.call("测试", max_tokens=1)
response = self.call("测试", max_tokens=CONNECTION_PROBE_MAX_TOKENS)
return response and response.content is not None
except Exception as e:
logger.error(f"OpenAI连接测试失败 (base_url={self.base_url or OPENAI_OFFICIAL_BASE_URL}): {e}")
Expand Down
36 changes: 36 additions & 0 deletions backend/core/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@
# 各家仍在维护的稳定别名 / 旗舰型号。实时列表失败时用这份,也作为下拉靠前的常用项。
# 日期以 2026-09 公开文档为准;具体可用性以账号权限为准。
CURATED_MODELS: Dict[str, List[str]] = {
# AI/ML API 是聚合网关:一个 key 覆盖下面各家,型号名带厂商前缀(openai/…、anthropic/…)。
"aimlapi": [
"openai/gpt-6-luna",
"openai/gpt-6.1-sol",
"anthropic/claude-sonnet-5.5",
"google/gemini-3.8-flash",
"deepseek/deepseek-v4-pro",
"moonshot/kimi-k3",
"zhipu/glm-5.2",
"alibaba/qwen3.7-max",
],
"dashscope": [
"qwen3.8-max",
"qwen3.8-flash",
Expand Down Expand Up @@ -77,6 +88,7 @@
}

DEFAULT_MODELS: Dict[str, str] = {
"aimlapi": "openai/gpt-6-luna",
"dashscope": "qwen-plus",
"openai": "gpt-5-mini",
"gemini": "gemini-3.8-flash",
Expand All @@ -88,6 +100,7 @@
}

PROVIDER_LABELS: Dict[str, str] = {
"aimlapi": "AI/ML API",
"dashscope": "通义千问",
"openai": "OpenAI",
"gemini": "Gemini",
Expand Down Expand Up @@ -143,6 +156,9 @@
_DATED_SNAPSHOT = re.compile(r"^(.+)-(?:\d{4}-\d{2}-\d{2}|\d{8})$")

SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
AIMLAPI_MODELS_BASE_URL = "https://api.aimlapi.com/v1"
# AI/ML API 的 /models 里对话、图像、视频、语音混在一起,id 上看不出来,只有 type 能区分
AIMLAPI_CHAT_MODEL_TYPE = "openai/chat-completions"
GEMINI_MODELS_URL = "https://generativelanguage.googleapis.com/v1beta/models"

_CACHE_TTL_SECONDS = 300.0
Expand Down Expand Up @@ -303,6 +319,24 @@ async def fetch_openai_compatible_models(base_url: str, api_key: str) -> List[st
return _extract_openai_model_ids(data)


async def fetch_aimlapi_models(api_key: str, base_url: str = "") -> List[str]:
"""AI/ML API:按 `type` 只留对话模型,否则 900+ 条里大半是图像 / 视频 / 语音。"""
from backend.core.llm_providers import normalize_base_url

url = normalize_base_url(base_url) or AIMLAPI_MODELS_BASE_URL
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
data = await _http_get_json(f"{url}/models", headers=headers)
items = data.get("data", data) if isinstance(data, dict) else data
names: List[str] = []
for item in items or []:
if not isinstance(item, dict) or item.get("type") != AIMLAPI_CHAT_MODEL_TYPE:
continue
name = item.get("id") or item.get("name")
if name:
names.append(str(name))
return names


async def fetch_gemini_models(api_key: str) -> List[str]:
if not api_key:
raise ValueError("缺少 Gemini API Key")
Expand Down Expand Up @@ -349,6 +383,8 @@ def _is_official_openai(base_url: str) -> bool:
async def fetch_live_models(provider: str, api_key: str = "", base_url: str = "") -> List[str]:
if provider == "gemini":
return await fetch_gemini_models(api_key)
if provider == "aimlapi":
return await fetch_aimlapi_models(api_key, base_url)
url = _official_base_url(provider, base_url)
return await fetch_openai_compatible_models(url, api_key)

Expand Down
81 changes: 79 additions & 2 deletions backend/tests/test_cloud_presets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""云端兼容预设:DeepSeek / Kimi / GLM / Grok 还原成 openai + 官方地址。"""
"""云端兼容预设:AI/ML API / DeepSeek / Kimi / GLM / Grok 还原成 openai + 官方地址。"""
import re
import sys
import types

Expand Down Expand Up @@ -49,6 +50,8 @@ def test_resolve_cloud_presets_use_official_endpoints():
assert resolve_cloud_preset("xai")[2].key == "grok"
assert resolve_cloud_preset("doubao")[1] == "https://ark.cn-beijing.volces.com/api/v3"
assert resolve_cloud_preset("ark")[2].default_model == "doubao-seed-2-1-lite-260915"
assert resolve_cloud_preset("aimlapi")[1] == "https://api.aimlapi.com/v1"
assert resolve_cloud_preset("aiml-api")[2].key == "aimlapi"
assert resolve_cloud_preset("dashscope") is None
assert resolve_cloud_preset("openai") is None

Expand Down Expand Up @@ -89,4 +92,78 @@ def test_manager_uses_seed_ark_key_not_openai(fake_openai, tmp_path):

def test_curated_lists_drop_retired_aliases():
assert "gpt-4o" not in CLOUD_PRESETS
assert set(CLOUD_PRESETS) == {"deepseek", "seed", "kimi", "glm", "grok"}
assert set(CLOUD_PRESETS) == {"aimlapi", "deepseek", "seed", "kimi", "glm", "grok"}


def test_manager_uses_aimlapi_key_not_openai(fake_openai, tmp_path):
settings = tmp_path / "settings.json"
settings.write_text(
'{"api": {"api_keys": {"openai": "sk-openai-should-not-be-used", "aimlapi": "aimlapi-official-key"}, '
'"api_provider": "aimlapi", "api_model": "openai/gpt-6-luna"}}',
encoding="utf-8",
)
info = LLMManager(settings_file=settings).get_current_provider_info()
assert info["provider"] == "aimlapi"
assert info["backend_provider"] == "openai"
assert info["base_url"] == "https://api.aimlapi.com/v1"
assert info["model"] == "openai/gpt-6-luna"
assert info["available"] is True
assert fake_openai.created[-1]["api_key"] == "aimlapi-official-key"
assert fake_openai.created[-1]["base_url"] == "https://api.aimlapi.com/v1"


def test_aimlapi_client_carries_attribution_headers(fake_openai, tmp_path):
"""归因头必须真的挂到 OpenAI 客户端上,否则网关那边这些调用不算 AutoClip 的。"""
from backend.core.aimlapi_attribution import AIMLAPI_PARTNER_ID

settings = tmp_path / "settings.json"
settings.write_text(
'{"api": {"api_keys": {"aimlapi": "aimlapi-official-key"}, "api_provider": "aimlapi"}}',
encoding="utf-8",
)
LLMManager(settings_file=settings)
headers = fake_openai.created[-1].get("default_headers") or {}
assert headers.get("X-AIMLAPI-Source") == "agent/autoclip"
assert headers.get("X-Title") == "AutoClip"
assert headers.get("HTTP-Referer") == "https://github.com/zhouxiaoka/autoclip"
# partner id 还没登记时不发空值;登记后必须是网关认的格式
if AIMLAPI_PARTNER_ID:
assert headers.get("X-AIMLAPI-Partner-ID") == AIMLAPI_PARTNER_ID
assert re.fullmatch(r"part_[A-Za-z0-9]{1,64}", AIMLAPI_PARTNER_ID)
else:
assert "X-AIMLAPI-Partner-ID" not in headers


def test_other_openai_compatible_endpoints_get_no_attribution(fake_openai, tmp_path):
"""OpenRouter / 本地服务不该收到我们的 partner id。"""
settings = tmp_path / "settings.json"
settings.write_text(
'{"api": {"api_keys": {"openai": "sk-openrouter"}, "api_provider": "openai", '
'"api_base_url": "https://openrouter.ai/api/v1", "api_model": "qwen/qwen3-235b-a22b"}}',
encoding="utf-8",
)
LLMManager(settings_file=settings)
created = fake_openai.created[-1]
assert created["base_url"] == "https://openrouter.ai/api/v1"
assert "default_headers" not in created


@pytest.mark.parametrize(
"url, expected",
[
("https://api.aimlapi.com/v1", True),
("api.aimlapi.com/v1", True),
("https://API.AIMLAPI.COM/v1", True),
("https://api.aimlapi.com.evil.tld/v1", False),
("https://notapi.aimlapi.com/v1", False),
("https://openrouter.ai/api/v1", False),
("http://localhost:11434/v1", False),
("", False),
(None, False),
],
)
def test_attribution_host_gate_is_exact(url, expected):
from backend.core.aimlapi_attribution import aimlapi_headers, is_aimlapi_url

assert is_aimlapi_url(url) is expected
assert bool(aimlapi_headers(url)) is expected
Loading