diff --git a/studio/backend/hub/utils/inventory_scan.py b/studio/backend/hub/utils/inventory_scan.py index fe3aadfd38..0f7ce6fe34 100644 --- a/studio/backend/hub/utils/inventory_scan.py +++ b/studio/backend/hub/utils/inventory_scan.py @@ -142,13 +142,18 @@ def _compute_all_hf_cache_scans() -> list: logger.warning("Could not scan active HF cache: %s", exc) for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir): - extra = extra_fn() - if extra.is_dir() and str(extra.resolve()) not in seen: - seen.add(str(extra.resolve())) - try: - scans.append(scan_cache_dir(cache_dir = str(extra))) - except Exception as exc: - logger.warning("Could not scan HF cache %s: %s", extra, exc) + try: + extra = extra_fn() + # is_dir()/resolve() can raise on an inaccessible path; skip it. + if not extra.is_dir(): + continue + resolved = str(extra.resolve()) + if resolved in seen: + continue + seen.add(resolved) + scans.append(scan_cache_dir(cache_dir = str(extra))) + except Exception as exc: + logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc) return scans diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 7151af33f3..a2f2eca81b 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -51,6 +51,14 @@ def _is_hidden_model(*values: str | None) -> bool: return any(v and any(n in v.lower() for n in needles) for v in values) +def _safe_resolve(path: Path) -> Optional[str]: + """resolve() to a string, or None when the path is inaccessible.""" + try: + return str(path.resolve()) + except OSError: + return None + + backend_path = Path(__file__).parent.parent.parent if str(backend_path) not in sys.path: sys.path.insert(0, str(backend_path)) @@ -676,9 +684,9 @@ async def list_local_models( # trusted Path objects are used for FS access; the user string is # used for matching only, never for path construction. allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir] - if legacy_hf.is_dir(): + if _safe_is_dir(legacy_hf): allowed_roots.append(legacy_hf) - if hf_default.is_dir(): + if _safe_is_dir(hf_default): allowed_roots.append(hf_default) try: from utils.paths import studio_root, outputs_root @@ -702,15 +710,20 @@ async def list_local_models( try: local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir) + # Resolve once; an inaccessible aux cache must skip that scan, not 500. + hf_cache_real = _safe_resolve(hf_cache_dir) + legacy_real = _safe_resolve(legacy_hf) + default_real = _safe_resolve(hf_default) + # Scan legacy Unsloth HF cache for backward compatibility. - if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve(): + if _safe_is_dir(legacy_hf) and legacy_real != hf_cache_real: local_models += _scan_hf_cache(legacy_hf) # Scan HF system default cache (may differ under env overrides). if ( - hf_default.is_dir() - and hf_default.resolve() != hf_cache_dir.resolve() - and hf_default.resolve() != legacy_hf.resolve() + _safe_is_dir(hf_default) + and default_real != hf_cache_real + and default_real != legacy_real ): local_models += _scan_hf_cache(hf_default) @@ -2069,13 +2082,10 @@ async def get_gguf_variants( best = _pick_best_gguf(filenames) default_variant = _extract_quant_label(best) if best else None - # Which variants are fully downloaded in the HF cache. For split - # GGUFs ALL shards must be present, so sum cached bytes per variant - # vs. the expected total. Cache dir casing may differ from the - # canonical repo_id, so match case-insensitively. - cached_bytes_by_quant: dict[str, int] = {} + # Per-snapshot so a split GGUF's shards must all sit in one snapshot; + # mmproj adapters are excluded so they can't inflate a quant's bytes. + cached_bytes_by_quant_per_snapshot: list[dict[str, int]] = [] try: - import re as _re from huggingface_hub import constants as hf_constants if not _is_valid_repo_id(repo_id): @@ -2088,21 +2098,31 @@ async def get_gguf_variants( snapshots = entry / "snapshots" if snapshots.is_dir(): for snap in snapshots.iterdir(): + by_quant: dict[str, int] = {} for f in _iter_gguf_paths(snap): - q = _extract_quant_label(f.name) - cached_bytes_by_quant[q] = ( - cached_bytes_by_quant.get(q, 0) + f.stat().st_size - ) + if _is_mmproj_filename(f.name): + continue + try: + size = f.stat().st_size + except OSError: + continue # broken symlink / unreadable: skip + q = _extract_quant_label(f.name).lower() + by_quant[q] = by_quant.get(q, 0) + size + if by_quant: + cached_bytes_by_quant_per_snapshot.append(by_quant) break except Exception: pass def _is_fully_downloaded(variant) -> bool: - cached = cached_bytes_by_quant.get(variant.quant, 0) - if cached == 0 or variant.size_bytes == 0: + if variant.size_bytes == 0: return False - # Rounding tolerance (symlinks vs real sizes). - return cached >= variant.size_bytes * 0.99 + # Complete within one snapshot (tolerance for symlink size jitter). + quant = variant.quant.lower() + return any( + by_quant.get(quant, 0) >= variant.size_bytes * 0.99 + for by_quant in cached_bytes_by_quant_per_snapshot + ) return GgufVariantsResponse( repo_id = repo_id, @@ -2157,16 +2177,26 @@ async def get_gguf_download_progress( for entry in cache_dir.iterdir(): if entry.name.lower() == target: # Completed .gguf files for this variant in snapshots. + # Exclude mmproj so a vision adapter can't satisfy a same-label + # main variant (e.g. mmproj-F16 vs an F16 weight). for f in _iter_gguf_paths(entry): + if _is_mmproj_filename(f.name): + continue fname = f.name.lower().replace("-", "").replace("_", "") if not variant_lower or variant_lower in fname: - downloaded_bytes += f.stat().st_size + try: + downloaded_bytes += f.stat().st_size + except OSError: + continue # broken symlink / unreadable: skip # In-progress (.incomplete) downloads in blobs. blobs_dir = entry / "blobs" if blobs_dir.is_dir(): for f in blobs_dir.iterdir(): if f.is_file() and f.name.endswith(".incomplete"): - in_progress_bytes += f.stat().st_size + try: + in_progress_bytes += f.stat().st_size + except OSError: + continue break total_progress_bytes = downloaded_bytes + in_progress_bytes @@ -2300,11 +2330,22 @@ def _get_repo_size_cached(repo_id: str) -> int: def _all_hf_cache_scans(): - """scan_cache_dir results for the active, legacy, and default HF caches.""" + """scan_cache_dir for the active, legacy, and default HF caches. + + Each probe is isolated: an unreadable auxiliary cache (permission denied, + broken symlink, OS-redirected ~/.cache) is skipped, not fatal, so the + Downloaded list never blanks out and downloads never leak into Recommended. + """ from huggingface_hub import scan_cache_dir from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir - scans = [scan_cache_dir()] + scans = [] + # Guard the active cache too: degrade to "no downloads" instead of raising. + try: + scans.append(scan_cache_dir()) + except Exception as exc: + logger.warning("Could not scan active HF cache: %s", exc) + seen: set[str] = set() try: # Resolve the active cache dir for dedup. @@ -2314,13 +2355,18 @@ def _all_hf_cache_scans(): pass for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir): - extra = extra_fn() - if extra.is_dir() and str(extra.resolve()) not in seen: - seen.add(str(extra.resolve())) - try: - scans.append(scan_cache_dir(cache_dir = str(extra))) - except Exception as exc: - logger.warning("Could not scan HF cache %s: %s", extra, exc) + try: + extra = extra_fn() + # is_dir()/resolve() can raise on an inaccessible path; skip it. + if not extra.is_dir(): + continue + resolved = str(extra.resolve()) + if resolved in seen: + continue + seen.add(resolved) + scans.append(scan_cache_dir(cache_dir = str(extra))) + except Exception as exc: + logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc) return scans @@ -2379,6 +2425,38 @@ def _repo_has_gguf_files(repo_info) -> bool: return _repo_gguf_size_bytes(repo_info) > 0 +def _blob_mtime(f) -> float: + """Blob modification time in epoch seconds (0.0 if unknown). + + Prefers HF metadata ``blob_last_modified``, falls back to stat(); uses + only mtimes (portable across Windows, macOS, Linux), never path parsing. + """ + ts = getattr(f, "blob_last_modified", None) + if isinstance(ts, (int, float)) and ts > 0: + return float(ts) + blob_path = getattr(f, "blob_path", None) + if blob_path: + try: + return float(Path(blob_path).stat().st_mtime) + except OSError: + pass + return 0.0 + + +def _repo_gguf_last_modified(repo_info) -> float: + """Newest mtime among a repo's primary (non-mmproj) GGUF blobs. + + Drives the Downloaded list's "last downloaded" ordering and groups a + multi-quant repo by its most recently downloaded quant. + """ + latest = 0.0 + for revision in repo_info.revisions: + for f in revision.files: + if _is_main_gguf_filename(f.file_name): + latest = max(latest, _blob_mtime(f)) + return latest + + @router.get("/cached-gguf") async def list_cached_gguf(current_subject: str = Depends(get_current_subject)): """List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache.""" @@ -2399,17 +2477,30 @@ async def list_cached_gguf(current_subject: str = Depends(get_current_subject)): continue key = repo_id.lower() existing = seen_lower.get(key) + last_modified = _repo_gguf_last_modified(repo_info) if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { + row = { "repo_id": repo_id, "size_bytes": total_size, "cache_path": str(repo_info.repo_path), } + # Keep the newest timestamp across duplicate caches; + # attach only when known so absent rows sort as oldest. + lm = max(last_modified, (existing or {}).get("last_modified", 0.0)) + if lm > 0: + row["last_modified"] = lm + seen_lower[key] = row + elif last_modified > existing.get("last_modified", 0.0): + existing["last_modified"] = last_modified except Exception as e: repo_label = getattr(repo_info, "repo_id", "") logger.warning(f"Skipping cached GGUF repo {repo_label}: {e}") continue - cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) + # Newest download first; stable repo_id tie-break for equal/missing mtimes. + cached = sorted( + seen_lower.values(), + key = lambda c: (-(c.get("last_modified") or 0.0), c["repo_id"].lower()), + ) return {"cached": cached} except Exception as e: logger.error(f"Error listing cached GGUF repos: {e}", exc_info = True) @@ -2447,18 +2538,39 @@ async def list_cached_models(current_subject: str = Depends(get_current_subject) ) if not has_weights: continue + last_modified = max( + ( + _blob_mtime(f) + for rev in repo_info.revisions + for f in rev.files + if f.file_name.endswith(_WEIGHT_EXTENSIONS) + ), + default = 0.0, + ) key = repo_id.lower() existing = seen_lower.get(key) if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { + row = { "repo_id": repo_id, "size_bytes": total_size, } + # Keep the newest timestamp across duplicate caches; + # attach only when known so absent rows sort as oldest. + lm = max(last_modified, (existing or {}).get("last_modified", 0.0)) + if lm > 0: + row["last_modified"] = lm + seen_lower[key] = row + elif last_modified > existing.get("last_modified", 0.0): + existing["last_modified"] = last_modified except Exception as e: repo_label = getattr(repo_info, "repo_id", "") logger.warning(f"Skipping cached model repo {repo_label}: {e}") continue - cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) + # Newest download first; stable repo_id tie-break for equal/missing mtimes. + cached = sorted( + seen_lower.values(), + key = lambda c: (-(c.get("last_modified") or 0.0), c["repo_id"].lower()), + ) return {"cached": cached} except Exception as e: logger.error(f"Error listing cached models: {e}", exc_info = True) diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py index 20aa37a2b4..5a4ca68ab4 100644 --- a/studio/backend/tests/test_cached_gguf_routes.py +++ b/studio/backend/tests/test_cached_gguf_routes.py @@ -371,3 +371,156 @@ def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(monkeyp "cache_path": str(vision_repo.repo_path), } ] + + +def _gfile(name: str, size: int, mtime: float) -> SimpleNamespace: + """A cached file carrying a Hugging Face ``blob_last_modified`` timestamp.""" + return SimpleNamespace( + file_name = name, + size_on_disk = size, + blob_path = None, + blob_last_modified = mtime, + ) + + +def test_all_hf_cache_scans_survives_inaccessible_aux_cache(monkeypatch, tmp_path): + """An unreadable auxiliary cache (e.g. an inaccessible + ``~/.cache/huggingface/hub``) must be skipped, not abort the scan. + Regression guard for ``extra.is_dir()`` raising and wiping the response. + """ + import huggingface_hub + import utils.paths as paths_mod + + active = SimpleNamespace( + repos = [_repo("Org/Active", [_file("Q4_K_M.gguf", 5_000)], tmp_path / "active")] + ) + + def _fake_scan(cache_dir = None): + if cache_dir is None: + return active + raise AssertionError("auxiliary scan should have been skipped") + + class _Boom: + def is_dir(self): + raise PermissionError(13, "Permission denied") + + def resolve(self): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(huggingface_hub, "scan_cache_dir", _fake_scan) + monkeypatch.setattr(paths_mod, "legacy_hf_cache_dir", lambda: _Boom()) + monkeypatch.setattr(paths_mod, "hf_default_cache_dir", lambda: _Boom()) + + scans = models_route._all_hf_cache_scans() + assert scans == [active] + + # End-to-end: the endpoint still returns the active cache's repo. + monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [active]) + result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user")) + assert result["cached"] == [ + { + "repo_id": "Org/Active", + "size_bytes": 5_000, + "cache_path": str(tmp_path / "active"), + } + ] + + +def test_list_cached_gguf_sorts_newest_first_grouping_by_latest_quant(monkeypatch, tmp_path): + """Downloaded is ordered newest-first, and a multi-quant repo is placed by + its most recently downloaded quant (``last_modified`` = newest quant).""" + older = _repo( + "Org/Older", + [_gfile("Older-Q4_K_M.gguf", 5_000, 1_000.0)], + tmp_path / "models--Org--Older", + ) + newer = _repo( + "Org/Newer", + [ + _gfile("Newer-Q4_K_M.gguf", 5_000, 2_000.0), + _gfile("Newer-Q8_0.gguf", 9_000, 3_000.0), # newest quant in the repo + ], + tmp_path / "models--Org--Newer", + ) + + monkeypatch.setattr( + models_route, + "_all_hf_cache_scans", + lambda: [SimpleNamespace(repos = [older, newer])], + ) + + result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user")) + + assert [c["repo_id"] for c in result["cached"]] == ["Org/Newer", "Org/Older"] + assert result["cached"][0]["last_modified"] == 3_000.0 + assert result["cached"][1]["last_modified"] == 1_000.0 + + +def test_list_cached_gguf_dedupe_keeps_newest_timestamp(monkeypatch, tmp_path): + """Same repo in two caches with equal size keeps the newest last_modified, + regardless of scan order.""" + older = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 1_000.0)], tmp_path / "a") + newer = _repo("org/dupe", [_gfile("dupe-Q4_K_M.gguf", 5_000, 9_000.0)], tmp_path / "b") + for scans in ([older, newer], [newer, older]): # both orders + monkeypatch.setattr( + models_route, + "_all_hf_cache_scans", + lambda s = scans: [SimpleNamespace(repos = [s[0]]), SimpleNamespace(repos = [s[1]])], + ) + result = asyncio.run(models_route.list_cached_gguf(current_subject = "t")) + assert len(result["cached"]) == 1 + assert result["cached"][0]["last_modified"] == 9_000.0 + + +def test_gguf_variants_mmproj_does_not_mark_quant_downloaded(monkeypatch, tmp_path): + """The per-quant 'downloaded' flag is driven by the real weight file in a + single snapshot; an mmproj vision adapter (matching a quant label) must + not make that quant appear downloaded.""" + import huggingface_hub.constants as hf_constants + + variants = [ + SimpleNamespace(filename = "model-Q4_K_M.gguf", quant = "Q4_K_M", size_bytes = 10_000), + SimpleNamespace(filename = "model-F16.gguf", quant = "F16", size_bytes = 20_000), + ] + monkeypatch.setattr( + models_route, "list_gguf_variants", lambda repo_id, hf_token = None: (variants, True) + ) + monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path)) + + snap = tmp_path / "models--org--repo" / "snapshots" / "rev" + snap.mkdir(parents = True) + (snap / "model-Q4_K_M.gguf").write_bytes(b"x" * 10_000) # real weight, fully present + (snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # mmproj adapter, label "F16" + + result = asyncio.run( + models_route.get_gguf_variants( + repo_id = "org/repo", hf_token = None, current_subject = "test-user" + ) + ) + + flags = {v.quant: v.downloaded for v in result.variants} + assert flags["Q4_K_M"] is True + assert flags["F16"] is False + + +def test_gguf_download_progress_excludes_mmproj(monkeypatch, tmp_path): + """A cached mmproj adapter must not count toward a same-label main + variant's download progress (mmproj-F16 vs an F16 weight).""" + import huggingface_hub.constants as hf_constants + + monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path)) + snap = tmp_path / "models--org--repo" / "snapshots" / "rev" + snap.mkdir(parents = True) + (snap / "mmproj-F16.gguf").write_bytes(b"y" * 20_000) # only the adapter on disk + + result = asyncio.run( + models_route.get_gguf_download_progress( + repo_id = "org/repo", + variant = "F16", + expected_bytes = 20_000, + current_subject = "test-user", + ) + ) + + assert result["downloaded_bytes"] == 0 + assert result["progress"] == 0 diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f61c210cf6..d87fb6aa09 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1371,12 +1371,11 @@ def _iter_hf_cache_snapshots(repo_id: str): return cache_dir = Path(hf_constants.HF_HUB_CACHE) - if not cache_dir.is_dir(): - return - target = f"models--{repo_id.replace('/', '--')}".lower() repo_dir: Optional[Path] = None try: + if not cache_dir.is_dir(): + return for entry in cache_dir.iterdir(): if entry.is_dir() and entry.name.lower() == target: repo_dir = entry @@ -1387,10 +1386,9 @@ def _iter_hf_cache_snapshots(repo_id: str): return snapshots = repo_dir / "snapshots" - if not snapshots.is_dir(): - return - try: + if not snapshots.is_dir(): + return snap_dirs = [s for s in snapshots.iterdir() if s.is_dir()] except OSError: return diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index e218103fd9..0afabb6423 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -63,6 +63,19 @@ function dedupe(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } +/** Newest-first by `last_modified` (epoch s), repo_id tie-break. Copies the + * input; treats a missing field as oldest for older-backend compatibility. */ +function sortByDownloadRecency( + rows: T[], +): T[] { + return [...rows].sort((a, b) => { + const at = a.last_modified ?? -1; + const bt = b.last_modified ?? -1; + if (at !== bt) return bt - at; + return a.repo_id.localeCompare(b.repo_id); + }); +} + /** Lowercase and strip separators for fuzzy search. */ function normalizeForSearch(s: string): string { return s.toLowerCase().replace(/[\s\-_\.]/g, ""); @@ -735,6 +748,33 @@ export function HubModelPicker({ const showHfSection = debouncedQuery.trim().length > 0; + // Newest-first (also covers older backends without `last_modified`). + const sortedCachedGguf = useMemo( + () => sortByDownloadRecency(cachedGguf), + [cachedGguf], + ); + const sortedCachedModels = useMemo( + () => sortByDownloadRecency(cachedModels), + [cachedModels], + ); + + // While searching, filter Downloaded by the query instead of hiding it, so a + // downloaded model the user is searching for stays visible. + const visibleCachedGguf = useMemo(() => { + if (!showHfSection) return sortedCachedGguf; + const q = normalizeForSearch(debouncedQuery.trim()); + return sortedCachedGguf.filter((c) => normalizeForSearch(c.repo_id).includes(q)); + }, [sortedCachedGguf, showHfSection, debouncedQuery]); + const visibleCachedModels = useMemo(() => { + if (!showHfSection) return sortedCachedModels; + const q = normalizeForSearch(debouncedQuery.trim()); + return sortedCachedModels.filter((c) => normalizeForSearch(c.repo_id).includes(q)); + }, [sortedCachedModels, showHfSection, debouncedQuery]); + + // Non-GGUF cached rows are not shown in chat-only mode, so the empty-state + // logic must use this (not visibleCachedModels) or the picker can go blank. + const visibleCachedModelRows = chatOnly ? [] : visibleCachedModels; + // Recommended models that match the current search query const filteredRecommendedIds = useMemo(() => { if (!showHfSection) return []; @@ -765,9 +805,11 @@ export function HubModelPicker({ return results .map((result) => result.id) .filter((id) => !recommendedSet.has(id)) + // Shown under Downloaded (kept visible while searching); no duplicate. + .filter((id) => !downloadedSet.has(id.toLowerCase())) .filter((id) => !chatOnly || isKnownGgufRepo(id)) .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)); - }, [recommendedSet, results, showHfSection, chatOnly, isKnownGgufRepo]); + }, [recommendedSet, downloadedSet, results, showHfSection, chatOnly, isKnownGgufRepo]); const metricsById = useMemo( () => @@ -897,23 +939,28 @@ export function HubModelPicker({
- {!cachedReady && !showHfSection ? ( + {/* First-load spinner only when nothing cached is shown yet. */} + {!cachedReady && + !showHfSection && + visibleCachedGguf.length === 0 && + visibleCachedModelRows.length === 0 ? (
Loading models…
- ) : !showHfSection && - (cachedGguf.length > 0 || - (!chatOnly && cachedModels.length > 0)) ? ( + ) : null} + + {/* Downloaded stays visible (filtered) while searching. */} + {visibleCachedGguf.length > 0 || visibleCachedModelRows.length > 0 ? ( <> } collapsed={downloadedCollapsed} onToggle={() => setDownloadedCollapsed((v) => !v)} >Downloaded - {!downloadedCollapsed && cachedGguf.map((c) => ( + {!downloadedCollapsed && visibleCachedGguf.map((c) => (
))} - {!downloadedCollapsed && !chatOnly && - cachedModels.map((c) => ( + {!downloadedCollapsed && + visibleCachedModelRows.map((c) => (
Hugging Face )} {hfIds.length === 0 && !isLoading ? ( - filteredRecommendedIds.length === 0 ? ( + filteredRecommendedIds.length === 0 && + visibleCachedGguf.length === 0 && + visibleCachedModelRows.length === 0 ? (
No matching models.
diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 110d573571..7a2c8e7e9a 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -139,6 +139,9 @@ export interface CachedGgufRepo { repo_id: string; size_bytes: number; cache_path: string; + /** Epoch seconds of the newest downloaded quant; sorts Downloaded + * newest-first. Optional for older-backend compatibility. */ + last_modified?: number; } export async function getGgufDownloadProgress( @@ -241,6 +244,9 @@ export async function listCachedGguf(): Promise { export interface CachedModelRepo { repo_id: string; size_bytes: number; + /** Epoch seconds of the newest downloaded weight file; sorts Downloaded + * newest-first. Optional for older-backend compatibility. */ + last_modified?: number; } export async function listCachedModels(): Promise {