Files
BiliNote/backend/app/services/model_fallback.py
huangjianwu bebf2e8c61 fix(backend): 修复 DeepSeek 模型列表为空 & whisper 下载不走代理 (#417)
问题1 — DeepSeek 模型下拉为空:
provider 的 /models 调用失败时,get_model_list 吞掉异常返回 [],
get_all_models_by_id 再对 [] 取 .data 触发 AttributeError 又被吞,
最终接口返回 {"code":0,"msg":"success","data":[]},把失败伪装成空成功,
用户看不到任何原因。
- 捕获并回传真实错误,不再二次吞
- 新增 model_fallback.normalize_models 兼容 SyncPage/list/dict,绝不再 .data 崩
- 内置供应商提供已知模型兜底清单(DeepSeek→deepseek-chat/deepseek-reasoner,
  Qwen→qwen-plus 等),动态拿不到时回退,保证下拉非空;动态可用时仍以动态为准

问题2 — whisper 模型下载不走代理(Docker 里代理没生效):
snapshot_download 既不读 UI 配的代理,HF_ENDPOINT 又固定 hf-mirror.com。
- ProxyConfigManager.apply_to_env() 把生效代理 export 到 HTTP(S)_PROXY/ALL_PROXY,
  huggingface_hub 即可复用;在每次下载前与启动时应用(覆盖转写按需下载)
- 网络类下载报错翻译成可操作提示(配代理 / 改 HF_ENDPOINT / 检查容器外网)
- .env.example 补充 HF_ENDPOINT 与代理覆盖说明

新增 tests/test_model_fallback.py、tests/test_proxy_apply_env.py(14 用例)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:54:44 +08:00

87 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内置供应商的回退模型清单 + 模型对象归一化issue #417
背景:设置页的「模型下拉」依赖 provider 的 `/v1/models` 动态列表。但这个接口
并不可靠——
- DeepSeek 的 `/models` 在部分账号/网络下取不到,下拉直接空白;
- 不少自建 OpenAI 兼容网关压根不实现 `/models`
- key 没有 inference 权限时也可能返回异常。
`OpenAI_compatible_provider.test_connection` 的注释里已经记录过这个不可靠性。)
所以对**内置供应商**额外维护一份已知可用清单兜底:动态拿不到时退回这份清单,
保证下拉永远有内容,用户不至于卡在空列表。清单数据写在
`app/db/builtin_providers.json` 的 `models` 字段里,单一数据源,方便维护。
本模块只依赖标准库,便于单测隔离加载(不触发 app 包的重依赖导入链)。
"""
import json
from pathlib import Path
from typing import Any, List, Optional
# builtin_providers.json 与本文件同属 backend/app 下app/services/ -> app/db/
_BUILTIN_JSON = Path(__file__).resolve().parent.parent / "db" / "builtin_providers.json"
def _load_builtin() -> List[dict]:
try:
return json.loads(_BUILTIN_JSON.read_text(encoding="utf-8"))
except Exception:
return []
def builtin_fallback_models(provider: Optional[dict]) -> List[str]:
"""按 provider 的 id 或 name忽略大小写匹配内置清单里的 models 字段。
自定义供应商DB 里 id 是 uuid通常 name 也对得上内置名,所以 id / name 都试。
匹配不到或没配 models 返回空列表。
"""
if not provider:
return []
keys = {str(provider.get("id", "")).strip().lower(), str(provider.get("name", "")).strip().lower()}
keys.discard("")
if not keys:
return []
for p in _load_builtin():
candidate = {str(p.get("id", "")).strip().lower(), str(p.get("name", "")).strip().lower()}
if keys & candidate:
models = p.get("models") or []
return [str(m) for m in models if m]
return []
def normalize_models(raw: Any) -> List[dict]:
"""把 SDK 返回值统一成 [{'id', 'object', 'owned_by', ...}] 列表。
兼容三种形态:
- openai SDK 的 SyncPage取 .data
- 普通 list含旧代码失败时返回的 [],绝不能再 .data
- list 里既可能是 pydantic Model 也可能是 dict
"""
if raw is None:
return []
data = getattr(raw, "data", raw) # SyncPage -> .datalist/tuple 原样
if not isinstance(data, (list, tuple)):
return []
out: List[dict] = []
for m in data:
if isinstance(m, dict):
d = m
elif hasattr(m, "model_dump"):
d = m.model_dump()
elif hasattr(m, "dict"):
d = m.dict()
else:
d = {"id": getattr(m, "id", None)}
if d.get("id"):
out.append(d)
return out
def as_model_dicts(model_ids: List[str], owned_by: str = "") -> List[dict]:
"""把模型名列表包成与 SDK Model 一致的 dict前端下拉直接复用同一套渲染。"""
return [
{"id": mid, "object": "model", "created": None, "owned_by": owned_by}
for mid in model_ids
]