From a2bbcd92fa0b860478efb779898cc49e12c96593 Mon Sep 17 00:00:00 2001 From: Marcel Hofer <49859266+goodguy1963@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:33:18 +0200 Subject: [PATCH] fix: route GGUF-only Gemma models safely --- gguf_models.json | 92 +++++++++++++++++++++ hf_models.json | 44 ---------- tests/test_new_features.py | 133 ++++++++++++++++++++++++++++++- thinkingllm_core/hf_models.py | 12 +-- thinkingllm_core/model_access.py | 96 +++++++++++++++------- 5 files changed, 292 insertions(+), 85 deletions(-) diff --git a/gguf_models.json b/gguf_models.json index d42dc4a..9eaec11 100644 --- a/gguf_models.json +++ b/gguf_models.json @@ -366,6 +366,46 @@ "defaults": { "context_length": 131072 } + }, + "Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": { + "author": "HauhauCS", + "repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", + "repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", + "commercial_status": "unclear", + "model_files": [ + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf" + ], + "defaults": { + "context_length": 131072 + } + }, + "Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": { + "author": "HauhauCS", + "repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", + "repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", + "commercial_status": "unclear", + "model_files": [ + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf" + ], + "defaults": { + "context_length": 131072 + } } }, "qwenVL_model": { @@ -611,6 +651,58 @@ "pool_size": 4194304 } }, + "Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": { + "author": "HauhauCS", + "repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", + "repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", + "commercial_status": "unclear", + "mmproj_file": "mmproj-Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-f16.gguf", + "model_files": [ + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf", + "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf" + ], + "defaults": { + "context_length": 131072, + "image_max_tokens": 4096, + "n_batch": 512, + "gpu_layers": -1, + "top_k": 64, + "pool_size": 4194304 + } + }, + "Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": { + "author": "HauhauCS", + "repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", + "repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", + "commercial_status": "unclear", + "mmproj_file": "mmproj-Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-f16.gguf", + "model_files": [ + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf", + "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf" + ], + "defaults": { + "context_length": 131072, + "image_max_tokens": 4096, + "n_batch": 512, + "gpu_layers": -1, + "top_k": 64, + "pool_size": 4194304 + } + }, "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF [Q4:2.4GB|Q5:2.8GB|Q6:3.2GB|Q8:4.2GB|F16:8GB|VRAM:~4GB]": { "author": "mradermacher", "repo_name": "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF", diff --git a/hf_models.json b/hf_models.json index f65c873..81d6ceb 100644 --- a/hf_models.json +++ b/hf_models.json @@ -209,28 +209,6 @@ "8bit": 22.0, "4bit": 14.0 } - }, - "Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": { - "repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", - "commercial_status": "unclear", - "default": false, - "quantized": false, - "vram_requirement": { - "full": 7.0, - "8bit": 4.5, - "4bit": 2.5 - } - }, - "Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": { - "repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", - "commercial_status": "unclear", - "default": false, - "quantized": false, - "vram_requirement": { - "full": 4.0, - "8bit": 2.5, - "4bit": 1.5 - } } }, "hf_text_models": { @@ -397,28 +375,6 @@ "8bit": 22.0, "4bit": 14.0 } - }, - "Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": { - "repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive", - "commercial_status": "unclear", - "default": false, - "quantized": false, - "vram_requirement": { - "full": 7.0, - "8bit": 4.5, - "4bit": 2.5 - } - }, - "Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": { - "repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive", - "commercial_status": "unclear", - "default": false, - "quantized": false, - "vram_requirement": { - "full": 4.0, - "8bit": 2.5, - "4bit": 1.5 - } } } } diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 8500305..29f6ee3 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -106,6 +106,18 @@ def build_loader_test_stubs() -> dict[str, types.ModuleType]: ), "huggingface_hub": build_stub_module( "huggingface_hub", + HfApi=type( + "HfApi", + (), + { + "__init__": lambda self, *args, **kwargs: None, + "list_repo_files": lambda self, *args, **kwargs: [ + "config.json", + "model.safetensors", + "processor_config.json", + ], + }, + ), hf_hub_download=lambda *args, **kwargs: "", snapshot_download=lambda *args, **kwargs: "", ), @@ -759,7 +771,11 @@ class TestModelRecommendations(unittest.TestCase): def test_gemma4_e2b_text_catalog_uses_current_hugging_face_files(self): payload = json.loads((PKG / "gguf_models.json").read_text(encoding="utf-8")) - entries = [entry for key, entry in (payload.get("Qwen_model") or {}).items() if "Gemma-4-E2B" in key] + entries = [ + entry + for key, entry in (payload.get("Qwen_model") or {}).items() + if "Gemma-4-E2B" in key and "Aggressive" not in key + ] self.assertTrue(entries) for entry in entries: @@ -770,6 +786,33 @@ class TestModelRecommendations(unittest.TestCase): "google_gemma-4-E2B-it-bf16.gguf", ]) + def test_hauhaucs_gemma4_gguf_only_models_are_not_in_transformers_catalogs(self): + hf_payload = json.loads((PKG / "hf_models.json").read_text(encoding="utf-8")) + gguf_payload = json.loads((PKG / "gguf_models.json").read_text(encoding="utf-8")) + expected = { + "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive": { + "model": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf", + "mmproj": "mmproj-Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-f16.gguf", + }, + "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive": { + "model": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf", + "mmproj": "mmproj-Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-f16.gguf", + }, + } + + for hf_section in ("hf_vl_models", "hf_text_models"): + repo_ids = {entry.get("repo_id") for entry in (hf_payload.get(hf_section) or {}).values()} + self.assertTrue(expected.keys().isdisjoint(repo_ids)) + + for repo_id, filenames in expected.items(): + text_entries = [entry for entry in (gguf_payload.get("Qwen_model") or {}).values() if entry.get("repo_id") == repo_id] + vision_entries = [entry for entry in (gguf_payload.get("qwenVL_model") or {}).values() if entry.get("repo_id") == repo_id] + self.assertEqual(1, len(text_entries)) + self.assertEqual(1, len(vision_entries)) + self.assertIn(filenames["model"], text_entries[0]["model_files"]) + self.assertIn(filenames["model"], vision_entries[0]["model_files"]) + self.assertEqual(filenames["mmproj"], vision_entries[0]["mmproj_file"]) + def test_qwen3_asr_gguf_catalog_is_removed(self): gguf_payload = json.loads((PKG / "gguf_models.json").read_text(encoding="utf-8")) vision_catalog = gguf_payload.get("qwenVL_model") or {} @@ -2103,6 +2146,94 @@ class TestHuggingFaceTokenSupport(unittest.TestCase): self.assertIn("", message) self.assertIn("access was rejected", message) + def test_hf_snapshot_rejects_gguf_only_repo_before_download_and_preserves_target(self): + module = self._load_qwenvl_with_stubs() + access_globals = module._download_model_snapshot.__globals__ + snapshot_download_mock = mock.Mock(side_effect=AssertionError("GGUF-only repository must not be downloaded as Transformers")) + + class FakeHfApi: + def __init__(self, *args, **kwargs): + pass + + def list_repo_files(self, **kwargs): + return ["README.md", "model-Q4_K_M.gguf", "mmproj-f16.gguf"] + + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / "existing-model" + target.mkdir() + sentinel = target / "user-file.gguf" + sentinel.write_bytes(b"preserve me") + with mock.patch.dict(access_globals, {"HfApi": FakeHfApi, "snapshot_download": snapshot_download_mock}): + with self.assertRaisesRegex(RuntimeError, "ThinkingLLM GGUF node"): + module._download_model_snapshot("example/gguf-only", target) + + self.assertEqual(b"preserve me", sentinel.read_bytes()) + snapshot_download_mock.assert_not_called() + + def test_hf_snapshot_stages_validation_and_preserves_existing_files(self): + module = self._load_qwenvl_with_stubs() + access_globals = module._download_model_snapshot.__globals__ + download_targets = [] + + class FakeHfApi: + def __init__(self, *args, **kwargs): + pass + + def list_repo_files(self, **kwargs): + return ["config.json", "model.safetensors", "processor_config.json"] + + def fake_snapshot_download(**kwargs): + staging = Path(kwargs["local_dir"]) + download_targets.append(staging) + staging.mkdir(parents=True) + (staging / "config.json").write_text("{}", encoding="utf-8") + (staging / "processor_config.json").write_text("{}", encoding="utf-8") + (staging / "model.safetensors").write_bytes(b"weights") + + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / "existing-model" + target.mkdir() + sentinel = target / "user-note.txt" + sentinel.write_text("preserve me", encoding="utf-8") + with mock.patch.dict(access_globals, {"HfApi": FakeHfApi, "snapshot_download": mock.Mock(side_effect=fake_snapshot_download)}): + module._download_model_snapshot("example/transformers", target, require_processor=True) + + self.assertEqual("preserve me", sentinel.read_text(encoding="utf-8")) + self.assertEqual(b"weights", (target / "model.safetensors").read_bytes()) + self.assertEqual(1, len(download_targets)) + self.assertNotEqual(target, download_targets[0]) + + def test_hf_snapshot_validation_failure_does_not_modify_existing_target(self): + module = self._load_qwenvl_with_stubs() + access_globals = module._download_model_snapshot.__globals__ + + class FakeHfApi: + def __init__(self, *args, **kwargs): + pass + + def list_repo_files(self, **kwargs): + return ["config.json", "model.safetensors"] + + def fake_incomplete_download(**kwargs): + staging = Path(kwargs["local_dir"]) + staging.mkdir(parents=True) + (staging / "config.json").write_text("{}", encoding="utf-8") + + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / "existing-model" + target.mkdir() + sentinel = target / "user-note.txt" + sentinel.write_text("preserve me", encoding="utf-8") + with mock.patch.dict( + access_globals, + {"HfApi": FakeHfApi, "snapshot_download": mock.Mock(side_effect=fake_incomplete_download)}, + ): + with self.assertRaisesRegex(RuntimeError, "existing target was preserved"): + module._download_model_snapshot("example/incomplete", target) + + self.assertEqual([sentinel], list(target.iterdir())) + self.assertEqual("preserve me", sentinel.read_text(encoding="utf-8")) + def test_hf_file_download_failure_raises_redacted_message(self): module = self._load_qwenvl_with_stubs() access_globals = module.download_hf_file_to_path.__globals__ diff --git a/thinkingllm_core/hf_models.py b/thinkingllm_core/hf_models.py index e4556e1..8d17fd5 100644 --- a/thinkingllm_core/hf_models.py +++ b/thinkingllm_core/hf_models.py @@ -689,22 +689,12 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab _download_model_snapshot( repo_id, target, + require_processor=require_processor, node_id=node_id, progress_label=progress_label or f"QwenVL HF Download: {model_name}", hf_token=hf_token, ) - if not _model_snapshot_has_required_files(target, require_processor=require_processor): - print(f"[QwenVL] Refreshed snapshot is still incomplete, retrying clean download: {target}") - _download_model_snapshot( - repo_id, - target, - force_clean_target=True, - node_id=node_id, - progress_label=progress_label or f"QwenVL HF Download: {model_name}", - hf_token=hf_token, - ) - if not _model_snapshot_has_required_files(target, require_processor=require_processor): missing = [] if not (target / "config.json").exists(): diff --git a/thinkingllm_core/model_access.py b/thinkingllm_core/model_access.py index 19732a0..10ba61a 100644 --- a/thinkingllm_core/model_access.py +++ b/thinkingllm_core/model_access.py @@ -3,13 +3,15 @@ import inspect import os import shutil +import tempfile import time from pathlib import Path try: - from huggingface_hub import hf_hub_download, snapshot_download + from huggingface_hub import HfApi, hf_hub_download, snapshot_download except ImportError: from huggingface_hub import snapshot_download + HfApi = None hf_hub_download = None try: from tqdm.auto import tqdm @@ -37,6 +39,12 @@ MODEL_STATUS_PRESENTATION = { } MODEL_INSTALLED_SUFFIX = " [installed]" MODEL_LOCAL_PREFIX = "[local] " +_PROCESSOR_CONFIG_FILENAMES = ( + "preprocessor_config.json", + "processor_config.json", + "image_processor_config.json", + "video_preprocessor_config.json", +) def _format_download_size(num_bytes) -> str: @@ -403,43 +411,73 @@ def _model_snapshot_has_required_files(target: Path, require_processor: bool = F return False if not (target / "config.json").exists(): return False - if require_processor: - processor_files = ( - "preprocessor_config.json", - "processor_config.json", - "image_processor_config.json", - "video_preprocessor_config.json", - ) - if not any((target / name).exists() for name in processor_files): - return False + if require_processor and not any((target / name).exists() for name in _PROCESSOR_CONFIG_FILENAMES): + return False return True -def _download_model_snapshot(repo_id: str, target: Path, *, force_clean_target: bool = False, node_id=None, progress_label: str | None = None, hf_token: str | None = None) -> None: +def _validate_transformers_repo_files(repo_id: str, repo_files: list[str], *, require_processor: bool = False) -> None: + root_files = {Path(str(filename)).as_posix() for filename in repo_files if filename} + missing = [] + if "config.json" not in root_files: + missing.append("config.json") + if not any("/" not in filename and filename.endswith((".safetensors", ".bin")) for filename in root_files): + missing.append("Transformers model weights") + if require_processor and not any(filename in root_files for filename in _PROCESSOR_CONFIG_FILENAMES): + missing.append("processor metadata") + if not missing: + return + + gguf_hint = " The repository contains GGUF files; select it through a ThinkingLLM GGUF node instead." if any( + filename.lower().endswith(".gguf") for filename in root_files + ) else "" + raise ValueError( + f"[QwenVL] {repo_id} is not a compatible Transformers snapshot: missing {', '.join(missing)}.{gguf_hint}" + ) + + +def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: bool = False, node_id=None, progress_label: str | None = None, hf_token: str | None = None) -> None: if COMMERCIAL_RELEASE: raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.") reporter = _DownloadProgressReporter(node_id=node_id, label=progress_label or "QwenVL HF Download", repo_id=repo_id) token_for_download = _clean_hf_token(hf_token) download_kwargs = {} - if force_clean_target and target.exists() and target.is_dir(): - print(f"[QwenVL] Removing incomplete model snapshot before retry: {target}") - reporter.stage("Cleaning incomplete snapshot", detail=target.name) - shutil.rmtree(target, ignore_errors=False) - reporter.stage("Preparing download", detail=target.name) + target.parent.mkdir(parents=True, exist_ok=True) + reporter.stage("Checking repository", detail=target.name) try: - download_kwargs = { - "repo_id": repo_id, - "local_dir": str(target), - "ignore_patterns": ["*.md", ".git*"], - "force_download": force_clean_target, - } - if token_for_download: - download_kwargs["token"] = token_for_download - tqdm_cls = reporter.make_tqdm_class() - if tqdm_cls is not None: - download_kwargs["tqdm_class"] = tqdm_cls - snapshot_download(**download_kwargs) - _cleanup_unneeded_snapshot_weights(target) + if HfApi is None: + raise RuntimeError("huggingface_hub.HfApi is unavailable; update huggingface-hub before downloading models") + api = HfApi(token=token_for_download) if token_for_download else HfApi() + repo_files = api.list_repo_files(repo_id=repo_id, repo_type="model") + _validate_transformers_repo_files(repo_id, repo_files, require_processor=require_processor) + + with tempfile.TemporaryDirectory(prefix=f".{target.name}.download-", dir=target.parent) as temp_dir: + staging_target = Path(temp_dir) / "snapshot" + reporter.stage("Preparing download", detail=target.name) + download_kwargs = { + "repo_id": repo_id, + "local_dir": str(staging_target), + "ignore_patterns": ["*.md", ".git*"], + } + if token_for_download: + download_kwargs["token"] = token_for_download + tqdm_cls = reporter.make_tqdm_class() + if tqdm_cls is not None: + download_kwargs["tqdm_class"] = tqdm_cls + snapshot_download(**download_kwargs) + _cleanup_unneeded_snapshot_weights(staging_target) + if not _model_snapshot_has_required_files(staging_target, require_processor=require_processor): + raise FileNotFoundError( + f"[QwenVL] Downloaded model snapshot is incomplete for {repo_id}; the existing target was preserved" + ) + + reporter.stage("Installing validated snapshot", detail=target.name) + if target.exists(): + if not target.is_dir(): + raise FileExistsError(f"[QwenVL] Model target exists and is not a directory: {target}") + shutil.copytree(staging_target, target, dirs_exist_ok=True) + else: + shutil.move(str(staging_target), str(target)) except Exception as exc: reporter.fail(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)) raise RuntimeError(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)) from exc