diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index ea385436b8..3f361ca5eb 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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, *, @@ -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 [ + 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 +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: @@ -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: diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 9d72846ca3..61226e52cb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1130,6 +1130,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]: @@ -1142,8 +1162,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] = {} @@ -1183,8 +1203,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(