e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
716 lines
24 KiB
Python
716 lines
24 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
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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(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}
|