* Studio: hide infra models from the hub cached inventory The hub inventory scans behind /api/hub/cached-gguf and /api/hub/cached-models returned the llama.cpp install validation probe (ggml-org/models) and the RAG embedder (unsloth/bge-small-en-v1.5[-GGUF]) as on-device models. Share the hidden-model check from routes/models.py via utils/models/hidden_models.py and apply it in both scans. A GGUF infra repo stays visible when the user explicitly downloaded a variant through the Hub, since variant manifests only exist for user-initiated downloads. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make On Device trust the hub inventory, match repo ids exactly, lighten the hidden-model import Follow-up on the hub cached-inventory hidden-model change, addressing the review. On Device now trusts the Hub inventory API for cached rows. The backend already hides the RAG embedder and the llama.cpp probe and re-includes a GGUF infra repo once the user downloads a variant through the Hub, but the frontend was re-hiding it by repo id, so the user-downloaded variant never appeared in the On Device list or the count. isVisibleInventoryRow now short-circuits cached rows (kind === "cache") to visible and keeps client-side needle hiding only for local filesystem rows and Discover. is_hidden_model matches Hub repo ids exactly (case-insensitive) against the probe plus the effective embedder and its GGUF companion, instead of substring matching the configured-embedder basename. A custom embedder with a generic basename like org/model no longer hides unrelated cached repos such as user/model-chat or org/model-instruct. The probe filename and local-path embedders keep exact matching. The helper moves to utils/hidden_models.py and is imported at module scope in the hub cache scanner, so it no longer pulls in utils/models/__init__ (the eager model-config/checkpoint stack) and a broken import fails at startup instead of being swallowed per-repo and silently emptying the inventory. routes.models keeps the _is_hidden_model and _safe_resolve aliases and drops the unused _HF_REPO_ID_RE re-export that was failing source lint. Tests: exact repo-id matching with a custom embedder, the cached-models scan keeping an unrelated repo, and a clean-interpreter check that the helper imports without the model-config stack. * Studio: match the llama.cpp probe filename on both path separators The hidden-model check compared the probe's on-disk filename with Path(value).name, which on a POSIX interpreter does not split a Windows-style path ("...\stories260K.gguf") and would let the probe through. Split on both separators so the probe is matched regardless of which OS produced the path, matching the tolerance of the previous substring check. Adds a Windows-path assertion to the probe test. * Studio: harden hidden infra model handling * Fix hidden cache row confirmation * Fix hidden local rows and confirmed hint merges * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle snapshot-configured hidden models * Hide basename-only default embedders * Fix dynamic embedder inventory filtering * Studio: hide the configured RAG embedder from Discover and feed rows --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
730 lines
25 KiB
Python
730 lines
25 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
|
|
|
|
"""Local model, HF cache, LM Studio, and Ollama inventory services.
|
|
|
|
Ollama logic lives in :mod:`hub.services.models.ollama`; this module
|
|
orchestrates all on-device sources and exposes the route handlers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from fastapi import HTTPException
|
|
from loggers import get_logger
|
|
|
|
from hub.schemas.inventory import LocalModelInfo, LocalModelListResponse, ModelFormat
|
|
from hub.storage.scan_folders import (
|
|
add_scan_folder,
|
|
list_scan_folders,
|
|
remove_scan_folder,
|
|
)
|
|
from hub.utils import inventory_scan as hf_cache_scan
|
|
from hub.utils.paths import (
|
|
hf_default_cache_dir,
|
|
legacy_hf_cache_dir,
|
|
lmstudio_model_dirs,
|
|
normalize_path,
|
|
ollama_model_dirs,
|
|
outputs_root,
|
|
path_is_same_or_child,
|
|
studio_root,
|
|
)
|
|
from hub.services.models import common as model_common
|
|
from hub.services.models.ollama import scan_ollama_dir
|
|
from utils.hidden_models import is_hidden_model
|
|
|
|
logger = get_logger(__name__)
|
|
_MAX_MODELS_PER_CUSTOM_FOLDER = 200
|
|
_MAX_CUSTOM_FOLDER_ENTRIES = 2000
|
|
_MODEL_SIGNAL_PROBE_LIMIT = 200
|
|
|
|
# Local aliases keep the extracted code close to the original implementation.
|
|
_is_model_directory = model_common._is_model_directory
|
|
_local_inventory_id = model_common._local_inventory_id
|
|
_local_model_info = model_common._local_model_info
|
|
_capabilities_for_format = model_common._capabilities_for_format
|
|
_apply_format_aware_partial = model_common._apply_format_aware_partial
|
|
_classify_local_path = model_common._classify_local_path
|
|
_is_main_gguf_filename = model_common._is_main_gguf_filename
|
|
_is_transformers_bin_weight_file = model_common._is_transformers_bin_weight_file
|
|
_prefer_complete_larger = model_common._prefer_complete_larger
|
|
_gguf_variant_state_summary = model_common._gguf_variant_state_summary
|
|
|
|
|
|
def _is_immediate_model_weight_file(path: Path) -> bool:
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".safetensors":
|
|
return True
|
|
if suffix == ".gguf":
|
|
return _is_main_gguf_filename(path.name)
|
|
if suffix == ".bin":
|
|
return _is_transformers_bin_weight_file(path)
|
|
return False
|
|
|
|
|
|
def _has_immediate_model_weight(
|
|
path: Path, *, probe_limit: int = _MODEL_SIGNAL_PROBE_LIMIT
|
|
) -> bool:
|
|
try:
|
|
for index, entry in enumerate(path.iterdir(), start = 1):
|
|
if index > probe_limit:
|
|
break
|
|
try:
|
|
if entry.is_file() and _is_immediate_model_weight_file(entry):
|
|
return True
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _has_immediate_model_signal(
|
|
path: Path, *, probe_limit: int = _MODEL_SIGNAL_PROBE_LIMIT
|
|
) -> bool:
|
|
try:
|
|
if (path / "config.json").exists() or (path / "adapter_config.json").exists():
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return _has_immediate_model_weight(path, probe_limit = probe_limit)
|
|
|
|
|
|
def _is_model_directory_for_scan(path: Path, *, entry_limit: int | None) -> bool:
|
|
if entry_limit is None:
|
|
return _is_model_directory(path)
|
|
try:
|
|
has_config = (path / "config.json").exists() or (path / "adapter_config.json").exists()
|
|
except OSError:
|
|
return False
|
|
return has_config and _has_immediate_model_weight(path)
|
|
|
|
|
|
def _resolve_hf_cache_dir() -> Path:
|
|
try:
|
|
from huggingface_hub.constants import HF_HUB_CACHE
|
|
return Path(HF_HUB_CACHE)
|
|
except Exception:
|
|
return Path.home() / ".cache" / "huggingface" / "hub"
|
|
|
|
|
|
def _scan_models_dir(
|
|
models_dir: Path,
|
|
*,
|
|
limit: int | None = None,
|
|
entry_limit: int | None = None,
|
|
) -> List[LocalModelInfo]:
|
|
if not models_dir.exists() or not models_dir.is_dir():
|
|
return []
|
|
|
|
_is_self_model = _is_model_directory_for_scan(
|
|
models_dir,
|
|
entry_limit = entry_limit,
|
|
)
|
|
|
|
if _is_self_model:
|
|
try:
|
|
updated_at = models_dir.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
return _classify_local_path(
|
|
models_dir,
|
|
"models_dir",
|
|
updated_at = updated_at,
|
|
)
|
|
|
|
found: List[LocalModelInfo] = []
|
|
visited = 0
|
|
try:
|
|
children = models_dir.iterdir()
|
|
except OSError:
|
|
return found
|
|
for child in children:
|
|
if limit is not None and len(found) >= limit:
|
|
break
|
|
visited += 1
|
|
if entry_limit is not None and visited > entry_limit:
|
|
break
|
|
try:
|
|
is_dir = child.is_dir()
|
|
is_gguf_file = not is_dir and child.suffix.lower() == ".gguf" and child.is_file()
|
|
if not is_dir and not is_gguf_file:
|
|
continue
|
|
has_model_files = is_gguf_file or _has_immediate_model_signal(child)
|
|
except OSError:
|
|
# Skip individual children that are unreadable (permissions, broken
|
|
# symlinks, etc.) rather than failing the entire scan.
|
|
continue
|
|
if not has_model_files:
|
|
continue
|
|
try:
|
|
updated_at = child.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
rows = _classify_local_path(
|
|
child,
|
|
"models_dir",
|
|
updated_at = updated_at,
|
|
)
|
|
if limit is not None:
|
|
rows = rows[: max(0, limit - len(found))]
|
|
found.extend(rows)
|
|
|
|
return found
|
|
|
|
|
|
def _safe_is_dir(path: Path) -> bool:
|
|
"""``Path.is_dir()`` treating an unreadable path (``PermissionError`` /
|
|
``OSError`` on a restricted ``~/.cache/huggingface/hub``) as "not a
|
|
directory", so the inventory skips that source instead of 500ing the Hub page.
|
|
"""
|
|
try:
|
|
return path.is_dir()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _hf_repo_dir_has_content(repo_dir: Path) -> bool:
|
|
blobs_dir = repo_dir / "blobs"
|
|
if not blobs_dir.is_dir():
|
|
return False
|
|
try:
|
|
for entry in blobs_dir.iterdir():
|
|
if entry.is_file() or entry.is_symlink():
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _scan_hf_cache(cache_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
|
|
if not _safe_is_dir(cache_dir):
|
|
return []
|
|
|
|
discovered: List[tuple[Path, str, Optional[float]]] = []
|
|
visited = 0
|
|
try:
|
|
entries = cache_dir.iterdir()
|
|
except OSError:
|
|
return []
|
|
for repo_dir in entries:
|
|
visited += 1
|
|
if entry_limit is not None and visited > entry_limit:
|
|
break
|
|
if not repo_dir.name.startswith("models--"):
|
|
continue
|
|
if not repo_dir.is_dir():
|
|
continue
|
|
if not _hf_repo_dir_has_content(repo_dir):
|
|
continue
|
|
repo_name = repo_dir.name[len("models--") :]
|
|
if not repo_name:
|
|
continue
|
|
model_id = repo_name.replace("--", "/")
|
|
try:
|
|
updated_at = repo_dir.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
discovered.append((repo_dir, model_id, updated_at))
|
|
|
|
found: list[LocalModelInfo] = []
|
|
for repo_dir, model_id, updated_at in discovered:
|
|
snapshot_partial = hf_cache_scan.is_snapshot_partial(
|
|
"model",
|
|
model_id,
|
|
repo_dir,
|
|
)
|
|
gguf_partial = hf_cache_scan.is_gguf_repo_partial(model_id, repo_dir)
|
|
has_gguf_variant_state, gguf_variant_state_size = _gguf_variant_state_summary(model_id)
|
|
snapshot_partial_transport = (
|
|
hf_cache_scan.partial_transport_for(
|
|
"model",
|
|
model_id,
|
|
repo_cache_dir = repo_dir,
|
|
)
|
|
if snapshot_partial
|
|
else None
|
|
)
|
|
resolved = hf_cache_scan.resolve_hf_cache_realpath(repo_dir)
|
|
scan_path = Path(resolved) if resolved else repo_dir
|
|
# partial=False here; _apply_format_aware_partial below rewrites per-row
|
|
# so a hybrid repo's gguf row doesn't taint its safetensors row.
|
|
rows = _classify_local_path(
|
|
scan_path,
|
|
"hf_cache",
|
|
load_path = repo_dir,
|
|
display_name = model_id.split("/")[-1],
|
|
model_id = model_id,
|
|
updated_at = updated_at,
|
|
partial = False,
|
|
)
|
|
if not rows:
|
|
if has_gguf_variant_state and gguf_partial:
|
|
rows = [
|
|
_local_model_info(
|
|
scan_path = repo_dir,
|
|
load_path = repo_dir,
|
|
source = "hf_cache",
|
|
model_format = "gguf",
|
|
display_name = model_id.split("/")[-1],
|
|
model_id = model_id,
|
|
updated_at = updated_at,
|
|
partial = True,
|
|
requires_variant = True,
|
|
size_bytes = gguf_variant_state_size,
|
|
)
|
|
]
|
|
else:
|
|
# Fallback row's model_format is "unknown"; either signal
|
|
# applies because we can't dispatch to a specific predicate.
|
|
rows = [
|
|
_local_model_info(
|
|
scan_path = repo_dir,
|
|
load_path = repo_dir,
|
|
source = "hf_cache",
|
|
model_format = "unknown",
|
|
display_name = model_id.split("/")[-1],
|
|
model_id = model_id,
|
|
updated_at = updated_at,
|
|
partial = snapshot_partial or gguf_partial,
|
|
)
|
|
]
|
|
elif (
|
|
has_gguf_variant_state
|
|
and gguf_partial
|
|
and not any(row.model_format == "gguf" for row in rows)
|
|
):
|
|
rows.append(
|
|
_local_model_info(
|
|
scan_path = repo_dir,
|
|
load_path = repo_dir,
|
|
source = "hf_cache",
|
|
model_format = "gguf",
|
|
display_name = model_id.split("/")[-1],
|
|
model_id = model_id,
|
|
updated_at = updated_at,
|
|
partial = True,
|
|
requires_variant = True,
|
|
size_bytes = gguf_variant_state_size,
|
|
)
|
|
)
|
|
rows = _apply_format_aware_partial(
|
|
rows,
|
|
snapshot_partial = snapshot_partial,
|
|
gguf_partial = gguf_partial,
|
|
snapshot_partial_transport = snapshot_partial_transport,
|
|
)
|
|
found.extend(rows)
|
|
return found
|
|
|
|
|
|
def _scan_lmstudio_dir(lm_dir: Path, *, entry_limit: int | None = None) -> List[LocalModelInfo]:
|
|
"""Scan an LM Studio models dir (``publisher/model-name`` folders of GGUFs, or top-level standalone GGUFs)."""
|
|
if not lm_dir.exists() or not lm_dir.is_dir():
|
|
return []
|
|
|
|
# If the dir is itself a model dir (config + weights), it's not an LM Studio
|
|
# publisher structure -- return it as a single entry rather than descend.
|
|
if _is_model_directory(lm_dir):
|
|
try:
|
|
updated_at = lm_dir.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
return _classify_local_path(
|
|
lm_dir,
|
|
"lmstudio",
|
|
updated_at = updated_at,
|
|
)
|
|
|
|
found: List[LocalModelInfo] = []
|
|
visited = 0
|
|
exhausted = False
|
|
|
|
def _consume_visit() -> bool:
|
|
nonlocal visited
|
|
visited += 1
|
|
return entry_limit is not None and visited > entry_limit
|
|
|
|
try:
|
|
children = lm_dir.iterdir()
|
|
except OSError:
|
|
return found
|
|
for child in children:
|
|
if _consume_visit():
|
|
break
|
|
try:
|
|
if not child.is_dir():
|
|
if child.suffix == ".gguf" and child.is_file():
|
|
try:
|
|
updated_at = child.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
found.extend(
|
|
_classify_local_path(
|
|
child,
|
|
"lmstudio",
|
|
updated_at = updated_at,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# Child is itself a model dir: surface it directly, not as a publisher.
|
|
if _is_model_directory(child):
|
|
try:
|
|
updated_at = child.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
found.extend(
|
|
_classify_local_path(
|
|
child,
|
|
"lmstudio",
|
|
updated_at = updated_at,
|
|
)
|
|
)
|
|
continue
|
|
|
|
# child is a publisher directory -- scan its sub-directories
|
|
for model_dir in child.iterdir():
|
|
if _consume_visit():
|
|
exhausted = True
|
|
break
|
|
try:
|
|
if model_dir.is_dir():
|
|
has_model = _has_immediate_model_signal(model_dir)
|
|
if not has_model:
|
|
continue
|
|
model_id = f"{child.name}/{model_dir.name}"
|
|
try:
|
|
updated_at = model_dir.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
found.extend(
|
|
_classify_local_path(
|
|
model_dir,
|
|
"lmstudio",
|
|
display_name = model_dir.name,
|
|
model_id = model_id,
|
|
updated_at = updated_at,
|
|
)
|
|
)
|
|
elif model_dir.suffix == ".gguf" and model_dir.is_file():
|
|
try:
|
|
updated_at = model_dir.stat().st_mtime
|
|
except OSError:
|
|
updated_at = None
|
|
found.extend(
|
|
_classify_local_path(
|
|
model_dir,
|
|
"lmstudio",
|
|
model_id = f"{child.name}/{model_dir.stem}",
|
|
updated_at = updated_at,
|
|
)
|
|
)
|
|
except OSError:
|
|
continue
|
|
if exhausted:
|
|
break
|
|
except OSError:
|
|
continue
|
|
return found
|
|
|
|
|
|
def _resolve_allowed_models_dir(models_dir: str, allowed_roots: list[Path]) -> Path:
|
|
"""Resolve a requested model scan directory without widening subpaths."""
|
|
if not models_dir or not models_dir.strip():
|
|
raise ValueError("Directory not allowed")
|
|
|
|
requested = Path(os.path.realpath(os.path.expanduser(normalize_path(models_dir.strip()))))
|
|
if any(path_is_same_or_child(requested, root) for root in allowed_roots):
|
|
return requested
|
|
|
|
raise ValueError("Directory not allowed")
|
|
|
|
|
|
def _coerce_scan_folder_path(raw_path: str) -> str:
|
|
"""Normalize a scan registration target; the registry stores directories, so a pasted weight-file path is reduced to its parent folder."""
|
|
if not raw_path or not raw_path.strip():
|
|
raise ValueError("Path cannot be empty")
|
|
raw = raw_path.strip()
|
|
if "\x00" in raw:
|
|
raise ValueError("Path cannot contain null bytes")
|
|
|
|
def normalize(value: str) -> Path:
|
|
return Path(os.path.realpath(os.path.expanduser(normalize_path(value))))
|
|
|
|
try:
|
|
normalized = normalize(raw)
|
|
except (OSError, ValueError) as e:
|
|
raise ValueError(f"Path is not readable: {e}") from e
|
|
try:
|
|
exists = normalized.exists()
|
|
is_dir = normalized.is_dir()
|
|
is_file = normalized.is_file()
|
|
except (OSError, ValueError) as e:
|
|
raise ValueError(f"Path is not readable: {e}") from e
|
|
|
|
if not exists and "\\" in raw:
|
|
try:
|
|
slash_normalized = normalize(raw.replace("\\", "/"))
|
|
slash_exists = slash_normalized.exists()
|
|
except (OSError, ValueError) as e:
|
|
raise ValueError(f"Path is not readable: {e}") from e
|
|
if slash_exists:
|
|
normalized = slash_normalized
|
|
try:
|
|
is_dir = normalized.is_dir()
|
|
is_file = normalized.is_file()
|
|
except (OSError, ValueError) as e:
|
|
raise ValueError(f"Path is not readable: {e}") from e
|
|
exists = True
|
|
|
|
if not exists:
|
|
return str(normalized)
|
|
if is_dir:
|
|
return str(normalized)
|
|
if is_file:
|
|
suffix = normalized.suffix.lower()
|
|
if suffix not in {".gguf", ".safetensors", ".bin"}:
|
|
raise ValueError("Path must be a folder or model weight file")
|
|
return str(normalized.parent)
|
|
return str(normalized)
|
|
|
|
|
|
async def _scan_source(label: str, scanner, path: Path) -> List[LocalModelInfo]:
|
|
try:
|
|
return await asyncio.to_thread(scanner, path)
|
|
except Exception as e:
|
|
logger.warning("Skipping %s scan for %s: %s", label, path, e)
|
|
return []
|
|
|
|
|
|
async def _collect_models_from_default_sources(
|
|
models_root: Path,
|
|
hf_cache_dir: Path,
|
|
legacy_hf: Path,
|
|
hf_default: Path,
|
|
lm_dirs: list[Path],
|
|
ollama_dirs: list[Path],
|
|
) -> List[LocalModelInfo]:
|
|
local_models = await _scan_source("models directory", _scan_models_dir, models_root)
|
|
local_models += await _scan_source("HF cache", _scan_hf_cache, hf_cache_dir)
|
|
|
|
if _safe_is_dir(legacy_hf) and legacy_hf.resolve() != hf_cache_dir.resolve():
|
|
local_models += await _scan_source("legacy HF cache", _scan_hf_cache, legacy_hf)
|
|
|
|
if (
|
|
_safe_is_dir(hf_default)
|
|
and hf_default.resolve() != hf_cache_dir.resolve()
|
|
and hf_default.resolve() != legacy_hf.resolve()
|
|
):
|
|
local_models += await _scan_source("default HF cache", _scan_hf_cache, hf_default)
|
|
|
|
for lm_dir in lm_dirs:
|
|
local_models += await _scan_source("LM Studio", _scan_lmstudio_dir, lm_dir)
|
|
|
|
for ollama_dir in ollama_dirs:
|
|
local_models += await _scan_source("Ollama", scan_ollama_dir, ollama_dir)
|
|
|
|
return local_models
|
|
|
|
|
|
def _scan_custom_folder(folder_path: Path) -> List[LocalModelInfo]:
|
|
supported_formats: set[ModelFormat] = {"gguf", "safetensors", "adapter"}
|
|
generic = [
|
|
m
|
|
for m in (
|
|
_scan_models_dir(
|
|
folder_path,
|
|
limit = _MAX_MODELS_PER_CUSTOM_FOLDER,
|
|
entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES,
|
|
)
|
|
+ _scan_hf_cache(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
|
|
+ _scan_lmstudio_dir(folder_path, entry_limit = _MAX_CUSTOM_FOLDER_ENTRIES)
|
|
)
|
|
if m.model_format in supported_formats
|
|
if not any(p in (".studio_links", "ollama_links") for p in Path(m.path).parts)
|
|
]
|
|
return generic[:_MAX_MODELS_PER_CUSTOM_FOLDER]
|
|
|
|
|
|
def _promote_to_custom_source(model: LocalModelInfo) -> LocalModelInfo:
|
|
if model.source == "hf_cache":
|
|
return model
|
|
return model.model_copy(
|
|
update = {
|
|
"source": "custom",
|
|
"model_id": None,
|
|
"inventory_id": _local_inventory_id(
|
|
"custom",
|
|
model.model_format,
|
|
model.path,
|
|
model.format_variant,
|
|
),
|
|
"capabilities": _capabilities_for_format(
|
|
model.model_format,
|
|
"custom",
|
|
partial = model.partial,
|
|
requires_variant = model.capabilities.requires_variant,
|
|
),
|
|
}
|
|
)
|
|
|
|
|
|
async def _collect_models_from_custom_folders() -> List[LocalModelInfo]:
|
|
try:
|
|
custom_folders = await asyncio.to_thread(list_scan_folders)
|
|
except Exception as e:
|
|
logger.warning("Could not load custom scan folders: %s", e)
|
|
return []
|
|
|
|
local_models: List[LocalModelInfo] = []
|
|
for folder in custom_folders:
|
|
folder_path = Path(normalize_path(folder["path"])).expanduser()
|
|
try:
|
|
custom_models = await asyncio.to_thread(_scan_custom_folder, folder_path)
|
|
except Exception as e:
|
|
logger.warning("Skipping unreadable scan folder %s: %s", folder_path, e)
|
|
continue
|
|
local_models.extend(_promote_to_custom_source(m) for m in custom_models)
|
|
return local_models
|
|
|
|
|
|
def _dedupe_local_models(local_models: List[LocalModelInfo]) -> list[LocalModelInfo]:
|
|
deduped: dict[str, LocalModelInfo] = {}
|
|
for model in local_models:
|
|
if model.source == "hf_cache" and model.model_id:
|
|
key = "\x00".join(
|
|
(
|
|
"hf_cache",
|
|
model.model_id.strip().lower(),
|
|
model.model_format,
|
|
model.format_variant or "",
|
|
)
|
|
)
|
|
else:
|
|
row_key = model.inventory_id or model.id
|
|
key = f"{row_key}\x00custom" if model.source == "custom" else row_key
|
|
existing = deduped.get(key)
|
|
if existing is None or _prefer_complete_larger(
|
|
model.partial,
|
|
model.size_bytes,
|
|
existing.partial,
|
|
existing.size_bytes,
|
|
):
|
|
deduped[key] = model
|
|
return sorted(
|
|
deduped.values(),
|
|
key = lambda item: (item.updated_at or 0),
|
|
reverse = True,
|
|
)
|
|
|
|
|
|
def _filter_hidden_models(local_models: List[LocalModelInfo]) -> list[LocalModelInfo]:
|
|
"""Remove infrastructure-only models from the shared local inventory."""
|
|
visible: list[LocalModelInfo] = []
|
|
for model in local_models:
|
|
resolved_cache_path = (
|
|
hf_cache_scan.resolve_hf_cache_realpath(Path(model.path))
|
|
if model.source == "hf_cache"
|
|
else None
|
|
)
|
|
if not is_hidden_model(model.id, model.model_id, model.path, resolved_cache_path):
|
|
visible.append(model)
|
|
return visible
|
|
|
|
|
|
async def list_local_models_response(models_dir: str = "./models") -> LocalModelListResponse:
|
|
"""List local model candidates from every supported on-device source."""
|
|
hf_cache_dir = _resolve_hf_cache_dir()
|
|
legacy_hf = legacy_hf_cache_dir()
|
|
hf_default = hf_default_cache_dir()
|
|
lm_dirs = lmstudio_model_dirs()
|
|
ollama_dirs = ollama_model_dirs()
|
|
|
|
allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir]
|
|
if _safe_is_dir(legacy_hf):
|
|
allowed_roots.append(legacy_hf)
|
|
if _safe_is_dir(hf_default):
|
|
allowed_roots.append(hf_default)
|
|
allowed_roots.extend([studio_root(), outputs_root()])
|
|
|
|
try:
|
|
models_root = _resolve_allowed_models_dir(models_dir, allowed_roots)
|
|
except ValueError:
|
|
raise HTTPException(status_code = 403, detail = "Directory not allowed")
|
|
|
|
try:
|
|
local_models = await _collect_models_from_default_sources(
|
|
models_root,
|
|
hf_cache_dir,
|
|
legacy_hf,
|
|
hf_default,
|
|
lm_dirs,
|
|
ollama_dirs,
|
|
)
|
|
local_models += await _collect_models_from_custom_folders()
|
|
models = _dedupe_local_models(_filter_hidden_models(local_models))
|
|
|
|
return LocalModelListResponse(
|
|
models_dir = str(models_root),
|
|
hf_cache_dir = str(hf_cache_dir),
|
|
lmstudio_dirs = [str(d) for d in lm_dirs],
|
|
ollama_dirs = [str(d) for d in ollama_dirs],
|
|
models = models,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error listing local models: {e}", exc_info = True)
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to list local models: {str(e)}",
|
|
)
|
|
|
|
|
|
def get_models_folder_response() -> dict:
|
|
"""Return the directory where downloaded models are stored.
|
|
|
|
This is the active HF hub cache (honors ``HF_HOME`` / ``HF_HUB_CACHE``);
|
|
the desktop app reveals it in the OS file manager.
|
|
"""
|
|
path = _resolve_hf_cache_dir()
|
|
# Create it if missing so "Open folder" works before the first download:
|
|
# HF builds the cache lazily, and studio only pre-creates the *default*
|
|
# dir, not a user's explicit HF_HOME / HF_HUB_CACHE.
|
|
try:
|
|
path.mkdir(parents = True, exist_ok = True)
|
|
except OSError as e:
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Failed to create models folder: {path}: {e}",
|
|
) from e
|
|
if not path.is_dir():
|
|
raise HTTPException(
|
|
status_code = 500,
|
|
detail = f"Models folder path is not a directory: {path}",
|
|
)
|
|
return {"path": str(path)}
|
|
|
|
|
|
def get_scan_folders_response() -> dict:
|
|
return {"folders": list_scan_folders()}
|
|
|
|
|
|
def add_scan_folder_response(path: str) -> dict:
|
|
try:
|
|
folder = add_scan_folder(_coerce_scan_folder_path(path))
|
|
except ValueError as e:
|
|
logger.warning("Scan folder rejected: %s (path=%s)", e, path)
|
|
raise HTTPException(status_code = 400, detail = str(e))
|
|
logger.info("Scan folder added: %s", folder.get("path"))
|
|
return folder
|
|
|
|
|
|
def remove_scan_folder_response(folder_id: int) -> dict:
|
|
remove_scan_folder(folder_id)
|
|
logger.info("Scan folder removed: id=%s", folder_id)
|
|
return {"ok": True}
|