Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A GGUF this computer's llama.cpp cannot load says so before it is chosen** (backlog 0867, operator QA item f).
XHToken/Spark-X2.6-4B (architecture `spark2_5`) downloaded fine and failed only at its first call. An installed
GGUF's row now carries `architecture`, and, when this install's llama-cpp-python does not name that architecture,
`loadable: false` with `unloadable_reason`: "This computer's llama.cpp (llama-cpp-python 0.3.x) cannot load the
spark2_5 architecture. Update llama-cpp-python, or choose another model." The check reads llama.cpp's own table of
architecture names in `libllama`, without importing it (`llamacpp_knows_architecture`).
- **A GGUF-only model in the Hugging Face cache says what it is** (backlog 0866, operator QA item c). The repos the
catalog downloads hold only the quant file, with no config or card, so `models list` reported them as an unknown
model with no task. The kind and tasks now come from the file's own `general.architecture`:
Expand Down
14 changes: 11 additions & 3 deletions abstractcore/config/model_materializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5043,7 +5043,7 @@ def _hf_local_kind_tasks(repo_path: Optional[Path]) -> Dict[str, Any]:
# (c): a model outside the catalog was not offered as the default text model).
gguf = _gguf_kind_tasks(snap, repo_path)
if gguf is not None:
kind, tasks, source = gguf["kind"], gguf["tasks"], gguf["tasks_source"]
return gguf
return {"kind": kind, "tasks": tasks, "tasks_source": source}


