diff --git a/.env.example b/.env.example index 3589634..985be50 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,12 @@ TRANSCRIBER_TYPE=fast-whisper WHISPER_MODEL_SIZE=tiny GROQ_TRANSCRIBER_MODEL=whisper-large-v3-turbo # groq提供的faster-whisper 默认为 whisper-large-v3-turbo + +# whisper 本地模型从 HuggingFace 下载。镜像默认 HF_ENDPOINT=https://hf-mirror.com(国内友好)。 +# 若下载失败(容器连不上镜像站),可在此覆盖为官方源或其它镜像,例如: +# HF_ENDPOINT=https://huggingface.co +# 注意:宿主机的 VPN/代理默认不会进入容器。若要让模型下载走代理,请在前端「设置」里 +# 配置代理(会自动应用到 HuggingFace 下载),或在下面用标准环境变量指定: +# HTTP_PROXY=http://host.docker.internal:7890 +# HTTPS_PROXY=http://host.docker.internal:7890 +# HF_ENDPOINT=https://hf-mirror.com diff --git a/backend/app/db/builtin_providers.json b/backend/app/db/builtin_providers.json index 40d6654..c111d2e 100644 --- a/backend/app/db/builtin_providers.json +++ b/backend/app/db/builtin_providers.json @@ -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", diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 14d05f0..1e08c22 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -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") diff --git a/backend/app/services/model.py b/backend/app/services/model.py index 576a2e1..eb823d0 100644 --- a/backend/app/services/model.py +++ b/backend/app/services/model.py @@ -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。 diff --git a/backend/app/services/model_fallback.py b/backend/app/services/model_fallback.py new file mode 100644 index 0000000..714127c --- /dev/null +++ b/backend/app/services/model_fallback.py @@ -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 -> .data;list/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 + ] diff --git a/backend/app/services/proxy_config_manager.py b/backend/app/services/proxy_config_manager.py index abbc636..93d9864 100644 --- a/backend/app/services/proxy_config_manager.py +++ b/backend/app/services/proxy_config_manager.py @@ -58,3 +58,22 @@ class ProxyConfigManager: if val: return val return None + + def apply_to_env(self) -> Optional[str]: + """把当前生效的代理 URL 写进进程环境变量,返回生效的 url(无则 None)。 + + 为什么需要(issue #417):huggingface_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 diff --git a/backend/main.py b/backend/main.py index 9f21a64..4b1d080 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,6 +60,13 @@ async def lifespan(app: FastAPI): logger.info("[startup 4/5] seed_default_providers() — 初始化默认 LLM 供应商") seed_default_providers() + # 把已配置的代理 export 到环境变量,让 huggingface_hub(whisper 模型下载) + # 也能走代理——含转写时的按需下载(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 策略决定是否重试") diff --git a/backend/tests/test_model_fallback.py b/backend/tests/test_model_fallback.py new file mode 100644 index 0000000..548750e --- /dev/null +++ b/backend/tests/test_model_fallback.py @@ -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 的 Model(pydantic)对象。""" + + 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 #417:DeepSeek /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() diff --git a/backend/tests/test_proxy_apply_env.py b/backend/tests/test_proxy_apply_env.py new file mode 100644 index 0000000..4ab32fc --- /dev/null +++ b/backend/tests/test_proxy_apply_env.py @@ -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()