Skip to content

Commit 81a7d7a

Browse files
authored
Merge pull request #466 from AlexWorland/feature/hf-cache-discovery
feat: resolve HF Hub cache layout in model directory scanning
2 parents edb7244 + 91018a7 commit 81a7d7a

File tree

4 files changed

+201
-33
lines changed

4 files changed

+201
-33
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,6 @@ omlx/_build_info.py
110110

111111
# Tailwind CSS standalone CLI binary (download on demand via build_css.py)
112112
omlx/admin/tailwindcss-*
113+
114+
# Git worktrees
115+
.worktrees/

omlx/admin/routes.py

Lines changed: 29 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2970,6 +2970,24 @@ async def list_hf_models(is_admin: bool = Depends(require_admin)):
29702970

29712971
model_dirs = global_settings.model.get_model_dirs(global_settings.base_path)
29722972

2973+
from ..model_discovery import _resolve_hf_cache_entry
2974+
2975+
def _add_model(model_path: Path, model_name: str) -> None:
2976+
if model_name in seen_names:
2977+
return
2978+
seen_names.add(model_name)
2979+
total_size = sum(
2980+
f.stat().st_size for f in model_path.rglob("*") if f.is_file()
2981+
)
2982+
models.append(
2983+
{
2984+
"name": model_name,
2985+
"path": str(model_path),
2986+
"size": total_size,
2987+
"size_formatted": format_size(total_size),
2988+
}
2989+
)
2990+
29732991
models = []
29742992
seen_names: set[str] = set()
29752993
for model_dir in model_dirs:
@@ -2981,44 +2999,22 @@ async def list_hf_models(is_admin: bool = Depends(require_admin)):
29812999

29823000
if (subdir / "config.json").exists():
29833001
# Level 1: direct model folder
2984-
if subdir.name in seen_names:
2985-
continue
2986-
seen_names.add(subdir.name)
2987-
total_size = sum(
2988-
f.stat().st_size for f in subdir.rglob("*") if f.is_file()
2989-
)
2990-
models.append(
2991-
{
2992-
"name": subdir.name,
2993-
"path": str(subdir),
2994-
"size": total_size,
2995-
"size_formatted": format_size(total_size),
2996-
}
2997-
)
3002+
_add_model(subdir, subdir.name)
29983003
else:
3004+
# HF Hub cache entry: models--Org--Name/snapshots/<hash>/
3005+
hf_resolved = _resolve_hf_cache_entry(subdir)
3006+
if hf_resolved is not None:
3007+
snapshot_path, model_name = hf_resolved
3008+
if (snapshot_path / "config.json").exists():
3009+
_add_model(snapshot_path, model_name)
3010+
continue
3011+
29993012
# Level 2: organization folder — scan children
30003013
for child in sorted(subdir.iterdir()):
30013014
if not child.is_dir() or child.name.startswith("."):
30023015
continue
3003-
if not (child / "config.json").exists():
3004-
continue
3005-
if child.name in seen_names:
3006-
continue
3007-
seen_names.add(child.name)
3008-
3009-
total_size = sum(
3010-
f.stat().st_size
3011-
for f in child.rglob("*")
3012-
if f.is_file()
3013-
)
3014-
models.append(
3015-
{
3016-
"name": child.name,
3017-
"path": str(child),
3018-
"size": total_size,
3019-
"size_formatted": format_size(total_size),
3020-
}
3021-
)
3016+
if (child / "config.json").exists():
3017+
_add_model(child, child.name)
30223018

30233019
return {"models": models}
30243020

omlx/model_discovery.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,30 @@ def _is_model_dir(path: Path) -> bool:
501501
return (path / "config.json").exists() and not _is_adapter_dir(path)
502502

503503

