diff --git a/studio/backend/core/inference/sd_cpp_backend.py b/studio/backend/core/inference/sd_cpp_backend.py index d8f9e89429..cf39441285 100644 --- a/studio/backend/core/inference/sd_cpp_backend.py +++ b/studio/backend/core/inference/sd_cpp_backend.py @@ -716,6 +716,25 @@ class SdCppDiffusionBackend: return () return tuple(r for r in (loading.repo_id, loading.base_repo, *loading.asset_repos) if r) + def loaded_repo_ids(self) -> tuple[str, ...]: + """Repo ids the COMMITTED native model reads from disk (empty when unloaded). + + The one-shot sd-cli re-reads the companion VAE / text-encoder files from the HF + cache on every generation (server mode keeps them in the resident process, but the + extra ids are harmless there), so the delete-cached guard must refuse those + companion repos while the model is loaded -- status().repo_id covers only the main + GGUF. Reconstructed from the committed family, mirroring loading_repo_ids().""" + with self._lock: + state = self._state + if state is None: + return () + fam = state.family + repos = [state.repo_id, state.base_repo] + if fam.sd_cpp_vae: + repos.append(fam.sd_cpp_vae[0]) + repos.extend(terepo for terepo, _f, _k in fam.sd_cpp_text_encoders) + return tuple(dict.fromkeys(r for r in repos if r)) + # ── Generate ─────────────────────────────────────────────────────────── def generate( diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index cf1785cd0c..f7784d4181 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -3599,6 +3599,17 @@ async def delete_cached_model( status_code = 400, detail = "Unload the model before deleting", ) + # The native sd.cpp one-shot engine re-reads its companion VAE / text-encoder files + # from the HF cache on every generation, so deleting a companion repo (e.g. + # comfyanonymous/flux_text_encoders) while a native GGUF is loaded would brick the + # next generation. status().repo_id only covers the main GGUF, so also refuse the + # committed companion repos the loaded engine reads from disk. + for lid in getattr(engine, "loaded_repo_ids", tuple)(): + if _loaded_id_matches_repo(str(lid).lower(), repo_id): + raise HTTPException( + status_code = 400, + detail = "Unload the model before deleting", + ) # Also refuse while a background image load is DOWNLOADING this repo (or its # companion base): status().loaded is still False in that window, but deleting # would remove blobs from under the in-flight download/assembly. diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py index 6c29daedf2..674aabe231 100644 --- a/studio/backend/tests/test_cached_gguf_routes.py +++ b/studio/backend/tests/test_cached_gguf_routes.py @@ -919,6 +919,62 @@ def test_delete_cached_refuses_video_loaded_repo(monkeypatch): 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 from the + # HF cache on every generation, so deleting a companion repo (comfyanonymous/flux_text_encoders) + # while a FLUX GGUF is loaded must be refused. The loaded main repo_id does not match the + # companion, so the guard relies on loaded_repo_ids() to cover the committed companions. + from fastapi import HTTPException + import core.inference.diffusion_engine_router as der + import core.inference.video as video_mod + import routes.inference as routes_inference + + monkeypatch.setattr( + routes_inference, + "get_llama_cpp_backend", + lambda: SimpleNamespace(is_loaded = False, model_identifier = None), + ) + monkeypatch.setattr( + models_route, + "get_inference_backend", + lambda: SimpleNamespace(active_model_name = None), + ) + 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", + lambda: SimpleNamespace( + status = lambda: {"loaded": False, "repo_id": None}, + loading_repo_ids = lambda: (), + ), + ) + + try: + asyncio.run( + models_route.delete_cached_model( + repo_id = "comfyanonymous/flux_text_encoders", + variant = None, + current_subject = "u", + ) + ) + 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_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 and Qwen/Qwen-Image-2512 are both real catalog artifacts, so diff --git a/studio/backend/tests/test_sd_cpp_backend.py b/studio/backend/tests/test_sd_cpp_backend.py index 68caeb58dd..1d0268effa 100644 --- a/studio/backend/tests/test_sd_cpp_backend.py +++ b/studio/backend/tests/test_sd_cpp_backend.py @@ -82,6 +82,23 @@ def _loaded_backend(fam_name = "z-image", engine = None): return b +def test_loaded_repo_ids_includes_native_companions(): + # The one-shot native engine re-reads its companion VAE / text-encoder files from the HF + # cache on every generation, so the delete-cached guard queries loaded_repo_ids() to refuse + # deleting an in-use companion repo. It must surface the committed family's VAE + text-encoder + # repos (plus the main + base repos), not just the loaded GGUF, and be empty once unloaded. + b = _loaded_backend("flux.1") + ids = set(b.loaded_repo_ids()) + fam = detect_family("flux.1") + assert "unsloth/Z-Image-Turbo-GGUF" in ids # the main GGUF repo + assert fam.base_repo in ids + assert fam.sd_cpp_vae[0] in ids # black-forest-labs/FLUX.1-schnell (VAE) + for terepo, _f, _k in fam.sd_cpp_text_encoders: + assert terepo in ids # comfyanonymous/flux_text_encoders + b._state = None + assert b.loaded_repo_ids() == () + + class _FakeServer: """Stands in for SdCppServer: records the spawn + one img_gen per whole batch."""