Match cached repo ids on a / boundary before blocking deletion

The /delete-cached guard refused deleting a cached repo whose id is a bare
prefix of the loaded one: with Qwen/Qwen-Image-2512 loaded, deleting the
separate cached Qwen/Qwen-Image returned 400 "Unload the model before
deleting" because loaded_id.startswith(repo_id) matched across the repo
boundary. Both are real, independently cached catalog artifacts, so a
supported delete was wrongly blocked.

Add a /-boundary aware _loaded_id_matches_repo helper (mirroring
hub/services/models/deletion.py) and use it at all six guard sites (chat,
Images, Video, and their in-flight loading-id loops), so only the loaded
repo itself or a file within it blocks deletion. Add a regression test
covering the sibling-prefix pair.
This commit is contained in:
Daniel Han 2026-07-07 16:34:29 +00:00
commit c5bc90c3cf
2 changed files with 83 additions and 6 deletions

View file

@ -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",

View file

@ -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