* fix(hub): stop demoting cached gguf variants on mmproj or filename mismatch - bug: a quant with its bytes on disk was marked not fully downloaded when the API-preferred main filenames did not match or the mmproj adapter was absent - fix: fall back to the on-disk quant byte signal, the same one inventory uses for on-device, so a present quant is no longer demoted - broaden mmproj detection to accept any mmproj-looking cached file, not only the API-preferred name * feat(hub): full-page redesign with trending feed, search, and persisted state - convert the hub to a full-page view with a new layout, model cards, and a sortable models table - add a trending/latest/finetune feed with model and section deep-link params validated on the hub route - add recent searches and rework model and dataset search with pagination and infinite scroll - persist feed and token state through a dedicated store and persist-storage layer * fix(hub): browse sorting, deep-link presets, dataset URLs, and persistence - sort dropdown: drive HF-wide browse across all repos by the chosen sort, respecting format and capability filters - section deep-link: apply the section preset (format and sort) on refresh and deep-link, not only on click - dataset detail URL: persist the resource kind so refresh and share resolve datasets correctly - gguf card: show a Loading state for hub-cache dir-path repos via repo-id match - active-model CTA: New Chat now actually opens a fresh chat - persist-storage: fix throttle keying with a Map and dedupe a duplicated util - transport-toggle: drop redundant controlled-tooltip state * Studio: polish hub redesign UI and unify segmented tabs Refinements on top of the hub full-page redesign: - Unify every segmented control (Discover/On Device, models/datasets, Unsloth/All, recent/name/size, settings tabs, train dataset source, profile shape, theme, OS toggle) on one filled-pill design. - Hub list now loads in larger batches with a shorter fetch interval so results fill in fast instead of dripping one row at a time. - Disable remote avatar fetches in list rows and brighten the colored initial fallbacks so they read clearly without network calls. - Add a split master-detail view for model lists and make it the default. - Left-align the split "Showing GGUF models" header with the rows below. - Round the "Load more" footer box and tidy On Device stats layout. - Show recent trainings on Recipes and Export, falling back to recent chats when there is no training history. * Studio: address hub review comments (filter warning + scrollMargin) - DiscoverFetchMoreFooter only shows the "results may be hidden by your filters" note when a filter is actually active, instead of always. - Use the destructured scrollMargin prop directly in the row transform rather than reaching into virtualizer.options.scrollMargin. * Fix/adjust Hub metadata and deep links for PR #6349 * Studio: drop avatar ring in hub split view The split master-pane rows (discover + on device) added a ring-1 around the owner avatar that read as a shadow. Remove it so split-view avatars match the flat avatars elsewhere; grid cards and the full list keep theirs. * Studio: hub sort + scope as dropdown pills beside view tabs Recent/Name/Size and Unsloth/All were segmented controls that dropped to their own row in the narrow split pane. Make each a compact dropdown pill (HubOptionMenu) that sits in the header actions slot next to the view-mode tabs in every layout, so split view no longer needs a separate row. * Studio: align hub list header with the view tabs and rows - Vertically center the "On device" / "Showing GGUF models" title with the dropdown pill and view-mode tabs (items-center instead of items-end), so a short title no longer sits low against the taller tab row. - Nudge the back chevron 2px further left (-ml-2) so its glyph edge lines up with the start of the row hover below it. * Studio: align back chevron tip with the row hover edge The arrow glyph is inset ~6px inside its centered icon box, so an edge-aligned button left the visible chevron sitting in from the column. Pull the button out (-ml-3.5) so the chevron tip lands on the row hover's left edge instead of floating to its right. * Studio: unify every bare tick on the shared check mark Point all plain checkmarks at the canonical @/lib/tick-icon tick (the one already used in the chat composer and menus), so there is a single tick across the app: - Hub: model-inspector, hub-option-menu, path-info-button were importing the stock hugeicons Tick02Icon; switch them to the shared icon. - Chat / assistant-ui: artifact-surface, prompt-storage-dialog, reasoning, tool-ui-python, tool-ui-terminal, tool-ui-code-execution, and the tool-fallback status map used lucide CheckIcon; render the shared tick via HugeiconsIcon instead (tool-fallback wraps it to fit its icon map). The circular CheckmarkCircle success badges are intentionally left as-is. No bare CheckIcon/stock Tick02Icon references remain; verified the tick renders in every converted spot via typecheck + build. * Studio: nudge back chevron 2px right -ml-3.5 pushed the chevron a touch too far left; -ml-3 sits it just inside the row hover edge, aligned with the avatars below. * Studio: search base-model chips across all publishers Clicking a Base model chip searches the Hub for the upstream repo, which lives under another publisher (google, meta, etc.). It left ownerScope at the default "unsloth", so the search hard-restricted to the Unsloth org and could never surface the base model. Switch the scope to "all" for this action. * Studio: label the safetensors list header "Safetensors" The focused list heading showed "Showing Checkpoint ... models" while the format dropdown labels the same checkpoint filter value "Safetensors". Match the dropdown so the header reads "Showing Safetensors ... models". * Studio: simplify the focused list heading to "Models" Drop the format/capability composition (e.g. "Showing Safetensors Reasoning models") so the focused list heading just reads "Models" (or "Datasets"). Search keeps its "Results for ..." label. * Studio: drop the header refresh button to the text baseline The refresh button sat at the heading's vertical centre. Nudge it down so it lines up with the bottom of the title text instead. * Studio: hide redundant "Back to Hub" in the split detail pane In split view on large screens the master list sits beside the detail, so the back button is redundant. Hide it there (lg) and reclaim the top space. It stays on the small-screen overlay and the full-page detail, where the list is hidden and back is the only way out. * Studio: match the readme scroll fade to the left column The detail pane relied on the sticky back-bar's fade, which is now hidden in split view. Add the same hub-scroll-fade overlay the master list uses so the readme fades consistently at the top when scrolled. The back-bar, when shown, sits above and covers it. * Studio: align Hub refresh button to the heading text bottom * Studio: nudge Hub refresh button up to the heading text * Studio: optically centre the HF token shield in its circle * Studio: preview the first visible on-device row in split view * Studio: calm the on-device row colour and fix size tooltip contrast * Studio: fix Hub reset tab and clear search when opening a section * Studio: fix Hub feed defaults, filter sync, and GGUF vision download state * Studio: condense Hub redesign code comments --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: shimmyshimmer <info@unsloth.ai> Co-authored-by: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: danielhanchen <michaelhan2050@gmail.com>
670 lines
26 KiB
Python
670 lines
26 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""GGUF variant resolution."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from collections import OrderedDict
|
|
from typing import NamedTuple, Optional
|
|
|
|
from fastapi import HTTPException
|
|
from loggers import get_logger
|
|
|
|
from hub.schemas.inventory import GgufVariantDetail, GgufVariantsResponse
|
|
from hub.utils import download_manifest
|
|
from hub.utils import download_registry
|
|
from hub.utils import inventory_scan as hf_cache_scan
|
|
from hub.utils.hf_errors import hf_error_status
|
|
from hub.utils.hf_cache_state import (
|
|
INCOMPLETE_SUFFIX,
|
|
iter_destructive_repo_cache_dirs,
|
|
)
|
|
from hub.utils.gguf import (
|
|
extract_quant_label,
|
|
iter_hf_cache_snapshots,
|
|
is_big_endian_gguf_path,
|
|
list_gguf_variants,
|
|
list_gguf_variants_from_hf_cache,
|
|
list_local_gguf_variants,
|
|
list_partial_gguf_variants_from_state,
|
|
pick_best_gguf,
|
|
)
|
|
from hub.utils.paths import (
|
|
is_local_path,
|
|
is_valid_repo_id as _is_valid_repo_id,
|
|
)
|
|
from hub.services.models.common import (
|
|
_is_mmproj_filename,
|
|
_is_mtp_drafter_path,
|
|
_iter_gguf_paths,
|
|
)
|
|
from hub.utils.gguf_plan import (
|
|
GgufVariantPlan as _GgufVariantRequirement,
|
|
build_gguf_variant_plans,
|
|
is_main_gguf_variant_path,
|
|
)
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_VARIANT_HASH_CACHE: "OrderedDict[tuple[str, str, str, bool], tuple[frozenset[str], float]]" = (
|
|
OrderedDict()
|
|
)
|
|
_VARIANT_REQUIREMENT_CACHE: "OrderedDict[tuple[str, str, str], tuple[_GgufVariantRequirement, float]]" = OrderedDict()
|
|
_VARIANT_REQUIREMENT_NEG_CACHE: "OrderedDict[tuple[str, str], float]" = OrderedDict()
|
|
_VARIANT_HASH_MAX = 512
|
|
# Blob hashes are derived from the same mutable remote revision metadata as
|
|
# variant requirements, so they must not outlive that freshness window.
|
|
_VARIANT_HASH_POS_TTL = 60.0
|
|
# Refresh resolved variant requirements so a moved repo revision is picked up
|
|
# within the session instead of being pinned for the backend's lifetime.
|
|
_VARIANT_REQUIREMENT_POS_TTL = 60.0
|
|
# Suppress retries on a metadata-fetch failure so a slow/flaky link doesn't
|
|
# re-hammer the API on every page refresh.
|
|
_VARIANT_REQUIREMENT_NEG_TTL = 60.0
|
|
# Fail fast on a slow link so the variant render isn't blocked for seconds.
|
|
_GGUF_METADATA_TIMEOUT_SECONDS = 5.0
|
|
_VARIANT_HASH_LOCK = threading.Lock()
|
|
|
|
|
|
class VariantIncompleteDeleteResult(NamedTuple):
|
|
deleted: int
|
|
unresolved: bool
|
|
|
|
|
|
def _variant_hash_cache_key(
|
|
repo_id: str, variant: str, hf_token: Optional[str]
|
|
) -> tuple[str, str, str]:
|
|
return (
|
|
repo_id.lower(),
|
|
variant.lower(),
|
|
hf_cache_scan.token_fingerprint(hf_token),
|
|
)
|
|
|
|
|
|
def _variant_blob_hash_cache_key(
|
|
repo_id: str, variant: str, hf_token: Optional[str], include_companions: bool
|
|
) -> tuple[str, str, str, bool]:
|
|
base = _variant_hash_cache_key(repo_id, variant, hf_token)
|
|
return (*base, include_companions)
|
|
|
|
|
|
def _variant_repo_cache_key(repo_id: str, hf_token: Optional[str]) -> tuple[str, str]:
|
|
return (repo_id.lower(), hf_cache_scan.token_fingerprint(hf_token))
|
|
|
|
|
|
def _variant_requirement_neg_cache_active(key: tuple[str, str]) -> bool:
|
|
with _VARIANT_HASH_LOCK:
|
|
cached_at = _VARIANT_REQUIREMENT_NEG_CACHE.get(key)
|
|
if cached_at is None:
|
|
return False
|
|
if (time.monotonic() - cached_at) < _VARIANT_REQUIREMENT_NEG_TTL:
|
|
_VARIANT_REQUIREMENT_NEG_CACHE.move_to_end(key)
|
|
return True
|
|
_VARIANT_REQUIREMENT_NEG_CACHE.pop(key, None)
|
|
return False
|
|
|
|
|
|
def _variant_requirement_neg_cache_set(key: tuple[str, str]) -> None:
|
|
with _VARIANT_HASH_LOCK:
|
|
_VARIANT_REQUIREMENT_NEG_CACHE[key] = time.monotonic()
|
|
_VARIANT_REQUIREMENT_NEG_CACHE.move_to_end(key)
|
|
while len(_VARIANT_REQUIREMENT_NEG_CACHE) > _VARIANT_HASH_MAX:
|
|
_VARIANT_REQUIREMENT_NEG_CACHE.popitem(last = False)
|
|
|
|
|
|
def _variant_requirement_neg_cache_clear(key: tuple[str, str]) -> None:
|
|
with _VARIANT_HASH_LOCK:
|
|
_VARIANT_REQUIREMENT_NEG_CACHE.pop(key, None)
|
|
|
|
|
|
def _variant_hash_cache_get(key: tuple[str, str, str, bool]) -> Optional[frozenset[str]]:
|
|
with _VARIANT_HASH_LOCK:
|
|
cached = _VARIANT_HASH_CACHE.get(key)
|
|
if cached is None:
|
|
return None
|
|
hashes, ts = cached
|
|
if (time.monotonic() - ts) >= _VARIANT_HASH_POS_TTL:
|
|
_VARIANT_HASH_CACHE.pop(key, None)
|
|
return None
|
|
_VARIANT_HASH_CACHE.move_to_end(key)
|
|
return hashes
|
|
|
|
|
|
def _variant_hash_cache_set(key: tuple[str, str, str, bool], hashes: frozenset[str]) -> None:
|
|
with _VARIANT_HASH_LOCK:
|
|
_VARIANT_HASH_CACHE[key] = (hashes, time.monotonic())
|
|
_VARIANT_HASH_CACHE.move_to_end(key)
|
|
while len(_VARIANT_HASH_CACHE) > _VARIANT_HASH_MAX:
|
|
_VARIANT_HASH_CACHE.popitem(last = False)
|
|
|
|
|
|
def _variant_requirement_cache_get(key: tuple[str, str, str]) -> Optional[_GgufVariantRequirement]:
|
|
with _VARIANT_HASH_LOCK:
|
|
cached = _VARIANT_REQUIREMENT_CACHE.get(key)
|
|
if cached is None:
|
|
return None
|
|
requirement, ts = cached
|
|
if (time.monotonic() - ts) >= _VARIANT_REQUIREMENT_POS_TTL:
|
|
_VARIANT_REQUIREMENT_CACHE.pop(key, None)
|
|
return None
|
|
_VARIANT_REQUIREMENT_CACHE.move_to_end(key)
|
|
return requirement
|
|
|
|
|
|
def _variant_requirement_cache_set_many(
|
|
repo_id: str, hf_token: Optional[str], requirements: dict[str, _GgufVariantRequirement]
|
|
) -> None:
|
|
with _VARIANT_HASH_LOCK:
|
|
now = time.monotonic()
|
|
for quant, requirement in requirements.items():
|
|
key = _variant_hash_cache_key(repo_id, quant, hf_token)
|
|
_VARIANT_REQUIREMENT_CACHE[key] = (requirement, now)
|
|
_VARIANT_REQUIREMENT_CACHE.move_to_end(key)
|
|
while len(_VARIANT_REQUIREMENT_CACHE) > _VARIANT_HASH_MAX:
|
|
_VARIANT_REQUIREMENT_CACHE.popitem(last = False)
|
|
|
|
|
|
def _build_gguf_variant_requirements(siblings: list) -> dict[str, _GgufVariantRequirement]:
|
|
return build_gguf_variant_plans(siblings)
|
|
|
|
|
|
def gguf_variant_requirements(
|
|
repo_id: str,
|
|
variant: str,
|
|
hf_token: Optional[str] = None,
|
|
) -> Optional[_GgufVariantRequirement]:
|
|
key = _variant_hash_cache_key(repo_id, variant, hf_token)
|
|
cached = _variant_requirement_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
requirements = _fetch_gguf_variant_requirements(repo_id, hf_token)
|
|
return requirements.get(variant.lower())
|
|
|
|
|
|
def _fetch_gguf_variant_requirements(
|
|
repo_id: str,
|
|
hf_token: Optional[str] = None,
|
|
*,
|
|
siblings: Optional[list] = None,
|
|
) -> dict[str, _GgufVariantRequirement]:
|
|
repo_key = _variant_repo_cache_key(repo_id, hf_token)
|
|
if siblings is None:
|
|
if _variant_requirement_neg_cache_active(repo_key):
|
|
return {}
|
|
try:
|
|
from huggingface_hub import HfApi
|
|
info = HfApi(token = hf_token).model_info(
|
|
repo_id,
|
|
files_metadata = True,
|
|
timeout = _GGUF_METADATA_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"model_info failed resolving GGUF files for %s: %s",
|
|
repo_id,
|
|
download_registry.scrub_secrets(str(e), hf_token = hf_token),
|
|
)
|
|
_variant_requirement_neg_cache_set(repo_key)
|
|
return {}
|
|
siblings = list(info.siblings)
|
|
requirements = _build_gguf_variant_requirements(siblings)
|
|
if requirements:
|
|
_variant_requirement_cache_set_many(repo_id, hf_token, requirements)
|
|
_variant_requirement_neg_cache_clear(repo_key)
|
|
return requirements
|
|
|
|
|
|
def _gguf_all_variant_requirements(
|
|
repo_id: str,
|
|
hf_token: Optional[str] = None,
|
|
*,
|
|
siblings: Optional[list] = None,
|
|
) -> dict[str, _GgufVariantRequirement]:
|
|
return _fetch_gguf_variant_requirements(repo_id, hf_token, siblings = siblings)
|
|
|
|
|
|
def _manifest_variant_blob_hashes(
|
|
repo_id: str,
|
|
variant: str,
|
|
*,
|
|
include_companions: bool = True,
|
|
) -> frozenset[str]:
|
|
manifest = download_manifest.read_manifest("model", repo_id, variant)
|
|
if manifest is None:
|
|
return frozenset()
|
|
variant_key = variant.lower()
|
|
hashes: set[str] = set()
|
|
for expected in manifest.expected_files:
|
|
if not expected.sha256:
|
|
continue
|
|
if include_companions:
|
|
hashes.add(expected.sha256)
|
|
continue
|
|
if is_main_gguf_variant_path(expected.path, variant_key):
|
|
hashes.add(expected.sha256)
|
|
return frozenset(hashes)
|
|
|
|
|
|
def gguf_variant_blob_hashes(
|
|
repo_id: str,
|
|
variant: str,
|
|
hf_token: Optional[str] = None,
|
|
*,
|
|
include_companions: bool = True,
|
|
allow_remote: bool = True,
|
|
) -> frozenset[str]:
|
|
key = _variant_blob_hash_cache_key(
|
|
repo_id,
|
|
variant,
|
|
hf_token,
|
|
include_companions,
|
|
)
|
|
cached = _variant_hash_cache_get(key)
|
|
if cached is not None:
|
|
return cached
|
|
hashes = _manifest_variant_blob_hashes(
|
|
repo_id,
|
|
variant,
|
|
include_companions = include_companions,
|
|
)
|
|
if hashes:
|
|
_variant_hash_cache_set(key, hashes)
|
|
return hashes
|
|
requirement_key = _variant_hash_cache_key(repo_id, variant, hf_token)
|
|
requirement = _variant_requirement_cache_get(requirement_key)
|
|
if requirement is None and allow_remote:
|
|
requirement = gguf_variant_requirements(repo_id, variant, hf_token)
|
|
if requirement is not None:
|
|
hashes = requirement.required_hashes if include_companions else requirement.main_hashes
|
|
if hashes:
|
|
_variant_hash_cache_set(key, hashes)
|
|
return hashes
|
|
return frozenset()
|
|
|
|
|
|
def _partial_transport_for_variant(repo_id: str, variant: str) -> Optional[str]:
|
|
return hf_cache_scan.partial_transport_for("model", repo_id, variant)
|
|
|
|
|
|
def delete_variant_incomplete_blobs_result(
|
|
repo_id: str,
|
|
variant: str,
|
|
hf_token: Optional[str],
|
|
*,
|
|
extra_hashes: frozenset[str] = frozenset(),
|
|
companions: bool = True,
|
|
) -> VariantIncompleteDeleteResult:
|
|
# With a sibling still downloading, ``companions=False`` keeps a shared mmproj
|
|
# from being unlinked out from under it; the repo's last delete reclaims it.
|
|
target_hashes = (
|
|
gguf_variant_blob_hashes(repo_id, variant, hf_token, include_companions = companions)
|
|
| extra_hashes
|
|
)
|
|
if not target_hashes:
|
|
has_variant_partial_state = hf_cache_scan.is_variant_partial(
|
|
repo_id,
|
|
variant,
|
|
incomplete_blob_hashes = set(),
|
|
variant_blob_hashes = frozenset(),
|
|
)
|
|
has_repo_partials = bool(download_registry.incomplete_blob_hashes("model", repo_id))
|
|
return VariantIncompleteDeleteResult(
|
|
deleted = 0,
|
|
unresolved = has_variant_partial_state and has_repo_partials,
|
|
)
|
|
deleted = 0
|
|
# Destructive iterator: only the exact-case match (or abort if ambiguous),
|
|
# so a case-variant sibling repo's partials are never unlinked.
|
|
for entry in iter_destructive_repo_cache_dirs("model", repo_id):
|
|
blobs_dir = entry / "blobs"
|
|
if not blobs_dir.is_dir():
|
|
continue
|
|
for h in target_hashes:
|
|
incomplete = blobs_dir / f"{h}{INCOMPLETE_SUFFIX}"
|
|
if incomplete.exists():
|
|
try:
|
|
incomplete.unlink()
|
|
deleted += 1
|
|
except OSError as e:
|
|
logger.warning(f"Failed to unlink {incomplete}: {e}")
|
|
return VariantIncompleteDeleteResult(deleted = deleted, unresolved = False)
|
|
|
|
|
|
async def get_gguf_variants_response(
|
|
repo_id: str,
|
|
prefer_local_cache: bool = False,
|
|
offline: bool = False,
|
|
local_path: Optional[str] = None,
|
|
hf_token: Optional[str] = None,
|
|
):
|
|
"""
|
|
List available GGUF quantization variants for a HuggingFace repo
|
|
or a local directory (e.g. LM Studio model folder).
|
|
|
|
Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.)
|
|
with file sizes, whether the model supports vision, and the recommended
|
|
default variant.
|
|
"""
|
|
|
|
def _compute() -> GgufVariantsResponse:
|
|
def _local_response(
|
|
response_repo_id: str, variants, has_vision: bool
|
|
) -> GgufVariantsResponse:
|
|
filenames = [v.filename for v in variants]
|
|
best = pick_best_gguf(filenames)
|
|
default_variant = extract_quant_label(best) if best else None
|
|
return GgufVariantsResponse(
|
|
repo_id = response_repo_id,
|
|
variants = [
|
|
GgufVariantDetail(
|
|
filename = v.filename,
|
|
quant = v.quant,
|
|
display_label = v.display_label,
|
|
size_bytes = v.size_bytes,
|
|
download_size_bytes = v.size_bytes,
|
|
downloaded = True,
|
|
)
|
|
for v in variants
|
|
],
|
|
has_vision = has_vision,
|
|
default_variant = default_variant,
|
|
)
|
|
|
|
def _partial_local_response(
|
|
response_repo_id: str, variants, has_vision: bool
|
|
) -> GgufVariantsResponse:
|
|
filenames = [v.filename for v in variants]
|
|
best = pick_best_gguf(filenames)
|
|
default_variant = extract_quant_label(best) if best else None
|
|
return GgufVariantsResponse(
|
|
repo_id = response_repo_id,
|
|
variants = [
|
|
GgufVariantDetail(
|
|
filename = v.filename,
|
|
quant = v.quant,
|
|
display_label = v.display_label,
|
|
size_bytes = v.size_bytes,
|
|
download_size_bytes = v.download_size_bytes or v.size_bytes,
|
|
downloaded = False,
|
|
partial = True,
|
|
partial_transport = _partial_transport_for_variant(
|
|
response_repo_id,
|
|
v.quant,
|
|
),
|
|
)
|
|
for v in variants
|
|
],
|
|
has_vision = has_vision,
|
|
default_variant = default_variant,
|
|
)
|
|
|
|
# Local directory path (e.g. LM Studio models) — scan filesystem
|
|
if is_local_path(repo_id):
|
|
variants, has_vision = list_local_gguf_variants(repo_id)
|
|
|
|
return _local_response(repo_id, variants, has_vision)
|
|
|
|
# Reject invalid remote repo_ids up front (like download/delete) so a
|
|
# malformed id returns 400 instead of a 500 from the HF client.
|
|
if not _is_valid_repo_id(repo_id):
|
|
raise HTTPException(status_code = 400, detail = f"Invalid repo_id: {repo_id!r}")
|
|
|
|
local_only = prefer_local_cache or offline
|
|
if local_only:
|
|
cached = list_gguf_variants_from_hf_cache(repo_id)
|
|
if cached is not None:
|
|
variants, has_vision = cached
|
|
return _local_response(repo_id, variants, has_vision)
|
|
if local_path and is_local_path(local_path):
|
|
variants, has_vision = list_local_gguf_variants(local_path)
|
|
if variants or has_vision:
|
|
return _local_response(repo_id, variants, has_vision)
|
|
partial = list_partial_gguf_variants_from_state(repo_id)
|
|
if partial is not None:
|
|
variants, has_vision = partial
|
|
return _partial_local_response(repo_id, variants, has_vision)
|
|
if local_path and offline:
|
|
return GgufVariantsResponse(
|
|
repo_id = repo_id,
|
|
variants = [],
|
|
has_vision = False,
|
|
default_variant = None,
|
|
)
|
|
if offline:
|
|
raise HTTPException(
|
|
status_code = 404,
|
|
detail = "No cached GGUF variants available while offline.",
|
|
)
|
|
|
|
try:
|
|
variants, has_vision, siblings = list_gguf_variants(repo_id, hf_token = hf_token)
|
|
except Exception:
|
|
cached = list_gguf_variants_from_hf_cache(repo_id)
|
|
if cached is not None:
|
|
variants, has_vision = cached
|
|
return _local_response(repo_id, variants, has_vision)
|
|
partial = list_partial_gguf_variants_from_state(repo_id)
|
|
if partial is not None:
|
|
variants, has_vision = partial
|
|
return _partial_local_response(repo_id, variants, has_vision)
|
|
raise
|
|
|
|
filenames = [v.filename for v in variants]
|
|
best = pick_best_gguf(filenames)
|
|
default_variant = extract_quant_label(best) if best else None
|
|
|
|
# Per-snapshot accounting: a variant counts as present only when one
|
|
# snapshot holds all its files (split GGUFs need every shard together),
|
|
# sizes are max across snapshots so shared blobs aren't double-counted,
|
|
# and keys are lowercased since cache dir casing can differ from repo_id.
|
|
cached_filenames_by_snapshot: list[dict[str, int]] = []
|
|
cached_quant_bytes_by_snapshot: list[dict[str, int]] = []
|
|
if _is_valid_repo_id(repo_id):
|
|
for snap in iter_hf_cache_snapshots(repo_id):
|
|
try:
|
|
gguf_paths = list(_iter_gguf_paths(snap))
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
logger.debug("Skipping GGUF cache snapshot %s: %s", snap, e)
|
|
continue
|
|
by_filename: dict[str, int] = {}
|
|
by_quant: dict[str, int] = {}
|
|
for f in gguf_paths:
|
|
try:
|
|
rel = f.relative_to(snap).as_posix()
|
|
size = f.stat().st_size
|
|
except (OSError, RuntimeError, ValueError) as e:
|
|
logger.debug("Skipping GGUF cache file %s: %s", f, e)
|
|
continue
|
|
key = rel.lower()
|
|
by_filename[key] = max(by_filename.get(key, 0), size)
|
|
if _is_mmproj_filename(f.name) or _is_mtp_drafter_path(rel):
|
|
continue
|
|
q = extract_quant_label(rel)
|
|
if is_big_endian_gguf_path(rel, q):
|
|
continue
|
|
q = q.lower()
|
|
by_quant[q] = by_quant.get(q, 0) + size
|
|
if by_filename:
|
|
cached_filenames_by_snapshot.append(by_filename)
|
|
if by_quant:
|
|
cached_quant_bytes_by_snapshot.append(by_quant)
|
|
|
|
requirements_by_quant = {
|
|
v.quant.lower(): _variant_requirement_cache_get(
|
|
_variant_hash_cache_key(repo_id, v.quant, hf_token)
|
|
)
|
|
for v in variants
|
|
}
|
|
if any(req is None for req in requirements_by_quant.values()):
|
|
fetched_requirements = _gguf_all_variant_requirements(
|
|
repo_id, hf_token, siblings = siblings
|
|
)
|
|
for v in variants:
|
|
key = v.quant.lower()
|
|
if requirements_by_quant.get(key) is None:
|
|
requirements_by_quant[key] = fetched_requirements.get(key)
|
|
|
|
def _filenames_cached(filenames: frozenset[str], expected_size: int) -> bool:
|
|
if not filenames:
|
|
return False
|
|
wanted = [name.lower() for name in filenames]
|
|
# All files must live in a single snapshot, not spread across several.
|
|
for by_filename in cached_filenames_by_snapshot:
|
|
cached = 0
|
|
for name in wanted:
|
|
size = by_filename.get(name)
|
|
if size is None:
|
|
break
|
|
cached += size
|
|
else:
|
|
return expected_size <= 0 or cached >= expected_size * 0.99
|
|
return False
|
|
|
|
def _any_mmproj_cached(filenames: frozenset[str]) -> bool:
|
|
if any(
|
|
by_filename.get(name.lower()) is not None
|
|
for by_filename in cached_filenames_by_snapshot
|
|
for name in filenames
|
|
):
|
|
return True
|
|
return any(
|
|
_is_mmproj_filename(name.rsplit("/", 1)[-1])
|
|
for by_filename in cached_filenames_by_snapshot
|
|
for name in by_filename
|
|
)
|
|
|
|
def _quant_bytes_present(quant: str, size_bytes: int) -> bool:
|
|
# Small rounding tolerance for symlinks vs real sizes.
|
|
if size_bytes <= 0:
|
|
return False
|
|
return any(
|
|
by_quant.get(quant, 0) >= size_bytes * 0.99
|
|
for by_quant in cached_quant_bytes_by_snapshot
|
|
)
|
|
|
|
def _is_fully_downloaded(variant) -> bool:
|
|
quant = variant.quant.lower()
|
|
requirement = requirements_by_quant.get(quant)
|
|
# Vision repos ship an mmproj adapter; any precision on disk suffices.
|
|
if (
|
|
requirement is not None
|
|
and _filenames_cached(
|
|
requirement.main_filenames,
|
|
requirement.main_size_bytes,
|
|
)
|
|
and (
|
|
not requirement.mmproj_filenames
|
|
or _any_mmproj_cached(requirement.mmproj_filenames)
|
|
)
|
|
):
|
|
return True
|
|
# Byte fallback so a present quant isn't demoted by a filename mismatch;
|
|
# vision repos still need an mmproj cached (any precision).
|
|
if not _quant_bytes_present(quant, variant.size_bytes):
|
|
return False
|
|
if (
|
|
requirement is not None
|
|
and requirement.mmproj_filenames
|
|
and not _any_mmproj_cached(requirement.mmproj_filenames)
|
|
):
|
|
return False
|
|
return True
|
|
|
|
partial_quants: set[str] = set()
|
|
partial_quant_transports: dict[str, Optional[str]] = {}
|
|
try:
|
|
incomplete_hashes = download_registry.incomplete_blob_hashes("model", repo_id)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to compute partial GGUF variants for {repo_id}: {e}")
|
|
incomplete_hashes = set()
|
|
scan_snapshot_dir = hf_cache_scan.resolve_snapshot_dir_for_scan("model", repo_id)
|
|
# Manifest + marker + main incomplete-blob check: catches variants whose
|
|
# download was cancelled or whose expected shards are missing/undersized.
|
|
for variant in variants:
|
|
try:
|
|
requirement = requirements_by_quant.get(variant.quant.lower())
|
|
variant_hashes = requirement.main_hashes if requirement is not None else None
|
|
if variant_hashes is None and incomplete_hashes:
|
|
variant_hashes = gguf_variant_blob_hashes(
|
|
repo_id,
|
|
variant.quant,
|
|
hf_token,
|
|
include_companions = False,
|
|
)
|
|
if hf_cache_scan.is_variant_partial(
|
|
repo_id,
|
|
variant.quant,
|
|
scan_snapshot_dir,
|
|
incomplete_blob_hashes = incomplete_hashes,
|
|
variant_blob_hashes = variant_hashes,
|
|
):
|
|
partial_quants.add(variant.quant)
|
|
partial_quant_transports[variant.quant] = _partial_transport_for_variant(
|
|
repo_id,
|
|
variant.quant,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Manifest-based partial check failed for " f"{repo_id}/{variant.quant}: {e}"
|
|
)
|
|
if incomplete_hashes:
|
|
for variant in variants:
|
|
requirement = requirements_by_quant.get(variant.quant.lower())
|
|
if requirement is None:
|
|
continue
|
|
# companion_hashes adds the MTP drafter (mmproj_hashes covers
|
|
# every mmproj precision in the repo, not just the planned one).
|
|
if (
|
|
(requirement.mmproj_hashes | requirement.companion_hashes) & incomplete_hashes
|
|
) and _filenames_cached(
|
|
requirement.main_filenames,
|
|
requirement.main_size_bytes,
|
|
):
|
|
partial_quants.add(variant.quant)
|
|
partial_quant_transports.setdefault(
|
|
variant.quant,
|
|
_partial_transport_for_variant(repo_id, variant.quant),
|
|
)
|
|
|
|
def _variant_detail(v) -> GgufVariantDetail:
|
|
is_partial = v.quant in partial_quants
|
|
requirement = requirements_by_quant.get(v.quant.lower())
|
|
return GgufVariantDetail(
|
|
filename = v.filename,
|
|
quant = v.quant,
|
|
display_label = v.display_label,
|
|
size_bytes = v.size_bytes,
|
|
download_size_bytes = (
|
|
requirement.download_size_bytes if requirement is not None else v.size_bytes
|
|
),
|
|
downloaded = _is_fully_downloaded(v) and not is_partial,
|
|
partial = is_partial,
|
|
partial_transport = (partial_quant_transports.get(v.quant) if is_partial else None),
|
|
)
|
|
|
|
return GgufVariantsResponse(
|
|
repo_id = repo_id,
|
|
variants = [_variant_detail(v) for v in variants],
|
|
has_vision = has_vision,
|
|
default_variant = default_variant,
|
|
)
|
|
|
|
try:
|
|
return await asyncio.to_thread(_compute)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
scrubbed = download_registry.scrub_secrets(str(e), hf_token = hf_token)
|
|
# Client-side HF error (missing repo, gated, bad token): pass the status through.
|
|
status = hf_error_status(e)
|
|
if status is not None:
|
|
raise HTTPException(status_code = status, detail = scrubbed)
|
|
logger.error("Error listing GGUF variants for %s: %s", repo_id, scrubbed)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = "Failed to list GGUF variants: " + scrubbed,
|
|
)
|