|
11 | 11 | DiscoveredModel, |
12 | 12 | _is_adapter_dir, |
13 | 13 | _is_unsupported_model, |
| 14 | + _resolve_hf_cache_entry, |
14 | 15 | detect_model_type, |
15 | 16 | discover_models, |
16 | 17 | discover_models_from_dirs, |
@@ -869,3 +870,139 @@ def test_audio_models_included_in_discovery(self, tmp_path): |
869 | 870 | assert models["whisper-large-v3"].model_type == "audio_stt" |
870 | 871 | assert "Qwen3-TTS" in models |
871 | 872 | 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