stop discarding the reason a GGUF would not load, and give every TTS backend the card's path

**Five models "won't load" and llama.cpp had said why.** The load-phase log
callback printed only lines matching a keyword list about layers, buffers and
devices. llama.cpp writes its diagnosis -- an unknown architecture, a missing
tensor -- in none of those words, so it matched nothing and was dropped, while
llama-cpp-python raised its one generic sentence: "Failed to load model from
file: <path>". That string is all the client got, and all the log had.

So every line is now kept in a 60-line ring, lines that look like a diagnosis
are printed whether or not they mention a buffer, and the raised error carries
llama.cpp's own words. A model this build genuinely cannot read is no longer
indistinguishable from one that did not fit, a bad path, or a corrupt download.

The retry ladder stops earlier too. It exists to answer one question -- does it
fit? -- by moving layers to the host, and an attempt with NO layers on the GPU
has already answered it. Three MTP/next-N checkpoints each burned six attempts
down to n_gpu_layers=0, every one failing in a fraction of a second with the
same message: 18 loads whose only effect was to push the reason further up the
log. (What those three have in common is visible in their metadata --
qwen35.nextn_predict_layers=1, blk.N.layer_output_scale.weight -- and with the
reason no longer discarded, the next attempt will say so outright instead of
leaving it to be inferred.)

**XTTS and parler were handed an alias instead of a repo id.** _load_backend
passed `model_path or model_name` to Kokoro and the bare `model_name` to
everyone else, and for a card registered under an alias that name is the alias.
Each isolated worker then failed in its own vocabulary, neither mentioning the
cause:

  XTTS:   "XTTS venv could not load 'XTTS-v2': ValueError: not enough values to
          unpack (expected 4, got 1)" -- coqui splits a model name into
          type/lang/dataset/model, and the bare alias has no separator at all.
  parler: "parler-tts-mini-multilingual is not the path to a directory
          containing a file named config.json" -- the org is missing, so the
          real repo is never looked up, and the worker reports a model that IS
          cached as one to download first.

