align llm provider registry with opencode endpoints

This commit is contained in:
jxxghp
2026-05-03 09:36:39 +08:00
parent cb6dcc6a2e
commit 10467244e0
3 changed files with 576 additions and 52 deletions

View File

@@ -768,12 +768,25 @@ class LLMHelper:
{"id": model_id, "name": model_id} {"id": model_id, "name": model_id}
for model_id in await self._get_google_models(api_key or "") for model_id in await self._get_google_models(api_key or "")
] ]
model_list_base_url = base_url
try:
from app.agent.llm.provider import LLMProviderManager
model_list_base_url = (
LLMProviderManager().resolve_model_list_base_url(
provider_id=provider,
base_url=base_url,
)
or base_url
)
except Exception:
model_list_base_url = base_url
return [ return [
{"id": model_id, "name": model_id} {"id": model_id, "name": model_id}
for model_id in await self._get_openai_compatible_models( for model_id in await self._get_openai_compatible_models(
provider, provider,
api_key or "", api_key or "",
base_url, model_list_base_url,
) )
] ]

View File

@@ -44,6 +44,16 @@ class ProviderAuthMethod:
description: str = "" description: str = ""
@dataclass(frozen=True)
class ProviderUrlPreset:
"""前端展示用的 Base URL 预设。"""
label: str
value: str
model_list_base_url: Optional[str] = None
models_dev_provider_id: Optional[str] = None
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderSpec: class ProviderSpec:
"""描述一个可接入的 LLM provider。""" """描述一个可接入的 LLM provider。"""
@@ -53,6 +63,7 @@ class ProviderSpec:
runtime: str runtime: str
models_dev_provider_id: Optional[str] = None models_dev_provider_id: Optional[str] = None
default_base_url: Optional[str] = None default_base_url: Optional[str] = None
base_url_presets: Tuple[ProviderUrlPreset, ...] = ()
base_url_editable: bool = False base_url_editable: bool = False
requires_base_url: bool = False requires_base_url: bool = False
supports_api_key: bool = True supports_api_key: bool = True
@@ -138,7 +149,158 @@ class LLMProviderManager(metaclass=Singleton):
label="设备码授权", label="设备码授权",
description="适合无回调环境,复制设备码到浏览器完成登录。", description="适合无回调环境,复制设备码到浏览器完成登录。",
) )
return ( url_preset = ProviderUrlPreset
def openai_provider(
provider_id: str,
name: str,
default_base_url: str,
sort_order: int,
*,
models_dev_provider_id: Optional[str] = None,
base_url_presets: Tuple[ProviderUrlPreset, ...] = (),
api_key_hint: Optional[str] = None,
description: Optional[str] = None,
model_list_strategy: str = "openai_compatible",
api_key_label: str = "API Key",
) -> ProviderSpec:
return ProviderSpec(
id=provider_id,
name=name,
runtime="openai_compatible",
models_dev_provider_id=models_dev_provider_id or provider_id,
default_base_url=default_base_url,
base_url_presets=base_url_presets,
api_key_label=api_key_label,
api_key_hint=api_key_hint or f"填写 {name} API Key。",
model_list_strategy=model_list_strategy,
description=description or f"{name} OpenAI-compatible 端点。",
sort_order=sort_order,
)
def catalog_openai_provider(
provider_id: str,
name: str,
default_base_url: str,
sort_order: int,
*,
models_dev_provider_id: Optional[str] = None,
base_url_presets: Tuple[ProviderUrlPreset, ...] = (),
api_key_hint: Optional[str] = None,
description: Optional[str] = None,
api_key_label: str = "API Key",
) -> ProviderSpec:
return openai_provider(
provider_id=provider_id,
name=name,
default_base_url=default_base_url,
sort_order=sort_order,
models_dev_provider_id=models_dev_provider_id,
base_url_presets=base_url_presets,
api_key_hint=api_key_hint,
description=description,
model_list_strategy="models_dev_only",
api_key_label=api_key_label,
)
def anthropic_provider(
provider_id: str,
name: str,
default_base_url: str,
sort_order: int,
*,
models_dev_provider_id: Optional[str] = None,
base_url_presets: Tuple[ProviderUrlPreset, ...] = (),
api_key_hint: Optional[str] = None,
description: Optional[str] = None,
) -> ProviderSpec:
return ProviderSpec(
id=provider_id,
name=name,
runtime="anthropic_compatible",
models_dev_provider_id=models_dev_provider_id or provider_id,
default_base_url=default_base_url,
base_url_presets=base_url_presets,
api_key_hint=api_key_hint or f"填写 {name} API Key。",
model_list_strategy="anthropic_compatible",
description=description or f"{name} Anthropic-compatible 端点。",
sort_order=sort_order,
)
catalog_openai_providers = (
("302ai", "302.AI", "https://api.302.ai/v1"),
("abacus", "Abacus", "https://routellm.abacus.ai/v1"),
("abliteration-ai", "abliteration.ai", "https://api.abliteration.ai/v1"),
("baseten", "Baseten", "https://inference.baseten.co/v1"),
("berget", "Berget.AI", "https://api.berget.ai/v1"),
("chutes", "Chutes", "https://llm.chutes.ai/v1"),
("clarifai", "Clarifai", "https://api.clarifai.com/v2/ext/openai/v1"),
("cloudferro-sherlock", "CloudFerro Sherlock", "https://api-sherlock.cloudferro.com/openai/v1/"),
("cloudflare-workers-ai", "Cloudflare Workers AI", "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1"),
("cortecs", "Cortecs", "https://api.cortecs.ai/v1"),
("digitalocean", "DigitalOcean", "https://inference.do-ai.run/v1"),
("dinference", "DInference", "https://api.dinference.com/v1"),
("drun", "D.Run (China)", "https://chat.d.run/v1"),
("evroc", "evroc", "https://models.think.evroc.com/v1"),
("fastrouter", "FastRouter", "https://go.fastrouter.ai/api/v1"),
("fireworks-ai", "Fireworks AI", "https://api.fireworks.ai/inference/v1/"),
("firmware", "Firmware", "https://app.frogbot.ai/api/v1"),
("friendli", "Friendli", "https://api.friendli.ai/serverless/v1"),
("helicone", "Helicone", "https://ai-gateway.helicone.ai/v1"),
("hpc-ai", "HPC-AI", "https://api.hpc-ai.com/inference/v1"),
("huggingface", "Hugging Face", "https://router.huggingface.co/v1"),
("iflowcn", "iFlow", "https://apis.iflow.cn/v1"),
("inception", "Inception", "https://api.inceptionlabs.ai/v1/"),
("inference", "Inference", "https://inference.net/v1"),
("io-net", "IO.NET", "https://api.intelligence.io.solutions/api/v1"),
("jiekou", "Jiekou.AI", "https://api.jiekou.ai/openai"),
("kilo", "Kilo Gateway", "https://api.kilo.ai/api/gateway"),
("kuae-cloud-coding-plan", "KUAE Cloud Coding Plan", "https://coding-plan-endpoint.kuaecloud.net/v1"),
("llama", "Llama", "https://api.llama.com/compat/v1/"),
("llmgateway", "LLM Gateway", "https://api.llmgateway.io/v1"),
("lucidquery", "LucidQuery AI", "https://lucidquery.com/api/v1"),
("meganova", "Meganova", "https://api.meganova.ai/v1"),
("mixlayer", "Mixlayer", "https://models.mixlayer.ai/v1"),
("moark", "Moark", "https://moark.com/v1"),
("modelscope", "ModelScope", "https://api-inference.modelscope.cn/v1"),
("morph", "Morph", "https://api.morphllm.com/v1"),
("nano-gpt", "NanoGPT", "https://nano-gpt.com/api/v1"),
("nebius", "Nebius Token Factory", "https://api.tokenfactory.nebius.com/v1"),
("neuralwatt", "Neuralwatt", "https://api.neuralwatt.com/v1"),
("nova", "Nova", "https://api.nova.amazon.com/v1"),
("novita-ai", "NovitaAI", "https://api.novita.ai/openai"),
("ovhcloud", "OVHcloud AI Endpoints", "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1"),
("perplexity-agent", "Perplexity Agent", "https://api.perplexity.ai/v1"),
("poe", "Poe", "https://api.poe.com/v1"),
("privatemode-ai", "Privatemode AI", "http://localhost:8080/v1"),
("qihang-ai", "QiHang", "https://api.qhaigc.net/v1"),
("qiniu-ai", "Qiniu", "https://api.qnaigc.com/v1"),
("regolo-ai", "Regolo AI", "https://api.regolo.ai/v1"),
("requesty", "Requesty", "https://router.requesty.ai/v1"),
("scaleway", "Scaleway", "https://api.scaleway.ai/v1"),
("stackit", "STACKIT", "https://api.openai-compat.model-serving.eu01.onstackit.cloud/v1"),
("stepfun", "StepFun", "https://api.stepfun.com/v1"),
("submodel", "submodel", "https://llm.submodel.ai/v1"),
("synthetic", "Synthetic", "https://api.synthetic.new/openai/v1"),
("the-grid-ai", "The Grid AI", "https://api.thegrid.ai/v1"),
("upstage", "Upstage", "https://api.upstage.ai/v1/solar"),
("vivgrid", "Vivgrid", "https://api.vivgrid.com/v1"),
("vultr", "Vultr", "https://api.vultrinference.com/v1"),
("wafer.ai", "Wafer", "https://pass.wafer.ai/v1"),
("wandb", "Weights & Biases", "https://api.inference.wandb.ai/v1"),
("zenmux", "ZenMux", "https://zenmux.ai/api/v1"),
)
catalog_openai_overrides = {
"cloudflare-workers-ai": {
"api_key_hint": "填写 Cloudflare API Token并将 Base URL 中的 ${CLOUDFLARE_ACCOUNT_ID} 替换为真实账户 ID。",
"description": "Cloudflare Workers AI OpenAI-compatible 端点,需要替换账户 ID。",
},
"privatemode-ai": {
"api_key_hint": "如未启用鉴权,可填写任意占位值。",
"description": "Privatemode AI 本地 OpenAI-compatible 端点。",
},
}
providers = [
ProviderSpec( ProviderSpec(
id="chatgpt", id="chatgpt",
name="ChatGPT", name="ChatGPT",
@@ -162,6 +324,14 @@ class LLMProviderManager(metaclass=Singleton):
description="Gemini / Google AI Studio。", description="Gemini / Google AI Studio。",
sort_order=20, sort_order=20,
), ),
anthropic_provider(
provider_id="anthropic",
name="Anthropic",
default_base_url="https://api.anthropic.com/v1",
sort_order=25,
api_key_hint="填写 Anthropic API Key。",
description="Anthropic Claude 官方端点。",
),
ProviderSpec( ProviderSpec(
id="deepseek", id="deepseek",
name="DeepSeek", name="DeepSeek",
@@ -172,6 +342,14 @@ class LLMProviderManager(metaclass=Singleton):
description="DeepSeek 官方平台。", description="DeepSeek 官方平台。",
sort_order=30, sort_order=30,
), ),
catalog_openai_provider(
provider_id="groq",
name="Groq",
default_base_url="https://api.groq.com/openai/v1",
sort_order=35,
api_key_hint="填写 Groq API Key。",
description="Groq 官方 OpenAI-compatible 端点。",
),
ProviderSpec( ProviderSpec(
id="openrouter", id="openrouter",
name="OpenRouter", name="OpenRouter",
@@ -182,6 +360,14 @@ class LLMProviderManager(metaclass=Singleton):
description="OpenRouter 聚合模型平台。", description="OpenRouter 聚合模型平台。",
sort_order=40, sort_order=40,
), ),
catalog_openai_provider(
provider_id="xai",
name="xAI",
default_base_url="https://api.x.ai/v1",
sort_order=45,
api_key_hint="填写 xAI API Key。",
description="xAI 官方 OpenAI-compatible 端点。",
),
ProviderSpec( ProviderSpec(
id="github-copilot", id="github-copilot",
name="GitHub Copilot", name="GitHub Copilot",
@@ -201,25 +387,140 @@ class LLMProviderManager(metaclass=Singleton):
description="通过 GitHub Copilot 订阅接入。", description="通过 GitHub Copilot 订阅接入。",
sort_order=50, sort_order=50,
), ),
ProviderSpec( catalog_openai_provider(
id="siliconflow", provider_id="github-models",
name="硅基流动", name="GitHub Models",
runtime="openai_compatible", default_base_url="https://models.github.ai/inference",
models_dev_provider_id="siliconflow", sort_order=55,
default_base_url="https://api.siliconflow.cn/v1", api_key_label="GitHub Token",
api_key_hint="填写硅基流动 API Key", api_key_hint="填写具有 GitHub Models 访问权限的 GitHub Token",
description="SiliconFlow 官方兼容端点。", description="GitHub Models 推理端点。",
sort_order=60,
), ),
ProviderSpec( openai_provider(
id="alibaba", provider_id="siliconflow",
name="硅基流动",
default_base_url="https://api.siliconflow.cn/v1",
sort_order=60,
models_dev_provider_id="siliconflow-cn",
base_url_presets=(
url_preset(
label="中国大陆",
value="https://api.siliconflow.cn/v1",
models_dev_provider_id="siliconflow-cn",
),
url_preset(
label="Global",
value="https://api.siliconflow.com/v1",
models_dev_provider_id="siliconflow",
),
),
api_key_hint="填写硅基流动 API Key可在中国大陆与 Global 端点间切换。",
description="SiliconFlow 官方兼容端点。",
),
catalog_openai_provider(
provider_id="moonshot",
name="Moonshot AI",
default_base_url="https://api.moonshot.cn/v1",
sort_order=62,
models_dev_provider_id="moonshotai-cn",
base_url_presets=(
url_preset(
label="中国站",
value="https://api.moonshot.cn/v1",
models_dev_provider_id="moonshotai-cn",
),
url_preset(
label="国际站",
value="https://api.moonshot.ai/v1",
models_dev_provider_id="moonshotai",
),
),
api_key_hint="填写 Moonshot / Kimi API Key可在中国站与国际站端点间切换。",
description="Moonshot / Kimi 官方兼容端点。",
),
anthropic_provider(
provider_id="kimi-coding",
name="Kimi for Coding",
default_base_url="https://api.kimi.com/coding/v1",
sort_order=63,
models_dev_provider_id="kimi-for-coding",
api_key_hint="填写 Moonshot / Kimi API Key。",
description="Moonshot Kimi Coding Anthropic-compatible 端点。",
),
openai_provider(
provider_id="zhipu",
name="智谱 GLM",
default_base_url="https://open.bigmodel.cn/api/paas/v4",
sort_order=65,
models_dev_provider_id="zhipuai",
base_url_presets=(
url_preset(
label="Token Plan / 通用 API",
value="https://open.bigmodel.cn/api/paas/v4",
models_dev_provider_id="zhipuai",
),
url_preset(
label="Coding Plan",
value="https://open.bigmodel.cn/api/coding/paas/v4",
model_list_base_url="https://open.bigmodel.cn/api/paas/v4",
models_dev_provider_id="zhipuai-coding-plan",
),
),
api_key_hint="填写智谱开放平台 API Key可在 Token Plan / 通用 API 与 Coding Plan 端点间切换。",
description="智谱开放平台国内站,支持通用 API 与 GLM Coding Plan 端点。",
),
catalog_openai_provider(
provider_id="zai",
name="Z.AI",
default_base_url="https://api.z.ai/api/paas/v4",
sort_order=66,
base_url_presets=(
url_preset(
label="Token Plan / 通用 API",
value="https://api.z.ai/api/paas/v4",
models_dev_provider_id="zai",
),
url_preset(
label="Coding Plan",
value="https://api.z.ai/api/coding/paas/v4",
models_dev_provider_id="zai-coding-plan",
),
),
api_key_hint="填写 Z.AI API Key可在通用 API 与 Coding Plan 端点间切换。",
description="Z.AI 官方端点。",
),
openai_provider(
provider_id="alibaba",
name="阿里云百炼", name="阿里云百炼",
runtime="openai_compatible",
models_dev_provider_id="alibaba",
default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
api_key_hint="填写 DashScope / Alibaba API Key。",
description="阿里云百炼兼容端点。",
sort_order=70, sort_order=70,
models_dev_provider_id="alibaba-cn",
base_url_presets=(
url_preset(
label="中国内地 / 通用",
value="https://dashscope.aliyuncs.com/compatible-mode/v1",
models_dev_provider_id="alibaba-cn",
),
url_preset(
label="国际站 / 通用",
value="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
models_dev_provider_id="alibaba",
),
url_preset(
label="中国内地 / Coding Plan",
value="https://coding.dashscope.aliyuncs.com/v1",
model_list_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
models_dev_provider_id="alibaba-coding-plan-cn",
),
url_preset(
label="国际站 / Coding Plan",
value="https://coding-intl.dashscope.aliyuncs.com/v1",
model_list_base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
models_dev_provider_id="alibaba-coding-plan",
),
),
api_key_hint="填写 DashScope / Alibaba API Key可在中国内地、国际站与 Coding Plan 端点间切换。",
description="阿里云百炼兼容端点。",
), ),
ProviderSpec( ProviderSpec(
id="volcengine", id="volcengine",
@@ -236,7 +537,19 @@ class LLMProviderManager(metaclass=Singleton):
runtime="openai_compatible", runtime="openai_compatible",
models_dev_provider_id="tencent-tokenhub", models_dev_provider_id="tencent-tokenhub",
default_base_url="https://tokenhub.tencentmaas.com/v1", default_base_url="https://tokenhub.tencentmaas.com/v1",
api_key_hint="填写 Tencent API Key。", base_url_presets=(
url_preset(
label="TokenHub",
value="https://tokenhub.tencentmaas.com/v1",
models_dev_provider_id="tencent-tokenhub",
),
url_preset(
label="Coding Plan",
value="https://api.lkeap.cloud.tencent.com/coding/v3",
models_dev_provider_id="tencent-coding-plan",
),
),
api_key_hint="填写 Tencent API Key可在 TokenHub 与 Coding Plan 端点间切换。",
model_list_strategy="models_dev_only", model_list_strategy="models_dev_only",
description="腾讯兼容端点。", description="腾讯兼容端点。",
sort_order=90, sort_order=90,
@@ -261,27 +574,125 @@ class LLMProviderManager(metaclass=Singleton):
description="Nvidia 集成推理平台。", description="Nvidia 集成推理平台。",
sort_order=110, sort_order=110,
), ),
ProviderSpec( catalog_openai_provider(
id="minimax", provider_id="opencode",
name="OpenCode",
default_base_url="https://opencode.ai/zen/v1",
sort_order=115,
base_url_presets=(
url_preset(
label="Zen",
value="https://opencode.ai/zen/v1",
models_dev_provider_id="opencode",
),
url_preset(
label="Go",
value="https://opencode.ai/zen/go/v1",
models_dev_provider_id="opencode-go",
),
),
api_key_hint="填写 OpenCode API Key可在 Zen 与 Go 端点间切换。",
description="OpenCode Zen / Go 端点。",
),
anthropic_provider(
provider_id="minimax",
name="MiniMax", name="MiniMax",
runtime="anthropic_compatible",
models_dev_provider_id="minimax",
default_base_url="https://api.minimaxi.com/anthropic/v1", default_base_url="https://api.minimaxi.com/anthropic/v1",
api_key_hint="填写 MiniMax API Key。",
model_list_strategy="anthropic_compatible",
description="MiniMax Anthropic-compatible 端点。",
sort_order=120, sort_order=120,
models_dev_provider_id="minimax-cn",
base_url_presets=(
url_preset(
label="中国内地 / 通用",
value="https://api.minimaxi.com/anthropic/v1",
models_dev_provider_id="minimax-cn",
),
url_preset(
label="国际站 / 通用",
value="https://api.minimax.io/anthropic/v1",
models_dev_provider_id="minimax",
),
),
api_key_hint="填写 MiniMax API Key可在中国内地与国际站通用端点间切换。",
description="MiniMax Anthropic-compatible 通用端点。",
), ),
ProviderSpec( anthropic_provider(
id="xiaomi", provider_id="minimax-coding",
name="MiniMax Coding Plan",
default_base_url="https://api.minimaxi.com/anthropic/v1",
sort_order=121,
models_dev_provider_id="minimax-cn-coding-plan",
base_url_presets=(
url_preset(
label="中国内地 / Coding Plan",
value="https://api.minimaxi.com/anthropic/v1",
models_dev_provider_id="minimax-cn-coding-plan",
),
url_preset(
label="国际站 / Coding Plan",
value="https://api.minimax.io/anthropic/v1",
models_dev_provider_id="minimax-coding-plan",
),
),
api_key_hint="填写 MiniMax API Key可在中国内地与国际站 Coding Plan 目录间切换。",
description="MiniMax Coding Plan Anthropic-compatible 端点。",
),
catalog_openai_provider(
provider_id="xiaomi",
name="Xiaomi", name="Xiaomi",
runtime="openai_compatible",
models_dev_provider_id="xiaomi",
default_base_url="https://api.xiaomimimo.com/v1", default_base_url="https://api.xiaomimimo.com/v1",
api_key_hint="填写 Xiaomi API Key。",
description="小米 Mimo 兼容端点。",
sort_order=130, sort_order=130,
base_url_presets=(
url_preset(
label="标准端点",
value="https://api.xiaomimimo.com/v1",
models_dev_provider_id="xiaomi",
),
url_preset(
label="Token Plan / 中国",
value="https://token-plan-cn.xiaomimimo.com/v1",
models_dev_provider_id="xiaomi-token-plan-cn",
),
url_preset(
label="Token Plan / 新加坡",
value="https://token-plan-sgp.xiaomimimo.com/v1",
models_dev_provider_id="xiaomi-token-plan-sgp",
),
url_preset(
label="Token Plan / 欧洲",
value="https://token-plan-ams.xiaomimimo.com/v1",
models_dev_provider_id="xiaomi-token-plan-ams",
),
),
api_key_hint="填写 Xiaomi API Key可在标准端点与各区域 Token Plan 端点间切换。",
description="小米 Mimo 兼容端点。",
), ),
catalog_openai_provider(
provider_id="lmstudio",
name="LM Studio",
default_base_url="http://127.0.0.1:1234/v1",
sort_order=135,
api_key_hint="如未启用鉴权,可填写任意占位值。",
description="LM Studio 本地 OpenAI-compatible 端点。",
),
]
for sort_order, (provider_id, name, base_url) in enumerate(
catalog_openai_providers,
start=200,
):
overrides = catalog_openai_overrides.get(provider_id, {})
providers.append(
catalog_openai_provider(
provider_id=provider_id,
name=name,
default_base_url=base_url,
sort_order=sort_order,
api_key_hint=overrides.get("api_key_hint"),
description=overrides.get("description"),
)
)
providers.append(
ProviderSpec( ProviderSpec(
id="openai", id="openai",
name="OpenAI Compatible", name="OpenAI Compatible",
@@ -292,9 +703,10 @@ class LLMProviderManager(metaclass=Singleton):
supports_api_key=True, supports_api_key=True,
api_key_hint="通用 OpenAI-compatible 兜底入口,需要手动填写 Base URL。", api_key_hint="通用 OpenAI-compatible 兜底入口,需要手动填写 Base URL。",
description="通用 OpenAI-compatible 模型服务。", description="通用 OpenAI-compatible 模型服务。",
sort_order=200, sort_order=1000,
), )
) )
return tuple(providers)
def list_providers(self) -> list[dict[str, Any]]: def list_providers(self) -> list[dict[str, Any]]:
"""返回前端可渲染的 provider 目录。""" """返回前端可渲染的 provider 目录。"""
@@ -305,7 +717,14 @@ class LLMProviderManager(metaclass=Singleton):
"id": spec.id, "id": spec.id,
"name": spec.name, "name": spec.name,
"runtime": spec.runtime, "runtime": spec.runtime,
"default_base_url": spec.default_base_url or "", "default_base_url": self._default_base_url_for_provider(spec) or "",
"base_url_presets": [
{
"label": preset.label,
"value": self._sanitize_base_url(preset.value) or "",
}
for preset in spec.base_url_presets
],
"base_url_editable": spec.base_url_editable, "base_url_editable": spec.base_url_editable,
"requires_base_url": spec.requires_base_url, "requires_base_url": spec.requires_base_url,
"supports_api_key": spec.supports_api_key, "supports_api_key": spec.supports_api_key,
@@ -344,6 +763,65 @@ class LLMProviderManager(metaclass=Singleton):
return None return None
return value.rstrip("/") return value.rstrip("/")
@classmethod
def _default_base_url_for_provider(cls, spec: ProviderSpec) -> Optional[str]:
default_base_url = cls._sanitize_base_url(spec.default_base_url)
if default_base_url:
return default_base_url
if not spec.base_url_presets:
return None
return cls._sanitize_base_url(spec.base_url_presets[0].value)
@classmethod
def _resolve_provider_model_list_base_url(
cls, spec: ProviderSpec, base_url: Optional[str]
) -> Optional[str]:
normalized_base_url = cls._sanitize_base_url(base_url)
if normalized_base_url:
for preset in spec.base_url_presets:
preset_value = cls._sanitize_base_url(preset.value)
if normalized_base_url != preset_value:
continue
return cls._sanitize_base_url(preset.model_list_base_url) or preset_value
return normalized_base_url
default_base_url = cls._default_base_url_for_provider(spec)
if default_base_url:
for preset in spec.base_url_presets:
preset_value = cls._sanitize_base_url(preset.value)
if preset_value != default_base_url:
continue
return cls._sanitize_base_url(preset.model_list_base_url) or preset_value
return default_base_url
@classmethod
def _resolve_provider_models_dev_provider_id(
cls, spec: ProviderSpec, base_url: Optional[str]
) -> Optional[str]:
normalized_base_url = cls._sanitize_base_url(base_url)
if normalized_base_url:
for preset in spec.base_url_presets:
preset_value = cls._sanitize_base_url(preset.value)
if normalized_base_url != preset_value:
continue
return preset.models_dev_provider_id or spec.models_dev_provider_id
return spec.models_dev_provider_id
default_base_url = cls._default_base_url_for_provider(spec)
if default_base_url:
for preset in spec.base_url_presets:
preset_value = cls._sanitize_base_url(preset.value)
if preset_value != default_base_url:
continue
return preset.models_dev_provider_id or spec.models_dev_provider_id
return spec.models_dev_provider_id
def resolve_model_list_base_url(
self, provider_id: str, base_url: Optional[str]
) -> Optional[str]:
spec = self.get_provider(provider_id)
return self._resolve_provider_model_list_base_url(spec, base_url)
@staticmethod @staticmethod
def _httpx_proxy_key() -> str: def _httpx_proxy_key() -> str:
"""兼容不同 httpx 版本的 proxy 参数名。""" """兼容不同 httpx 版本的 proxy 参数名。"""
@@ -492,16 +970,22 @@ class LLMProviderManager(metaclass=Singleton):
return cached return cached
raise LLMProviderError(f"获取 models.dev 数据失败: {err}") from err raise LLMProviderError(f"获取 models.dev 数据失败: {err}") from err
async def _models_dev_provider_payload(self, provider_id: str) -> dict[str, Any]: async def _models_dev_provider_payload(
self, provider_id: str, base_url: Optional[str] = None
) -> dict[str, Any]:
spec = self.get_provider(provider_id) spec = self.get_provider(provider_id)
if not spec.models_dev_provider_id: models_dev_provider_id = self._resolve_provider_models_dev_provider_id(
spec,
base_url,
)
if not models_dev_provider_id:
return {} return {}
return (await self.get_models_dev_data()).get(spec.models_dev_provider_id, {}) or {} return (await self.get_models_dev_data()).get(models_dev_provider_id, {}) or {}
async def _models_dev_model( async def _models_dev_model(
self, provider_id: str, model_id: str self, provider_id: str, model_id: str, base_url: Optional[str] = None
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
payload = await self._models_dev_provider_payload(provider_id) payload = await self._models_dev_provider_payload(provider_id, base_url=base_url)
models = payload.get("models") if isinstance(payload, dict) else None models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict): if not isinstance(models, dict):
return None return None
@@ -649,7 +1133,11 @@ class LLMProviderManager(metaclass=Singleton):
results = [] results = []
response = await client.models.list() response = await client.models.list()
for model in response.data: for model in response.data:
metadata = await self._models_dev_model(provider_id, model.id) or {} metadata = await self._models_dev_model(
provider_id,
model.id,
base_url=base_url,
) or {}
results.append( results.append(
self._normalize_model_record( self._normalize_model_record(
model_id=model.id, model_id=model.id,
@@ -664,13 +1152,14 @@ class LLMProviderManager(metaclass=Singleton):
self, self,
provider_id: str, provider_id: str,
transport: str = "openai", transport: str = "openai",
base_url: Optional[str] = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
某些 provider 没有统一稳定的 models.list 行为, 某些 provider 没有统一稳定的 models.list 行为,
因此优先读取 models.dev 目录;若未来 provider 暴露标准 models 接口, 因此优先读取 models.dev 目录;若未来 provider 暴露标准 models 接口,
再平滑补充实时刷新即可。 再平滑补充实时刷新即可。
""" """
payload = await self._models_dev_provider_payload(provider_id) payload = await self._models_dev_provider_payload(provider_id, base_url=base_url)
models = payload.get("models") if isinstance(payload, dict) else None models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict): if not isinstance(models, dict):
raise LLMProviderError(f"{provider_id} 暂无可用模型目录") raise LLMProviderError(f"{provider_id} 暂无可用模型目录")
@@ -825,10 +1314,11 @@ class LLMProviderManager(metaclass=Singleton):
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""返回标准化后的模型目录。""" """返回标准化后的模型目录。"""
spec = self.get_provider(provider_id) spec = self.get_provider(provider_id)
if force_refresh and spec.models_dev_provider_id: if self._resolve_provider_models_dev_provider_id(spec, base_url):
# 对依赖 models.dev 的 provider 主动刷新一次缓存,保证“刷新模型列表” # 对依赖 models.dev 的 provider 主动刷新一次缓存,保证“刷新模型列表”
# 在使用目录型 provider 时也能拿到最新参数。 # 在使用目录型 provider 时也能拿到最新参数。
await self.get_models_dev_data(force_refresh=True) if force_refresh:
await self.get_models_dev_data(force_refresh=True)
runtime = await self.resolve_runtime( runtime = await self.resolve_runtime(
provider_id, provider_id,
model=None, model=None,
@@ -848,7 +1338,10 @@ class LLMProviderManager(metaclass=Singleton):
return await self._list_models_from_openai_compatible( return await self._list_models_from_openai_compatible(
provider_id="chatgpt", provider_id="chatgpt",
api_key=runtime["api_key"], api_key=runtime["api_key"],
base_url=runtime["base_url"], base_url=self._resolve_provider_model_list_base_url(
spec,
runtime["base_url"],
),
default_headers=runtime.get("default_headers"), default_headers=runtime.get("default_headers"),
) )
@@ -856,28 +1349,40 @@ class LLMProviderManager(metaclass=Singleton):
return await self._list_models_from_models_dev_only( return await self._list_models_from_models_dev_only(
provider_id=provider_id, provider_id=provider_id,
transport="anthropic", transport="anthropic",
base_url=base_url,
) )
if spec.model_list_strategy == "models_dev_only": if spec.model_list_strategy == "models_dev_only":
return await self._list_models_from_models_dev_only( return await self._list_models_from_models_dev_only(
provider_id=provider_id, provider_id=provider_id,
transport="openai", transport="openai",
base_url=base_url,
) )
# openai-compatible / deepseek 默认走官方 models 端点。 # openai-compatible / deepseek 默认走官方 models 端点。
return await self._list_models_from_openai_compatible( return await self._list_models_from_openai_compatible(
provider_id=provider_id, provider_id=provider_id,
api_key=runtime["api_key"], api_key=runtime["api_key"],
base_url=runtime["base_url"], base_url=self._resolve_provider_model_list_base_url(
spec,
runtime["base_url"],
),
default_headers=runtime.get("default_headers"), default_headers=runtime.get("default_headers"),
) )
async def resolve_model_metadata( async def resolve_model_metadata(
self, provider_id: str, model_id: Optional[str] self,
provider_id: str,
model_id: Optional[str],
base_url: Optional[str] = None,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
if not model_id: if not model_id:
return None return None
metadata = await self._models_dev_model(provider_id, model_id) metadata = await self._models_dev_model(
provider_id,
model_id,
base_url=base_url,
)
if metadata: if metadata:
return metadata return metadata
if provider_id == "chatgpt": if provider_id == "chatgpt":
@@ -1366,7 +1871,11 @@ class LLMProviderManager(metaclass=Singleton):
"runtime": spec.runtime, "runtime": spec.runtime,
"model_id": model, "model_id": model,
"model_record": model_record, "model_record": model_record,
"model_metadata": await self.resolve_model_metadata(provider_id, model), "model_metadata": await self.resolve_model_metadata(
provider_id,
model,
base_url=base_url,
),
"default_headers": None, "default_headers": None,
"use_responses_api": None, "use_responses_api": None,
"auth_mode": "api_key", "auth_mode": "api_key",
@@ -1401,7 +1910,8 @@ class LLMProviderManager(metaclass=Singleton):
{ {
"runtime": "openai_compatible", "runtime": "openai_compatible",
"api_key": normalized_api_key, "api_key": normalized_api_key,
"base_url": normalized_base_url or spec.default_base_url, "base_url": normalized_base_url
or self._default_base_url_for_provider(spec),
"auth_mode": "api_key", "auth_mode": "api_key",
} }
) )
@@ -1448,7 +1958,9 @@ class LLMProviderManager(metaclass=Singleton):
return result return result
if spec.runtime == "anthropic_compatible": if spec.runtime == "anthropic_compatible":
effective_base_url = normalized_base_url or spec.default_base_url effective_base_url = normalized_base_url or self._default_base_url_for_provider(
spec
)
if not normalized_api_key: if not normalized_api_key:
raise LLMProviderAuthError(f"{spec.name} 需要填写 API Key") raise LLMProviderAuthError(f"{spec.name} 需要填写 API Key")
if not effective_base_url: if not effective_base_url:
@@ -1464,7 +1976,7 @@ class LLMProviderManager(metaclass=Singleton):
) )
return result return result
effective_base_url = normalized_base_url or spec.default_base_url effective_base_url = normalized_base_url or self._default_base_url_for_provider(spec)
if spec.requires_base_url and not effective_base_url: if spec.requires_base_url and not effective_base_url:
raise LLMProviderAuthError(f"{spec.name} 需要填写 Base URL") raise LLMProviderAuthError(f"{spec.name} 需要填写 Base URL")
if not normalized_api_key: if not normalized_api_key:

View File

@@ -12,7 +12,6 @@ from app.agent.llm import (
LLMTestTimeout, LLMTestTimeout,
render_auth_result_html, render_auth_result_html,
) )
from app.core.config import settings
from app.db.models import User from app.db.models import User
from app.db.user_oper import ( from app.db.user_oper import (
get_current_active_superuser_async, get_current_active_superuser_async,