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>
This commit is contained in:
huangjianwu
2026-06-29 13:54:44 +08:00
parent 6a43043874
commit bebf2e8c61
9 changed files with 373 additions and 18 deletions

View File

@@ -13,7 +13,8 @@
"type": "built-in",
"logo": "DeepSeek",
"api_key": "",
"base_url": "https://api.deepseek.com"
"base_url": "https://api.deepseek.com",
"models": ["deepseek-chat", "deepseek-reasoner"]
},
{
"id": "qwen",
@@ -21,7 +22,8 @@
"type": "built-in",
"logo": "Qwen",
"api_key": "",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-plus", "qwen-turbo", "qwen-max", "qwen-long"]
},
{
"id": "Claude",

View File

@@ -240,6 +240,38 @@ class ModelDownloadRequest(BaseModel):
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"
def _friendly_download_error(e: Exception) -> str:
"""把 HuggingFace 的网络类报错翻译成用户能照着做的提示issue #417
典型原文:'An error happened while trying to locate the file on the Hub and we
cannot find the requested files in the local cache...' —— 本质是连不上 Hub。
用户大概率不知道:默认走 hf-mirror.com 镜像,可配代理或改 HF_ENDPOINT。
"""
raw = str(e)
lowered = raw.lower()
network_markers = (
"locate the file on the hub",
"couldn't connect",
"connection error",
"connecttimeout",
"read timed out",
"max retries exceeded",
"failed to establish",
"name or service not known",
"temporary failure in name resolution",
)
if any(m in lowered for m in network_markers):
endpoint = os.getenv("HF_ENDPOINT", "https://huggingface.co")
return (
f"{raw}\n"
f"——连不上模型仓库(当前 HF_ENDPOINT={endpoint})。可尝试:"
f"1) 在「设置」里配置可用代理;"
f"2) 设置环境变量 HF_ENDPOINT 切换镜像(国内可用 https://hf-mirror.com"
f"3) 确认容器能访问外网/镜像站后重试。"
)
return raw
def _do_download_whisper(model_size: str):
"""后台下载 faster-whisper 模型(支持内置 size / 自定义 repo_id / 本地路径)。
@@ -250,9 +282,14 @@ def _do_download_whisper(model_size: str):
"""
from huggingface_hub import snapshot_download
from app.transcriber.whisper_models import resolve_whisper_model, is_local_target
from app.services.proxy_config_manager import ProxyConfigManager
try:
dl_state.mark_downloading(model_size)
# 让 UI 配的代理对 HuggingFace 下载也生效issue #417容器里代理没生效
proxy = ProxyConfigManager().apply_to_env()
if proxy:
logger.info(f"whisper 下载走代理: {proxy}")
model_dir = get_model_dir("whisper")
# 已经下好就不重复下
@@ -289,8 +326,9 @@ def _do_download_whisper(model_size: str):
logger.info(f"whisper 模型下载完成: {model_size}")
dl_state.mark_done(model_size)
except Exception as e:
msg = _friendly_download_error(e)
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
dl_state.mark_failed(model_size, str(e))
dl_state.mark_failed(model_size, msg)
def _do_download_mlx_whisper(model_size: str):
@@ -300,6 +338,12 @@ def _do_download_mlx_whisper(model_size: str):
dl_state.mark_downloading(key)
from huggingface_hub import snapshot_download as hf_download
from app.transcriber.mlx_whisper_transcriber import resolve_mlx_repo_id
from app.services.proxy_config_manager import ProxyConfigManager
# 让 UI 配的代理对 HuggingFace 下载也生效issue #417
proxy = ProxyConfigManager().apply_to_env()
if proxy:
logger.info(f"mlx-whisper 下载走代理: {proxy}")
try:
repo_id = resolve_mlx_repo_id(model_size)
@@ -319,8 +363,9 @@ def _do_download_mlx_whisper(model_size: str):
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
dl_state.mark_done(key)
except Exception as e:
msg = _friendly_download_error(e)
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
dl_state.mark_failed(key, str(e))
dl_state.mark_failed(key, msg)
@router.post("/transcriber_download")

View File

@@ -83,22 +83,56 @@ class ModelService:
return enabled_models
@staticmethod
def get_all_models_by_id(provider_id: str, verbose: bool = False):
"""拉取某供应商的可选模型列表,用于设置页下拉。
历史坑issue #417旧实现对 get_model_list 的返回值直接取 `.data`,但
get_model_list 在 /models 调用失败时会吞掉异常返回 `[]`,于是 `[].data`
触发 AttributeError又被这里的 except 吞成 `[]` —— 最终接口返回
`{"code":0,"msg":"success","data":[]}`把「DeepSeek /models 取不到」伪装成
成功的空列表,用户完全看不到原因。
现在:
1. 直接捕获 /models 的真实异常(不再二次吞);
2. normalize_models 兼容 SyncPage / list / dict绝不再 `.data` 崩;
3. 动态拿不到(失败或空)时退回内置已知清单,保证下拉非空;
4. 仍然为空且确有报错时,把报错带回去(前端可提示,不再假装成功)。
"""
from app.services.model_fallback import (
builtin_fallback_models,
normalize_models,
as_model_dicts,
)
provider = ProviderService.get_provider_by_id(provider_id)
if not provider:
logger.warning(f"[{provider_id}] 供应商不存在")
return {"models": []}
models: list = []
error: str | None = None
try:
provider = ProviderService.get_provider_by_id(provider_id)
models = ModelService.get_model_list(provider["id"], verbose=verbose)
print(type(models))
serializable_models = [m.dict() for m in models.data]
model_list = {
"models": serializable_models
}
logger.info(f"[{provider['name']}] 获取模型成功")
return model_list
config = ModelService._build_model_config(provider)
gpt = GPTFactory().from_config(config)
models = normalize_models(gpt.list_models())
if verbose:
print(f"[{provider['name']}] 动态模型列表: {models}")
except Exception as e:
# print(f"[{provider_id}] 获取模型失败: {e}")
logger.error(f"[{provider_id}] 获取模型失败: {e}")
return []
error = str(e)
logger.warning(f"[{provider['name']}] 动态获取模型失败,尝试回退内置清单: {e}")
if not models:
fallback = builtin_fallback_models(provider)
if fallback:
logger.info(f"[{provider['name']}] /models 为空,回退内置清单: {fallback}")
models = as_model_dicts(fallback, owned_by=provider.get("name", ""))
result = {"models": models}
if not models and error:
# 既没动态结果也没兜底清单:把真实报错带回去,别再伪装成功
result["error"] = error
else:
logger.info(f"[{provider['name']}] 获取模型成功,共 {len(models)}")
return result
@staticmethod
def connect_test(id: str, model: str | None = None) -> bool:
"""连通性测试:发一条最小化 chat completion。

View File

@@ -0,0 +1,86 @@
"""内置供应商的回退模型清单 + 模型对象归一化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
]

