diff --git a/studio/backend/tests/test_gguf_routing.py b/studio/backend/tests/test_gguf_routing.py new file mode 100644 index 0000000000..1b299cba19 --- /dev/null +++ b/studio/backend/tests/test_gguf_routing.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Tests for GGUF routing in detect_gguf_model. + +Regression test for the bug where a .gguf file temporarily appears +inaccessible on Windows during llama-server process teardown, causing +is_file() to return False and the model to be routed to the transformers +backend instead of llama-server. +""" + +import sys +import os +import types +from pathlib import Path +from unittest.mock import patch + +# Stub structlog before importing backend modules (mirrors other tests in this suite) +if "structlog" not in sys.modules: + + class _DummyLogger: + def __getattr__(self, _): + return lambda *a, **k: None + + sys.modules["structlog"] = types.SimpleNamespace( + get_logger = lambda *a, **k: _DummyLogger(), + BoundLogger = _DummyLogger, + ) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from utils.models.model_config import detect_gguf_model + + +def test_detects_gguf_file_normally(tmp_path): + """Normal case: .gguf file exists and is accessible.""" + gguf = tmp_path / "gpt-oss-20b-MXFP4.gguf" + gguf.write_bytes(b"") + result = detect_gguf_model(str(gguf)) + assert result is not None + assert result.endswith("gpt-oss-20b-MXFP4.gguf") + + +def test_detects_gguf_when_stat_raises_oserror(tmp_path): + """ + Regression: on Windows, both is_file() and exists() call stat() internally. + During the brief lock window after llama-server is killed, stat() raises + OSError, causing both to return False. detect_gguf_model must still route + to llama-server based on the file extension alone. + """ + gguf = tmp_path / "gpt-oss-20b-MXFP4.gguf" + gguf.write_bytes(b"") + + original_stat = Path.stat + + def flaky_stat(self, **kwargs): + if self.suffix.lower() == ".gguf": + raise OSError("file temporarily inaccessible (Windows lock window)") + return original_stat(self, **kwargs) + + with patch.object(Path, "stat", flaky_stat): + result = detect_gguf_model(str(gguf)) + + assert result is not None, ( + "detect_gguf_model returned None when stat() raised OSError. " + "This causes the model to fall through to the transformers backend." + ) + + +def test_does_not_detect_mmproj_as_main_model(tmp_path): + """mmproj files must never be returned as the primary model.""" + mmproj = tmp_path / "mmproj-model-f16.gguf" + mmproj.write_bytes(b"") + result = detect_gguf_model(str(mmproj)) + assert result is None + + +def test_detects_gguf_in_directory(tmp_path): + """Directory containing a .gguf file is resolved to that file.""" + gguf = tmp_path / "model-Q4_K_M.gguf" + gguf.write_bytes(b"") + result = detect_gguf_model(str(tmp_path)) + assert result is not None + assert result.endswith("model-Q4_K_M.gguf") + + +def test_directory_named_like_gguf_scans_inside(tmp_path): + """A directory named *.gguf resolves the real .gguf inside, not itself.""" + gguf_dir = tmp_path / "mymodel.gguf" + gguf_dir.mkdir() + inner = gguf_dir / "model-Q4_K_M.gguf" + inner.write_bytes(b"") + result = detect_gguf_model(str(gguf_dir)) + assert result is not None + assert result.endswith("model-Q4_K_M.gguf") + + +def test_returns_none_for_non_gguf_path(tmp_path): + """Non-.gguf paths with no .gguf files inside return None.""" + result = detect_gguf_model(str(tmp_path)) + assert result is None diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 993995ee57..dc34444ccb 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -1156,13 +1156,18 @@ def detect_gguf_model(path: str) -> Optional[str]: p = Path(path) # Case 1: direct .gguf file - if p.suffix.lower() == ".gguf" and p.is_file(): + if p.suffix.lower() == ".gguf": if _is_mmproj(p.name): return None - # Use absolute (not resolve) to preserve symlink names -- e.g. - # Ollama .studio_links/model.gguf -> blobs/sha256-... should - # keep the readable symlink name, not the opaque blob hash. - return str(p.absolute()) + # Extension is authoritative: don't gate on is_file()/exists(), which + # can fail in the Windows lock window after llama-server is killed. + try: + is_dir = p.is_dir() + except OSError: + is_dir = False # stat() unavailable in the lock window + if not is_dir: + return str(p.absolute()) # absolute() keeps symlink names readable + # Directory named "*.gguf": fall through to the dir scan below. # Case 2: directory containing .gguf files (skip mmproj) if p.is_dir():