504+
def _resolve_hf_cache_entry(path: Path) -> tuple[Path, str] | None:
505+
"""Resolve an HF Hub cache entry (models--Org--Name/) to its active snapshot.
506+
507+
Returns (snapshot_path, model_name) or None if not a valid HF cache entry.
508+
"""
509+
name = path.name
510+
if not name.startswith("models--") or name.count("--") < 2:
511+
return None
512+
513+
# "models--Org--Name" → "Name"
514+
model_name = name.split("--", 2)[2]
515+
516+
try:
517+
commit_hash = (path / "refs" / "main").read_text().strip()
518+
except OSError:
519+
return None
520+
521+
snapshot = path / "snapshots" / commit_hash
522+
if not snapshot.is_dir():
523+
return None
524+
525+
return snapshot, model_name
526+
527+
504528
def _register_model(
505529
models: dict[str, DiscoveredModel],
506530
model_dir: Path,
@@ -607,6 +631,14 @@ def discover_models(model_dir: Path) -> dict[str, DiscoveredModel]:
607631
# Level 1: direct model folder
608632
_register_model(models, subdir, subdir.name)
609633
else:
634+
# HF Hub cache entry: models--Org--Name/snapshots/<hash>/
635+
hf_resolved = _resolve_hf_cache_entry(subdir)
636+
if hf_resolved is not None:
637+
snapshot_path, model_name = hf_resolved
638+
if _is_model_dir(snapshot_path):
639+
_register_model(models, snapshot_path, model_name)
640+
continue
641+
610642
# Level 2: organization folder — scan children
611643
has_children = False
612644
for child in sorted(subdir.iterdir()):

tests/test_model_discovery.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DiscoveredModel,
1212
_is_adapter_dir,
1313
_is_unsupported_model,
14+
_resolve_hf_cache_entry,
1415
detect_model_type,
1516
discover_models,
1617
discover_models_from_dirs,
@@ -869,3 +870,139 @@ def test_audio_models_included_in_discovery(self, tmp_path):
869870
assert models["whisper-large-v3"].model_type == "audio_stt"
870871
assert "Qwen3-TTS" in models
871872
assert models["Qwen3-TTS"].model_type == "audio_tts"
873+
874+
875+
class TestHfCacheDiscovery:
876+
"""Tests for HF Hub cache entry resolution and discovery."""
877+
878+
FAKE_COMMIT = "abc123def456"
879+
880+
def _make_model(self, path: Path, model_type: str = "llama"):
881+
"""Helper to create a valid model directory."""
882+
path.mkdir(parents=True, exist_ok=True)
883+
(path / "config.json").write_text(json.dumps({"model_type": model_type}))
884+
(path / "model.safetensors").write_bytes(b"0" * 1000)
885+
886+
def _make_hf_cache_entry(self, parent: Path, org: str, name: str):
887+
"""Helper to create a bare HF Hub cache directory layout (no model files)."""
888+
entry = parent / f"models--{org}--{name}"
889+
refs = entry / "refs"
890+
refs.mkdir(parents=True)
891+
(refs / "main").write_text(self.FAKE_COMMIT)
892+
snapshot = entry / "snapshots" / self.FAKE_COMMIT
893+
snapshot.mkdir(parents=True)
894+
return entry, snapshot
895+
896+
def _make_hf_cache_model(self, parent: Path, org: str, name: str, model_type: str = "llama"):
897+
"""Helper to create an HF cache entry with a valid model in the snapshot."""
898+
_, snapshot = self._make_hf_cache_entry(parent, org, name)
899+
(snapshot / "config.json").write_text(json.dumps({"model_type": model_type}))
900+
(snapshot / "model.safetensors").write_bytes(b"0" * 1000)
901+
902+
def test_resolve_valid_entry(self, tmp_path):
903+
"""Valid HF cache entry resolves to snapshot path and model name."""
904+
entry, snapshot = self._make_hf_cache_entry(tmp_path, "mlx-community", "Qwen3-8B-4bit")
905+
906+
result = _resolve_hf_cache_entry(entry)
907+
assert result is not None
908+
assert result[0] == snapshot
909+
assert result[1] == "Qwen3-8B-4bit"
910+
911+
def test_resolve_regular_dir_returns_none(self, tmp_path):
912+
"""Regular directory without models-- prefix returns None."""
913+
regular = tmp_path / "mlx-community"
914+
regular.mkdir()
915+
assert _resolve_hf_cache_entry(regular) is None
916+
917+
def test_resolve_single_separator_returns_none(self, tmp_path):
918+
"""models--Name (no org separator) returns None."""
919+
entry = tmp_path / "models--NoOrg"
920+
entry.mkdir()
921+
assert _resolve_hf_cache_entry(entry) is None
922+
923+
def test_resolve_missing_refs_main_returns_none(self, tmp_path):
924+
"""Missing refs/main returns None."""
925+
entry = tmp_path / "models--mlx-community--Qwen3-8B"
926+
entry.mkdir(parents=True)
927+
assert _resolve_hf_cache_entry(entry) is None
928+
929+
def test_resolve_missing_snapshot_returns_none(self, tmp_path):
930+
"""Valid refs/main but missing snapshot directory returns None."""
931+
entry = tmp_path / "models--mlx-community--Qwen3-8B"
932+
refs = entry / "refs"
933+
refs.mkdir(parents=True)
934+
(refs / "main").write_text("deadbeef")
935+
assert _resolve_hf_cache_entry(entry) is None
936+
937+
def test_resolve_strips_whitespace_from_refs(self, tmp_path):
938+
"""Trailing newline in refs/main is stripped (matches real HF cache)."""
939+
entry, snapshot = self._make_hf_cache_entry(tmp_path, "mlx-community", "Qwen3-8B")
940+
# Overwrite with trailing newline (like real HF cache)
941+
(entry / "refs" / "main").write_text(self.FAKE_COMMIT + "\n")
942+
943+
result = _resolve_hf_cache_entry(entry)
944+
assert result is not None
945+
assert result[0] == snapshot
946+
947+
def test_discover_hf_cache_model(self, tmp_path):
948+
"""HF cache entries are discovered as models."""
949+
self._make_hf_cache_model(tmp_path, "mlx-community", "Qwen3-8B-4bit")
950+
951+
models = discover_models(tmp_path)
952+
assert len(models) == 1
953+
assert "Qwen3-8B-4bit" in models
954+
assert models["Qwen3-8B-4bit"].model_type == "llm"
955+
956+
def test_discover_multiple_hf_cache_models(self, tmp_path):
957+
"""Multiple HF cache entries are all discovered."""
958+
self._make_hf_cache_model(tmp_path, "mlx-community", "Qwen3-8B-4bit")
959+
self._make_hf_cache_model(tmp_path, "mlx-community", "Mistral-7B-v0.3")
960+
961+
models = discover_models(tmp_path)
962+
assert len(models) == 2
963+
assert "Qwen3-8B-4bit" in models
964+
assert "Mistral-7B-v0.3" in models
965+
966+
def test_hf_cache_model_path_points_to_snapshot(self, tmp_path):
967+
"""model_path points to the snapshot dir, not the cache entry."""
968+
self._make_hf_cache_model(tmp_path, "mlx-community", "Qwen3-8B-4bit")
969+
970+
models = discover_models(tmp_path)
971+
assert models["Qwen3-8B-4bit"].model_path == str(
972+
tmp_path / "models--mlx-community--Qwen3-8B-4bit" / "snapshots" / self.FAKE_COMMIT
973+
)
974+
975+
def test_hf_cache_without_config_json_skipped(self, tmp_path):
976+
"""HF cache entries without config.json in snapshot are skipped."""
977+
self._make_hf_cache_entry(tmp_path, "mlx-community", "NoConfig")
978+
979+
models = discover_models(tmp_path)
980+
assert len(models) == 0
981+
982+
def test_mixed_flat_and_hf_cache(self, tmp_path):
983+
"""Mix of flat models and HF cache entries."""
984+
self._make_model(tmp_path / "mistral-7b")
985+
self._make_hf_cache_model(tmp_path, "mlx-community", "Qwen3-8B-4bit")
986+
987+
models = discover_models(tmp_path)
988+
assert len(models) == 2
989+
assert "mistral-7b" in models
990+
assert "Qwen3-8B-4bit" in models
991+
992+
def test_mixed_org_and_hf_cache(self, tmp_path):
993+
"""Mix of org folders and HF cache entries."""
994+
self._make_model(tmp_path / "Qwen" / "Qwen3-8B", model_type="qwen2")
995+
self._make_hf_cache_model(tmp_path, "mlx-community", "Mistral-7B")
996+
997+
models = discover_models(tmp_path)
998+
assert len(models) == 2
999+
assert "Qwen3-8B" in models
1000+
assert "Mistral-7B" in models
1001+
1002+
def test_hf_cache_does_not_fall_through_to_org_scan(self, tmp_path):
1003+
"""HF cache entries don't get scanned as org folders."""
1004+
self._make_hf_cache_model(tmp_path, "mlx-community", "Qwen3-8B-4bit")
1005+
1006+
models = discover_models(tmp_path)
1007+
assert len(models) == 1
1008+
assert "Qwen3-8B-4bit" in models

0 commit comments

Comments
 (0)