diff --git a/studio/backend/hub/services/models/cache_inventory.py b/studio/backend/hub/services/models/cache_inventory.py index 581d68d43c..b7a42283a0 100644 --- a/studio/backend/hub/services/models/cache_inventory.py +++ b/studio/backend/hub/services/models/cache_inventory.py @@ -327,7 +327,7 @@ def _scan_cached_gguf() -> list[dict]: continue repo_id = repo_info.repo_id repo_path = Path(repo_info.repo_path) - snapshot_path = _cached_model_snapshot_path(repo_path) + snapshot_path = _cached_gguf_repo_snapshot_path(repo_path) total_size = _repo_gguf_size_bytes(repo_info) has_variant_state, variant_state_size = _gguf_variant_state_summary( repo_id, @@ -552,13 +552,12 @@ def _repo_non_gguf_model_payload(repo_info) -> _CachedNonGgufPayload: ) -def _weightful_snapshot_path(repo_path: Path) -> Optional[Path]: - """Newest snapshot dir holding config.json plus a safetensors weight. +def _newest_snapshot_where(repo_path: Path, loadable) -> Optional[Path]: + """Newest snapshots/* dir (by mtime) whose entry names satisfy *loadable*. - Inactive-cache rows emit this snapshot path as their load_id, which the - load consumes directly (it bypasses the repo-id resolver), so a newest - metadata-only revision must not be emitted while an older revision holds - the weights the row was classified from. + Inactive-cache rows emit the selected snapshot path as their load_id, + which the load consumes directly (it bypasses the repo-id resolver), so + the snapshot must actually hold the format the row was classified from. """ snapshots = repo_path / "snapshots" try: @@ -566,14 +565,54 @@ def _weightful_snapshot_path(repo_path: Path) -> Optional[Path]: except OSError: return None - def _loadable(rev: Path) -> bool: + def _ok(rev: Path) -> bool: try: names = [entry.name for entry in rev.iterdir()] except OSError: return False - return "config.json" in names and any(n.endswith(".safetensors") for n in names) + return loadable(names) - candidates = [rev for rev in revisions if _loadable(rev)] + candidates = [rev for rev in revisions if _ok(rev)] + if not candidates: + return None + try: + return max(candidates, key = lambda rev: rev.stat().st_mtime).resolve() + except OSError: + return None + + +def _weightful_snapshot_path(repo_path: Path) -> Optional[Path]: + """Newest snapshot holding config.json plus a safetensors weight (the + non-GGUF resolvers' complete-revision predicate).""" + return _newest_snapshot_where( + repo_path, + lambda names: "config.json" in names + and any(n.endswith(".safetensors") for n in names), + ) + + +def _gguf_snapshot_path(repo_path: Path) -> Optional[Path]: + """Newest snapshot holding a GGUF file (top level or one folder deep, + where multi-quant repos keep per-quant subfolders).""" + direct = _newest_snapshot_where( + repo_path, + lambda names: any(_is_gguf_filename(n.lower()) for n in names), + ) + if direct is not None: + return direct + snapshots = repo_path / "snapshots" + try: + revisions = [entry for entry in snapshots.iterdir() if entry.is_dir()] + except OSError: + return None + + def _has_nested_gguf(rev: Path) -> bool: + try: + return any(True for _ in rev.glob("*/*.gguf")) + except OSError: + return False + + candidates = [rev for rev in revisions if _has_nested_gguf(rev)] if not candidates: return None try: @@ -593,6 +632,19 @@ def _cached_model_snapshot_path(repo_path: Path) -> Optional[Path]: return path if path.is_dir() else None +def _cached_gguf_repo_snapshot_path(repo_path: Path) -> Optional[Path]: + """Snapshot path for a GGUF row: a safetensors-bearing revision of a + mixed repo must not become the GGUF row's load target.""" + gguf_snapshot = _gguf_snapshot_path(repo_path) + if gguf_snapshot is not None: + return gguf_snapshot + resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_path) + if not resolved: + return None + path = Path(resolved) + return path if path.is_dir() else None + + def _read_json_object(path: Path) -> dict: try: with open(path, "r", encoding = "utf-8") as f: diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 7f2119d118..922879cb5b 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4573,7 +4573,13 @@ async def _load_model_impl( # to match. Off-loop: tier resolution reads configs. if effective_load_in_4bit and not config.is_gguf: from utils.transformers_version import latest_tier_active_for - if await asyncio.to_thread(latest_tier_active_for, config.identifier, request.hf_token): + + # Local-only loads probe the tier against the resolved snapshot: + # a repo id would reach _remote_lora_base's raw HTTP request and + # Hub config reads, while a local path resolves from config.json + # on disk (non-canonical ids skip the remote adapter probe). + _tier_target = config.path if request.local_files_only else config.identifier + if await asyncio.to_thread(latest_tier_active_for, _tier_target, request.hf_token): effective_load_in_4bit = False logger.info( f"Latest-transformers sidecar active for '{model_log_label}' - " @@ -4595,10 +4601,19 @@ async def _load_model_impl( # Apply the training coexistence policy before the unload step below # frees the resident model. Off-loop: the default-mode guard does sync work. + # Local-only non-GGUF loads size against the resolved snapshot so the + # guard's memory estimation reads local files instead of hf model_info + # (which performs no offline-mode check). GGUF sizing already reads + # the cached file under its own local-only handling. + _guard_identifier = ( + config.path + if request.local_files_only and not config.is_gguf + else model_identifier + ) await asyncio.to_thread( _guard_chat_load_against_training, config, - model_identifier = model_identifier, + model_identifier = _guard_identifier, hf_token = request.hf_token, load_in_4bit = effective_load_in_4bit, max_seq_length = request.max_seq_length, diff --git a/studio/backend/tests/test_local_snapshot_resolution.py b/studio/backend/tests/test_local_snapshot_resolution.py index 903b3aa52d..3aeed0ffb7 100644 --- a/studio/backend/tests/test_local_snapshot_resolution.py +++ b/studio/backend/tests/test_local_snapshot_resolution.py @@ -162,6 +162,41 @@ def test_refless_fallback_picks_newest_snapshot_with_config(tmp_path): assert Path(resolved).resolve() == new.resolve() +def test_gguf_rows_select_gguf_bearing_snapshot_in_mixed_repos(tmp_path): + """A mixed repo caching a newer safetensors revision beside an older GGUF + revision: the GGUF row's snapshot selection must return the GGUF-bearing + revision, not the safetensors one the model row prefers.""" + import os + import sys + import time + + backend_dir = str(Path(__file__).resolve().parent.parent) + if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + from hub.services.models.cache_inventory import ( + _cached_gguf_repo_snapshot_path, + _cached_model_snapshot_path, + ) + + repo_dir = tmp_path / "models--org--mixed" + gguf_rev = repo_dir / "snapshots" / ("a" * 40) + gguf_rev.mkdir(parents = True) + (gguf_rev / "mixed-Q4_K_M.gguf").write_text("gguf-bytes") + stale = time.time() - 1000 + os.utime(gguf_rev, (stale, stale)) + st_rev = repo_dir / "snapshots" / ("b" * 40) + st_rev.mkdir(parents = True) + (st_rev / "config.json").write_text("{}") + (st_rev / "model.safetensors").write_text("weights") + + gguf_pick = _cached_gguf_repo_snapshot_path(repo_dir) + assert gguf_pick is not None + assert Path(gguf_pick).resolve() == gguf_rev.resolve() + model_pick = _cached_model_snapshot_path(repo_dir) + assert model_pick is not None + assert Path(model_pick).resolve() == st_rev.resolve() + + def test_weightless_newest_snapshot_does_not_shadow_complete_older_one(tmp_path): """A newest metadata-only revision (config.json, no weights) must not win over an older revision holding the inventoried safetensors weights: the diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index e5d4f1375e..e7a14a1a55 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -1324,6 +1324,31 @@ def test_local_only_gguf_reuse_and_platform_gates_are_authoritative(): assert 'if selected_category == "safetensors":' in inventory +def test_route_preflights_and_gguf_rows_stay_format_true(): + """Round-19 gates. The /load route's sidecar tier probe and training + guard size local-only candidates against the resolved snapshot path (a + repo id would reach _remote_lora_base's raw HTTP request and hf + model_info, neither of which honors offline mode). GGUF cached rows + select a GGUF-bearing snapshot, so a mixed repo's safetensors revision + cannot become the GGUF row's load target.""" + route = _read_backend("routes/inference.py") + assert "_tier_target = config.path if request.local_files_only else config.identifier" in route + guard_block = route.split("_guard_identifier = (", 1)[1] + guard_block = guard_block.split("await asyncio.to_thread(", 1)[0] + assert "config.path" in guard_block + assert "request.local_files_only and not config.is_gguf" in guard_block + assert "model_identifier = _guard_identifier," in route + + inventory = _read_backend("hub/services/models/cache_inventory.py") + assert "def _newest_snapshot_where(" in inventory + assert "def _gguf_snapshot_path(" in inventory + assert "def _cached_gguf_repo_snapshot_path(" in inventory + gguf_scan = inventory.split("def _scan_cached_gguf(", 1)[1] + gguf_scan = gguf_scan.split("def ", 1)[0] + assert "_cached_gguf_repo_snapshot_path(repo_path)" in gguf_scan + assert "_cached_model_snapshot_path(repo_path)" not in gguf_scan + + def test_gguf_background_loads_never_download_companions(): """A cached GGUF load can still fetch from the Hub through its optional companions (mmproj, MTP drafter) or a cache-miss main quant. Background