Every backend that resolves a checkpoint now gets the path. The XTTS worker's
own translation table is fixed to match on the last path segment as well, since
it is reached by other callers too: it required a "/" before, which is precisely
the case that does not apply.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 9b21a962
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.5.29" __version__ = "0.5.30"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -632,12 +632,30 @@ def load_backend(model_name: str, model_path: Optional[str], config: Optional[di ...@@ -632,12 +632,30 @@ def load_backend(model_name: str, model_path: Optional[str], config: Optional[di
def _load_backend(model_name: str, model_path: Optional[str], config: Optional[dict]): def _load_backend(model_name: str, model_path: Optional[str], config: Optional[dict]):
fam = _family(model_name) fam = _family(model_name)
config = config or {} config = config or {}
# Every backend that has to RESOLVE a checkpoint gets the card's path, with
# the request's name only as a fallback. `model_name` is whatever the caller
# asked for, which for a card registered under an alias is the alias -- and
# an alias is not a repo id. Kokoro already did this; the others took
# `model_name`, and both isolated workers failed on it, each in its own way:
#
# XTTS: "XTTS venv could not load 'XTTS-v2': ValueError: not enough
# values to unpack (expected 4, got 1)" -- coqui splits a model
# name into type/lang/dataset/model, and the bare alias has no
# separator at all (the HF id 'coqui/XTTS-v2' has one, which is
# the case the worker's translation table was written for).
# parler: "parler-tts-mini-multilingual is not the path to a directory
# containing a file named config.json" -- the org is missing, so
# the real repo 'parler-tts/parler-tts-mini-multilingual' is
# never looked up, and the worker reports it as "not in the cache,
# download it from the model interface first" for a model that IS
# in the cache.
_ckpt = model_path or model_name
if fam == "kokoro": if fam == "kokoro":
return _KokoroBackend(model_path or model_name, config) return _KokoroBackend(_ckpt, config)
if fam == "coqui": if fam == "coqui":
return _CoquiBackend(model_name, config) return _CoquiBackend(_ckpt, config)
if fam == "bark": if fam == "bark":
return _BarkBackend(model_name, config) return _BarkBackend(_ckpt, config)
if fam == "melo": if fam == "melo":
# MeloTTS is OpenVoice V2's base speaker model. Its pins conflict with this # MeloTTS is OpenVoice V2's base speaker model. Its pins conflict with this
# stack the way parler's do, so it runs in an isolated venv worker and is # stack the way parler's do, so it runs in an isolated venv worker and is
...@@ -645,9 +663,9 @@ def _load_backend(model_name: str, model_path: Optional[str], config: Optional[d ...@@ -645,9 +663,9 @@ def _load_backend(model_name: str, model_path: Optional[str], config: Optional[d
if config.get("service_url"): if config.get("service_url"):
return _RemoteSpeechBackend(config, family="melo") return _RemoteSpeechBackend(config, family="melo")
from codai.api import melotts_worker from codai.api import melotts_worker
url = melotts_worker.ensure_service(model_name) url = melotts_worker.ensure_service(_ckpt)
return _RemoteSpeechBackend({**config, "service_url": url}, family="melo", return _RemoteSpeechBackend({**config, "service_url": url}, family="melo",
managed_model=model_name) managed_model=_ckpt)
if fam == "parler": if fam == "parler":
# An explicit service_url points at an externally-run service. Otherwise # An explicit service_url points at an externally-run service. Otherwise
# coderai fully manages the worker: bootstrap its venv, spawn it, and # coderai fully manages the worker: bootstrap its venv, spawn it, and
...@@ -655,10 +673,10 @@ def _load_backend(model_name: str, model_path: Optional[str], config: Optional[d ...@@ -655,10 +673,10 @@ def _load_backend(model_name: str, model_path: Optional[str], config: Optional[d
if config.get("service_url"): if config.get("service_url"):
return _RemoteSpeechBackend(config, family="parler") return _RemoteSpeechBackend(config, family="parler")
from codai.api import parler_worker from codai.api import parler_worker
url = parler_worker.ensure_service(model_name) url = parler_worker.ensure_service(_ckpt)
return _RemoteSpeechBackend({**config, "service_url": url}, return _RemoteSpeechBackend({**config, "service_url": url},
family="parler", managed_model=model_name) family="parler", managed_model=_ckpt)
return _TransformersBackend(model_name, config) return _TransformersBackend(_ckpt, config)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
......
...@@ -22,6 +22,7 @@ import json ...@@ -22,6 +22,7 @@ import json
import re import re
import threading import threading
import time import time
from collections import deque
from typing import AsyncIterator, Optional, Union, List, Dict, Any from typing import AsyncIterator, Optional, Union, List, Dict, Any
from pathlib import Path from pathlib import Path
...@@ -611,6 +612,33 @@ _KV_OBSERVED: dict = {"gpu": 0.0, "cpu": 0.0} ...@@ -611,6 +612,33 @@ _KV_OBSERVED: dict = {"gpu": 0.0, "cpu": 0.0}
_KV_BUF_RE = re.compile( _KV_BUF_RE = re.compile(
r"llama_kv_cache[^:]*:\s*(\S+)\s+KV buffer size\s*=\s*([0-9.]+)\s*MiB") r"llama_kv_cache[^:]*:\s*(\S+)\s+KV buffer size\s*=\s*([0-9.]+)\s*MiB")
#: The last lines llama.cpp logged during the CURRENT load, whatever they said.
#: llama-cpp-python raises one generic sentence for every failure -- "Failed to
#: load model from file: <path>" -- while the REASON is written by llama.cpp to
#: its log callback. The callback below used to print only lines matching a
#: keyword list about layers and buffers, so an error naming the architecture or
#: the missing tensor matched nothing and was dropped. Four models then failed
#: with no reason anywhere, through six retries each.
_LOG_TAIL: deque = deque(maxlen=60)
#: Lines worth surfacing even though they say nothing about layers or buffers.
_LOG_TROUBLE = ("error", "failed", "unsupported", "unknown", "missing",
"not supported", "cannot", "invalid", "corrupt", "assert")
def llama_log_tail(limit: int = 6, trouble_only: bool = True) -> str:
"""The tail of llama.cpp's own log for the load just attempted.
``trouble_only`` keeps the lines that look like a diagnosis, which is almost
always what a failure needs; the whole tail is there when it is not.
"""
lines = list(_LOG_TAIL)
if trouble_only:
picked = [ln for ln in lines
if any(t in ln.lower() for t in _LOG_TROUBLE)]
lines = picked or lines
return " | ".join(lines[-max(1, int(limit)):])
def _install_layer_log_callback(): def _install_layer_log_callback():
"""Replace llama.cpp's log callback with one that prints load-time layer/buffer """Replace llama.cpp's log callback with one that prints load-time layer/buffer
...@@ -625,6 +653,7 @@ def _install_layer_log_callback(): ...@@ -625,6 +653,7 @@ def _install_layer_log_callback():
return None return None
_KV_OBSERVED["gpu"] = 0.0 _KV_OBSERVED["gpu"] = 0.0
_KV_OBSERVED["cpu"] = 0.0 _KV_OBSERVED["cpu"] = 0.0
_LOG_TAIL.clear()
# Keywords that identify interesting load-phase messages # Keywords that identify interesting load-phase messages
_KEEP = ( _KEEP = (
...@@ -642,7 +671,13 @@ def _install_layer_log_callback(): ...@@ -642,7 +671,13 @@ def _install_layer_log_callback():
if _m: if _m:
_where = "cpu" if _m.group(1).upper().startswith("CPU") else "gpu" _where = "cpu" if _m.group(1).upper().startswith("CPU") else "gpu"
_KV_OBSERVED[_where] += float(_m.group(2)) / 1024.0 _KV_OBSERVED[_where] += float(_m.group(2)) / 1024.0
if msg and any(k in msg for k in _KEEP): if msg:
# EVERY line is kept, so a failure can be explained afterwards
# even when the line said nothing this filter was looking for.
_LOG_TAIL.append(msg)
_low = msg.lower()
if msg and (any(k in msg for k in _KEEP)
or any(t in _low for t in _LOG_TROUBLE)):
print(f" [llama.cpp] {msg}", flush=True) print(f" [llama.cpp] {msg}", flush=True)
except Exception: except Exception:
pass pass
...@@ -1938,10 +1973,28 @@ class VulkanBackend(ModelBackend): ...@@ -1938,10 +1973,28 @@ class VulkanBackend(ModelBackend):
break break
except Exception as e: except Exception as e:
_last_err = e _last_err = e
_why = llama_log_tail()
print(f"Error loading GGUF model " print(f"Error loading GGUF model "
f"(attempt {_attempt + 1}/{_max_attempts}): {e}") f"(attempt {_attempt + 1}/{_max_attempts}): {e}"
+ (f"\n llama.cpp said: {_why}" if _why else ""))
if _attempt + 1 >= _max_attempts: if _attempt + 1 >= _max_attempts:
break break
# The ladder exists to answer ONE question -- does it fit? --
# by moving layers to the host. An attempt with NO layers on
# the GPU has already answered it: whatever is wrong is not
# VRAM, and four more attempts cannot discover that. Measured
# on three MTP/next-N checkpoints: six attempts each, down to
# n_gpu_layers=0, every one failing in ~0.2 s with the same
# generic "Failed to load model from file" -- 18 loads whose
# only effect was to bury the reason further up the log.
_was = llama_kwargs.get('n_gpu_layers', self.n_gpu_layers)
if _was == 0:
print(f" not retrying: this attempt had every layer on "
f"the CPU and still failed, so the model is not "
f"too big for the GPU — llama.cpp cannot load this "
f"file at all"
+ (f" ({_why})" if _why else ""))
break
_cur = llama_kwargs.get('n_gpu_layers', self.n_gpu_layers) _cur = llama_kwargs.get('n_gpu_layers', self.n_gpu_layers)
_tot = _block_count or (_cur if (_cur and _cur > 0) else 40) _tot = _block_count or (_cur if (_cur and _cur > 0) else 40)
if _cur is None or _cur < 0: if _cur is None or _cur < 0:
...@@ -1953,7 +2006,17 @@ class VulkanBackend(ModelBackend): ...@@ -1953,7 +2006,17 @@ class VulkanBackend(ModelBackend):
print(f" Retrying with n_gpu_layers={_new} " print(f" Retrying with n_gpu_layers={_new} "
f"({max(0, _tot - _new)}/{_tot} layers on CPU)…") f"({max(0, _tot - _new)}/{_tot} layers on CPU)…")
if self.model is None: if self.model is None:
print(f"Error loading model after {_max_attempts} attempts: {_last_err}") # Raise with llama.cpp's own words attached. The exception
# llama-cpp-python produces names the file and nothing else, and
# that string is what reaches the client -- so a model that this
# build genuinely cannot read was indistinguishable from one that
# did not fit, from a bad path, or from a corrupt download.
_why = llama_log_tail(limit=4)
print(f"Error loading model after {_max_attempts} attempts: "
f"{_last_err}" + (f"\n llama.cpp said: {_why}" if _why else ""))
if _why and str(_why) not in str(_last_err):
raise RuntimeError(f"{_last_err} — llama.cpp reported: {_why}") \
from _last_err
raise _last_err raise _last_err
finally: finally:
_params_mod.llama_model_default_params = _orig_params _params_mod.llama_model_default_params = _orig_params
......
...@@ -202,3 +202,117 @@ def test_an_exact_name_wins_even_unhealthy(registry_with_a_split_engine): ...@@ -202,3 +202,117 @@ def test_an_exact_name_wins_even_unhealthy(registry_with_a_split_engine):
up = _FakeEngine("nvidia-gguf", "nvidia", healthy=True) up = _FakeEngine("nvidia-gguf", "nvidia", healthy=True)
reg._engines = {1: up, 2: down} reg._engines = {1: up, 2: down}
assert reg.by_name("nvidia") is down assert reg.by_name("nvidia") is down
# ---------------------------------------------------------------------------
# "Five models won't load" -- llama.cpp said why and the filter dropped it
# ---------------------------------------------------------------------------
def test_llama_cpp_log_lines_are_all_kept():
"""The callback printed only lines matching a keyword list about layers and
buffers. An error naming the architecture or a missing tensor matches none
of them, so three MTP checkpoints failed with no reason anywhere in the log
and llama-cpp-python's one generic sentence reached the client."""
from codai.backends import vulkan as V
V._LOG_TAIL.clear()
V._LOG_TAIL.extend([
"llama_model_loader: loaded meta data",
"load_tensors: layer 0 assigned to device CUDA0",
"error loading model: missing tensor 'blk.0.nextn.weight'",
])
tail = V.llama_log_tail()
assert "missing tensor" in tail
def test_the_tail_prefers_lines_that_look_like_a_diagnosis():
"""A 60-line ring ending in routine chatter must still surface the error."""
from codai.backends import vulkan as V
V._LOG_TAIL.clear()
V._LOG_TAIL.append("unknown model architecture: 'qwen35moe'")
for i in range(40):
V._LOG_TAIL.append(f"load_tensors: layer {i} assigned to device CUDA0")
tail = V.llama_log_tail()
assert "unknown model architecture" in tail
def test_the_whole_tail_is_available_when_nothing_looks_wrong():
from codai.backends import vulkan as V
V._LOG_TAIL.clear()
V._LOG_TAIL.extend(["routine one", "routine two"])
assert "routine two" in V.llama_log_tail()
assert "routine two" in V.llama_log_tail(trouble_only=False)
def test_an_empty_log_yields_no_claim():
from codai.backends import vulkan as V
V._LOG_TAIL.clear()
assert V.llama_log_tail() == ""
def test_the_ladder_stops_once_cpu_only_has_failed():
"""The retry ladder answers "does it fit?" by moving layers to the host. An
attempt with no layers on the GPU has answered it — the three MTP models
each burned six attempts down to n_gpu_layers=0, every one failing in a
fraction of a second with the same generic message."""
src = _src("codai/backends/vulkan.py")
assert "not retrying: this attempt had every layer on" in src
head = src.index("Error loading GGUF model ")
body = src[head:head + 2000]
assert "if _was == 0:" in body
def test_the_raised_error_carries_llama_cpps_words():
src = _src("codai/backends/vulkan.py")
assert "llama.cpp reported:" in src
def test_trouble_lines_are_printed_even_when_no_keyword_matches():
src = _src("codai/backends/vulkan.py")
head = src.index("def _install_layer_log_callback")
body = src[head:src.index("async def _aiter_blocking")]
assert "_LOG_TROUBLE" in body
assert "_LOG_TAIL.append(msg)" in body
# ---------------------------------------------------------------------------
# "XTTS-v2 and parler don't load" -- both got an alias, not a repo id
# ---------------------------------------------------------------------------
def test_every_tts_backend_is_given_the_cards_path():
"""Kokoro already took `model_path or model_name`; coqui, bark, parler, melo
and the transformers fallback took the request's name, which for a card
registered under an alias is the alias."""
src = _src("codai/api/tts_backends.py")
head = src.index("def _load_backend(")
body = src[head:src.index("# encoding", head)]
assert "_ckpt = model_path or model_name" in body
assert "_CoquiBackend(_ckpt" in body
assert "_BarkBackend(_ckpt" in body
assert "_KokoroBackend(_ckpt" in body
assert "_TransformersBackend(_ckpt" in body
assert "parler_worker.ensure_service(_ckpt)" in body
assert "melotts_worker.ensure_service(_ckpt)" in body
# And no backend is left taking the bare request name.
assert "_CoquiBackend(model_name" not in body
assert "_BarkBackend(model_name" not in body
def test_the_xtts_worker_recognises_an_org_less_alias():
"""'XTTS venv could not load XTTS-v2: not enough values to unpack
(expected 4, got 1)' — coqui splits a name into four parts and the bare
alias has no separator at all. The old test required a '/', which is
exactly the case that does not apply."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"_xtts_svc", os.path.join(_ROOT, "tools/xtts_service.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
four = "tts_models/multilingual/multi-dataset/xtts_v2"
assert mod._coqui_name("XTTS-v2") == four
assert mod._coqui_name("coqui/XTTS-v2") == four
assert mod._coqui_name("xtts_v2") == four
assert mod._coqui_name("xtts_v1.1").endswith("xtts_v1.1")
assert mod._coqui_name("coqui/xtts-v1.1").endswith("xtts_v1.1")
# Already coqui's own four-part naming, and an unrelated model: untouched.
assert mod._coqui_name(four) == four
assert mod._coqui_name("some/other-model") == "some/other-model"
...@@ -48,7 +48,17 @@ def _coqui_name(name: str) -> str: ...@@ -48,7 +48,17 @@ def _coqui_name(name: str) -> str:
mapped = _HF_TO_COQUI.get(n.lower()) mapped = _HF_TO_COQUI.get(n.lower())
if mapped: if mapped:
return mapped return mapped
if "/" in n and "xtts" in n.lower(): # Match on the LAST path segment, so an org-less alias is recognised too.
# The previous test required a "/", which is exactly the case that does not
# apply to a card registered as "XTTS-v2": coqui then split a name with no
# separator and reported "not enough values to unpack (expected 4, got 1)",
# an error about its own four-part naming that says nothing about the alias
# that caused it. The caller passes the card's path now, but the worker is
# reached by other paths too and must not depend on that.
tail = n.rsplit("/", 1)[-1].lower().replace("_", "-")
if "xtts" in tail:
if "v1.1" in tail or "v1-1" in tail:
return "tts_models/multilingual/multi-dataset/xtts_v1.1"
# An unknown XTTS repo: the v2 architecture is the safe assumption. # An unknown XTTS repo: the v2 architecture is the safe assumption.
return "tts_models/multilingual/multi-dataset/xtts_v2" return "tts_models/multilingual/multi-dataset/xtts_v2"
return n return n
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment