From 9119739d02d8da4314fa17dfe0aa84ccd13b5128 Mon Sep 17 00:00:00 2001 From: Laurent-Philippe Albou <24249870+lpalbou@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:23:39 +0100 Subject: [PATCH 1/2] 0867: a GGUF this llama.cpp cannot load says so on its installed row (libllama's own architecture table; loadable false + reason); tests --- CHANGELOG.md | 6 ++ abstractcore/config/model_materializer.py | 14 +++- abstractcore/utils/model_cache.py | 72 +++++++++++++++++++ .../0867_gguf_architecture_support_check.md | 33 +++++++++ tests/config/test_installed_gguf_tasks.py | 28 +++++++- 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 docs/backlog/completed/0867_gguf_architecture_support_check.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6e8405..451636e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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`: diff --git a/abstractcore/config/model_materializer.py b/abstractcore/config/model_materializer.py index 8d83f85e..7b6273c8 100644 --- a/abstractcore/config/model_materializer.py +++ b/abstractcore/config/model_materializer.py @@ -5010,7 +5010,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} @@ -5056,9 +5056,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]: diff --git a/abstractcore/utils/model_cache.py b/abstractcore/utils/model_cache.py index 8418a1e2..9f9bbf78 100644 --- a/abstractcore/utils/model_cache.py +++ b/abstractcore/utils/model_cache.py @@ -620,3 +620,75 @@ 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 = {} + + +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: + candidates = sorted(p for p in lib_dir.iterdir() if p.is_file() and p.name.lower().startswith(("libllama", "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 + return (b"\x00" + 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." + ) diff --git a/docs/backlog/completed/0867_gguf_architecture_support_check.md b/docs/backlog/completed/0867_gguf_architecture_support_check.md new file mode 100644 index 00000000..db0890ef --- /dev/null +++ b/docs/backlog/completed/0867_gguf_architecture_support_check.md @@ -0,0 +1,33 @@ +# 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. It checks whether the architecture name + is one of its C strings, which is how llama.cpp keeps its table of architecture names. 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_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`). diff --git a/tests/config/test_installed_gguf_tasks.py b/tests/config/test_installed_gguf_tasks.py index a185779f..87394257 100644 --- a/tests/config/test_installed_gguf_tasks.py +++ b/tests/config/test_installed_gguf_tasks.py @@ -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: @@ -59,3 +59,29 @@ 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\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 From 788b9b43b490ecba57a2f9236c91705c22f7c5e4 Mon Sep 17 00:00:00 2001 From: Laurent-Philippe Albou <24249870+lpalbou@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:26:47 +0100 Subject: [PATCH 2/2] 0867 review: sentinel names (llama, qwen2) gate the table; libllama preferred; a name may be a merged string tail (end-anchored match, fail-safe); tests --- abstractcore/utils/model_cache.py | 24 +++++++++++++-- .../0867_gguf_architecture_support_check.md | 13 ++++++-- tests/config/test_installed_gguf_tasks.py | 30 ++++++++++++++++++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/abstractcore/utils/model_cache.py b/abstractcore/utils/model_cache.py index 9f9bbf78..4b5e5a4e 100644 --- a/abstractcore/utils/model_cache.py +++ b/abstractcore/utils/model_cache.py @@ -627,6 +627,8 @@ def read_gguf_architecture(path: Path) -> Optional[str]: # --------------------------------------------------------------------------- _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]: @@ -642,7 +644,11 @@ def _llama_library_bytes() -> Optional[bytes]: return None lib_dir = Path(list(spec.submodule_search_locations)[0]) / "lib" try: - candidates = sorted(p for p in lib_dir.iterdir() if p.is_file() and p.name.lower().startswith(("libllama", "llama."))) + 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: @@ -674,7 +680,21 @@ def llamacpp_knows_architecture(arch: Optional[str]) -> Optional[bool]: data = _llama_library_bytes() if data is None: return None - return (b"\x00" + name.encode("utf-8") + b"\x00") in data + # 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]: diff --git a/docs/backlog/completed/0867_gguf_architecture_support_check.md b/docs/backlog/completed/0867_gguf_architecture_support_check.md index db0890ef..af23edc6 100644 --- a/docs/backlog/completed/0867_gguf_architecture_support_check.md +++ b/docs/backlog/completed/0867_gguf_architecture_support_check.md @@ -11,9 +11,13 @@ because its `general.architecture` is `spark2_5`, which that llama-cpp-python bu ## 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. It checks whether the architecture name - is one of its C strings, which is how llama.cpp keeps its table of architecture names. It returns None without - llama-cpp-python. + 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`. @@ -24,6 +28,9 @@ because its `general.architecture` is `spark2_5`, which that llama-cpp-python bu - `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. diff --git a/tests/config/test_installed_gguf_tasks.py b/tests/config/test_installed_gguf_tasks.py index 87394257..8bc6f8cf 100644 --- a/tests/config/test_installed_gguf_tasks.py +++ b/tests/config/test_installed_gguf_tasks.py @@ -66,7 +66,7 @@ def test_a_gguf_this_llamacpp_cannot_load_says_so(tmp_path, monkeypatch) -> None installed row now says it cannot be loaded here, before it is chosen.""" from abstractcore.utils import model_cache - lib = b"\x00llama\x00qwen35\x00gemma4\x00qwen3tts\x00" + 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 @@ -85,3 +85,31 @@ def test_the_real_llama_library_knows_its_own_architectures() -> None: 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()