Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions studio/backend/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,47 @@ def _resolve_hf_cache_dir() -> Path:
return Path.home() / ".cache" / "huggingface" / "hub"


def _is_model_directory(d: Path) -> bool:
"""Return ``True`` when *d* looks like a model directory.

A model directory must have **both** a config file (``config.json`` or
``adapter_config.json``) **and** actual model weight files. Both
conditions are required: a bare directory with only loose ``.gguf``
files (no config) might be a mixed collection, and a ``config.json``
alone (no weights) is not a model directory.

Excludes ``mmproj`` GGUF files (vision projectors) and non-weight
``.bin`` files (``tokenizer.bin``, ``vocab.bin``, etc.) from the
weight check to avoid false positives.
"""

def _is_weight_file(f: Path) -> bool:
suffix = f.suffix.lower()
if suffix == ".safetensors":
return True
if suffix == ".gguf":
return "mmproj" not in f.name.lower()
if suffix == ".bin":
name = f.name.lower()
return (
name.startswith("pytorch_model")
or name.startswith("model")
or name.startswith("adapter_model")
or name.startswith("consolidated")
)
return False

try:
has_config = (d / "config.json").exists() or (
d / "adapter_config.json"
).exists()
if not has_config:
return False
return any(_is_weight_file(f) for f in d.iterdir() if f.is_file())
except OSError:
return False


def _scan_models_dir(
models_dir: Path,
*,
Expand All @@ -146,6 +187,23 @@ def _scan_models_dir(
if not models_dir.exists() or not models_dir.is_dir():
return []

_is_self_model = _is_model_directory(models_dir)

if _is_self_model:
try:
updated_at = models_dir.stat().st_mtime
except OSError:
updated_at = None
return [
Comment on lines +192 to +197
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue scanning after identifying root model directory

This early return drops all nested models whenever the scan root itself has config.json/weights. In mixed layouts (a root model plus additional model subfolders), the scanner now returns only the root entry, so valid child models disappear from custom-folder and LM Studio discovery results. Instead of returning immediately, add the root model to found and continue scanning children so both root and nested models are discoverable.

Useful? React with 👍 / 👎.

LocalModelInfo(
id = str(models_dir),
display_name = models_dir.name,
path = str(models_dir),
source = "models_dir",
updated_at = updated_at,
),
]

found: List[LocalModelInfo] = []
for child in models_dir.iterdir():
if limit is not None and len(found) >= limit:
Expand Down Expand Up @@ -243,6 +301,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
if not lm_dir.exists() or not lm_dir.is_dir():
return []

# If the directory itself is a model directory (has config AND weight
# files), it is not an LM Studio publisher structure -- return it as a
# single model entry. We cannot skip it silently because this function
# is the only scanner called for default LM Studio roots.
if _is_model_directory(lm_dir):
try:
updated_at = lm_dir.stat().st_mtime
except OSError:
updated_at = None
return [
LocalModelInfo(
id = str(lm_dir),
display_name = lm_dir.name,
path = str(lm_dir),
source = "lmstudio",
updated_at = updated_at,
),
]

found: List[LocalModelInfo] = []
for child in lm_dir.iterdir():
try:
Expand All @@ -263,6 +340,25 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]:
)
continue

# If the child directory itself looks like a model directory
# (has config AND weight files), surface it directly instead
# of descending into it as a publisher.
if _is_model_directory(child):
try:
updated_at = child.stat().st_mtime
except OSError:
updated_at = None
found.append(
LocalModelInfo(
id = str(child),
display_name = child.name,
path = str(child),
source = "lmstudio",
updated_at = updated_at,
),
)
continue

# child is a publisher directory -- scan its sub-directories
for model_dir in child.iterdir():
try:
Expand Down
28 changes: 24 additions & 4 deletions studio/backend/utils/models/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,26 @@ def list_gguf_variants(
return variants, has_vision


def _resolve_gguf_dir(p: Path) -> Optional[Path]:
"""Resolve a path to the directory containing GGUF variants.

If *p* is already a directory, returns it directly. If *p* is a ``.gguf``
file whose parent directory has model metadata (``config.json`` or
``adapter_config.json``), returns the parent -- all GGUFs in that
directory belong to the same model. Returns ``None`` for loose standalone
GGUFs (no config) to avoid cross-wiring unrelated models.
"""
if p.is_dir():
return p
if p.is_file() and p.suffix.lower() == ".gguf":
parent = p.parent
if (parent / "config.json").exists() or (
parent / "adapter_config.json"
).exists():
return parent
return None


def list_local_gguf_variants(
directory: str,
) -> tuple[list[GgufVariantInfo], bool]:
Expand All @@ -1046,8 +1066,8 @@ def list_local_gguf_variants(
Returns:
(variants, has_vision): list of non-mmproj GGUF variants + vision flag.
"""
p = Path(directory)
if not p.is_dir():
p = _resolve_gguf_dir(Path(directory))
if p is None:
return [], False

quant_totals: dict[str, int] = {}
Expand Down Expand Up @@ -1087,8 +1107,8 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:

Returns the resolved absolute path, or ``None`` if no match.
"""
p = Path(directory)
if not p.is_dir():
p = _resolve_gguf_dir(Path(directory))
if p is None:
return None

matches = sorted(
Expand Down