Studio: fix loading split GGUFs from the local HF cache (#7273)
* Studio: don't resolve HF cache GGUF symlinks to blob paths * Studio: handle split GGUF symlink layouts
This commit is contained in:
parent
f3c085ad9e
commit
54f21b3a87
2 changed files with 134 additions and 5 deletions
|
|
@ -119,6 +119,13 @@ def _build_cache(
|
|||
return snap
|
||||
|
||||
|
||||
def _symlink_or_skip(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hf_cache(tmp_path, monkeypatch):
|
||||
"""Point ``huggingface_hub.constants.HF_HUB_CACHE`` at a temp dir."""
|
||||
|
|
@ -1084,7 +1091,7 @@ class TestListLocalGgufVariantsSubdir:
|
|||
target.write_bytes(b"\0" * 20)
|
||||
|
||||
out = _find_local_gguf_by_variant(str(tmp_path), "Q4_K_M")
|
||||
assert out == str(target.resolve())
|
||||
assert out == str(target.absolute())
|
||||
|
||||
def test_find_local_gguf_by_variant_skips_big_endian_only_match(self, tmp_path):
|
||||
from utils.models.model_config import _find_local_gguf_by_variant
|
||||
|
|
@ -1094,6 +1101,57 @@ class TestListLocalGgufVariantsSubdir:
|
|||
|
||||
assert _find_local_gguf_by_variant(str(tmp_path), "Q4_K_M") is None
|
||||
|
||||
def test_find_local_gguf_by_variant_keeps_split_symlink_name(self, tmp_path):
|
||||
from utils.models.model_config import _find_local_gguf_by_variant
|
||||
|
||||
blobs = tmp_path / "blobs"
|
||||
blobs.mkdir()
|
||||
snap = tmp_path / "snapshots" / "rev" / "BF16"
|
||||
snap.mkdir(parents = True)
|
||||
(tmp_path / "snapshots" / "rev" / "config.json").write_text("{}")
|
||||
for i, sha in enumerate(("aa" * 32, "bb" * 32), start = 1):
|
||||
(blobs / sha).write_bytes(b"\0" * 10)
|
||||
_symlink_or_skip(snap / f"model-BF16-0000{i}-of-00002.gguf", blobs / sha)
|
||||
|
||||
out = _find_local_gguf_by_variant(str(tmp_path / "snapshots" / "rev"), "BF16")
|
||||
assert out is not None
|
||||
assert Path(out).name == "model-BF16-00001-of-00002.gguf"
|
||||
|
||||
def test_detect_gguf_model_keeps_split_symlink_name(self, tmp_path):
|
||||
from utils.models.model_config import detect_gguf_model
|
||||
|
||||
blobs = tmp_path / "blobs"
|
||||
blobs.mkdir()
|
||||
snap = tmp_path / "snapshots" / "rev"
|
||||
snap.mkdir(parents = True)
|
||||
for i, (sha, size) in enumerate((("cc" * 32, 10), ("dd" * 32, 20)), start = 1):
|
||||
(blobs / sha).write_bytes(b"\0" * size)
|
||||
_symlink_or_skip(snap / f"model-BF16-0000{i}-of-00002.gguf", blobs / sha)
|
||||
|
||||
out = detect_gguf_model(str(snap))
|
||||
assert out is not None
|
||||
assert Path(out).name == "model-BF16-00001-of-00002.gguf"
|
||||
|
||||
def test_lone_split_symlink_uses_colocated_target_shards(self, tmp_path):
|
||||
from utils.models.model_config import _find_local_gguf_by_variant, detect_gguf_model
|
||||
|
||||
target_dir = tmp_path / "external" / "BF16"
|
||||
target_dir.mkdir(parents = True)
|
||||
target = target_dir / "model-BF16-00001-of-00002.gguf"
|
||||
target.write_bytes(b"\0" * 10)
|
||||
(target_dir / "model-BF16-00002-of-00002.gguf").write_bytes(b"\0" * 10)
|
||||
|
||||
local = tmp_path / "local"
|
||||
local.mkdir()
|
||||
(local / "config.json").write_text("{}")
|
||||
link = local / target.name
|
||||
_symlink_or_skip(link, target)
|
||||
|
||||
expected = str(target.absolute())
|
||||
assert _find_local_gguf_by_variant(str(local), "BF16") == expected
|
||||
assert detect_gguf_model(str(local)) == expected
|
||||
assert detect_gguf_model(str(link)) == expected
|
||||
|
||||
def test_model_config_variant_ignores_big_endian_sibling(self, tmp_path):
|
||||
from utils.models.model_config import ModelConfig
|
||||
|
||||
|
|
|
|||
|
|
@ -1249,6 +1249,77 @@ def _iter_gguf_files(directory: Path, recursive: bool = False):
|
|||
yield f
|
||||
|
||||
|
||||
_GGUF_SPLIT_FILE_RE = re.compile(
|
||||
r"^(?P<prefix>.+)-(?P<index>\d{5})-of-(?P<total>\d{5})\.gguf$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _colocated_first_split_shard(path: Path) -> tuple[Optional[Path], bool]:
|
||||
"""Return shard 1 and whether every shard is beside *path*."""
|
||||
match = _GGUF_SPLIT_FILE_RE.match(path.name)
|
||||
if match is None:
|
||||
return None, False
|
||||
|
||||
prefix = match.group("prefix").casefold()
|
||||
total_text = match.group("total")
|
||||
total = int(total_text)
|
||||
if total < 1:
|
||||
return None, False
|
||||
|
||||
first: Optional[Path] = None
|
||||
indices: set[int] = set()
|
||||
try:
|
||||
siblings = path.parent.iterdir()
|
||||
for sibling in siblings:
|
||||
sibling_match = _GGUF_SPLIT_FILE_RE.match(sibling.name)
|
||||
if (
|
||||
sibling_match is None
|
||||
or sibling_match.group("prefix").casefold() != prefix
|
||||
or sibling_match.group("total") != total_text
|
||||
):
|
||||
continue
|
||||
try:
|
||||
if not sibling.is_file():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
index = int(sibling_match.group("index"))
|
||||
if not 1 <= index <= total:
|
||||
continue
|
||||
indices.add(index)
|
||||
if index == 1:
|
||||
first = sibling
|
||||
except OSError:
|
||||
return None, False
|
||||
|
||||
return first, first is not None and len(indices) == total
|
||||
|
||||
|
||||
def _local_gguf_load_path(path: Path) -> Path:
|
||||
"""Choose a loadable local path while preserving complete symlink sets."""
|
||||
if _GGUF_SPLIT_FILE_RE.match(path.name) is None:
|
||||
return path.absolute()
|
||||
|
||||
first, complete = _colocated_first_split_shard(path)
|
||||
if complete and first is not None:
|
||||
return first.absolute()
|
||||
|
||||
try:
|
||||
is_symlink = path.is_symlink()
|
||||
except OSError:
|
||||
is_symlink = False
|
||||
if is_symlink:
|
||||
try:
|
||||
target = path.resolve()
|
||||
except OSError:
|
||||
return (first or path).absolute()
|
||||
target_first, _ = _colocated_first_split_shard(target)
|
||||
return (target_first or target).absolute()
|
||||
|
||||
return (first or path).absolute()
|
||||
|
||||
|
||||
def detect_mmproj_file(path: str, search_root: Optional[str] = None) -> Optional[str]:
|
||||
"""Find the mmproj GGUF for a model.
|
||||
|
||||
|
|
@ -1434,7 +1505,7 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
except OSError:
|
||||
is_dir = False # stat() unavailable in the lock window
|
||||
if not is_dir:
|
||||
return str(p.absolute()) # absolute() keeps symlink names readable
|
||||
return str(_local_gguf_load_path(p))
|
||||
# Directory named "*.gguf": fall through to the dir scan below.
|
||||
|
||||
# Case 2: directory containing .gguf files (skip mmproj / MTP drafter)
|
||||
|
|
@ -1452,7 +1523,7 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
gguf_files.append(f)
|
||||
gguf_files.sort(key = lambda f: f.stat().st_size, reverse = True)
|
||||
if gguf_files:
|
||||
return str(gguf_files[0].resolve())
|
||||
return str(_local_gguf_load_path(gguf_files[0]))
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -1879,7 +1950,7 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
|
|||
For sharded GGUFs (multiple files sharing a quant label), returns the
|
||||
first shard (sorted by name), which is what ``llama-server -m`` expects.
|
||||
|
||||
Returns the resolved absolute path, or ``None`` if no match.
|
||||
Returns the absolute path, or ``None`` if no match.
|
||||
"""
|
||||
p = _resolve_gguf_dir(Path(directory))
|
||||
if p is None:
|
||||
|
|
@ -1900,7 +1971,7 @@ def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]:
|
|||
matches.append(f)
|
||||
matches.sort()
|
||||
if matches:
|
||||
return str(matches[0].resolve())
|
||||
return str(_local_gguf_load_path(matches[0]))
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue