unsloth/studio/backend/tests/test_cached_gguf_routes.py
2026-07-27 11:38:08 +00:00

1943 lines
73 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import asyncio
import sys
import types
from pathlib import Path
from types import SimpleNamespace
# Keep this test runnable without optional logging deps.
if "structlog" not in sys.modules:
class _DummyLogger:
def __getattr__(self, _name):
return lambda *args, **kwargs: None
sys.modules["structlog"] = types.SimpleNamespace(
BoundLogger = _DummyLogger,
get_logger = lambda *args, **kwargs: _DummyLogger(),
)
import routes.models as models_route
from hub.services.models import gguf_variants as GV
def _repo(
repo_id: str,
files: list[SimpleNamespace],
repo_path: Path,
*,
revisions: list[SimpleNamespace] | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
repo_id = repo_id,
repo_type = "model",
repo_path = repo_path,
revisions = revisions or [SimpleNamespace(files = files)],
)
def _file(
name: str,
size_on_disk: int,
*,
blob_path: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
file_name = name,
size_on_disk = size_on_disk,
blob_path = blob_path,
)
def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
nested = tmp_path / "snapshots" / "rev"
nested.mkdir(parents = True)
lower = nested / "Q4_K_M.gguf"
upper = nested / "Q8_0.GGUF"
other = nested / "README.md"
lower.write_text("a")
upper.write_text("b")
other.write_text("c")
result = sorted(path.name for path in models_route._iter_gguf_paths(tmp_path))
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
def test_legacy_hf_scan_uses_snapshot_path_for_inactive_cache(tmp_path):
repo = tmp_path / "models--Org--Model"
snapshot = repo / "snapshots" / "revision"
snapshot.mkdir(parents = True)
[row] = models_route._scan_hf_cache(tmp_path, active_cache = False)
assert row.model_id == "Org/Model"
assert row.id == str(snapshot.resolve())
assert row.path == str(snapshot.resolve())
def test_collect_local_models_scans_previous_cache(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active.mkdir()
snapshot = previous / "models--Org--Previous" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr("utils.hf_cache_settings.known_hf_hub_caches", lambda: [active, previous])
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
previous_row = next(row for row in rows if row.model_id == "Org/Previous")
assert previous_row.id == str(snapshot.resolve())
def test_collect_local_models_prefers_complete_previous_copy(monkeypatch, tmp_path):
active = tmp_path / "active"
previous = tmp_path / "previous"
active_partial = active / "models--Org--Model" / "blobs" / "abc.incomplete"
active_partial.parent.mkdir(parents = True)
active_partial.write_bytes(b"partial")
snapshot = previous / "models--Org--Model" / "snapshots" / "revision"
snapshot.mkdir(parents = True)
(snapshot / "model.safetensors").write_bytes(b"complete")
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr("utils.paths.legacy_hf_cache_dir", lambda: tmp_path / "legacy")
monkeypatch.setattr("utils.paths.hf_default_cache_dir", lambda: tmp_path / "default")
monkeypatch.setattr("utils.paths.lmstudio_model_dirs", lambda: [])
monkeypatch.setattr(
"utils.hf_cache_settings.known_hf_hub_caches",
lambda: [active, previous],
)
monkeypatch.setattr("storage.studio_db.list_scan_folders", lambda: [])
rows = models_route.collect_local_models(tmp_path / "models")
[row] = [row for row in rows if row.model_id == "Org/Model"]
assert row.id == str(snapshot.resolve())
assert row.partial is False
assert row.active_cache is False
def test_list_cached_gguf_reports_snapshot_load_id_for_inactive_cache(monkeypatch, tmp_path):
"""Only a repo outside the active cache needs a snapshot load_id."""
active = tmp_path / "active"
snapshot = tmp_path / "legacy" / "models--Org--Away" / "snapshots" / "rev"
snapshot.mkdir(parents = True)
(snapshot / "Q4_K_M.gguf").write_bytes(b"\0")
away = _repo(
"Org/Away",
[],
tmp_path / "legacy" / "models--Org--Away",
revisions = [
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000)], snapshot_path = snapshot),
],
)
here = _repo("Org/Here", [_file("Q4_K_M.gguf", 6_000)], active / "models--Org--Here")
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [away, here])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = {
c["repo_id"]: c
for c in asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
}
assert rows["Org/Away"]["load_id"] == str(snapshot)
assert "load_id" not in rows["Org/Here"]
def test_list_cached_gguf_load_id_follows_snapshot_dir_mtime(monkeypatch, tmp_path):
"""Pick the snapshot variant discovery reads: newest directory, not newest blob."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Multi"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Q4_K_M.gguf").write_bytes(b"\0")
(newer / "Q8_0.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Multi",
[],
repo_dir,
revisions = [
# The older directory holds the newer blob, which is what diverges.
SimpleNamespace(
files = [_file("Q4_K_M.gguf", 5_000, blob_path = "b1")], snapshot_path = older
),
SimpleNamespace(files = [_file("Q8_0.gguf", 6_000, blob_path = "b2")], snapshot_path = newer),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
monkeypatch.setattr(
models_route, "_blob_mtime", lambda f: 9_000 if f.blob_path == "b1" else 1.0
)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(newer)
def test_list_cached_gguf_load_id_skips_partial_split_snapshot(monkeypatch, tmp_path):
"""A half-downloaded split quant must not beat an older snapshot that can load."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Split"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
# Only part 1 of 3 landed before the download was interrupted.
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Split",
[],
repo_dir,
revisions = [
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
SimpleNamespace(
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = newer
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(older)
def test_list_cached_gguf_omits_load_id_when_no_snapshot_is_complete(monkeypatch, tmp_path):
"""With only a half-downloaded split quant, fall back to the repo id, not a path."""
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Torn"
snapshot = repo_dir / "snapshots" / "rev"
snapshot.mkdir(parents = True)
(snapshot / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
repo = _repo(
"Org/Torn",
[],
repo_dir,
revisions = [
SimpleNamespace(
files = [_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000)], snapshot_path = snapshot
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert "load_id" not in rows[0]
def test_list_cached_gguf_skips_snapshot_with_one_incomplete_variant(monkeypatch, tmp_path):
"""A good quant beside a half-downloaded one is still not a safe load target."""
import os
active = tmp_path / "active"
repo_dir = tmp_path / "legacy" / "models--Org--Mixed"
older, newer = repo_dir / "snapshots" / "rev-a", repo_dir / "snapshots" / "rev-b"
for path in (older, newer):
path.mkdir(parents = True)
(older / "Model-Q8_0.gguf").write_bytes(b"\0")
# rev-b has a complete Q8_0 AND a half-downloaded split Q4_K_M. The picker
# enumerates the whole directory, so it would offer the broken one.
(newer / "Model-Q8_0.gguf").write_bytes(b"\0")
(newer / "Model-Q4_K_M-00001-of-00003.gguf").write_bytes(b"\0")
os.utime(older, (1_000, 1_000))
os.utime(newer, (2_000, 2_000))
repo = _repo(
"Org/Mixed",
[],
repo_dir,
revisions = [
SimpleNamespace(files = [_file("Model-Q8_0.gguf", 5_000)], snapshot_path = older),
SimpleNamespace(
files = [
_file("Model-Q8_0.gguf", 5_000),
_file("Model-Q4_K_M-00001-of-00003.gguf", 6_000),
],
snapshot_path = newer,
),
],
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
monkeypatch.setattr(models_route, "_resolve_hf_cache_dir", lambda: active)
rows = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))["cached"]
assert rows[0]["load_id"] == str(older)
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(monkeypatch, tmp_path):
repo = _repo(
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
[_file("Q4_K_M.gguf", 5_000), _file("README.md", 10)],
tmp_path / "models--HauhauCS--Gemma",
)
scan = SimpleNamespace(repos = [repo])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_path):
repo = _repo(
"Org/Model-Without-Suffix",
[_file("Q8_0.GGUF", 7_000)],
tmp_path / "models--Org--Model-Without-Suffix",
)
scan = SimpleNamespace(repos = [repo])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Model-Without-Suffix",
"size_bytes": 7_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
"task": None,
}
]
def test_is_hidden_model_hides_validation_probe_everywhere():
"""Every picker (model list, local, cached GGUF, cached models) gates on
_is_hidden_model, so hiding the probe here hides it in the search menu too.
Cover both forms callers pass: the reconstructed repo id and the on-disk
snapshot path."""
assert models_route._is_hidden_model("ggml-org/models")
assert models_route._is_hidden_model("ggml-org/models/tinyllamas/stories260K.gguf")
assert models_route._is_hidden_model(
None, "/hf/models--ggml-org--models/snapshots/abc/tinyllamas/stories260K.gguf"
)
# A Windows-style snapshot path must match too, even on a POSIX interpreter
# (the filename check splits on both separators).
assert models_route._is_hidden_model(
r"C:\Users\u\.cache\huggingface\hub\models--ggml-org--models\snapshots\abc\tinyllamas\stories260K.gguf"
)
assert not models_route._is_hidden_model("unsloth/gemma-3-270m-it-GGUF")
# The exact-filename needle must not hide a real repo that merely
# references stories260K in its name.
assert not models_route._is_hidden_model("user/stories260K-finetune-GGUF")
def test_is_hidden_model_hides_dictation_models(tmp_path):
assert models_route._is_hidden_model("unsloth/whisper-tiny")
assert models_route._is_hidden_model("unsloth/whisper-base")
assert models_route._is_hidden_model("unsloth/whisper-small")
assert models_route._is_hidden_model("unsloth/whisper-large-v3-turbo")
assert models_route._is_hidden_model(
"/hf/models--unsloth--whisper-large-v3/snapshots/abc/model.safetensors"
)
assert not models_route._is_hidden_model("user/whisper-finetune")
assert not models_route._is_hidden_model(
"C:\\cache\\models--unsloth--whisper-small-finetune\\model.safetensors"
)
custom = tmp_path / "custom-whisper"
custom.mkdir()
(custom / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(custom / "model.safetensors").write_bytes(b"weights")
assert models_route._is_hidden_model(
"user/custom-checkpoint",
str(custom / "model.safetensors"),
)
named_only = tmp_path / "whisper-finetune"
named_only.mkdir()
(named_only / "config.json").write_text('{"model_type": "llama"}')
assert not models_route._is_hidden_model("user/whisper-finetune", str(named_only))
def test_list_cached_models_hides_custom_whisper_by_config(monkeypatch, tmp_path):
# Regression: the legacy /cached-models picker must pass the snapshot path so
# the config check hides a custom (non-curated) Whisper checkpoint; a bare
# repo id cannot ("user/whisper-finetune" is not in the curated set).
repo_path = tmp_path / "models--user--whisper-finetune"
snap = repo_path / "snapshots" / "abc"
snap.mkdir(parents = True)
(snap / "config.json").write_text(
'{"model_type": "whisper", "architectures": ["WhisperForConditionalGeneration"]}'
)
(snap / "model.safetensors").write_bytes(b"weights")
captured: list = []
real_hidden = models_route._is_hidden_model
def spy(*values):
captured.append(values)
return real_hidden(*values)
monkeypatch.setattr(models_route, "_is_hidden_model", spy)
repo = _repo(
"user/whisper-finetune",
[SimpleNamespace(file_name = "model.safetensors", size_on_disk = 10)],
repo_path,
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [repo])]
)
result = asyncio.run(
models_route.list_cached_models(current_subject = "test-user", hf_token = None)
)
# The route passed the snapshot path (not just the repo id) ...
assert any(str(repo_path) in values for values in captured)
# ... so the custom Whisper checkpoint is hidden from the chat picker.
assert result["cached"] == []
def test_is_hidden_model_matches_repo_ids_exactly(monkeypatch):
"""A custom embedder with a generic basename is hidden by EXACT repo-id
match only, so unrelated cached repos that merely contain the basename stay
visible. Regression: substring basename matching hid real chat models like
``user/model-chat`` from the On Device inventory."""
from core.rag import config as rag_config
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
# The exact embedder repo and its GGUF companion are hidden.
assert models_route._is_hidden_model("org/model")
assert models_route._is_hidden_model("org/model-GGUF")
# Unrelated repos that merely contain "model" must NOT be hidden.
assert not models_route._is_hidden_model("user/model-chat")
assert not models_route._is_hidden_model("org/model-instruct")
assert not models_route._is_hidden_model("acme/remodelled-chat")
# The validation probe stays hidden regardless of embedder config.
assert models_route._is_hidden_model("ggml-org/models")
def test_is_hidden_model_matches_repo_derived_local_paths(monkeypatch):
"""Match exact repo-derived cache and LM Studio paths."""
from core.rag import config as rag_config
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/model")
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/model-GGUF")
assert models_route._is_hidden_model(
"/cache/models--org--model/snapshots/abc/model.safetensors"
)
assert models_route._is_hidden_model(
r"C:\Users\u\.cache\huggingface\hub\models--org--model-GGUF\snapshots\abc"
)
assert models_route._is_hidden_model("/lm-studio/org/model-GGUF/model-Q8_0.gguf")
assert not models_route._is_hidden_model("/lm-studio/user/model-chat/model-Q8_0.gguf")
assert not models_route._is_hidden_model("/cache/models--org--model-instruct")
def test_is_hidden_model_prefers_existing_relative_path(monkeypatch, tmp_path):
"""Prefer an existing relative path over repo-id syntax."""
from core.rag import config as rag_config
embedder = tmp_path / "models" / "embedder"
embedder.mkdir(parents = True)
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "models/embedder")
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/embedder-GGUF")
assert models_route._is_hidden_model(str(embedder))
def test_is_hidden_model_keeps_stale_default_embedder_hidden(monkeypatch):
"""Keep default embedders hidden after a settings change."""
from core.rag import config as rag_config
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
assert models_route._is_hidden_model("unsloth/bge-small-en-v1.5")
assert models_route._is_hidden_model("unsloth/bge-small-en-v1.5-GGUF")
assert models_route._is_hidden_model("/models/bge-small-en-v1.5")
assert models_route._is_hidden_model("/models/bge-small-en-v1.5-F16.gguf")
assert models_route._is_hidden_model(r"C:\models\bge-small-en-v1.5-Q8_0.gguf")
# Repo IDs still use exact matching, and similar local basenames must have
# a real separator after the static default name.
assert not models_route._is_hidden_model("user/bge-small-en-v1.5-chat")
assert not models_route._is_hidden_model("/models/bge-small-en-v1.50")
def test_is_hidden_model_keeps_env_default_hidden_after_override(monkeypatch):
"""A persisted override must not expose the deployment's env default."""
from core.rag import config as rag_config
monkeypatch.delenv("RAG_EMBED_GGUF_REPO", raising = False)
monkeypatch.setattr(rag_config, "EMBEDDING_MODEL", "org/env-default")
monkeypatch.setattr(rag_config, "effective_embedding_model", lambda: "org/custom")
monkeypatch.setattr(rag_config, "effective_gguf_repo", lambda: "org/custom-GGUF")
assert models_route._is_hidden_model("org/env-default")
assert models_route._is_hidden_model("org/env-default-GGUF")
assert models_route._is_hidden_model("org/custom")
assert models_route._is_hidden_model("org/custom-GGUF")
assert not models_route._is_hidden_model("org/env-default-chat")
def test_hidden_models_importable_without_heavy_model_stack():
"""The hub cache scanner imports ``is_hidden_model`` at module scope, so it
must not drag in ``utils/models/__init__`` (the model-config + checkpoint
stack). Verify in a clean interpreter that importing the helper touches
neither ``utils.models`` nor those heavy submodules, and still classifies
the probe."""
import os
import subprocess
import textwrap
backend = Path(__file__).resolve().parents[1]
code = textwrap.dedent(
"""
import sys
class _Blocker:
_blocked = (
"utils.models",
"utils.models.model_config",
"utils.models.checkpoints",
)
def find_spec(self, name, path=None, target=None):
if name in self._blocked:
raise ImportError("blocked heavy import: " + name)
return None
sys.meta_path.insert(0, _Blocker())
from utils.hidden_models import is_hidden_model
loaded = sorted(m for m in sys.modules if m.startswith("utils.models"))
assert not loaded, loaded
assert is_hidden_model("ggml-org/models") is True
assert is_hidden_model("unsloth/gemma-3-270m-it-GGUF") is False
print("HIDDEN_MODELS_IMPORT_OK")
"""
)
env = dict(os.environ, PYTHONPATH = str(backend))
proc = subprocess.run(
[sys.executable, "-c", code],
capture_output = True,
text = True,
env = env,
)
assert proc.returncode == 0, proc.stderr
assert "HIDDEN_MODELS_IMPORT_OK" in proc.stdout
def test_list_cached_gguf_hides_llama_validation_probe(monkeypatch, tmp_path):
"""The ggml-org/models / stories260K install validation probe can land in
the HF cache as a side effect of installing the prebuilt llama-server.
It is not a chat model (it sorts smallest and would be auto-selected), so
pickers must hide it while keeping real cached models."""
probe = _repo(
"ggml-org/models",
[_file("tinyllamas/stories260K.gguf", 1_000)],
tmp_path / "models--ggml-org--models",
)
real = _repo(
"unsloth/gemma-3-270m-it-GGUF",
[_file("gemma-3-270m-it-UD-Q4_K_XL.gguf", 200_000)],
tmp_path / "models--unsloth--gemma-3-270m-it-GGUF",
)
monkeypatch.setattr(
models_route, "_all_hf_cache_scans", lambda: [SimpleNamespace(repos = [probe, real])]
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
repo_ids = [c["repo_id"] for c in result["cached"]]
assert "ggml-org/models" not in repo_ids
assert "unsloth/gemma-3-270m-it-GGUF" in repo_ids
def test_list_cached_gguf_skips_repos_without_positive_gguf_size(monkeypatch, tmp_path):
missing = _repo(
"Org/ReadmeOnly",
[_file("README.md", 10)],
tmp_path / "models--Org--ReadmeOnly",
)
zero = _repo(
"Org/ZeroSize",
[_file("Q4_K_M.gguf", 0)],
tmp_path / "models--Org--ZeroSize",
)
scan = SimpleNamespace(repos = [missing, zero])
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(monkeypatch, tmp_path):
smaller = _repo(
"Org/Dupe",
[_file("Q4_K_M.gguf", 2_000)],
tmp_path / "models--Org--Dupe-a",
)
larger = _repo(
"org/dupe",
[_file("Q4_K_M.gguf", 5_000), _file("Q6_K.gguf", 1_000)],
tmp_path / "models--Org--Dupe-b",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [
SimpleNamespace(repos = [smaller]),
SimpleNamespace(repos = [larger]),
],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "org/dupe",
"size_bytes": 6_000,
"cache_path": str(larger.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp_path):
shared = "blobs/shared-q4"
repo = _repo(
"Org/SharedBlobRepo",
[],
tmp_path / "models--Org--SharedBlobRepo",
revisions = [
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
],
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [repo])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/SharedBlobRepo",
"size_bytes": 5_000,
"cache_path": str(repo.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_models_skips_non_suffix_repo_when_gguf_files_exist(monkeypatch, tmp_path):
mixed = _repo(
"Org/MixedRepo",
[
_file("Q4_K_M.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MixedRepo",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mixed])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_models_prefers_complete_over_larger_partial(monkeypatch, tmp_path):
# The same repo cached in two roots: a LARGER but PARTIAL copy must not shadow a SMALLER but
# COMPLETE one, or the picker (which drops partial rows) hides a usable model.
complete = _repo(
"Org/Dup",
[_file("model.safetensors", 10_000)],
tmp_path / "root_a" / "models--Org--Dup",
)
partial = _repo(
"Org/Dup",
[_file("model.safetensors", 15_000)],
tmp_path / "root_b" / "models--Org--Dup",
)
# The larger copy (root_b) is the partial one; the smaller (root_a) is complete.
monkeypatch.setattr(
models_route,
"_cached_repo_partial",
lambda repo_id, repo_cache_dir = None: "root_b" in str(repo_cache_dir),
)
monkeypatch.setattr(models_route, "_cached_repo_task", lambda repo_info: None)
# List the partial (larger) FIRST, so the old size-only rule would have picked it.
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [partial, complete])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
assert len(result["cached"]) == 1
row = result["cached"][0]
assert row["repo_id"] == "Org/Dup"
# The COMPLETE (smaller) copy won.
assert row.get("partial") is not True
assert row["size_bytes"] == 10_000
def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(monkeypatch, tmp_path):
"""Mixed repo still surfaces in cached-gguf as a GGUF download."""
mixed = _repo(
"Org/MixedRepo",
[
_file("Q4_K_M.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MixedRepo",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mixed])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/MixedRepo",
"size_bytes": 5_000,
"cache_path": str(mixed.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
"""``size_on_disk = None`` (partial download) is treated as zero, not a
TypeError from ``sum()`` that wipes the response."""
partial = _repo(
"Org/PartialDownload",
[_file("Q4_K_M.gguf", None), _file("Q6_K.gguf", 5_000)],
tmp_path / "models--Org--PartialDownload",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [partial])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/PartialDownload",
"size_bytes": 5_000,
"cache_path": str(partial.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(monkeypatch, tmp_path):
"""One repo raising during classification must not poison the response."""
class _ExplodingRepo:
repo_id = "Org/Broken"
repo_type = "model"
repo_path = tmp_path / "models--Org--Broken"
@property
def revisions(self):
raise RuntimeError("boom")
healthy = _repo(
"Org/Healthy",
[_file("Q4_K_M.gguf", 5_000)],
tmp_path / "models--Org--Healthy",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [_ExplodingRepo(), healthy])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Healthy",
"size_bytes": 5_000,
"cache_path": str(healthy.repo_path),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
"""A repo whose only ``.gguf`` is an mmproj vision adapter is not a GGUF
repo: mmproj is filtered out, leaving zero variants."""
mmproj_only = _repo(
"Org/MmprojOnly",
[
_file("mmproj-Q8_0.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MmprojOnly",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mmproj_only])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == []
def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
"""A safetensors repo with an auxiliary mmproj adapter still surfaces in
cached-models as a normal model."""
mmproj_aux = _repo(
"Org/MmprojAux",
[
_file("mmproj-Q8_0.gguf", 5_000),
_file("model.safetensors", 10_000),
],
tmp_path / "models--Org--MmprojAux",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [mmproj_aux])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
assert result["cached"] == [{"repo_id": "Org/MmprojAux", "size_bytes": 15_000, "task": None}]
def test_list_cached_models_tags_diffusers_pipeline_as_text_to_image(monkeypatch, tmp_path):
"""A cached diffusers pipeline repo (model_index.json present) is tagged
text-to-image so the chat picker hides it, while a plain checkpoint isn't."""
diffusion = _repo(
"Tongyi-MAI/Z-Image-Turbo",
[
_file("model_index.json", 1_000),
_file("text_encoder/model.safetensors", 9_000),
_file("transformer/diffusion_pytorch_model.safetensors", 9_000),
],
tmp_path / "models--Tongyi-MAI--Z-Image-Turbo",
)
checkpoint = _repo(
"unsloth/Llama-3.2-1B-Instruct",
[_file("config.json", 1_000), _file("model.safetensors", 9_000)],
tmp_path / "models--unsloth--Llama-3.2-1B-Instruct",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [diffusion, checkpoint])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
by_repo = {c["repo_id"]: c["task"] for c in result["cached"]}
assert by_repo == {
"Tongyi-MAI/Z-Image-Turbo": "text-to-image",
"unsloth/Llama-3.2-1B-Instruct": None,
}
def test_list_cached_models_marks_companion_only_pipeline_partial(monkeypatch, tmp_path):
"""A companion-only prefetch (VAE / text-encoder / model_index.json but no transformer) carries
a root model_index.json yet is not a loadable pipeline, so it must be marked partial. A sibling
repo that DOES ship its transformer shards stays complete."""
companion_only = _repo(
"black-forest-labs/FLUX.1-dev",
[
_file("model_index.json", 1_000),
_file("vae/diffusion_pytorch_model.safetensors", 9_000),
_file("text_encoder/model.safetensors", 9_000),
],
tmp_path / "models--black-forest-labs--FLUX.1-dev",
)
complete = _repo(
"Tongyi-MAI/Z-Image-Turbo",
[
_file("model_index.json", 1_000),
_file("text_encoder/model.safetensors", 9_000),
_file("transformer/diffusion_pytorch_model.safetensors", 9_000),
],
tmp_path / "models--Tongyi-MAI--Z-Image-Turbo",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [companion_only, complete])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
by_repo = {c["repo_id"]: c for c in result["cached"]}
assert by_repo["black-forest-labs/FLUX.1-dev"].get("partial") is True
assert by_repo["Tongyi-MAI/Z-Image-Turbo"].get("partial") is None
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeypatch, tmp_path):
"""A vision GGUF repo (main weight + mmproj) is a GGUF repo; reported size
is the main weight only, since mmproj is filtered at classification."""
vision_repo = _repo(
"Org/VisionGguf",
[
_file("Q4_K_M.gguf", 5_000),
_file("mmproj-Q8_0.gguf", 1_000),
],
tmp_path / "models--Org--VisionGguf",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [vision_repo])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/VisionGguf",
"size_bytes": 5_000,
"cache_path": str(vision_repo.repo_path),
"has_vision": True,
"task": None,
}
]
def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace:
"""A cached file carrying a Hugging Face ``blob_last_modified`` timestamp."""
return SimpleNamespace(
file_name = name,
size_on_disk = size,
blob_path = None,
blob_last_modified = mtime,
)
def test_all_hf_cache_scans_uses_shared_inventory(monkeypatch, tmp_path):
from hub.utils import inventory_scan
active = SimpleNamespace(
repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")]
)
monkeypatch.setattr(inventory_scan, "all_hf_cache_scans", lambda: [active])
scans = models_route._all_hf_cache_scans()
assert scans == [active]
# End-to-end: the endpoint still returns the active cache's repo.
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [active])
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert result["cached"] == [
{
"repo_id": "Org/Active",
"size_bytes": 5_000,
"cache_path": str(tmp_path / "active"),
"has_vision": False,
"task": None,
}
]
def test_list_cached_gguf_sorts_newest_first_grouping_by_latest_quant(monkeypatch, tmp_path):
"""Downloaded is ordered newest-first, and a multi-quant repo is placed by
its most recently downloaded quant (``last_modified`` = newest quant)."""
older = _repo(
"Org/Older",
[_gfile("Older-Q4_K_M.gguf", 5_000, 1_000.0)],
tmp_path / "models--Org--Older",
)
newer = _repo(
"Org/Newer",
[
_gfile("Newer-Q4_K_M.gguf", 5_000, 2_000.0),
_gfile("Newer-Q8_0.gguf", 9_000, 3_000.0), # newest quant in the repo
],
tmp_path / "models--Org--Newer",
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [older, newer])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
assert [c["repo_id"] for c in result["cached"]] == ["Org/Newer", "Org/Older"]
assert result["cached"][0]["last_modified"] == 3_000.0
assert result["cached"][1]["last_modified"] == 1_000.0
def test_list_cached_gguf_dedupe_keeps_newest_timestamp(monkeypatch, tmp_path):
"""Same repo in two caches with equal size keeps the newest last_modified,
regardless of scan order."""
older = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 1_000.0)], tmp_path / "a")
newer = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 9_000.0)], tmp_path / "b")
for scans in ([older, newer], [newer, older]): # both orders
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda s = scans: [SimpleNamespace(repos = [s[0]]), SimpleNamespace(repos = [s[1]])],
)
result = asyncio.run(models_route.list_cached_gguf(current_subject = "t"))
assert len(result["cached"]) == 1
assert result["cached"][0]["last_modified"] == 9_000.0
def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_path):
"""The per-quant 'downloaded' flag is driven by the real weight file in a
single snapshot; an mmproj vision adapter (matching a quant label) must
not make that quant appear downloaded."""
variants = [
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10_000,
),
SimpleNamespace(
filename = "model-F16.gguf",
quant = "F16",
display_label = None,
size_bytes = 20_000,
),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, True, []),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present
(snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16"
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
flags = {v.quant: v.downloaded for v in result.variants}
assert flags["Q4_K_M"] is True
assert flags["F16"] is False
def test_gguf_variants_route_scopes_local_probe_to_selected_cache(monkeypatch, tmp_path):
snapshot = tmp_path / "inactive" / "models--org--repo" / "snapshots" / "rev"
snapshot.mkdir(parents = True)
calls = []
async def scoped_variants(repo_id, **kwargs):
calls.append((repo_id, kwargs))
return SimpleNamespace(
repo_id = repo_id,
variants = [],
has_vision = False,
default_variant = None,
)
context_calls = []
monkeypatch.setattr(GV, "get_gguf_variants_response", scoped_variants)
monkeypatch.setattr(
models_route,
"_read_native_context_length",
lambda model, *, is_local: context_calls.append((model, is_local)) or 8192,
)
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo",
prefer_local_cache = True,
local_path = str(snapshot),
hf_token = None,
current_subject = "test-user",
)
)
assert calls == [
(
"org/repo",
{
"prefer_local_cache": True,
"local_path": str(snapshot),
"hf_token": None,
},
)
]
assert context_calls == [(str(snapshot), True)]
assert result.context_length == 8192
def test_gguf_variants_ignore_big_endian_siblings(monkeypatch, tmp_path):
siblings = [
SimpleNamespace(rfilename = "model-Q4_K_M-be.gguf", size = 100),
SimpleNamespace(rfilename = "model-Q4_K_M.gguf", size = 10),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (
[
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10,
)
],
False,
siblings,
),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
assert [(v.quant, v.filename, v.size_bytes, v.downloaded) for v in result.variants] == [
("Q4_K_M", "model-Q4_K_M.gguf", 10, True)
]
def test_gguf_variants_cached_big_endian_does_not_satisfy_variant(monkeypatch, tmp_path):
variants = [
SimpleNamespace(
filename = "model-Q4_K_M.gguf",
quant = "Q4_K_M",
display_label = None,
size_bytes = 10,
),
]
monkeypatch.setattr(
GV,
"list_gguf_variants",
lambda repo_id, hf_token = None: (variants, False, []),
)
monkeypatch.setattr(
GV,
"_local_main_gguf_blobs_by_quant",
lambda _repo_id, repo_cache_dir = None: {},
)
snap = tmp_path / "models--org--repo" / "snapshots" / "rev"
snap.mkdir(parents = True)
(snap / "model-Q4_K_M-be.gguf").write_bytes(b"x" * 10)
monkeypatch.setattr(GV, "iter_hf_cache_snapshots", lambda _repo_id, root = None: [snap])
result = asyncio.run(
models_route.get_gguf_variants(
repo_id = "org/repo", hf_token = None, current_subject = "test-user"
)
)
assert result.variants[0].downloaded is False
def test_legacy_gguf_progress_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(repo_id, *, variant, expected_bytes, hf_token):
calls.append((repo_id, variant, expected_bytes, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
monkeypatch.setattr(
"hub.services.models.downloads.get_gguf_download_progress_response",
shared,
)
result = asyncio.run(
models_route.get_gguf_download_progress(
repo_id = "org/repo",
variant = "Q4_K_M",
expected_bytes = 20,
hf_token = "token",
current_subject = "test-user",
)
)
assert result["progress"] == 0.5
assert calls == [("org/repo", "Q4_K_M", 20, "token")]
def test_legacy_model_progress_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(repo_id, *, hf_token):
calls.append((repo_id, hf_token))
return {"downloaded_bytes": 10, "expected_bytes": 20, "progress": 0.5}
monkeypatch.setattr(
"hub.services.models.downloads.get_download_progress_response",
shared,
)
result = asyncio.run(
models_route.get_download_progress(
repo_id = "org/repo",
hf_token = "token",
current_subject = "test-user",
)
)
assert result["progress"] == 0.5
assert calls == [("org/repo", "token")]
def test_legacy_delete_delegates_to_shared_service(monkeypatch):
calls = []
async def shared(
repo_id,
variant,
hf_token,
cache_path = None,
):
calls.append((repo_id, variant, hf_token, cache_path))
return {"status": "deleted", "repo_id": repo_id}
monkeypatch.setattr(
"hub.services.models.deletion.delete_cached_model_response",
shared,
)
result = asyncio.run(
models_route.delete_cached_model(
repo_id = "org/repo",
variant = None,
cache_path = "/data/hf/hub",
hf_token = "token",
current_subject = "test-user",
)
)
assert result == {"status": "deleted", "repo_id": "org/repo"}
assert calls == [("org/repo", None, "token", "/data/hf/hub")]
def test_arch_to_task_hides_unsupported_diffusion_from_chat():
assert models_route._arch_to_task("flux") == "text-to-image"
assert models_route._arch_to_task("z_image") == "text-to-image"
assert models_route._arch_to_task("qwen_image") == "text-to-image"
assert models_route._arch_to_task("llama") == "text-generation"
assert models_route._arch_to_task(None) is None
# Known-but-unsupported diffusion archs get a task that is NEITHER chat nor a loadable image
# task, so the chat picker hides them and the Images picker leaves them out.
for arch in ("sdxl", "sd1", "sd3", "lumina2", "hidream", "cosmos", "hyvid"):
task = models_route._arch_to_task(arch)
assert task == models_route._UNSUPPORTED_DIFFUSION_TASK
assert task not in ("text-generation", "text-to-image")
# A video arch with a REGISTERED VideoFamily surfaces with the Video-picker task.
assert models_route._arch_to_task("ltxv") == models_route._VIDEO_GEN_TASK
assert models_route._arch_to_task("ltxv") not in ("text-generation", "text-to-image")
# A video arch that does not resolve from the bare arch alone ("wan" covers both the loadable
# TI2V-5B and the A14B MoE) stays unsupported when no name is available to disambiguate.
assert models_route._arch_to_task("wan") == models_route._UNSUPPORTED_DIFFUSION_TASK
assert models_route._arch_to_task("wan") not in ("text-generation", "text-to-image")
# With a repo/file name hint, the loadable TI2V-5B Wan GGUF resolves to the Video task while the
# A14B MoE stays unsupported, matching the loader's own name-aware detection.
assert (
models_route._arch_to_task("wan", ("QuantStack/Wan2.2-TI2V-5B-GGUF",))
== models_route._VIDEO_GEN_TASK
)
assert (
models_route._arch_to_task("wan", (None, "Wan2.2-TI2V-5B-Q4_K_M.gguf"))
== models_route._VIDEO_GEN_TASK
)
assert (
models_route._arch_to_task("wan", ("QuantStack/Wan2.2-T2V-A14B-GGUF",))
== models_route._UNSUPPORTED_DIFFUSION_TASK
)
# Drift guard: every diffusion arch llama.cpp rejects as a chat model must classify here as some
# non-chat task (image, video, or unsupported).
from core.inference.llama_cpp import LlamaCppBackend
classified = (
models_route._DIFFUSION_GGUF_ARCHS
| models_route._UNSUPPORTED_DIFFUSION_GGUF_ARCHS
| models_route._AMBIGUOUS_DIFFUSION_GGUF_ARCHS
| models_route._VIDEO_GGUF_ARCHS
)
missing = {a for a in LlamaCppBackend._DIFFUSION_ARCHES if a.lower() not in classified}
assert not missing, f"diffusion archs would still show in chat: {missing}"
def test_arch_to_task_resolves_z_image_gguf_tagged_lumina2():
# Z-Image's DiT is a Lumina2 derivative, so unsloth/Z-Image-GGUF and unsloth/Z-Image-Turbo-GGUF
# both declare general.architecture = "lumina2". Reading the arch alone tagged the whole
# Z-Image GGUF line unsupported and hid it from the Images "On Device" list, even though
# validate_load_request loads it happily.
for repo, fname in (
("unsloth/Z-Image-Turbo-GGUF", "z-image-turbo-Q4_K_M.gguf"),
("unsloth/Z-Image-GGUF", "z-image-Q8_0.gguf"),
):
assert models_route._arch_to_task("lumina2", (repo, fname)) == "text-to-image"
# The filename alone carries the family for a bare local .gguf pick.
assert models_route._arch_to_task("lumina2", (None, fname)) == "text-to-image"
# An unrecognised repo on the shared arch stays hidden rather than being guessed loadable.
assert (
models_route._arch_to_task("lumina2", ("someone/mystery-gguf", "model-Q4_K.gguf"))
== models_route._UNSUPPORTED_DIFFUSION_TASK
)
def test_arch_to_task_agrees_with_the_loader_on_ambiguous_archs():
# The picker and the loader must not disagree: whatever _arch_to_task advertises as a loadable
# image model on a shared arch, validate_load_request must accept, and whatever it hides,
# validate_load_request must reject. Otherwise the Images list either hides a working model
# (the Z-Image bug) or offers one that 400s on click.
from core.inference.diffusion import DiffusionBackend
from core.inference.diffusion_families import _FAMILIES
backend = DiffusionBackend.__new__(DiffusionBackend) # validation touches no state
for fam in _FAMILIES:
repo = f"unsloth/{fam.name}-GGUF"
fname = f"{fam.name}-Q4_K_M.gguf"
task = models_route._arch_to_task("lumina2", (repo, fname))
try:
backend.validate_load_request(repo, gguf_filename = fname, model_kind = "gguf")
loader_accepts = True
except (ValueError, FileNotFoundError):
loader_accepts = False
assert (
task == "text-to-image"
) == loader_accepts, f"{fam.name}: picker task={task} but loader accepts={loader_accepts}"
def _clear_chat_delete_guards(monkeypatch):
"""Report chat + orchestrator idle so only the Images / Video guards can refuse a delete."""
import core.inference as core_inference
import routes.inference as routes_inference
monkeypatch.setattr(
routes_inference,
"get_llama_cpp_backend",
lambda: SimpleNamespace(
is_active = False,
is_loaded = False,
model_identifier = None,
hf_variant = None,
),
)
monkeypatch.setattr(
core_inference,
"get_inference_backend",
lambda: SimpleNamespace(active_model_name = None),
)
def _idle_video_backend():
return SimpleNamespace(
status = lambda: {"loaded": False, "repo_id": None},
loading_repo_ids = lambda: (),
)
def _idle_diffusion_engine():
return SimpleNamespace(
status = lambda: {"loaded": False, "repo_id": None},
loaded_repo_ids = lambda: (),
loading_repo_ids = lambda: (),
)
def test_delete_cached_refuses_diffusion_loaded_repo(monkeypatch):
# The cached-delete guard refuses deleting a repo the diffusion (Images) backend has loaded, so
# its GGUF can't be removed from under a live pipeline.
from fastapi import HTTPException
from hub.services.models import deletion
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
_clear_chat_delete_guards(monkeypatch)
monkeypatch.setattr(
der,
"get_active_diffusion_engine",
lambda: SimpleNamespace(
status = lambda: {"loaded": True, "repo_id": "org/Z-Image-GGUF"},
loaded_repo_ids = lambda: (),
loading_repo_ids = lambda: (),
),
)
monkeypatch.setattr(video_mod, "get_video_backend", _idle_video_backend)
try:
asyncio.run(deletion.delete_cached_model_response("org/Z-Image-GGUF"))
assert False, "expected HTTPException refusing the delete"
except HTTPException as e:
assert e.status_code == 400
assert "Unload the model before deleting" in e.detail
def test_delete_cached_refuses_video_loaded_repo(monkeypatch):
# Same for the Video backend, which shares the On-Device GGUF delete UI with chat/Images.
from fastapi import HTTPException
from hub.services.models import deletion
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
_clear_chat_delete_guards(monkeypatch)
monkeypatch.setattr(der, "get_active_diffusion_engine", _idle_diffusion_engine)
monkeypatch.setattr(
video_mod,
"get_video_backend",
lambda: SimpleNamespace(
status = lambda: {"loaded": True, "repo_id": "unsloth/LTX-2.3-GGUF"},
loading_repo_ids = lambda: (),
),
)
try:
asyncio.run(deletion.delete_cached_model_response("unsloth/LTX-2.3-GGUF"))
assert False, "expected HTTPException refusing the delete"
except HTTPException as e:
assert e.status_code == 400
assert "Unload the model before deleting" in e.detail
def test_delete_cached_refuses_loaded_native_companion_repo(monkeypatch):
# The native sd.cpp one-shot engine re-reads its companion VAE / text-encoder files every
# generation, so deleting a companion repo while a FLUX GGUF is loaded must be refused. The
# loaded repo_id does not match the companion, so the guard relies on loaded_repo_ids().
from fastapi import HTTPException
from hub.services.models import deletion
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
_clear_chat_delete_guards(monkeypatch)
monkeypatch.setattr(
der,
"get_active_diffusion_engine",
lambda: SimpleNamespace(
status = lambda: {"loaded": True, "repo_id": "unsloth/FLUX.1-dev-GGUF"},
loaded_repo_ids = lambda: (
"unsloth/FLUX.1-dev-GGUF",
"black-forest-labs/FLUX.1-dev",
"comfyanonymous/flux_text_encoders",
),
loading_repo_ids = lambda: (),
),
)
monkeypatch.setattr(video_mod, "get_video_backend", _idle_video_backend)
try:
asyncio.run(deletion.delete_cached_model_response("comfyanonymous/flux_text_encoders"))
assert False, "expected HTTPException refusing the in-use companion delete"
except HTTPException as e:
assert e.status_code == 400
assert "Unload the model before deleting" in e.detail
def test_delete_cached_refuses_repo_a_diffusion_load_is_downloading(monkeypatch):
# status().loaded is still False while a background Images load DOWNLOADS the repo, but deleting
# then would remove blobs from under it, so loading_repo_ids() must refuse.
from fastapi import HTTPException
from hub.services.models import deletion
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
_clear_chat_delete_guards(monkeypatch)
monkeypatch.setattr(
der,
"get_active_diffusion_engine",
lambda: SimpleNamespace(
status = lambda: {"loaded": False, "repo_id": None},
loaded_repo_ids = lambda: (),
loading_repo_ids = lambda: ("unsloth/Qwen-Image-2512-GGUF",),
),
)
monkeypatch.setattr(video_mod, "get_video_backend", _idle_video_backend)
try:
asyncio.run(deletion.delete_cached_model_response("unsloth/Qwen-Image-2512-GGUF"))
assert False, "expected HTTPException refusing the delete mid-download"
except HTTPException as e:
assert e.status_code == 400
assert "An Images model load is using this repo" in e.detail
def test_delete_cached_allows_sibling_of_loaded_diffusion_repo(monkeypatch):
# A loaded Images repo must not block deleting a DIFFERENT cached repo that merely shares a name
# prefix (Qwen/Qwen-Image vs Qwen/Qwen-Image-2512). The guard is `/`-boundary aware.
from fastapi import HTTPException
from hub.services.models import deletion
import core.inference.diffusion_engine_router as der
import core.inference.video as video_mod
_clear_chat_delete_guards(monkeypatch)
monkeypatch.setattr(
der,
"get_active_diffusion_engine",
lambda: SimpleNamespace(
status = lambda: {"loaded": True, "repo_id": "Qwen/Qwen-Image-2512"},
loaded_repo_ids = lambda: (),
loading_repo_ids = lambda: (),
),
)
monkeypatch.setattr(video_mod, "get_video_backend", _idle_video_backend)
# Stub the destructive stage: this test is about the guard boundary, not the cache walk.
monkeypatch.setattr(
deletion,
"_delete_cached_model_blocking",
lambda repo_id, variant, hf_token, cache_path = None: {
"status": "deleted",
"repo_id": repo_id,
},
)
# The sibling repo clears every guard and reaches the delete.
result = asyncio.run(deletion.delete_cached_model_response("Qwen/Qwen-Image"))
assert result == {"status": "deleted", "repo_id": "Qwen/Qwen-Image"}
# The loaded repo itself is still refused (exact match).
try:
asyncio.run(deletion.delete_cached_model_response("Qwen/Qwen-Image-2512"))
assert False, "expected HTTPException refusing delete of the loaded repo"
except HTTPException as e:
assert e.status_code == 400
assert "Unload the model before deleting" in e.detail
def test_cached_repo_partial_scopes_probe_to_snapshot_dir(monkeypatch):
# The partial probe must be scoped to the snapshot row being listed: unscoped, a stale
# .incomplete copy in one cache root would flag a complete copy in another as partial and hide
# the usable model.
import hub.utils.inventory_scan as scan
calls = []
def _fake(
repo_type,
repo_id,
repo_cache_dir = None,
):
calls.append((repo_type, repo_id, repo_cache_dir))
return False
monkeypatch.setattr(scan, "is_snapshot_partial", _fake)
snapshot_dir = Path("/root_a/models--Org--Repo/snapshots/abc")
assert models_route._cached_repo_partial("Org/Repo", snapshot_dir) is False
assert calls == [("model", "Org/Repo", snapshot_dir)]
monkeypatch.setattr(scan, "is_snapshot_partial", lambda *a, **k: True)
assert models_route._cached_repo_partial("Org/Repo", snapshot_dir) is True
# A probe error is swallowed (never hides a usable repo over a scan glitch).
def _boom(*a, **k):
raise RuntimeError("scan glitch")
monkeypatch.setattr(scan, "is_snapshot_partial", _boom)
assert models_route._cached_repo_partial("Org/Repo", snapshot_dir) is False
def test_repo_has_pipeline_index_requires_root_model_index(tmp_path):
# Only a ROOT model_index.json makes a repo pipeline-loadable, so a nested subdir one must NOT
# clear the single_file flag. CachedFileInfo.file_name is the basename, so the helper scopes by
# snapshot path -- a name-only match would claim both.
snap = tmp_path / "snapshots" / "abc"
nested = SimpleNamespace(
file_name = "model_index.json",
file_path = snap / "prior" / "model_index.json",
)
repo_nested = SimpleNamespace(
repo_id = "unsloth/nested-index",
revisions = [SimpleNamespace(files = [nested], snapshot_path = snap)],
)
assert models_route._repo_has_pipeline_index(repo_nested) is False
root = SimpleNamespace(
file_name = "model_index.json",
file_path = snap / "model_index.json",
)
repo_root = SimpleNamespace(
repo_id = "unsloth/root-index",
revisions = [SimpleNamespace(files = [root], snapshot_path = snap)],
)
assert models_route._repo_has_pipeline_index(repo_root) is True
def test_pipeline_scans_read_the_snapshot_the_loader_will_open(tmp_path):
# A repo cached twice -- an older complete snapshot plus a newer companion-only one, the shape a
# GGUF load leaves when it prefetches the base repo's VAE / text encoder and skips the
# transformer -- must be judged on the snapshot from_pretrained resolves, i.e. the newest by
# mtime. Scanning every revision let the OLD snapshot's transformer satisfy completeness, so the
# row read as on-device while the load would fail offline or silently pull multi-GB weights.
import os
import hub.utils.inventory_scan as scan
repo_dir = tmp_path / "models--Org--Repo"
old_snap = repo_dir / "snapshots" / "old"
new_snap = repo_dir / "snapshots" / "new"
for d in (old_snap / "transformer", new_snap / "vae"):
d.mkdir(parents = True)
(old_snap / "model_index.json").write_text("{}", encoding = "utf-8")
(new_snap / "model_index.json").write_text("{}", encoding = "utf-8")
# Make "new" unambiguously newer than "old" for the mtime rule both this and the loader use.
os.utime(old_snap, (1_000_000, 1_000_000))
os.utime(new_snap, (2_000_000, 2_000_000))
def _rev(snap, files):
return SimpleNamespace(
snapshot_path = snap,
last_modified = float(snap.stat().st_mtime),
files = [SimpleNamespace(file_name = Path(f).name, file_path = snap / f) for f in files],
)
info = SimpleNamespace(
repo_id = "Org/Repo",
repo_path = repo_dir,
revisions = [
_rev(old_snap, ["model_index.json", "transformer/diffusion_pytorch_model.safetensors"]),
_rev(new_snap, ["model_index.json", "vae/diffusion_pytorch_model.safetensors"]),
],
)
assert scan.repo_has_pipeline_index(info) is True
assert scan.repo_pipeline_missing_denoiser(info) is True
# The reverse cache (the complete snapshot is the newer one) still reports complete.
os.utime(old_snap, (3_000_000, 3_000_000))
info.revisions = [
_rev(old_snap, ["model_index.json", "transformer/diffusion_pytorch_model.safetensors"]),
_rev(new_snap, ["model_index.json", "vae/diffusion_pytorch_model.safetensors"]),
]
assert scan.repo_pipeline_missing_denoiser(info) is False
def test_list_cached_models_flags_single_file_diffusion_repos(monkeypatch, tmp_path):
# A diffusion-tagged repo with NO top-level model_index.json is a single-file checkpoint, so it
# carries single_file=True; a full pipeline repo and a chat repo carry no flag.
single = _repo(
"unsloth/Qwen-Image-fp8-single",
[_file("qwen-image-fp8.safetensors", 10_000)],
tmp_path / "models--unsloth--Qwen-Image-fp8-single",
)
pipeline = _repo(
"unsloth/Qwen-Image-pipeline",
[_file("model_index.json", 10), _file("transformer/model.safetensors", 10_000)],
tmp_path / "models--unsloth--Qwen-Image-pipeline",
)
chat = _repo(
"Org/ChatRepo",
[_file("model.safetensors", 10_000)],
tmp_path / "models--Org--ChatRepo",
)
monkeypatch.setattr(
models_route,
"_cached_repo_task",
lambda repo_info: ("text-to-image" if "Qwen-Image" in repo_info.repo_id else None),
)
monkeypatch.setattr(
models_route,
"_all_hf_cache_scans",
lambda: [SimpleNamespace(repos = [single, pipeline, chat])],
)
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
rows = {r["repo_id"]: r for r in result["cached"]}
assert rows["unsloth/Qwen-Image-fp8-single"].get("single_file") is True
assert "single_file" not in rows["unsloth/Qwen-Image-pipeline"]
assert "single_file" not in rows["Org/ChatRepo"]
def _pipeline_repo(repo_id: str, tmp_path: Path) -> SimpleNamespace:
return _repo(
repo_id,
[
_file("model_index.json", 1_000),
_file("transformer/diffusion_pytorch_model.safetensors", 5_000_000),
],
tmp_path / f"models--{repo_id.replace('/', '--')}",
)
def test_cached_repo_task_gates_an_image_pipeline_on_the_load_path_trust_rule(tmp_path):
"""Every advertised row must be loadable. A cached community pipeline has a model_index.json
like any other, so tagging it text-to-image put a row in the Images picker that the loader's
trust check refuses -- the pick 400s. Gate the tag on the same rule."""
assert models_route._cached_repo_task(_pipeline_repo("unsloth/Qwen-Image", tmp_path)) == (
"text-to-image"
)
assert (
models_route._cached_repo_task(_pipeline_repo("someone/their-sdxl-mix", tmp_path)) is None
)
def test_cached_repo_task_hides_an_untrusted_video_repo_instead_of_listing_it_under_images(
monkeypatch, tmp_path
):
"""A detected video pipeline that fails the video trust rule used to fall through to the image
fallback and show up in the Images picker, where it is just as unloadable."""
import core.inference.video as video_mod
repo = _pipeline_repo("someone/their-ltx-fork", tmp_path)
monkeypatch.setattr(
"core.inference.video_families.detect_video_family",
lambda repo_id: object(),
)
monkeypatch.setattr(video_mod, "_is_trusted_video_repo", lambda repo_id: False)
assert models_route._cached_repo_task(repo) is None
monkeypatch.setattr(video_mod, "_is_trusted_video_repo", lambda repo_id: True)
assert models_route._cached_repo_task(repo) == models_route._VIDEO_GEN_TASK
def test_hub_cached_rows_carry_the_task_the_pickers_filter_on(monkeypatch, tmp_path):
"""The picker's On Device rows come from the /api/hub inventory, not the models API. Without a
task on those rows the Images and Video pickers filtered every one of them out, and the chat
picker's diffusion routing (which reads the same field) never fired."""
from hub.schemas.inventory import CachedGgufRepo, CachedModelRepo
from hub.services.models import cache_inventory
assert "task" in CachedGgufRepo.model_fields
assert "task" in CachedModelRepo.model_fields
repo = _pipeline_repo("unsloth/Qwen-Image", tmp_path)
monkeypatch.setattr(
"routes.models._cached_repo_task", lambda repo_info: "text-to-image", raising = True
)
assert cache_inventory._cached_row_task(repo, gguf = False) == "text-to-image"
monkeypatch.setattr(
"routes.models._repo_gguf_task", lambda repo_info: "text-generation", raising = True
)
assert cache_inventory._cached_row_task(repo, gguf = True) == "text-generation"
def test_hub_cached_row_task_never_hides_a_row_when_classification_fails(monkeypatch, tmp_path):
# Best-effort, like the models API: a classifier that raises leaves the row untagged rather
# than dropping it from the listing.
from hub.services.models import cache_inventory
def _boom(repo_info):
raise RuntimeError("unreadable")
monkeypatch.setattr("routes.models._cached_repo_task", _boom, raising = True)
assert cache_inventory._cached_row_task(_pipeline_repo("a/b", tmp_path), gguf = False) is None
def test_hub_local_rows_are_tagged_with_their_task():
"""/api/hub/local feeds the same pickers, and its rows were untagged too."""
import inspect
from hub.schemas.inventory import LocalModelInfo
from hub.services.models import local_inventory
assert "task" in LocalModelInfo.model_fields
src = inspect.getsource(local_inventory.list_local_models_response)
assert "_local_model_task" in src
assert 'model_copy(update = {"task"' in src
def test_pipeline_class_guard_fires_before_any_download():
# The 0.39-only families (Flux2Klein, Z-Image, Krea 2, LTX-2, HunyuanImage) used to die with a
# bare AttributeError deep in the load, after the checkpoint had been fetched, on the older
# diffusers that packaging still allows on Python 3.9. Validation refuses first, with the
# version and the fix in the message.
import pytest
from core.inference.diffusion_families import _FAMILIES, assert_pipeline_class_available
# Present -> no raise (every shipped family resolves on a current diffusers).
import diffusers
for fam in _FAMILIES:
assert_pipeline_class_available(fam.pipeline_class, fam.name)
stub = types.SimpleNamespace(__version__ = "0.37.0")
real = sys.modules.get("diffusers")
sys.modules["diffusers"] = stub
try:
with pytest.raises(RuntimeError) as excinfo:
assert_pipeline_class_available("ZImagePipeline", "z-image")
finally:
if real is not None:
sys.modules["diffusers"] = real
else: # pragma: no cover
del sys.modules["diffusers"]
msg = str(excinfo.value)
assert "z-image" in msg and "ZImagePipeline" in msg
assert "0.39" in msg and "0.37.0" in msg
assert "3.10" in msg # names the Python floor that carries a new enough diffusers
assert diffusers is not None
def test_cached_pipeline_needs_a_detectable_image_family(monkeypatch):
# A top-level model_index.json only proves the repo is a diffusers pipeline. An unsloth-hosted
# pipeline of a class this backend cannot assemble cleared the trust gate, was advertised to the
# Images picker as text-to-image, and then deterministically failed validate_load_request, which
# resolves the family the same way. Both gates now, mirroring the video branch above.
monkeypatch.setattr(models_route, "_repo_has_pipeline_index", lambda info: True)
def _task(repo_id):
return models_route._cached_repo_task(SimpleNamespace(repo_id = repo_id, repo_path = "/x"))
# Trusted AND a detected family -> claimed by Images.
assert _task("unsloth/Z-Image-Turbo") == "text-to-image"
assert _task("unsloth/FLUX.1-dev") == "text-to-image"
# Trusted but no image family the loader can detect -> not advertised.
assert _task("unsloth/some-unsupported-pipeline") is None
# Untrusted keeps its existing refusal.
assert _task("someone/random-diffusers-pipeline") is None
def test_cached_repo_task_agrees_with_the_image_loader(monkeypatch):
# Same invariant as the GGUF arch test: whatever the picker advertises as a loadable image
# model, validate_load_request must accept.
from core.inference.diffusion import DiffusionBackend
monkeypatch.setattr(models_route, "_repo_has_pipeline_index", lambda info: True)
backend = DiffusionBackend.__new__(DiffusionBackend)
for repo_id in (
"unsloth/Z-Image-Turbo",
"unsloth/FLUX.1-dev",
"unsloth/some-unsupported-pipeline",
"unsloth/stable-audio-open-1.0",
):
task = models_route._cached_repo_task(SimpleNamespace(repo_id = repo_id, repo_path = "/x"))
try:
backend.validate_load_request(repo_id)
loader_accepts = True
except (ValueError, FileNotFoundError, RuntimeError):
loader_accepts = False
assert (
task == "text-to-image"
) == loader_accepts, f"{repo_id}: picker task={task} but loader accepts={loader_accepts}"
def test_cached_picker_hides_a_family_this_diffusers_cannot_build(monkeypatch):
# The newer families exist only from diffusers 0.39, which cannot be installed on Python 3.9 at
# all (diffusers dropped 3.9 in 0.38). Advertising one there is a pick that can only fail, and
# `pip install -U diffusers` cannot fix it without a Python upgrade -- so the picker applies the
# same availability check validate_load_request does.
import types
import routes.models as models_module
from core.inference.diffusion_families import detect_family, family_pipeline_available
fam = detect_family("unsloth/Z-Image-Turbo")
assert fam is not None
# Present in this environment's diffusers, so the row is offered.
assert family_pipeline_available(fam) is True
monkeypatch.setattr(models_module, "_repo_is_diffusers", lambda info: True)
monkeypatch.setattr("core.inference.diffusion._is_trusted_diffusion_repo", lambda repo_id: True)
info = types.SimpleNamespace(repo_id = "unsloth/Z-Image-Turbo")
assert models_module._cached_repo_task(info) == "text-to-image"
# An older diffusers without the pipeline class hides the row instead.
monkeypatch.setattr(
"core.inference.diffusion_families.family_pipeline_available", lambda f: False
)
assert models_module._cached_repo_task(info) is None
def test_family_pipeline_available_fails_open_without_diffusers(monkeypatch):
# No diffusers at all is a different problem; the load path reports it properly, and a listing
# must not silently hide every image model over it.
import sys
from core.inference.diffusion_families import detect_family, family_pipeline_available
monkeypatch.setitem(sys.modules, "diffusers", None)
assert family_pipeline_available(detect_family("unsloth/Z-Image-Turbo")) is True
assert family_pipeline_available(None) is False