View File

@@ -58,3 +58,22 @@ class ProxyConfigManager:
if val:
return val
return None
def apply_to_env(self) -> Optional[str]:
"""把当前生效的代理 URL 写进进程环境变量,返回生效的 url无则 None
为什么需要issue #417huggingface_hub / requests 这类库**只认**环境变量
HTTP_PROXY / HTTPS_PROXY / ALL_PROXY不读我们 UI 配置文件。whisper 模型用
snapshot_download 从 HuggingFace 拉取,如果用户只在设置页填了代理,下载根本
不走代理 —— 就是用户说的「Docker 容器里代理没生效」。在下载前/启动时调用本
方法,把 UI 配的代理 export 到环境变量HF 下载就能复用同一个代理。
大小写别名都写,覆盖不同库的读取习惯。
"""
url = self.get_proxy_url()
if not url:
return None
for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy"):
os.environ[key] = url
return url

View File

@@ -60,6 +60,13 @@ async def lifespan(app: FastAPI):
logger.info("[startup 4/5] seed_default_providers() — 初始化默认 LLM 供应商")
seed_default_providers()
# 把已配置的代理 export 到环境变量,让 huggingface_hubwhisper 模型下载)
# 也能走代理——含转写时的按需下载issue #417
from app.services.proxy_config_manager import ProxyConfigManager
_proxy = ProxyConfigManager().apply_to_env()
if _proxy:
logger.info(f" 已应用全局代理到环境变量: {_proxy}")
logger.info("[startup 5/5] 启动完成,等待请求")
except Exception:
logger.exception("[startup FAILED] 后端启动期异常,详见堆栈;容器会退出并由 restart 策略决定是否重试")

View File

