When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.
Class fix — one shared classifier in core/failure.py covers every surface:
- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
hint that names the configured mirror, says it may be down, points at
Settings → Models → Hugging Face mirror, suggests the official endpoint
when the model isn't cached, and notes the restart requirement (HF reads
HF_ENDPOINT at backend start). Checked before the video-download network
class so a model download's "timed out" no longer gets the "video server"
hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
ALL routes that can leak a model-load error benefit (generate, dub,
archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
auto-repaired" message now names WHY the auto-repair failed (mirror
outage, offline mode, full disk no longer read identically), which also
lets the mirror hint fire on that surface when applicable.
Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).
Fixes #874
Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
209 lines
8.6 KiB
Python
209 lines
8.6 KiB
Python
"""#874: a model download that fails because the CONFIGURED Hugging Face
|
|
mirror (HF_ENDPOINT, Settings → Models → Hugging Face mirror) is unreachable
|
|
must surface an actionable error that (1) names the mirror, (2) says it may be
|
|
down, (3) points at the setting + the restart requirement (HF reads
|
|
HF_ENDPOINT at backend start), and (4) suggests the official endpoint when the
|
|
model isn't cached — instead of leaking the raw transformers message
|
|
("We couldn't connect to 'https://hf-mirror.com' …") as a bare 500 detail.
|
|
|
|
Fail-before/pass-after: before the fix `classify()` had no mirror class and
|
|
`build_failure()` / `append_hf_mirror_hint()` attached no hint to these
|
|
reasons. Also covers the #886 family: when the incomplete-cache auto-repair
|
|
fails, the surfaced message now names WHY (so a mirror outage / offline mode /
|
|
full disk stop reading identically).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from core import failure
|
|
|
|
# The exact transformers wording from issue #874 (mirror down, model not cached).
|
|
_TRANSFORMERS_874 = (
|
|
"We couldn't connect to 'https://hf-mirror.com' to load the files, and "
|
|
"couldn't find them in the cached files.\n"
|
|
"Check your internet connection or see how to run the library in offline "
|
|
"mode at 'https://huggingface.co/docs/transformers/installation#offline-mode'."
|
|
)
|
|
|
|
# huggingface_hub / requests shape: names the mirror HOST, not the full URL.
|
|
_HUB_CONN_ERROR = (
|
|
"(MaxRetryError(\"HTTPSConnectionPool(host='hf-mirror.com', port=443): "
|
|
"Max retries exceeded with url: /api/models/k2-fsa/OmniVoice (Caused by "
|
|
"NewConnectionError('Failed to establish a new connection: "
|
|
"[Errno 61] Connection refused'))\"))"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mirror_env(monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "https://hf-mirror.com")
|
|
|
|
|
|
def test_transformers_connect_error_classifies_as_mirror(mirror_env):
|
|
assert failure.classify(_TRANSFORMERS_874) == "HF_MIRROR_UNREACHABLE"
|
|
|
|
|
|
def test_hub_connection_error_classifies_by_host(mirror_env):
|
|
assert failure.classify(_HUB_CONN_ERROR) == "HF_MIRROR_UNREACHABLE"
|
|
|
|
|
|
def test_hint_names_mirror_setting_restart_and_official(mirror_env):
|
|
evt = failure.build_failure(
|
|
OSError(_TRANSFORMERS_874), stage="model-load", include_diagnostic=False
|
|
)
|
|
assert evt["docs_topic"] == "HF_MIRROR_UNREACHABLE"
|
|
hint = evt["hint"]
|
|
assert "https://hf-mirror.com" in hint # (1) names the configured mirror
|
|
assert "may be down" in hint # (2) says it may be down
|
|
# (3) the setting path + the restart requirement (HF_ENDPOINT applies at start)
|
|
assert "Settings → Models → Hugging Face mirror" in hint
|
|
assert "restart" in hint.lower()
|
|
# (4) suggests the official endpoint for an un-cached model
|
|
assert "Hugging Face (official)" in hint
|
|
|
|
|
|
def test_timeout_with_mirror_not_misclassified_as_video_network(mirror_env):
|
|
# A bare "timed out" used to fall into VIDEO_DOWNLOAD_NETWORK, whose hint
|
|
# talks about "the video server" — a model download naming the mirror host
|
|
# must classify as the mirror class instead.
|
|
reason = "HTTPSConnectionPool(host='hf-mirror.com', port=443): Read timed out."
|
|
assert failure.classify(reason) == "HF_MIRROR_UNREACHABLE"
|
|
|
|
|
|
def test_no_class_without_configured_mirror(monkeypatch):
|
|
# The same #874 reason with NO mirror configured: not this class (the
|
|
# official endpoint being unreachable is plain connectivity, not a
|
|
# switch-your-mirror problem).
|
|
monkeypatch.delenv("HF_ENDPOINT", raising=False)
|
|
from core import prefs
|
|
|
|
monkeypatch.setattr(prefs, "get", lambda key, default=None: default)
|
|
assert failure.classify(_TRANSFORMERS_874) == ""
|
|
assert failure.hf_mirror_hint(_TRANSFORMERS_874) == ""
|
|
|
|
|
|
def test_official_endpoint_is_not_a_mirror(monkeypatch):
|
|
monkeypatch.setenv("HF_ENDPOINT", "https://huggingface.co")
|
|
assert failure.hf_mirror_hint(_TRANSFORMERS_874) == ""
|
|
# Trailing slash normalizes away too.
|
|
monkeypatch.setenv("HF_ENDPOINT", "https://huggingface.co/")
|
|
assert failure.hf_mirror_hint(_TRANSFORMERS_874) == ""
|
|
|
|
|
|
def test_non_hf_connectivity_error_gets_no_mirror_hint(mirror_env):
|
|
# A random socket failure (e.g. a local LLM provider being down) while a
|
|
# mirror happens to be configured must NOT get the mirror hint.
|
|
assert failure.hf_mirror_hint("Connection refused by localhost:11434") == ""
|
|
|
|
|
|
def test_append_helper_appends_or_passes_through(mirror_env):
|
|
# append_hf_mirror_hint is the 500-detail surface (main.py global handler)
|
|
# and the model-install SSE surface (setup/download.py).
|
|
out = failure.append_hf_mirror_hint(_TRANSFORMERS_874)
|
|
assert out.startswith(_TRANSFORMERS_874)
|
|
assert "Settings → Models → Hugging Face mirror" in out
|
|
assert failure.append_hf_mirror_hint("some unrelated failure") == "some unrelated failure"
|
|
|
|
|
|
def test_journal_classifies_mirror_download_as_network_error(mirror_env):
|
|
# Bug reports auto-attach the journal entry — #874's error was UNKNOWN.
|
|
from core import error_journal
|
|
|
|
assert error_journal.classify_exception(OSError(_TRANSFORMERS_874)) == "NETWORK_ERROR"
|
|
|
|
|
|
# ── #886 family: the incomplete-cache auto-repair failure names its cause ───
|
|
|
|
|
|
@pytest.fixture
|
|
def model_manager(monkeypatch):
|
|
for mod_name in ("core.config", "services.model_manager"):
|
|
if getattr(sys.modules.get(mod_name), "__file__", None) is None:
|
|
sys.modules.pop(mod_name, None)
|
|
|
|
import services.model_manager as mm
|
|
|
|
monkeypatch.setattr(mm, "_torch", None)
|
|
monkeypatch.setattr(mm, "_OmniVoice", None)
|
|
monkeypatch.setattr(mm, "model", None)
|
|
monkeypatch.setenv("OMNIVOICE_MODEL", "test/checkpoint")
|
|
monkeypatch.delenv("OMNIVOICE_PRELOAD_TTS_ASR", raising=False)
|
|
monkeypatch.delenv("HF_HUB_OFFLINE", raising=False)
|
|
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising=False)
|
|
monkeypatch.setattr(mm, "_lazy_torch", lambda: SimpleNamespace(float16="float16"))
|
|
monkeypatch.setattr(mm, "get_best_device", lambda: "cpu")
|
|
monkeypatch.setattr(mm, "_last_repair_error", "")
|
|
return mm
|
|
|
|
|
|
_TRUNCATED = OSError(
|
|
"test/checkpoint does not appear to have a file named pytorch_model.bin "
|
|
"or model.safetensors"
|
|
)
|
|
|
|
|
|
def test_repair_records_why_it_failed(model_manager, monkeypatch):
|
|
import huggingface_hub
|
|
|
|
def boom(**kwargs):
|
|
raise OSError(_TRANSFORMERS_874)
|
|
|
|
monkeypatch.setattr(huggingface_hub, "snapshot_download", boom)
|
|
monkeypatch.setenv("OMNIVOICE_MODEL_REPAIR_BACKOFF_S", "0")
|
|
monkeypatch.setenv("OMNIVOICE_MODEL_REPAIR_RETRIES", "1")
|
|
assert model_manager._repair_model_cache("test/checkpoint") is False
|
|
assert "hf-mirror.com" in model_manager._last_repair_error
|
|
|
|
|
|
def test_repair_failure_message_names_cause_and_mirror(model_manager, monkeypatch, mirror_env):
|
|
"""#886 family: 'could not be auto-repaired' used to drop the cause, so a
|
|
mirror outage, offline mode, and a full disk all read identically. The
|
|
message now carries the cause — and because the cause text is part of the
|
|
surfaced error, the shared #874 mirror hint fires on it downstream."""
|
|
monkeypatch.setattr(
|
|
model_manager, "_repair_model_cache", lambda checkpoint, **kw: False
|
|
)
|
|
monkeypatch.setattr(
|
|
model_manager, "_last_repair_error", f"OSError: {_TRANSFORMERS_874}"
|
|
)
|
|
|
|
class BrokenOmniVoice:
|
|
@staticmethod
|
|
def from_pretrained(*args, **kwargs):
|
|
raise _TRUNCATED
|
|
|
|
monkeypatch.setattr(model_manager, "_lazy_omnivoice", lambda: BrokenOmniVoice)
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
model_manager._load_model_sync()
|
|
msg = str(exc_info.value)
|
|
assert "incomplete" in msg # the existing actionable class is preserved
|
|
assert "Settings → Models" in msg
|
|
assert "hf-mirror.com" in msg # NEW: the cause is named
|
|
# …and the surfaced text now carries enough signal for the shared mirror
|
|
# hint to fire on the 500-detail surface (main.py appends it).
|
|
assert failure.hf_mirror_hint(msg) != ""
|
|
|
|
|
|
def test_repair_failure_without_cause_keeps_legacy_message(model_manager, monkeypatch):
|
|
"""No recorded cause (e.g. tests/plugins stubbing repair) → the message is
|
|
byte-compatible with the pre-#874 wording, no dangling clause."""
|
|
monkeypatch.setattr(
|
|
model_manager, "_repair_model_cache", lambda checkpoint, **kw: False
|
|
)
|
|
monkeypatch.setattr(model_manager, "_last_repair_error", "")
|
|
|
|
class BrokenOmniVoice:
|
|
@staticmethod
|
|
def from_pretrained(*args, **kwargs):
|
|
raise _TRUNCATED
|
|
|
|
monkeypatch.setattr(model_manager, "_lazy_omnivoice", lambda: BrokenOmniVoice)
|
|
|
|
with pytest.raises(RuntimeError, match="interrupted download"):
|
|
model_manager._load_model_sync()
|