diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index c1afc5c87e..cf1785cd0c 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -3528,6 +3528,14 @@ async def list_cached_models( return {"cached": []} +def _loaded_id_matches_repo(loaded_id: str, repo_id: str) -> bool: + """True when *loaded_id* is *repo_id* or a file within it; ``/``-boundary aware so a + loaded ``org/model-2512`` does not block deleting the sibling cached ``org/model``.""" + rid = repo_id.lower() + lid = loaded_id.lower() + return lid == rid or lid.startswith(f"{rid}/") + + @router.delete("/delete-cached") async def delete_cached_model( repo_id: str = Body(...), @@ -3549,7 +3557,7 @@ async def delete_cached_model( llama_backend = get_llama_cpp_backend() if llama_backend.is_loaded and llama_backend.model_identifier: loaded_id = llama_backend.model_identifier.lower() - if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()): + if _loaded_id_matches_repo(loaded_id, repo_id): raise HTTPException( status_code = 400, detail = "Unload the model before deleting", @@ -3563,7 +3571,7 @@ async def delete_cached_model( inference_backend = get_inference_backend() if inference_backend.active_model_name: active = inference_backend.active_model_name.lower() - if active == repo_id.lower() or active.startswith(repo_id.lower()): + if _loaded_id_matches_repo(active, repo_id): raise HTTPException( status_code = 400, detail = "Unload the model before deleting", @@ -3586,7 +3594,7 @@ async def delete_cached_model( diffusion_status = engine.status() if diffusion_status.get("loaded") and diffusion_status.get("repo_id"): loaded_id = str(diffusion_status["repo_id"]).lower() - if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()): + if _loaded_id_matches_repo(loaded_id, repo_id): raise HTTPException( status_code = 400, detail = "Unload the model before deleting", @@ -3597,7 +3605,7 @@ async def delete_cached_model( loading_ids = getattr(engine, "loading_repo_ids", tuple)() for lid in loading_ids: lid = str(lid).lower() - if lid == repo_id.lower() or lid.startswith(repo_id.lower()): + if _loaded_id_matches_repo(lid, repo_id): raise HTTPException( status_code = 400, detail = "An Images model load is using this repo; wait for it to finish", @@ -3618,7 +3626,7 @@ async def delete_cached_model( video_status = video_backend.status() if video_status.get("loaded") and video_status.get("repo_id"): loaded_id = str(video_status["repo_id"]).lower() - if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()): + if _loaded_id_matches_repo(loaded_id, repo_id): raise HTTPException( status_code = 400, detail = "Unload the model before deleting", @@ -3628,7 +3636,7 @@ async def delete_cached_model( # from under the in-flight download/assembly -- same as the Images guard above. for lid in getattr(video_backend, "loading_repo_ids", tuple)(): lid = str(lid).lower() - if lid == repo_id.lower() or lid.startswith(repo_id.lower()): + if _loaded_id_matches_repo(lid, repo_id): raise HTTPException( status_code = 400, detail = "A Video model load is using this repo; wait for it to finish", diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py index 1dc391cd30..6c29daedf2 100644 --- a/studio/backend/tests/test_cached_gguf_routes.py +++ b/studio/backend/tests/test_cached_gguf_routes.py @@ -919,6 +919,75 @@ def test_delete_cached_refuses_video_loaded_repo(monkeypatch): 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 + # with Qwen-Image-2512 loaded, deleting the sibling Qwen-Image is a supported operation. The + # guard is `/`-boundary aware, so it refuses only the loaded repo (or a file within it), not + # a prefix sibling. + 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": "Qwen/Qwen-Image-2512"}, + loading_repo_ids = lambda: (), + ), + ) + monkeypatch.setattr( + video_mod, + "get_video_backend", + lambda: SimpleNamespace( + status = lambda: {"loaded": False, "repo_id": None}, + loading_repo_ids = lambda: (), + ), + ) + # Nothing cached, so a delete that clears the guards reaches the not-found path. + monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: []) + + # The sibling repo clears every guard and reaches the cache lookup, which 404s -- it is NOT + # refused with the 400 "Unload" the un-delimited prefix match would have produced. + try: + asyncio.run( + models_route.delete_cached_model( + repo_id = "Qwen/Qwen-Image", + variant = None, + current_subject = "u", + ) + ) + assert False, "expected HTTPException (404 not-found) after clearing the guard" + except HTTPException as e: + assert e.status_code == 404, f"sibling delete wrongly blocked: {e.status_code} {e.detail}" + + # The loaded repo itself is still refused (exact match). + try: + asyncio.run( + models_route.delete_cached_model( + repo_id = "Qwen/Qwen-Image-2512", + variant = None, + current_subject = "u", + ) + ) + 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, the scan # spans every HF cache root, so a stale .incomplete copy in one root would flag a complete