fix(setup): weight-aware install-state so truncated model cache isn't read as installed (#622) (#626)

A first-run user whose model download was interrupted after the config/
tokenizer files landed but before the weight shard got stranded on the
Models & Engines page: GET /models computed "installed" purely from cache
size on disk, so a size-positive-but-weight-less cache reported installed=true,
the wizard hid the re-download button, and the model manager (Settings → Models)
that could repair it was unreachable behind the wizard gate.

Make install-state weight-aware. The boolean weight-floor scan now lives in
models.py (the lowest module in the setup import graph) as snapshot_has_weights()
+ cache_is_complete(); list_models() and recommendations() downgrade a truncated
cache to installed=false (+ an explicit incomplete=true on /models), so the
existing "install" action re-appears and the user can re-download in-wizard.

Fixes the whole class, not just /models: download.py's install-time validator
now delegates to the same shared scan (one source of the floors, can't drift),
matching the load-time repair in model_manager.py (#581/#606).

config_only repos (pyannote/speaker-diarization-3.1 — a pipeline whose real
weights live in referenced sub-repos and whose own cache is legitimately tiny)
carry a new config_only:true hint in models.yaml and are exempt, so they're not
false-flagged as incomplete.

Tests: tests/test_mm2_lifecycle.py — snapshot_has_weights truncated-vs-complete,
cache_is_complete on a truncated weight repo + config-only exemption, and
list_models downgrading a size-positive truncated cache to installed=false /
incomplete=true. Full backend suite green (1832 passed).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-06-22 22:38:38 +05:30
committed by GitHub
co-authored by mergetest Claude Opus 4.8
parent d21fb765e2
commit 1fe68ba11e
4 changed files with 197 additions and 35 deletions
+18 -33
View File
@@ -21,7 +21,17 @@ from pydantic import BaseModel
from core import prefs
from utils import hf_progress
from utils import download_aggregator
from .models import KNOWN_MODELS, invalidate_cache
# Weight-floor scan (MM2-07 / #352) lives in ``models.py`` — the lowest module in
# the setup import graph — so install-time validation here, the first-run
# install-state detector (#622), and load-time repair share one set of floors and
# can't drift apart. ``_MIN_WEIGHT_BYTES``/``_WEIGHT_FLOORS`` re-exported for tests.
from .models import ( # noqa: F401
KNOWN_MODELS,
invalidate_cache,
snapshot_has_weights,
_MIN_WEIGHT_BYTES,
_WEIGHT_FLOORS,
)
logger = logging.getLogger("omnivoice.setup.download")
router = APIRouter()
@@ -223,51 +233,26 @@ def _safe_put(queue: asyncio.Queue, event) -> None:
# model.safetensors" (#352). 5 MB clears every weight format we ship
# (safetensors/bin shards, onnx, pt, gguf) without false-positiving on
# config-only aux repos.
_MIN_WEIGHT_BYTES = 5 * 1024 * 1024
# Per-role weight-file floors (MM2-07). A valid model has at least one
# recognized weight file at or above its extension's floor. ONNX graphs are
# legitimately small (a complete model can be well under 5 MB), so a single
# 5 MB rule false-positives on them as "truncated" (#352 over-trigger); give
# .onnx a lower floor while still rejecting a 0/KB partial. Tensor formats keep
# the original 5 MB floor.
_WEIGHT_FLOORS = {
".safetensors": _MIN_WEIGHT_BYTES,
".bin": _MIN_WEIGHT_BYTES,
".ckpt": _MIN_WEIGHT_BYTES,
".pt": _MIN_WEIGHT_BYTES,
".pth": _MIN_WEIGHT_BYTES,
".gguf": _MIN_WEIGHT_BYTES,
".onnx": 64 * 1024, # a real ONNX graph is ≥ tens of KB; a truncated one is bytes
}
def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
"""Raise OSError when a finished snapshot has no plausible weight file —
surfaces the truncated-download class (#352) at install time, where the
retry loop and the UI's re-download path can deal with it, instead of at
first synthesis with an opaque transformers error.
A snapshot is valid if it contains a recognized weight file meeting its
per-extension floor (MM2-07) OR any file ≥ the global 5 MB floor (the
original lenient catch — kept so this is never stricter than before)."""
Delegates the weight check to ``models.snapshot_has_weights`` (single source of
the floors); only the install-time error message lives here."""
if snapshot_has_weights(snapshot_path):
return
biggest = 0
try:
biggest = 0
for root, _dirs, files in os.walk(snapshot_path, followlinks=True):
for f in files:
try:
size = os.path.getsize(os.path.join(root, f))
biggest = max(biggest, os.path.getsize(os.path.join(root, f)))
except OSError:
continue
biggest = max(biggest, size)
ext = os.path.splitext(f)[1].lower()
floor = _WEIGHT_FLOORS.get(ext)
if floor is not None and size >= floor:
return # a recognized weight file of plausible size
if size >= _MIN_WEIGHT_BYTES:
return # original lenient catch (non-standard weight names)
except OSError:
return # can't inspect — don't block the install on the checker itself
pass
raise OSError(
f"{repo_id}: download finished but no model weights were found in the "
"snapshot (largest file "
+99 -2
View File
@@ -146,6 +146,94 @@ def _hub_cache_roots() -> list[str]:
return roots
# ── Weight-presence (truncated-cache) detection ─────────────────────────────
# A cache that downloaded config/tokenizer files but not the weight shard still
# occupies bytes on disk, so a size-only "installed" check (#352/#581/#606) reads
# it as installed and the first-run wizard hides the re-download button, stranding
# the user (#622). These helpers tell a *complete* snapshot from a truncated one by
# checking for a plausible weight file — the same class `download.py` guards at
# install time and `model_manager.py` repairs at load time. Shared here (the lowest
# module in the setup import graph; `download.py` imports from this module) so the
# floors live in exactly one place and can't drift between the three call sites.
_MIN_WEIGHT_BYTES = 5 * 1024 * 1024 # tensor formats: a real shard is ≥ a few MB
# Per-extension floors. ONNX graphs are legitimately small (a complete model can be
# well under 5 MB), so they get a lower floor that still rejects a bytes-only partial.
_WEIGHT_FLOORS = {
".safetensors": _MIN_WEIGHT_BYTES,
".bin": _MIN_WEIGHT_BYTES,
".ckpt": _MIN_WEIGHT_BYTES,
".pt": _MIN_WEIGHT_BYTES,
".pth": _MIN_WEIGHT_BYTES,
".gguf": _MIN_WEIGHT_BYTES,
".onnx": 64 * 1024,
}
def snapshot_has_weights(snapshot_path: str) -> bool:
"""True when a finished snapshot dir holds a plausible weight file.
A snapshot is complete if it contains a recognized weight file meeting its
per-extension floor OR any file ≥ the global 5 MB floor (the lenient catch for
non-standard weight names). Returns True when the path can't be inspected — an
un-walkable dir must never be reported as truncated, only a confirmed weight-less
one. `getsize` follows symlinks, so HF's snapshot→blob links resolve correctly;
a broken link (missing blob) raises OSError and is skipped, i.e. counts as absent.
"""
try:
for root, _dirs, files in os.walk(snapshot_path, followlinks=True):
for f in files:
try:
size = os.path.getsize(os.path.join(root, f))
except OSError:
continue
ext = os.path.splitext(f)[1].lower()
floor = _WEIGHT_FLOORS.get(ext)
if floor is not None and size >= floor:
return True
if size >= _MIN_WEIGHT_BYTES:
return True
except OSError:
return True # can't inspect — don't mislabel as truncated
return False
def _snapshot_dirs(repo_id: str) -> list[str]:
"""Existing snapshot revision dirs for a repo across the candidate cache roots."""
name = _repo_dir_name(repo_id)
dirs: list[str] = []
for root in _hub_cache_roots():
snaps = os.path.join(root, name, "snapshots")
try:
for rev in os.listdir(snaps):
rev_dir = os.path.join(snaps, rev)
if os.path.isdir(rev_dir):
dirs.append(rev_dir)
except OSError:
continue
return dirs
def cache_is_complete(model: dict) -> bool:
"""True when this model's on-disk cache is usable (not a truncated download).
Config-only repos (``config_only: true`` in models.yaml — e.g. pyannote's
diarisation pipeline, whose real weights live in referenced sub-repos) carry no
weight file of their own, so the weight check would false-positive them as
incomplete (#622 caveat). They're exempt: cache presence alone means complete.
A weight-bearing repo is complete only if at least one of its snapshots has
weights; if no snapshot dir is found on disk we can't prove truncation, so we
don't downgrade (the size-based caller already decided it's cached).
"""
if model.get("config_only"):
return True
dirs = _snapshot_dirs(model["repo_id"])
if not dirs:
return True
return any(snapshot_has_weights(d) for d in dirs)
def _is_cached_on_disk(repo_id: str) -> bool:
"""Direct-filesystem fallback for is_cached when scan_cache_dir is unavailable.
@@ -286,9 +374,15 @@ def list_models():
out = []
for m in KNOWN_MODELS:
cached = cached_by_repo.get(m["repo_id"])
on_disk = cached is not None and cached["size_on_disk"] > 0
# A size-positive cache can still be a truncated download (config landed,
# weight shard didn't). Treat that as not-installed + incomplete so the
# wizard re-offers the download instead of stranding the user (#622).
incomplete = on_disk and not cache_is_complete(m)
out.append({
**m,
"installed": cached is not None and cached["size_on_disk"] > 0,
"installed": on_disk and not incomplete,
"incomplete": incomplete,
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
"nb_files": cached["nb_files"] if cached else 0,
"supported": _model_supported(m),
@@ -383,6 +477,9 @@ def recommendations():
entries = []
for rid in recommended_ids:
meta = known_by_id.get(rid, {})
# Mirror /models: a truncated cache (weights missing) is not installed, so
# the wizard counts it toward the remaining download instead of "all set".
installed = rid in cached_ids and cache_is_complete(meta or {"repo_id": rid})
entries.append({
"repo_id": rid,
"label": meta.get("label", rid),
@@ -390,7 +487,7 @@ def recommendations():
"size_gb": meta.get("size_gb", 0),
"required": bool(meta.get("required", False)),
"note": meta.get("note"),
"installed": rid in cached_ids,
"installed": installed,
})
to_download_gb = sum(e["size_gb"] for e in entries if not e["installed"])
+5
View File
@@ -14,6 +14,10 @@
# required (optional) — true if the app needs this model to function
# platforms (optional) — restrict to specific OS+arch tags (e.g. darwin-arm64, cuda)
# note (optional) — shown in the UI as a tooltip/footnote
# config_only (optional) — true for pipeline repos that ship no weight file of
# their own (weights live in referenced sub-repos). Such
# a cache is legitimately tiny, so the truncated-download
# (weights-missing) detector must NOT flag it incomplete.
# ─────────────────────────────────────────────────────────────────────────
models:
@@ -122,6 +126,7 @@ models:
label: "pyannote speaker diarisation (multi-speaker videos)"
role: Diarisation
size_gb: 0.8
config_only: true # pipeline repo; real weights live in referenced sub-repos
note: "Needs an HF_TOKEN with license accepted."
# ── Optional TTS ──────────────────────────────────────────────────────
+75
View File
@@ -161,3 +161,78 @@ def test_truncated_snapshot_still_rejected(tmp_path):
def test_large_tensor_weight_passes(tmp_path):
(tmp_path / "model.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
dl._validate_snapshot_has_weights("x/big", str(tmp_path)) # must not raise
# ── #622: install-state detector is weight-aware (truncated cache ≠ installed) ─
import api.routers.setup.models as models # noqa: E402
def _make_snapshot(cache_root, repo_id, files):
"""Build a minimal HF-style snapshots/<rev>/ dir and return its cache root."""
name = "models--" + repo_id.replace("/", "--")
rev = cache_root / name / "snapshots" / "abc123"
rev.mkdir(parents=True)
for fname, data in files.items():
(rev / fname).write_bytes(data)
return rev
def test_snapshot_has_weights_distinguishes_truncated(tmp_path):
full = tmp_path / "full"; full.mkdir()
(full / "config.json").write_bytes(b"{}")
(full / "model.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
assert models.snapshot_has_weights(str(full)) is True
trunc = tmp_path / "trunc"; trunc.mkdir()
(trunc / "config.json").write_bytes(b"{}")
(trunc / "tokenizer.json").write_bytes(b"x" * 4096)
assert models.snapshot_has_weights(str(trunc)) is False
def test_cache_is_complete_flags_truncated_weight_repo(tmp_path, monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
# Weight-bearing repo with config only (interrupted download) → incomplete.
_make_snapshot(tmp_path, "k2-fsa/OmniVoice", {"config.json": b"{}"})
assert models.cache_is_complete({"repo_id": "k2-fsa/OmniVoice"}) is False
# Same repo once the shard lands → complete.
_make_snapshot(
tmp_path / "ok", "k2-fsa/OmniVoice",
{"config.json": b"{}", "model.safetensors": b"\0" * (6 * 1024 * 1024)},
)
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "ok"))
assert models.cache_is_complete({"repo_id": "k2-fsa/OmniVoice"}) is True
def test_cache_is_complete_exempts_config_only_repo(tmp_path, monkeypatch):
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
# pyannote pipeline ships no weight of its own — a tiny cache is legit, not
# truncated; the config_only hint must keep it from being flagged incomplete.
_make_snapshot(tmp_path, "pyannote/speaker-diarization-3.1", {"config.yaml": b"x"})
assert models.cache_is_complete(
{"repo_id": "pyannote/speaker-diarization-3.1", "config_only": True}
) is True
def test_list_models_downgrades_truncated_cache(tmp_path, monkeypatch):
"""A size-positive but weight-less cache must report installed=False so the
first-run wizard re-offers the download instead of stranding the user (#622)."""
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
_make_snapshot(tmp_path, "k2-fsa/OmniVoice", {"config.json": b"{}"})
class _Repo:
def __init__(self, rid, size):
self.repo_id, self.size_on_disk = rid, size
self.last_accessed, self.nb_files = 0, 1
class _Info:
repos = [_Repo("k2-fsa/OmniVoice", 4096)] # size > 0 (config landed)
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "scan_cache_dir", lambda: _Info())
models.invalidate_cache()
out = models.list_models()
row = next(m for m in out["models"] if m["repo_id"] == "k2-fsa/OmniVoice")
assert row["installed"] is False
assert row["incomplete"] is True
models.invalidate_cache()