From 0c753aba3054907bf418086e587a38081dc71081 Mon Sep 17 00:00:00 2001 From: JYYYYYT Date: Sun, 5 Apr 2026 14:54:26 +0800 Subject: [PATCH 01/10] fix(studio): scan custom folder as model when it contains config + weights When a custom scan folder points directly at a model directory (e.g. gemma-4-e2b-it-gguf/ containing config.json and .gguf files), _scan_models_dir previously skipped the directory itself and listed individual .gguf files as standalone models. The gguf-variants endpoint then received file paths instead of directory paths, causing list_local_gguf_variants to return an empty list ("No GGUF variants found"). Three fixes: 1. _scan_models_dir: detect when the scanned directory itself is a model (has BOTH a config file AND weight files) and return it as a single entry. Both conditions are required to avoid false positives on bare .gguf collections or config-only directories. 2. _scan_lmstudio_dir: early-return when the directory has config files (not a publisher structure), and skip child directories that are model directories rather than treating them as publisher folders. 3. list_local_gguf_variants: fall back to the parent directory when a .gguf file path is passed instead of a directory. --- studio/backend/routes/models.py | 55 +++++++++++++++++++++ studio/backend/utils/models/model_config.py | 7 ++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 1e31a91e26..46a420a37d 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -146,6 +146,40 @@ 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(models_dir.glob("*.gguf")) + or any(models_dir.glob("*.safetensors")) + or any(models_dir.glob("*.bin")) + ) + _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 [ + 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: @@ -243,6 +277,15 @@ 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 [] + except OSError: + pass + found: List[LocalModelInfo] = [] for child in lm_dir.iterdir(): try: @@ -263,6 +306,18 @@ 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")) + ) + if _child_is_model: + continue + # child is a publisher directory -- scan its sub-directories for model_dir in child.iterdir(): try: diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f97ea993eb..b1649a8e10 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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 + else: + return [], False quant_totals: dict[str, int] = {} quant_first_file: dict[str, str] = {} From d955ee22c702070e5e0d785d1dcae23a632ad759 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 08:23:49 +0000 Subject: [PATCH 02/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/models.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 46a420a37d..2a86b36b7b 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -152,10 +152,9 @@ def _scan_models_dir( # 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_config = (models_dir / "config.json").exists() or ( + models_dir / "adapter_config.json" + ).exists() _has_weights = ( any(models_dir.glob("*.gguf")) or any(models_dir.glob("*.safetensors")) @@ -281,7 +280,9 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: # 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(): + if (lm_dir / "config.json").exists() or ( + lm_dir / "adapter_config.json" + ).exists(): return [] except OSError: pass From 19f06ffa8162dd160d77fc9691f46965232d2bc4 Mon Sep 17 00:00:00 2001 From: JYYYYYT Date: Sun, 5 Apr 2026 18:00:44 +0800 Subject: [PATCH 03/10] address review: optimize glob, add .gguf to child check, add variant lookup fallback --- studio/backend/routes/models.py | 13 ++++++++----- studio/backend/utils/models/model_config.py | 7 ++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 46a420a37d..c78bb54df0 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -156,10 +156,10 @@ def _scan_models_dir( (models_dir / "config.json").exists() or (models_dir / "adapter_config.json").exists() ) - _has_weights = ( - any(models_dir.glob("*.gguf")) - or any(models_dir.glob("*.safetensors")) - or any(models_dir.glob("*.bin")) + _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: @@ -281,7 +281,9 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: # 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(): + if (lm_dir / "config.json").exists() or ( + lm_dir / "adapter_config.json" + ).exists(): return [] except OSError: pass @@ -314,6 +316,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: or (child / "adapter_config.json").exists() or any(child.glob("*.safetensors")) or any(child.glob("*.bin")) + or any(child.glob("*.gguf")) ) if _child_is_model: continue diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index b1649a8e10..4a6b565a1a 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1093,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 From a4e5673ec487df636323c4738c869dcf4d141894 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:02:40 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/models.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index c78bb54df0..79c03c8fec 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -152,10 +152,9 @@ def _scan_models_dir( # 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_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() From 7d4391f84b7577789e2a22c0d8cedf2ffd98b7de Mon Sep 17 00:00:00 2001 From: JYYYYYT Date: Mon, 6 Apr 2026 15:29:17 +0800 Subject: [PATCH 05/10] address review: add explanatory comment to empty except block --- studio/backend/routes/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 79c03c8fec..2ad3b2700b 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -285,6 +285,8 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: ).exists(): return [] except OSError: + # If we cannot stat the config files (permissions, broken symlink, + # etc.), treat the directory as a normal LM Studio root and continue. pass found: List[LocalModelInfo] = [] From e810fe23d68ed158c10ef26f7d795fd90a376505 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 14:13:37 +0000 Subject: [PATCH 06/10] Fix review findings: tighten GGUF fallback, LM Studio skip, and self-model scan - list_local_gguf_variants / _find_local_gguf_by_variant: only fall back to parent directory when parent has model metadata (config.json / adapter_config.json), preventing cross-wiring of unrelated sibling GGUFs in loose directories. - _scan_lmstudio_dir root check: require BOTH config AND weight files (consistent with _scan_models_dir) so a stray config.json alone does not suppress scanning of nested LM Studio models. - _scan_lmstudio_dir child check: only skip children with config metadata, not weight files -- publisher directories may legitimately contain direct .gguf files that the inner scan handles. - _scan_models_dir: do not early-return when directory is a self-model; instead prepend the self-model entry and continue scanning children. Skip the loose .gguf scan when root is already a model to avoid duplicate entries. --- studio/backend/routes/models.py | 45 ++++++++++++--------- studio/backend/utils/models/model_config.py | 27 ++++++++++--- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 2ad3b2700b..7fbb2c6a75 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -164,12 +164,14 @@ def _scan_models_dir( except OSError: _is_self_model = False + found: List[LocalModelInfo] = [] + if _is_self_model: try: updated_at = models_dir.stat().st_mtime except OSError: updated_at = None - return [ + found.append( LocalModelInfo( id = str(models_dir), display_name = models_dir.name, @@ -177,9 +179,8 @@ def _scan_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: break @@ -212,8 +213,10 @@ def _scan_models_dir( updated_at = updated_at, ), ) - # Also scan for standalone .gguf files directly in the models directory - if limit is None or len(found) < limit: + # Also scan for standalone .gguf files directly in the models directory. + # Skip this when the directory itself is a model -- its weight files are + # already represented by the single self-model entry above. + if not _is_self_model and (limit is None or len(found) < limit): for gguf_file in models_dir.glob("*.gguf"): if limit is not None and len(found) >= limit: break @@ -276,17 +279,24 @@ 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. + # If the directory itself is a model directory (has config AND weight + # files), it is not an LM Studio publisher structure -- _scan_models_dir + # already handles it. Both conditions are required so that a stray + # config.json alone does not suppress scanning of nested LM Studio models. try: - if (lm_dir / "config.json").exists() or ( + _has_config = (lm_dir / "config.json").exists() or ( lm_dir / "adapter_config.json" - ).exists(): + ).exists() + _has_weights = any( + f.suffix.lower() in (".gguf", ".safetensors", ".bin") + for f in lm_dir.iterdir() + if f.is_file() + ) + if _has_config and _has_weights: return [] except OSError: - # If we cannot stat the config files (permissions, broken symlink, - # etc.), treat the directory as a normal LM Studio root and continue. + # If we cannot stat the files (permissions, broken symlink, etc.), + # treat the directory as a normal LM Studio root and continue. pass found: List[LocalModelInfo] = [] @@ -309,15 +319,14 @@ 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. + # If the child directory itself looks like a model directory + # (has config metadata), skip it -- _scan_models_dir already + # handles it. Only check for config files here, NOT weight + # files: a publisher directory may legitimately contain direct + # .gguf files that the inner scan handles. _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")) ) if _child_is_model: continue diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 4a6b565a1a..bb7d22931d 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1047,11 +1047,20 @@ 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 a .gguf file path was passed (e.g. a standalone .gguf entry), fall + # back to the parent directory only when the parent has model metadata + # (config.json / adapter_config.json). This confirms all GGUFs in that + # directory belong to the same model. For loose standalone GGUFs without + # a config file, return empty to avoid cross-wiring unrelated models. if not p.is_dir(): if p.is_file() and p.suffix.lower() == ".gguf": - p = p.parent + parent = p.parent + if (parent / "config.json").exists() or ( + parent / "adapter_config.json" + ).exists(): + p = parent + else: + return [], False else: return [], False @@ -1093,11 +1102,17 @@ 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 a .gguf file path was passed, use its parent directory only when + # the parent has model metadata, confirming the GGUFs belong together. if not p.is_dir(): if p.is_file() and p.suffix.lower() == ".gguf": - p = p.parent + parent = p.parent + if (parent / "config.json").exists() or ( + parent / "adapter_config.json" + ).exists(): + p = parent + else: + return None else: return None From a1e035a13f2b4a7044a546ce8e5c727b90f6d84d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 14:27:50 +0000 Subject: [PATCH 07/10] Refactor: extract _is_model_directory and _resolve_gguf_dir helpers - Extract _is_model_directory() helper in models.py to deduplicate the config+weights detection logic used by both _scan_models_dir and _scan_lmstudio_dir. Single source of truth for model directory heuristic. - Extract _resolve_gguf_dir() helper in model_config.py to deduplicate the GGUF file-to-parent resolution logic used by both list_local_gguf_variants and _find_local_gguf_by_variant. - Restore early return in _scan_models_dir when directory is a self-model to prevent surfacing bogus entries from nested helper directories like 1_Pooling/ or tokenizer/ that also contain config.json. - Use _is_model_directory() for child skip in _scan_lmstudio_dir, requiring BOTH config AND weights consistently. --- studio/backend/routes/models.py | 87 +++++++++------------ studio/backend/utils/models/model_config.py | 57 ++++++-------- 2 files changed, 62 insertions(+), 82 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 7fbb2c6a75..2dca0498d6 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -138,6 +138,30 @@ 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** weight files (``.gguf``, ``.safetensors``, + or ``.bin``). 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. + """ + try: + has_config = (d / "config.json").exists() or ( + d / "adapter_config.json" + ).exists() + if not has_config: + return False + return any( + f.suffix.lower() in (".gguf", ".safetensors", ".bin") + for f in d.iterdir() + if f.is_file() + ) + except OSError: + return False + + def _scan_models_dir( models_dir: Path, *, @@ -146,32 +170,14 @@ 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 - - found: List[LocalModelInfo] = [] + _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 - found.append( + return [ LocalModelInfo( id = str(models_dir), display_name = models_dir.name, @@ -179,8 +185,9 @@ def _scan_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: break @@ -213,10 +220,8 @@ def _scan_models_dir( updated_at = updated_at, ), ) - # Also scan for standalone .gguf files directly in the models directory. - # Skip this when the directory itself is a model -- its weight files are - # already represented by the single self-model entry above. - if not _is_self_model and (limit is None or len(found) < limit): + # Also scan for standalone .gguf files directly in the models directory + if limit is None or len(found) < limit: for gguf_file in models_dir.glob("*.gguf"): if limit is not None and len(found) >= limit: break @@ -281,23 +286,9 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: # If the directory itself is a model directory (has config AND weight # files), it is not an LM Studio publisher structure -- _scan_models_dir - # already handles it. Both conditions are required so that a stray - # config.json alone does not suppress scanning of nested LM Studio models. - try: - _has_config = (lm_dir / "config.json").exists() or ( - lm_dir / "adapter_config.json" - ).exists() - _has_weights = any( - f.suffix.lower() in (".gguf", ".safetensors", ".bin") - for f in lm_dir.iterdir() - if f.is_file() - ) - if _has_config and _has_weights: - return [] - except OSError: - # If we cannot stat the files (permissions, broken symlink, etc.), - # treat the directory as a normal LM Studio root and continue. - pass + # already handles it. + if _is_model_directory(lm_dir): + return [] found: List[LocalModelInfo] = [] for child in lm_dir.iterdir(): @@ -320,15 +311,9 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: continue # If the child directory itself looks like a model directory - # (has config metadata), skip it -- _scan_models_dir already - # handles it. Only check for config files here, NOT weight - # files: a publisher directory may legitimately contain direct - # .gguf files that the inner scan handles. - _child_is_model = ( - (child / "config.json").exists() - or (child / "adapter_config.json").exists() - ) - if _child_is_model: + # (has config AND weight files), skip it -- _scan_models_dir + # already handles it. + if _is_model_directory(child): continue # child is a publisher directory -- scan its sub-directories diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index bb7d22931d..b6e4686417 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -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]: @@ -1046,23 +1066,9 @@ def list_local_gguf_variants( Returns: (variants, has_vision): list of non-mmproj GGUF variants + vision flag. """ - p = Path(directory) - # If a .gguf file path was passed (e.g. a standalone .gguf entry), fall - # back to the parent directory only when the parent has model metadata - # (config.json / adapter_config.json). This confirms all GGUFs in that - # directory belong to the same model. For loose standalone GGUFs without - # a config file, return empty to avoid cross-wiring unrelated models. - if not p.is_dir(): - if p.is_file() and p.suffix.lower() == ".gguf": - parent = p.parent - if (parent / "config.json").exists() or ( - parent / "adapter_config.json" - ).exists(): - p = parent - else: - return [], False - else: - return [], False + p = _resolve_gguf_dir(Path(directory)) + if p is None: + return [], False quant_totals: dict[str, int] = {} quant_first_file: dict[str, str] = {} @@ -1101,20 +1107,9 @@ 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 .gguf file path was passed, use its parent directory only when - # the parent has model metadata, confirming the GGUFs belong together. - if not p.is_dir(): - if p.is_file() and p.suffix.lower() == ".gguf": - parent = p.parent - if (parent / "config.json").exists() or ( - parent / "adapter_config.json" - ).exists(): - p = parent - else: - return None - else: - return None + p = _resolve_gguf_dir(Path(directory)) + if p is None: + return None matches = sorted( f From d3f9545572c5ba863a4d67d19d7a07052015fb2d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 14:39:16 +0000 Subject: [PATCH 08/10] Fix LM Studio scan regression: surface model dirs instead of skipping _scan_lmstudio_dir is the only scanner called for default LM Studio roots. When a model directory was detected, the previous fix returned [] or continued, causing those models to silently disappear. Now _scan_lmstudio_dir surfaces model directories directly as lmstudio entries instead of skipping them: - Root-level model dir: return it as a single entry - Child model dir: append it to found and continue (don't descend into it as a publisher directory) This preserves the fix for duplicate broken entries while ensuring no models are dropped from LM Studio scans. --- studio/backend/routes/models.py | 36 ++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 2dca0498d6..bb2b7a634d 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -285,10 +285,23 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: return [] # If the directory itself is a model directory (has config AND weight - # files), it is not an LM Studio publisher structure -- _scan_models_dir - # already handles it. + # 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): - return [] + 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(): @@ -311,9 +324,22 @@ 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), skip it -- _scan_models_dir - # already handles it. + # (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 From 0ddc5d09128df3987c631b2b6c007d34f07f9750 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Apr 2026 14:58:27 +0000 Subject: [PATCH 09/10] Tighten _is_model_directory to exclude mmproj and non-weight .bin files The previous heuristic treated any .gguf/.safetensors/.bin file as a model weight, causing false positives: - config.json + mmproj-BF16.gguf (vision projector, not a model weight) - config.json + tokenizer.bin (tokenizer file, not a model weight) Now _is_weight_file excludes: - .gguf files containing "mmproj" in the name - .bin files that are not pytorch_model*, model*, adapter_model*, or consolidated* (excludes tokenizer.bin, vocab.bin, etc.) - .safetensors files are always treated as weights (safe assumption) --- studio/backend/routes/models.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index bb2b7a634d..2b7caa44d3 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -142,11 +142,32 @@ 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** weight files (``.gguf``, ``.safetensors``, - or ``.bin``). 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. + ``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" @@ -154,7 +175,7 @@ def _is_model_directory(d: Path) -> bool: if not has_config: return False return any( - f.suffix.lower() in (".gguf", ".safetensors", ".bin") + _is_weight_file(f) for f in d.iterdir() if f.is_file() ) From 44ac1f74cee01974ff0075a036a36b529ca40016 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:29:41 +0000 Subject: [PATCH 10/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/routes/models.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 2b7caa44d3..1034a5dc5e 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -174,11 +174,7 @@ def _is_weight_file(f: Path) -> bool: ).exists() if not has_config: return False - return any( - _is_weight_file(f) - for f in d.iterdir() - if f.is_file() - ) + return any(_is_weight_file(f) for f in d.iterdir() if f.is_file()) except OSError: return False