Expand Down Expand Up @@ -5089,9 +5089,17 @@ def _gguf_kind_tasks(snap: Path, repo_path: Optional[Path]) -> Optional[Dict[str
name = repo_path.name.split("--", 2)[-1] if repo_path is not None else ""
if "embed" in name.lower():
return {"kind": "embedding", "tasks": ["text_embedding"], "tasks_source": "gguf"}
from ..utils.model_cache import gguf_unloadable_reason

out: Dict[str, Any] = {"kind": "model", "tasks": [], "tasks_source": "gguf", "architecture": arch}
if lowered in _GGUF_TEXT_ARCH:
return {"kind": "model", "tasks": ["text_generation"], "tasks_source": "gguf"}
return {"kind": "model", "tasks": [], "tasks_source": "gguf"} # an architecture AbstractCore does not know
out["tasks"] = ["text_generation"]
# Batch item (f): a GGUF this install's llama.cpp cannot load says so here, before it is chosen,
# never at its first call (XHToken/Spark-X2.6-4B: `spark2_5`).
reason = gguf_unloadable_reason(arch)
if reason:
out.update(loadable=False, unloadable_reason=reason)
return out


def _lmstudio_kind_tasks(item: Dict[str, Any]) -> Dict[str, Any]:
Expand Down
92 changes: 92 additions & 0 deletions abstractcore/utils/model_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,3 +669,95 @@ def read_gguf_architecture(path: Path) -> Optional[str]:
except Exception:
return None
return None


# ---------------------------------------------------------------------------
# Which GGUF architectures this install's llama.cpp can load (operator QA 2026-10-28 (f))
# ---------------------------------------------------------------------------

_LLAMA_LIB_CACHE: dict = {}
#: Architecture names every llama.cpp build has carried for years: the scan's sanity check.
_LLAMA_ARCH_SENTINELS = (b"llama", b"qwen2")


def _llama_library_bytes() -> Optional[bytes]:
"""The bytes of llama-cpp-python's `libllama` (the table of architecture names lives there),
found WITHOUT importing the package (no GPU init). None when llama-cpp-python is absent."""
import importlib.util

try:
spec = importlib.util.find_spec("llama_cpp")
except Exception:
return None
if spec is None or not spec.submodule_search_locations:
return None
lib_dir = Path(list(spec.submodule_search_locations)[0]) / "lib"
try:
files = [p for p in lib_dir.iterdir() if p.is_file()]
# `libllama.*` first (the shared library itself), then a Windows `llama.dll`.
candidates = sorted(p for p in files if p.name.lower().startswith("libllama")) or sorted(
p for p in files if p.name.lower().startswith("llama.")
)
except Exception:
return None
if not candidates:
return None
lib = candidates[0].resolve()
try:
st = lib.stat()
except OSError:
return None
key = (str(lib), st.st_mtime_ns, st.st_size)
cached = _LLAMA_LIB_CACHE.get("lib")
if cached and cached[0] == key:
return cached[1]
try:
data = lib.read_bytes()
except OSError:
return None
_LLAMA_LIB_CACHE["lib"] = (key, data)
return data


def llamacpp_knows_architecture(arch: Optional[str]) -> Optional[bool]:
"""Whether this install's llama.cpp names the GGUF `general.architecture` ``arch`` (its
architecture table is a list of C strings in `libllama`). None when it cannot be told:
no llama-cpp-python, or no architecture."""
name = str(arch or "").strip()
if not name:
return None
data = _llama_library_bytes()
if data is None:
return None
# The table is trusted only when names every llama.cpp build carries are found the same way:
# a library laid out differently (stripped, a stub, another build) must not turn every GGUF
# into "cannot load" (merge desk review).
if not all((sentinel + b"\x00") in data for sentinel in _LLAMA_ARCH_SENTINELS):
if not _LLAMA_LIB_CACHE.get("sentinel_logged"):
_LLAMA_LIB_CACHE["sentinel_logged"] = True
import logging

logging.getLogger(__name__).debug("libllama does not hold the architecture table as expected; architecture support unknown")
return None
# A C string's END is certain; its start is not: the linker merges a name into the tail of a
# longer one ("qwen2" lives inside another string in the server's libllama). So the test is
# "a string ends with the name": a false "known" only leaves a model offered (fail-safe), never
# a false "cannot load".
return (name.encode("utf-8") + b"\x00") in data


def gguf_unloadable_reason(arch: Optional[str]) -> Optional[str]:
"""The sentence when this install's llama.cpp cannot load ``arch``, else None."""
if llamacpp_knows_architecture(arch) is not False:
return None
try:
from importlib.metadata import version

installed = version("llama-cpp-python")
except Exception:
installed = ""
which = f"llama-cpp-python {installed}" if installed else "llama-cpp-python"
return (
f"This computer's llama.cpp ({which}) cannot load the {arch} architecture. "
"Update llama-cpp-python, or choose another model."
)
40 changes: 40 additions & 0 deletions docs/backlog/completed/0867_gguf_architecture_support_check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 0867 — A GGUF this computer's llama.cpp cannot load says so before it is chosen

**Status**: completed (2026-10-29, unreleased) · **Priority**: P1 · **Created**: 2026-10-29
**Cross-package**: operator QA batch item (f). Stacked on 0866 (lpalbou/AbstractCore#18): the installed row's
GGUF facts. The gateway console shows the reason and refuses **Use as default** for such a model.

## Why
On the OVH server XHToken/Spark-X2.6-4B-GGUF downloaded and could be set as a route; it failed only at Test,
because its `general.architecture` is `spark2_5`, which that llama-cpp-python build does not know.

## What shipped
- `abstractcore/utils/model_cache.py`:
- `llamacpp_knows_architecture(arch)` reads llama-cpp-python's `libllama` bytes, found with `find_spec`
without importing the package (no GPU init), cached by file identity. `libllama.*` is preferred over another
`llama.*` file. It checks whether a C string ENDS with the architecture name: the linker merges a name into
the tail of a longer one ("qwen2" in the server's libllama), so a leading NUL cannot be required. The error
is fail-safe: a false "known" leaves a model offered, and never produces a false "cannot load".
- The table is trusted only when the sentinels `llama` and `qwen2` are found the same way. A library laid out
differently (stripped, a stub) gives None, so nothing is claimed (merge desk review).
- It returns None without llama-cpp-python.
- `gguf_unloadable_reason(arch)` gives the sentence, naming the installed llama-cpp-python version.
- `abstractcore/config/model_materializer.py` `_gguf_kind_tasks`: an installed GGUF's row carries `architecture`.
When llama.cpp does not know it, the row also carries `loadable: false` and `unloadable_reason`.

## Evidence
- Live on the server, checked against its real libllama: `qwen35`, `gemma4`, `qwen3`, `llama`, `lfm2`, `hy_v3` and
`qwen3tts` are known; `spark2_5` is not.
- `tests/config/test_installed_gguf_tasks.py`:
- `test_a_gguf_this_llamacpp_cannot_load_says_so`, with a stand-in library: spark2_5 reads loadable false with
the sentence; qwen35 has no flag; without llama-cpp-python nothing is claimed.
- `test_a_library_without_the_table_claims_nothing`: no sentinel, so no flag. Red without the sentinel check
(mutation-checked).
- `test_libllama_is_preferred_over_another_llama_file`.
- `test_the_real_llama_library_knows_its_own_architectures` runs against this machine's real library: `llama` is
known, a made-up name is not.
- `tests/config` passes.

## Follow-ups
- Before download: the Hugging Face API serves `gguf.architecture` per repo. Flagging a catalog or search artifact
as not loadable here is the next step (hub facts, then `_build_artifact`).
56 changes: 55 additions & 1 deletion tests/config/test_installed_gguf_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def _repo(tmp_path: Path, repo: str, files: dict) -> Path:
])
def test_a_gguf_only_repo_says_what_it_is(tmp_path, repo, files, kind, tasks) -> None:
got = mm._hf_local_kind_tasks(_repo(tmp_path, repo, files))
assert got == {"kind": kind, "tasks": tasks, "tasks_source": "gguf"}
assert {k: got[k] for k in ("kind", "tasks", "tasks_source")} == {"kind": kind, "tasks": tasks, "tasks_source": "gguf"}


def test_a_repo_with_its_own_config_keeps_the_config_answer(tmp_path) -> None:
Expand All @@ -59,3 +59,57 @@ def test_every_seed_gguf_architecture_is_a_known_text_architecture() -> None:
assert "nomic-bert" not in mm._GGUF_TEXT_ARCH
for arch in ("qwen35", "gemma4", "llama", "lfm2", "gpt-oss", "deepseek2", "mistral3"):
assert arch in mm._GGUF_TEXT_ARCH


def test_a_gguf_this_llamacpp_cannot_load_says_so(tmp_path, monkeypatch) -> None:
"""Batch item (f): XHToken/Spark-X2.6-4B (`spark2_5`) failed only at its first call. The
installed row now says it cannot be loaded here, before it is chosen."""
from abstractcore.utils import model_cache

lib = b"\x00llama\x00qwen2\x00qwen35\x00gemma4\x00qwen3tts\x00"
monkeypatch.setattr(model_cache, "_llama_library_bytes", lambda: lib)
spark = mm._hf_local_kind_tasks(_repo(tmp_path, "XHToken/Spark-X2.6-4B-GGUF", {"Spark-X2.6-4B-Q4_K_M.gguf": "spark2_5"}))
assert spark["architecture"] == "spark2_5" and spark["loadable"] is False
assert "cannot load the spark2_5 architecture" in spark["unloadable_reason"]
qwen = mm._hf_local_kind_tasks(_repo(tmp_path / "b", "unsloth/Qwen3.5-9B-GGUF", {"Qwen3.5-9B-Q4_K_M.gguf": "qwen35"}))
assert qwen["tasks"] == ["text_generation"] and "loadable" not in qwen
# No llama-cpp-python here: nothing is claimed either way.
monkeypatch.setattr(model_cache, "_llama_library_bytes", lambda: None)
assert "loadable" not in mm._hf_local_kind_tasks(_repo(tmp_path / "c", "XHToken/Spark-X2.6-4B-GGUF", {"s.gguf": "spark2_5"}))


def test_the_real_llama_library_knows_its_own_architectures() -> None:
from abstractcore.utils.model_cache import llamacpp_knows_architecture

known = llamacpp_knows_architecture("llama")
if known is None:
pytest.skip("llama-cpp-python is not installed here")
assert known is True and llamacpp_knows_architecture("no_such_arch_zz") is False


def test_a_library_without_the_table_claims_nothing(tmp_path, monkeypatch) -> None:
"""A libllama found but laid out differently (stripped, a stub): every architecture would read
unknown to it. The sentinel names are missing, so nothing is claimed (merge desk review)."""
from abstractcore.utils import model_cache

monkeypatch.setattr(model_cache, "_llama_library_bytes", lambda: b"\x00qwen35\x00gemma4\x00") # no llama / qwen2
row = mm._hf_local_kind_tasks(_repo(tmp_path, "XHToken/Spark-X2.6-4B-GGUF", {"Spark-X2.6-4B-Q4_K_M.gguf": "spark2_5"}))
assert "loadable" not in row and "unloadable_reason" not in row
assert model_cache.llamacpp_knows_architecture("qwen35") is None


def test_libllama_is_preferred_over_another_llama_file(tmp_path, monkeypatch) -> None:
import importlib.util
from types import SimpleNamespace

from abstractcore.utils import model_cache

pkg = tmp_path / "llama_cpp"
(pkg / "lib").mkdir(parents=True)
(pkg / "lib" / "llama.cfg").write_bytes(b"not a library")
(pkg / "lib" / "libllama.so").write_bytes(b"\x00llama\x00qwen2\x00qwen35\x00")
monkeypatch.setattr(importlib.util, "find_spec", lambda name: SimpleNamespace(submodule_search_locations=[str(pkg)]))
model_cache._LLAMA_LIB_CACHE.clear()
assert model_cache.llamacpp_knows_architecture("qwen35") is True
assert model_cache.llamacpp_knows_architecture("spark2_5") is False
model_cache._LLAMA_LIB_CACHE.clear()
Loading