fix(studio): custom folder scan fails to find GGUF variants when pointing directly at a model directory (#4860)
Fix custom folder scanning when pointing directly at a model directory. When a user adds a custom scan folder that points directly at a model directory (e.g. /path/to/gemma-4-e2b-it-gguf/ containing config.json and gemma-4-E2B-it-BF16.gguf), the model list previously showed individual .gguf files as separate entries instead of recognizing the directory as a single model. Clicking any entry showed "No GGUF variants found" because list_local_gguf_variants received a file path and immediately returned empty. Changes: - Add _is_model_directory() helper that detects directories with both config metadata and actual model weight files (excludes mmproj GGUFs and non-weight .bin files like tokenizer.bin) - _scan_models_dir: detect self-model and return single directory entry - _scan_lmstudio_dir: surface model directories directly instead of descending into them as publisher folders; handle both root and child model directories - Add _resolve_gguf_dir() helper for GGUF path resolution that only falls back to parent directory when parent has model metadata - list_local_gguf_variants / _find_local_gguf_by_variant: use resolver so .gguf file paths inside model directories work correctly
This commit is contained in:
parent
0835f0a61b
commit
aa4c6010e1
2 changed files with 120 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue