Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
57 changes: 57 additions & 0 deletions studio/backend/routes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,39 @@ def _scan_models_dir(
if not models_dir.exists() or not models_dir.is_dir():
return []

# Check if the directory itself IS a model (has a model config AND
# weight files). Both conditions are required: a bare directory with
# only loose .gguf files (no config) might be a mixed collection that
# should list files individually, and a config.json alone (no weights)
# does not make a model directory.
try:
_has_config = (models_dir / "config.json").exists() or (
models_dir / "adapter_config.json"
).exists()
_has_weights = any(
f.suffix.lower() in (".gguf", ".safetensors", ".bin")
for f in models_dir.iterdir()
if f.is_file()
)
_is_self_model = _has_config and _has_weights
except OSError:
_is_self_model = False

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 +276,17 @@ 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 files),
# it is not an LM Studio publisher structure -- _scan_models_dir
# already handles it.
try:
if (lm_dir / "config.json").exists() or (
lm_dir / "adapter_config.json"
).exists():
return []

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 Avoid short-circuiting LM Studio scans on root config files

This early return assumes _scan_models_dir will handle model-directory detection, but for LM Studio roots list_local_models invokes only _scan_lmstudio_dir. If a discovered LM Studio directory (for example a downloadsFolder override) points directly to a model directory with config.json, this branch now drops it entirely and no model is listed. The LM scanner should either emit that model itself or the caller must also run _scan_models_dir for LM Studio roots.

Useful? React with 👍 / 👎.

except OSError:
pass

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

# If the child directory itself looks like a model (has config
# or model weight files), skip it -- _scan_models_dir already
# handles it. Only treat it as a publisher directory otherwise.
_child_is_model = (
(child / "config.json").exists()
or (child / "adapter_config.json").exists()
or any(child.glob("*.safetensors"))
or any(child.glob("*.bin"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The _child_is_model check in _scan_lmstudio_dir is missing a check for .gguf files. Since LM Studio primarily uses GGUF models, omitting this check means that model directories containing only GGUF files and a config will not be correctly identified and skipped, leading to the duplicate/broken entries this PR aims to fix.

            _child_is_model = (
                (child / "config.json").exists()
                or (child / "adapter_config.json").exists()
                or any(child.glob("*.safetensors"))
                or any(child.glob("*.bin"))
                or any(child.glob("*.gguf"))
            )

or any(child.glob("*.gguf"))
)
if _child_is_model:
continue

# child is a publisher directory -- scan its sub-directories
for model_dir in child.iterdir():
try:
Expand Down
14 changes: 12 additions & 2 deletions studio/backend/utils/models/model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1047,8 +1047,13 @@ def list_local_gguf_variants(
(variants, has_vision): list of non-mmproj GGUF variants + vision flag.
"""
p = Path(directory)
# If a file path was passed (e.g. a standalone .gguf entry), scan its
# parent directory instead so we still find the correct variants.
if not p.is_dir():
return [], False
if p.is_file() and p.suffix.lower() == ".gguf":
p = p.parent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize local GGUF file paths before variant lookup

This change returns variants when repo_id is a .gguf file by scanning the parent directory, but downstream loading still treats that same repo_id as a file path. When a user selects a quant, ModelConfig.from_identifier(..., gguf_variant=...) calls _find_local_gguf_by_variant(path, variant), which only works for directories, so GGUF detection is skipped and the model load path becomes invalid for standalone file entries. In short, variant selection now appears to work for file rows but can fail at load time unless path normalization is made consistent across both code paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep GGUF file entries scoped to their own variants

When a standalone .gguf file path is passed, this fallback now scans the entire parent directory, which breaks the “loose GGUF files are separate entries” behavior from _scan_models_dir. In a folder containing multiple unrelated GGUF models, variant listing for one file can include sibling-model quants, and load-time resolution can pick a different file than the one the user selected (silent model switch). This is especially risky because the UI recommends a default variant from the merged set, not from the selected file.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

P1 Badge Keep GGUF file entries scoped to their own variants

When a standalone .gguf file path is passed, this fallback now scans the entire parent directory, which breaks the “loose GGUF files are separate entries” behavior from _scan_models_dir. In a folder containing multiple unrelated GGUF models, variant listing for one file can include sibling-model quants, and load-time resolution can pick a different file than the one the user selected (silent model switch). This is especially risky because the UI recommends a default variant from the merged set, not from the selected file.

Useful? React with 👍 / 👎.

This fallback is a defensive safety net that only triggers for standalone .gguf file entries (Phase 2 of _scan_models_dir), i.e. directories without config.json
that contain loose GGUF files. The primary fix — the _is_self_model check — ensures that any proper model directory (with both config and weights) is returned as a
single entry with a directory path, so this fallback is never reached in that case.

For the loose-file scenario, the previous behavior was returning an empty array ("No GGUF variants found"), which was completely non-functional. Scanning the
parent directory is imperfect when unrelated models coexist, but it's strictly better than returning nothing. This is an edge case of an edge case — a directory with
multiple unrelated .gguf files and no config.json — and not a regression from this PR.

else:
return [], False

quant_totals: dict[str, int] = {}
quant_first_file: dict[str, str] = {}
Expand Down Expand Up @@ -1088,8 +1093,13 @@ 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 a file path was passed (e.g. a standalone .gguf entry), use its
# parent directory so variant lookup still works.
if not p.is_dir():
return None
if p.is_file() and p.suffix.lower() == ".gguf":
p = p.parent
else:
return None

matches = sorted(
f
Expand Down