diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 1e31a91e26..79c03c8fec 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -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 [ + 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 +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 [] + except OSError: + pass + found: List[LocalModelInfo] = [] for child in lm_dir.iterdir(): try: @@ -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")) + 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: diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index f97ea993eb..4a6b565a1a 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] = {} @@ -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