@@ -0,0 +1,89 @@
import importlib.util
import pathlib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "app" / "services" / "model_fallback.py"
spec = importlib.util.spec_from_file_location("model_fallback", MODULE_PATH)
if spec is None or spec.loader is None:
raise ImportError("model_fallback module spec not found")
model_fallback = importlib.util.module_from_spec(spec)
spec.loader.exec_module(model_fallback)
builtin_fallback_models = model_fallback.builtin_fallback_models
normalize_models = model_fallback.normalize_models
as_model_dicts = model_fallback.as_model_dicts
class FakeModel:
"""模拟 openai SDK 的 Modelpydantic对象。"""
def __init__(self, mid, created=None, owned_by="x"):
self.id = mid
self.created = created
self.object = "model"
self.owned_by = owned_by
def dict(self):
return {"id": self.id, "created": self.created, "object": self.object, "owned_by": self.owned_by}
class FakeSyncPage:
def __init__(self, data):
self.data = data
class TestBuiltinFallbackModels(unittest.TestCase):
def test_deepseek_by_id_has_fallback(self):
# issue #417DeepSeek /models 不稳定,必须有兜底清单
models = builtin_fallback_models({"id": "deepseek", "name": "DeepSeek"})
self.assertIn("deepseek-chat", models)
self.assertIn("deepseek-reasoner", models)
def test_match_by_name_case_insensitive(self):
models = builtin_fallback_models({"id": "whatever-uuid", "name": "deepseek"})
self.assertIn("deepseek-chat", models)
def test_unknown_provider_returns_empty(self):
self.assertEqual(builtin_fallback_models({"id": "nope", "name": "nope"}), [])
def test_none_provider_returns_empty(self):
self.assertEqual(builtin_fallback_models(None), [])
class TestNormalizeModels(unittest.TestCase):
def test_syncpage_with_models(self):
page = FakeSyncPage([FakeModel("deepseek-chat"), FakeModel("deepseek-reasoner")])
out = normalize_models(page)
self.assertEqual([m["id"] for m in out], ["deepseek-chat", "deepseek-reasoner"])
def test_plain_list_of_models(self):
out = normalize_models([FakeModel("a"), FakeModel("b")])
self.assertEqual([m["id"] for m in out], ["a", "b"])
def test_empty_list_does_not_raise(self):
# 关键回归:旧代码对 [] 取 .data 会 AttributeError
self.assertEqual(normalize_models([]), [])
def test_list_of_dicts(self):
out = normalize_models([{"id": "x", "object": "model"}])
self.assertEqual(out[0]["id"], "x")
def test_drops_entries_without_id(self):
out = normalize_models([{"object": "model"}, {"id": "ok"}])
self.assertEqual([m["id"] for m in out], ["ok"])
def test_none_returns_empty(self):
self.assertEqual(normalize_models(None), [])
class TestAsModelDicts(unittest.TestCase):
def test_shape_matches_sdk(self):
out = as_model_dicts(["deepseek-chat"], owned_by="DeepSeek")
self.assertEqual(out[0]["id"], "deepseek-chat")
self.assertEqual(out[0]["object"], "model")
self.assertEqual(out[0]["owned_by"], "DeepSeek")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
import importlib.util
import os
import pathlib
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
MODULE_PATH = ROOT / "app" / "services" / "proxy_config_manager.py"
spec = importlib.util.spec_from_file_location("proxy_config_manager", MODULE_PATH)
if spec is None or spec.loader is None:
raise ImportError("proxy_config_manager module spec not found")
pcm = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pcm)
ProxyConfigManager = pcm.ProxyConfigManager
PROXY_ENV_KEYS = (
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy",
)
class TestApplyToEnv(unittest.TestCase):
def setUp(self):
self._saved = {k: os.environ.get(k) for k in PROXY_ENV_KEYS}
for k in PROXY_ENV_KEYS:
os.environ.pop(k, None)
self._tmp = tempfile.TemporaryDirectory()
self.cfg_path = os.path.join(self._tmp.name, "proxy.json")
def tearDown(self):
for k, v in self._saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
self._tmp.cleanup()
def test_enabled_proxy_exported_to_env(self):
mgr = ProxyConfigManager(filepath=self.cfg_path)
mgr.update_config(enabled=True, url="http://127.0.0.1:7890")
returned = mgr.apply_to_env()
self.assertEqual(returned, "http://127.0.0.1:7890")
# huggingface_hub / requests 只认环境变量,必须 export 进去
for k in PROXY_ENV_KEYS:
self.assertEqual(os.environ.get(k), "http://127.0.0.1:7890", k)
def test_no_proxy_returns_none_and_no_env(self):
mgr = ProxyConfigManager(filepath=self.cfg_path)
returned = mgr.apply_to_env()
self.assertIsNone(returned)
for k in PROXY_ENV_KEYS:
self.assertIsNone(os.environ.get(k), k)
def test_env_fallback_is_idempotent(self):
# 没配文件代理但环境已有代理apply 应把它补全到所有别名(含小写)
os.environ["HTTPS_PROXY"] = "http://10.0.0.1:1080"
mgr = ProxyConfigManager(filepath=self.cfg_path)
returned = mgr.apply_to_env()
self.assertEqual(returned, "http://10.0.0.1:1080")
self.assertEqual(os.environ.get("https_proxy"), "http://10.0.0.1:1080")
if __name__ == "__main__":
unittest.main()