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:
parent
ffa8a56391
commit
c5bc90c3cf
2 changed files with 83 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue