From ba44dccb11dc9471cc8b33f7b533bf53673a9afb Mon Sep 17 00:00:00 2001 From: Stan Date: Wed, 23 Sep 2026 13:38:06 +0500 Subject: [PATCH 1/3] feat: add AI/ML API as a cloud provider preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI/ML API is an OpenAI-compatible aggregation gateway: one key reaches 300+ chat models (GPT, Claude, Gemini, Qwen, DeepSeek, Kimi, GLM, Grok), which is the same shape as the existing deepseek / seed / kimi / glm / grok presets — so it lands as one more CloudPreset rather than a new provider class. What this adds: - `aimlapi` cloud preset (base_url + default model + its own api key setting), wired through the settings page, the first-run wizard, the CLI (`--provider aimlapi`) and `autoclip providers`. - Attribution headers (`X-AIMLAPI-Source`, `HTTP-Referer`, `X-Title`) on requests to the gateway, gated on the exact hostname `api.aimlapi.com` so OpenRouter / Ollama / vLLM never receive them. - A model catalogue that filters the live `/models` list by `type`: the gateway returns 958 entries of which 367 are chat models, and the id alone does not say which is which. Also fixes a connectivity-test bug this surfaced: the probe asked for `max_tokens=1`, which reasoning models cannot finish, so the settings page reported "connection failed" for a perfectly good key. Measured 2026-09-23: openai/gpt-6-luna 400s at 1 token and answers at 16; claude-sonnet-5, gemini-3.8-flash and deepseek-v4-pro answer at 1. The probe now asks for 256. Verified against the live gateway: the request lands on api.aimlapi.com/v1/chat/completions carrying the attribution headers, the completion comes back, and the model dropdown resolves to 342 chat models with the curated ones first. Backend suite: 282 passed. --- backend/api/v1/settings.py | 5 +- backend/cli.py | 1 + backend/core/aimlapi_attribution.py | 54 +++++++++++++++ backend/core/cloud_presets.py | 14 +++- backend/core/llm_manager.py | 3 + backend/core/llm_providers.py | 15 +++- backend/core/model_catalog.py | 36 ++++++++++ backend/tests/test_cloud_presets.py | 81 +++++++++++++++++++++- backend/tests/test_llm_provider_config.py | 25 +++++++ docs/CLI_AND_MCP.md | 1 + docs/MULTI_LLM_PROVIDER_GUIDE.md | 1 + env.example | 5 +- frontend/src/components/FirstRunWizard.tsx | 15 +++- frontend/src/hooks/useFirstRun.ts | 3 +- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/es.json | 1 + frontend/src/i18n/locales/fr.json | 1 + frontend/src/i18n/locales/ja.json | 1 + frontend/src/i18n/locales/ko.json | 1 + frontend/src/i18n/locales/pt.json | 1 + frontend/src/i18n/locales/ru.json | 1 + frontend/src/i18n/locales/zh.json | 1 + frontend/src/pages/SettingsPage.tsx | 12 ++-- frontend/src/utils/apiConfigCheck.tsx | 2 + skills/autoclip/SKILL.md | 1 + 25 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 backend/core/aimlapi_attribution.py diff --git a/backend/api/v1/settings.py b/backend/api/v1/settings.py index 045ccc4ff..b5ba428c0 100644 --- a/backend/api/v1/settings.py +++ b/backend/api/v1/settings.py @@ -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密钥") @@ -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数") @@ -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, @@ -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, diff --git a/backend/cli.py b/backend/cli.py index 411b589e2..365ae2c98 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -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"), diff --git a/backend/core/aimlapi_attribution.py b/backend/core/aimlapi_attribution.py new file mode 100644 index 000000000..b404d90c0 --- /dev/null +++ b/backend/core/aimlapi_attribution.py @@ -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/`,与 AI/ML API 侧登记的一致。 +AIMLAPI_SOURCE = "agent/autoclip" +# 合作伙伴 ID 由 AI/ML API 侧随机生成后登记。留空表示「还没有」—— +# 留空时不发这个头,而不是发一个不存在的值(未知 id 会被静默忽略)。 +AIMLAPI_PARTNER_ID = "" + +_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 diff --git a/backend/core/cloud_presets.py b/backend/core/cloud_presets.py index 58e169697..4c15957ed 100644 --- a/backend/core/cloud_presets.py +++ b/backend/core/cloud_presets.py @@ -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` 这种名字。 @@ -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", @@ -70,6 +79,9 @@ class CloudPreset: } _ALIASES = { + "aiml": "aimlapi", + "aiml-api": "aimlapi", + "aimlapi.com": "aimlapi", "deepseek-ai": "deepseek", "moonshot": "kimi", "moonshotai": "kimi", diff --git a/backend/core/llm_manager.py b/backend/core/llm_manager.py index 2349dc0cd..91b1fe4d4 100644 --- a/backend/core/llm_manager.py +++ b/backend/core/llm_manager.py @@ -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": "", @@ -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", ""), @@ -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"), diff --git a/backend/core/llm_providers.py b/backend/core/llm_providers.py index 7df2c9bc3..6ed70a170 100644 --- a/backend/core/llm_providers.py +++ b/backend/core/llm_providers.py @@ -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): @@ -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}], @@ -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) @@ -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") @@ -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}") diff --git a/backend/core/model_catalog.py b/backend/core/model_catalog.py index 0a58138d8..2a5d74597 100644 --- a/backend/core/model_catalog.py +++ b/backend/core/model_catalog.py @@ -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-sol", + "anthropic/claude-sonnet-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", @@ -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", @@ -88,6 +100,7 @@ } PROVIDER_LABELS: Dict[str, str] = { + "aimlapi": "AI/ML API", "dashscope": "通义千问", "openai": "OpenAI", "gemini": "Gemini", @@ -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 @@ -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") @@ -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) diff --git a/backend/tests/test_cloud_presets.py b/backend/tests/test_cloud_presets.py index 5292bed29..708ec1872 100644 --- a/backend/tests/test_cloud_presets.py +++ b/backend/tests/test_cloud_presets.py @@ -1,4 +1,5 @@ -"""云端兼容预设:DeepSeek / Kimi / GLM / Grok 还原成 openai + 官方地址。""" +"""云端兼容预设:AI/ML API / DeepSeek / Kimi / GLM / Grok 还原成 openai + 官方地址。""" +import re import sys import types @@ -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 @@ -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 diff --git a/backend/tests/test_llm_provider_config.py b/backend/tests/test_llm_provider_config.py index ea6be7975..cbb36c411 100644 --- a/backend/tests/test_llm_provider_config.py +++ b/backend/tests/test_llm_provider_config.py @@ -224,3 +224,28 @@ def test_settings_file_provider_wins_over_env(manager_env, monkeypatch): manager = LLMManager(settings_file=manager_env) assert manager.settings["llm_provider"] == "dashscope" assert manager.settings["model_name"] == "qwen-max" + + +def test_connection_probe_leaves_room_for_reasoning_models(fake_openai): + """探针以前只给 1 个 token,推理型模型(gpt-6-luna 等)必定 400,key 好的也报连接失败。""" + import types as _types + + from backend.core.llm_providers import CONNECTION_PROBE_MAX_TOKENS, OpenAIProvider + + captured = {} + + class _Completions: + def create(self, **params): + captured.update(params) + message = _types.SimpleNamespace(content="ok") + choice = _types.SimpleNamespace(message=message, finish_reason="stop") + return _types.SimpleNamespace(choices=[choice], usage=None) + + provider = OpenAIProvider(api_key="aimlapi-key", model_name="openai/gpt-6-luna", + base_url="https://api.aimlapi.com/v1") + provider.client.chat = _types.SimpleNamespace(completions=_Completions()) + + assert provider.test_connection() is True + # 实测 2026-09-23:gpt-6-luna 给 1 个 token 必 400,16 才够;这里守住「不是 1」这条线 + assert captured["max_tokens"] == CONNECTION_PROBE_MAX_TOKENS + assert CONNECTION_PROBE_MAX_TOKENS >= 16 diff --git a/docs/CLI_AND_MCP.md b/docs/CLI_AND_MCP.md index 4bc206ada..f2bb838e5 100644 --- a/docs/CLI_AND_MCP.md +++ b/docs/CLI_AND_MCP.md @@ -45,6 +45,7 @@ autoclip doctor autoclip run talk.mp4 # 用桌面应用设置页里配好的模型 autoclip run talk.mp4 --provider ollama # 本地 Ollama(默认 qwen2.5:7b,无需 key) autoclip run talk.mp4 --provider lmstudio --model qwen2.5-7b-instruct +autoclip run talk.mp4 --provider aimlapi --api-key # 一个 key 300+ 模型;--model 可换成 anthropic/claude-sonnet-5 等 autoclip run talk.mp4 --provider openai --base-url https://api.deepseek.com/v1 --model deepseek-chat --api-key sk-... autoclip run talk.mp4 --srt talk.srt --category knowledge --min-score 0.6 autoclip run talk.mp4 --json # 给脚本 / agent:stdout 只有一个 JSON diff --git a/docs/MULTI_LLM_PROVIDER_GUIDE.md b/docs/MULTI_LLM_PROVIDER_GUIDE.md index c4373974c..d13b59519 100644 --- a/docs/MULTI_LLM_PROVIDER_GUIDE.md +++ b/docs/MULTI_LLM_PROVIDER_GUIDE.md @@ -12,6 +12,7 @@ | 提供商 | 显示名称 | 主要模型 | 特点 | |--------|----------|----------|------| +| `aimlapi` | AI/ML API | openai/gpt-6-luna, anthropic/claude-sonnet-5, google/gemini-3.8-flash | 聚合网关:一个 key 打通 300+ 对话模型,按 `厂商/型号` 命名 | | `dashscope` | 阿里通义千问 | qwen-plus, qwen-max, qwen-turbo | 国内访问稳定,中文理解好 | | `openai` | OpenAI | gpt-3.5-turbo, gpt-4, gpt-4-turbo | 支持兼容接口与自定义 Base URL | | `gemini` | Google Gemini | gemini-2.5-flash, gemini-1.5-pro | 多模态支持,上下文长 | diff --git a/env.example b/env.example index 0159e739b..7dd480091 100644 --- a/env.example +++ b/env.example @@ -12,10 +12,13 @@ SENTRY_DSN= # API配置 # 桌面版在设置页配置即可;Docker / 脚本模式没有设置页时用下面的环境变量。 -# LLM_PROVIDER: dashscope | openai | gemini | deepseek | seed | kimi | glm | grok | ollama | lmstudio +# LLM_PROVIDER: aimlapi | dashscope | openai | gemini | deepseek | seed | kimi | glm | grok | ollama | lmstudio # (settings.json 里已保存的提供商优先) LLM_PROVIDER=dashscope API_DASHSCOPE_API_KEY=your_dashscope_api_key_here +# AI/ML API:一个 key 覆盖 GPT / Claude / Gemini / Qwen / DeepSeek 等 300+ 对话模型 +# (LLM_PROVIDER=aimlapi,地址和默认模型由预设给出,不用填 OPENAI_BASE_URL) +# API_AIMLAPI_API_KEY= # API_OPENAI_API_KEY= # API_GEMINI_API_KEY= # API_DEEPSEEK_API_KEY= diff --git a/frontend/src/components/FirstRunWizard.tsx b/frontend/src/components/FirstRunWizard.tsx index f764f8a16..859eadd51 100644 --- a/frontend/src/components/FirstRunWizard.tsx +++ b/frontend/src/components/FirstRunWizard.tsx @@ -190,6 +190,7 @@ const FirstRunWizard: React.FC = ({ onComplete }) => { api: { api_keys: { // 只更新当前提供商的API key,保持其他提供商的值 + aimlapi: config.llmProvider === 'aimlapi' ? config.llmApiKey : (existingApiKeys.aimlapi || ''), dashscope: config.llmProvider === 'dashscope' ? config.llmApiKey : (existingApiKeys.dashscope || ''), openai: config.llmProvider === 'openai' ? config.llmApiKey : (existingApiKeys.openai || ''), gemini: config.llmProvider === 'gemini' ? config.llmApiKey : (existingApiKeys.gemini || ''), @@ -203,7 +204,8 @@ const FirstRunWizard: React.FC = ({ onComplete }) => { jimeng_secret: existingApiKeys.jimeng_secret || '' }, api_provider: config.llmProvider, - api_model: config.llmProvider === 'dashscope' ? 'qwen-plus' : + api_model: config.llmProvider === 'aimlapi' ? 'openai/gpt-6-luna' : + config.llmProvider === 'dashscope' ? 'qwen-plus' : config.llmProvider === 'openai' ? 'gpt-5-mini' : config.llmProvider === 'gemini' ? 'gemini-3.8-flash' : config.llmProvider === 'deepseek' ? 'deepseek-flash' : @@ -370,6 +372,11 @@ const FirstRunWizard: React.FC = ({ onComplete }) => { // 获取API Key获取方式的智能提示 const getApiKeyHelp = (provider: string) => { const helpMap: Record = { + aimlapi: { + name: 'AI/ML API', + url: 'https://aimlapi.com/app/keys', + description: '注册 AI/ML API 账号,在 Keys 页面创建密钥;一个 key 覆盖 GPT / Claude / Gemini / Qwen 等模型' + }, dashscope: { name: '阿里云通义千问', url: 'https://dashscope.aliyun.com', @@ -462,6 +469,12 @@ const FirstRunWizard: React.FC = ({ onComplete }) => { style={{ marginBottom: '12px' }} >