load an embedder the way its entry says, and refuse an impossible request before the load

Three faults, two of them reported from a production render on 2026-10-29.

**A local GGUF embedder was fetched from the Hub.**
`gme-Qwen2-VL-2B-Instruct-Q4_K_M` and `dinov2-large-Q8_0` answered

    500 Failed to load embedding model: 404 Client Error …
    huggingface.co/sentence-transformers/<id>/resolve/main/

for files sitting in /AI/guffcache. The loader dispatches on the NAME — `.gguf`
goes to llama.cpp, or for a ViT to the dinov2-embed server, and anything else
to sentence-transformers, which prepends `sentence-transformers/` to a name
with no slash and asks the Hub. These entries are registered under three ids
(path, basename, basename without extension) and the request used the last.
The config lookup missed for the same reason, which is worse than it looks: it
carries `mmproj`, so the vision GGUF would have lost its image tower even with
the dispatch right. Both now resolve through the entry.

**An impossible request paid for a model load before being refused.** Four
texts and four images to a text-only 8 GB embedder: forty seconds of loading,
then the 400 it could have had immediately — and a caller cannot tell that
from a hang. `_may_embed_images` answers from the config and the HF config
before anything loads, and only when something positively says "no image
tower"; what it cannot determine still goes to the load, where the exact
check on the loaded object decides as before.

**XTTS installed no torch.** `pip install coqui-tts soundfile requests`
brought coqui-tts 0.28.0 and its ninety dependencies and no torch at all, so
the install reported success and `import TTS` failed. It has a pinned
requirements-xtts.txt now, with the same torch/torchaudio pair as parler.

Both TTS engines also stopped hiding the reason: venv_env.import_ok and
venv_env.gpu_conv_ok carry the real message out, /v1/audio/speech/capabilities
serves it as env_detail, and the XTTS 501 no longer claims no venv was found
over a venv that is there and populated with the wrong stack.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 94502ada
......@@ -269,6 +269,13 @@ Request extras: `image` (URL, data URI, path or base64), `embedding_types` to pi
dense/sparse/colbert, `dimensions` to truncate, and `quantization` (TurboQuant `turbo8` …
`turbo2`) for packed low-bit vectors.
The backend comes from what the model's entry POINTS AT, not from the id the request
used: a GGUF embedder is registered under its path, its basename and the basename
without the extension, and only the entry says which file to open and which `mmproj`
goes with it. Sending `image` to a model that has no image tower is a `400` before
anything is loaded — the same answer in both directions, since text to an image-only
model has always been one.
**`POST /v1/rerank`** adds cross-encoder reranking (e.g. `bge-reranker-v2-m3`) — query +
documents in, `relevance_score` per document out. No extra dependency; rerankers are
registered in the embedding model category.
......@@ -608,6 +615,7 @@ built on demand from the admin UI:
| `requirements-crisperwhisper.txt` | CrisperWhisper verbatim STT | yes |
| `requirements-pyannote.txt` | Speaker diarization | yes |
| `requirements-parler.txt` | Parler-TTS expressive speech | yes |
| `requirements-xtts.txt` | coqui XTTS voice cloning | yes |
---
......
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.5.22"
__version__ = "0.5.24"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -1094,6 +1094,57 @@ def _load_embedding_model(model_name: str, device: str, model_config: dict = Non
raise RuntimeError(f"Cannot load embedding model '{model_name}': {e}")
def _may_embed_images(model_name: str, model_config: dict = None) -> bool:
"""Whether this model COULD embed images, decided before it is loaded.
:func:`_supports_images` asks the loaded object, which is the right answer
and an expensive one: an image request to a text-only model spent the whole
load before being refused. Measured on zeiss, 2026-10-29 -- four texts and
four images sent to a text-only 8 GB embedder, forty seconds of loading,
and then the 400 it could have had immediately.
Conservative on purpose. It answers False only when something positively
says "no image tower": a special backend that is text-only, or an HF config
with neither a vision tower nor a vision-only architecture. Anything it
cannot determine comes back True and the loaded-object check decides, so a
model this does not recognise is never refused by mistake.
"""
cfg = model_config or {}
raw = cfg.get('_raw_cfg') if isinstance(cfg.get('_raw_cfg'), dict) else {}
name = str(model_name or '')
low = name.lower()
# A GGUF embedder sees images only through a configured projector, which is
# exactly what the mmproj field is.
if low.endswith('.gguf'):
return bool(cfg.get('mmproj') or raw.get('mmproj')) or 'dinov2' in _os_basename(low)
# The logical ids: their own encoders, not an HF config between them.
for probe, answer in ((_is_vpr, True), (_is_salad, True)):
try:
if probe(name):
return answer
except Exception:
pass
if low in ('geoclip', 'geoclip-location'):
return low == 'geoclip'
trust = bool(cfg.get('trust_remote_code', raw.get('trust_remote_code', True)))
try:
if _is_dual_encoder(name, trust) or _is_vision_only(name, trust):
return True
except Exception:
return True # unreadable config: let the load decide
# The config was readable and describes neither an image tower nor an
# image-only encoder.
try:
from transformers import AutoConfig
AutoConfig.from_pretrained(name, trust_remote_code=trust)
except Exception:
return True # cannot be read at all: not our call to make
return False
def _supports_images(model_obj) -> bool:
"""True if this loaded model can embed images (shared space for clip/ST
multimodal; image-only space for the 'vision' backend)."""
......@@ -2229,7 +2280,53 @@ async def _run_embeddings(request: EmbeddingsRequest, http_request: Request = No
model_obj = model_info.get('model_object')
_emb_cfg = (multi_model_manager.config.get(f"embedding:{model_name}")
or multi_model_manager.config.get(model_name) or {})
or multi_model_manager.config.get(model_name)
# …and through the alias/basename resolver, because
# `self.config` is keyed by the registration id (for a local
# file, its full path) while a request usually arrives as the
# basename. A miss here is not harmless: it drops every
# per-model setting, `mmproj` included, so a vision GGUF
# embedder loses its image tower.
or multi_model_manager._config_for_model(model_name) or {})
# Load what the entry actually points at. A GGUF embedder is registered
# under three ids -- its path, its basename, and the basename without the
# extension -- and `request_model` can hand back any of them. The loader
# dispatches on the NAME: `.gguf` goes to llama.cpp (or, for a ViT, to
# dinov2-embed), and anything else goes to sentence-transformers, which
# prepends `sentence-transformers/` to a name with no slash and asks the
# Hub for it. Measured on zeiss, 2026-10-29:
#
# gme-Qwen2-VL-2B-Instruct-Q4_K_M → 500 "Failed to load embedding model:
# 404 … huggingface.co/sentence-transformers/<id>/resolve/main/"
#
# for a file sitting in /AI/guffcache. The entry's own path settles it.
_cfg_path = str(_emb_cfg.get('path') or _emb_cfg.get('model_path') or '').strip()
if _cfg_path and _cfg_path != model_name:
print(f"[embeddings] '{model_name}' is a config of '{_cfg_path}'", flush=True)
model_name = _cfg_path
texts: List[str] = []
if request.input is not None:
texts = [request.input] if isinstance(request.input, str) else list(request.input)
images: List[str] = []
if request.image is not None:
images = [request.image] if isinstance(request.image, str) else list(request.image)
if not texts and not images:
raise HTTPException(
status_code=400, detail="Provide 'input' (text) and/or 'image'.")
# Refuse an impossible request BEFORE loading anything. The same refusal
# used to come after the load: images sent to a text-only 8 GB embedder
# spent forty seconds loading it and then answered 400. A caller cannot
# tell that from a hang, and the work was wasted either way.
if images and model_obj is None and not _may_embed_images(model_name, _emb_cfg):
raise HTTPException(
status_code=400,
detail=f"Model '{model_name}' is text-only; image embedding needs a "
"multimodal model (CLIP/SigLIP family, e.g. "
"sentence-transformers/clip-ViT-B-32 or jinaai/jina-clip-v2), "
"or a GGUF embedder with an `mmproj` configured.")
if model_obj is None:
# Serialize loads per model: a burst of first-requests (a bulk indexer)
......@@ -2242,16 +2339,6 @@ async def _run_embeddings(request: EmbeddingsRequest, http_request: Request = No
model_obj = await _load_embedding_locked(
request, model_key, model_name, _emb_cfg)
texts: List[str] = []
if request.input is not None:
texts = [request.input] if isinstance(request.input, str) else list(request.input)
images: List[str] = []
if request.image is not None:
images = [request.image] if isinstance(request.image, str) else list(request.image)
if not texts and not images:
raise HTTPException(
status_code=400, detail="Provide 'input' (text) and/or 'image'.")
if images and not _supports_images(model_obj):
raise HTTPException(
status_code=400,
......
......@@ -57,64 +57,17 @@ def _venv_python() -> Path:
def _import_check(py: Path) -> tuple:
"""``(ok, detail)`` for "are this venv's packages importable".
The detail matters. "install did not yield an importable package" is what
this used to say, which names neither the package nor the reason --
measured on zeiss, 2026-10-29, where the install had in fact succeeded and
the import was failing on a torchaudio that did not match the pinned
torch.
"""
try:
r = venv_env.run([str(py), "-c", "import parler_tts, soundfile"],
capture_output=True, text=True, timeout=600)
except Exception as exc:
return False, f"the import could not be run ({exc})"
if r.returncode == 0:
return True, ""
tail = ((r.stderr or "") or (r.stdout or "")).strip().splitlines()
return False, (tail[-1][:300] if tail else f"exit {r.returncode}")
"""Whether this venv's packages import. See venv_env.import_ok."""
return venv_env.import_ok(py, ("parler_tts", "soundfile"))
def _pip_ok(py: Path) -> bool:
return _import_check(py)[0]
#: The op that failed. Parler's audio decoder (DAC) is convolutional, so a
#: venv whose CUDA stack cannot run one convolution cannot synthesize anything
#: -- however importable its packages are.
_GPU_PROBE = (
"import torch, torch.nn.functional as F\n"
"if not torch.cuda.is_available():\n"
" raise SystemExit(0)\n"
"x = torch.zeros(1, 1, 16, device='cuda')\n"
"w = torch.zeros(1, 1, 3, device='cuda')\n"
"F.conv1d(x, w).sum().item()\n")
def _gpu_ok(py: Path) -> tuple:
"""``(ok, detail)`` — whether this venv can actually run on the GPU.
"Are the packages importable" is not the same question, and the difference
cost a whole bring-up. Measured on zeiss, 2026-10-29: the venv imported
parler_tts and soundfile perfectly, the service started, the model loaded,
and the first synthesis died in the decoder's conv1d with
``CUDNN_STATUS_SUBLIBRARY_LOADING_FAILED`` — a CUDA-13 torch that pip had
resolved on its own. ``_pip_ok`` answered True for that venv for ever, so
nothing ever rebuilt it.
A CPU-only host passes: it exits before the convolution, because there is
no GPU stack to be broken.
"""
try:
r = venv_env.run([str(py), "-c", _GPU_PROBE],
capture_output=True, text=True, timeout=600)
except Exception as exc:
return False, f"the probe could not be run ({exc})"
if r.returncode == 0:
return True, ""
tail = ((r.stderr or "") or (r.stdout or "")).strip().splitlines()
return False, (tail[-1][:300] if tail else f"exit {r.returncode}")
"""Whether this venv can run the decoder's convolution on the GPU."""
return venv_env.gpu_conv_ok(py)
def _bootstrap_venv() -> Path:
......
......@@ -212,18 +212,22 @@ class _CoquiBackend:
# Build the venv on first use, the way parler does. Opt out with
# CODERAI_XTTS_AUTOINSTALL=0 on a host that manages its own venvs
# or has no network.
if not xtts_worker.available() and \
os.environ.get("CODERAI_XTTS_AUTOINSTALL", "1") != "0":
_ok, _why = xtts_worker.health()
if not _ok and os.environ.get("CODERAI_XTTS_AUTOINSTALL", "1") != "0":
xtts_worker.bootstrap()
if xtts_worker.available():
_ok, _why = xtts_worker.health()
if _ok:
print(f"[tts] coqui-tts from {xtts_worker.venv_dir()}", flush=True)
self._tts = xtts_worker.XttsSubprocess(model_name)
return
# Say what is actually wrong with the venv. This used to report
# that no venv had been found, over a venv that was there and
# populated, which sent the operator looking in the wrong place.
raise MissingEngineError(
"Coqui/XTTS models need the coqui-tts package, and no venv "
"containing it was found (CODERAI_XTTS_VENV, /opt/coderai/venvs/TTS, "
"~/.coderai/xtts_venv). It cannot be installed beside "
"transformers 5.x: build it in its own venv."
f"Coqui/XTTS models need their own venv ({xtts_worker.venv_dir()}), "
f"and this one is not usable: {_why}. It cannot be installed "
f"beside the server's transformers; coderai builds it on first "
f"use unless CODERAI_XTTS_AUTOINSTALL=0."
) from e
import torch
self._tts = TTS(model_name).to("cuda" if torch.cuda.is_available() else "cpu")
......
......@@ -401,3 +401,59 @@ def pip_install_args(requirements, fallback_pins) -> list:
f"only codai/, tools/ and third_party/ over the image); installing "
f"the same pins inline", flush=True)
return list(fallback_pins)
def import_ok(py, modules) -> Tuple[bool, str]:
"""``(ok, detail)`` for "can this venv import these modules".
The detail is the point. "installed but still not importable" and
"install did not yield an importable package" are what two workers used to
say, and neither names the module or the reason -- measured on zeiss,
2026-10-29, where one install had in fact succeeded and the import was
failing on a mismatched torchaudio, and another had no torch at all.
"""
mods = ", ".join(modules)
try:
r = run([str(py), "-c", f"import {mods}"],
capture_output=True, text=True, timeout=600)
except Exception as exc:
return False, f"the import could not be run ({exc})"
if r.returncode == 0:
return True, ""
tail = ((r.stderr or "") or (r.stdout or "")).strip().splitlines()
return False, (tail[-1][:300] if tail else f"exit {r.returncode}")
#: One convolution on the GPU. Speech models are convolutional (a vocoder, a
#: DAC decoder), so a venv whose CUDA stack cannot run this cannot synthesize
#: anything -- however importable its packages are.
_GPU_PROBE = (
"import torch, torch.nn.functional as F\n"
"if not torch.cuda.is_available():\n"
" raise SystemExit(0)\n"
"x = torch.zeros(1, 1, 16, device='cuda')\n"
"w = torch.zeros(1, 1, 3, device='cuda')\n"
"F.conv1d(x, w).sum().item()\n")
def gpu_conv_ok(py) -> Tuple[bool, str]:
"""``(ok, detail)`` — whether this venv can actually run on the GPU.
"Are the packages importable" is a different question, and the difference
cost a bring-up: a venv imported parler_tts and soundfile perfectly, the
service started, the model loaded, and the first synthesis died in the
decoder's conv1d with ``CUDNN_STATUS_SUBLIBRARY_LOADING_FAILED`` -- a
CUDA-13 torch pip had resolved on its own. The import check answered True
for that venv for ever, so nothing ever rebuilt it.
A CPU-only host passes: it exits before the convolution, because there is
no GPU stack there to be broken.
"""
try:
r = run([str(py), "-c", _GPU_PROBE],
capture_output=True, text=True, timeout=600)
except Exception as exc:
return False, f"the probe could not be run ({exc})"
if r.returncode == 0:
return True, ""
tail = ((r.stderr or "") or (r.stdout or "")).strip().splitlines()
return False, (tail[-1][:300] if tail else f"exit {r.returncode}")
......@@ -34,6 +34,17 @@ from codai.api import venv_env
_REPO_ROOT = Path(__file__).resolve().parents[2]
_SERVICE = _REPO_ROOT / "tools" / "xtts_service.py"
_REQUIREMENTS = _REPO_ROOT / "requirements-xtts.txt"
#: The same pins as requirements-xtts.txt, for an install where that file is
#: not present (the container mounts only codai/, tools/ and third_party/ over
#: the image). A test keeps the two in step.
_PINS = ("--extra-index-url", "https://download.pytorch.org/whl/cu124",
"torch==2.6.0+cu124",
"torchaudio==2.6.0+cu124",
"coqui-tts",
"soundfile>=0.12.0",
"requests>=2.28.0")
def venv_dir() -> Path:
......@@ -51,19 +62,34 @@ def venv_python() -> Path:
"python.exe" if os.name == "nt" else "python")
def available() -> bool:
"""True when there is a venv with coqui-tts in it to talk to."""
def health() -> tuple:
"""``(ok, detail)`` for the venv behind XTTS.
Two questions, because either one failing means no speech: are the
packages importable, and can the stack run a convolution on the GPU (a
vocoder is convolutional, and a venv with a mismatched CUDA stack imports
perfectly and dies on the first one). The detail is carried out so the
caller can say WHY -- "coqui-tts installed but still not importable" is
what this used to report, and it named neither the module nor the reason.
Both run in a clean environment: the server exports PYTHONHOME, and under
it the probe imports the SERVER's site-packages, so a populated venv
answered "coqui-tts is not installed" for ever (see venv_env).
"""
py = venv_python()
if not py.is_file() or not _SERVICE.is_file():
return False
try:
# Clean environment: the server exports PYTHONHOME, and under it this
# probe imports the SERVER's site-packages, so a populated venv
# answered "coqui-tts is not installed" for ever (see venv_env).
return venv_env.run([str(py), "-c", "import TTS"],
capture_output=True, timeout=120).returncode == 0
except Exception:
return False
if not py.is_file():
return False, f"there is no interpreter at {py}"
if not _SERVICE.is_file():
return False, f"the worker script is missing: {_SERVICE}"
ok, why = venv_env.import_ok(py, ("TTS",))
if not ok:
return False, why
return venv_env.gpu_conv_ok(py)
def available() -> bool:
"""True when there is a venv with a working coqui-tts in it to talk to."""
return health()[0]
_bootstrap_lock = threading.Lock()
......@@ -116,10 +142,13 @@ def bootstrap(timeout: int = 3600) -> bool:
if not venv_python().is_file() or not _venv_ok():
print(f"[xtts] preparing the isolated venv at {d} …", flush=True)
py = venv_env.create_venv(d)
print("[xtts] installing coqui-tts into the isolated venv "
"(first run, downloads several GB) …", flush=True)
venv_env.run([str(py), "-m", "pip", "install", "--no-input",
"coqui-tts", "soundfile", "requests"],
print("[xtts] installing coqui-tts + a pinned torch into the "
"isolated venv (first run, downloads several GB) …", flush=True)
# From the pinned file, not `pip install coqui-tts`: that installed
# coqui-tts and its ninety dependencies with NO torch at all, so
# the install reported success and `import TTS` failed.
venv_env.run([str(py), "-m", "pip", "install", "--no-input"]
+ venv_env.pip_install_args(_REQUIREMENTS, _PINS),
check=True, timeout=timeout)
except Exception as exc:
# A partially built venv is worse than none: `available()` stays
......@@ -130,11 +159,11 @@ def bootstrap(timeout: int = 3600) -> bool:
print(f"[xtts] bootstrap failed ({type(exc).__name__}: "
f"{str(exc)[:200]}); XTTS stays unavailable", flush=True)
return False
ok = available()
ok, why = health()
_bootstrapped = ok
if not ok:
print("[xtts] coqui-tts installed but still not importable",
flush=True)
print(f"[xtts] the install finished but the venv is not usable: "
f"{why}", flush=True)
return ok
......
......@@ -125,9 +125,24 @@ And the service itself latches cudnn off if it still fails there: torch has
native kernels for those convolutions, the decoder is a fraction of the work,
and the alternative is no speech at all.
XTTS had the same shape one layer along: `pip install coqui-tts soundfile
requests` installed coqui-tts 0.28.0 and its ninety dependencies and **no
torch at all**, so the install reported success and `import TTS` failed. It
has a pinned `requirements-xtts.txt` now too, with the same torch/torchaudio
pair.
Both engines also stopped hiding the reason. "coqui-tts installed but still
not importable" and "parler-tts install did not yield an importable package"
name neither the module nor the fault; `venv_env.import_ok` and
`venv_env.gpu_conv_ok` carry the real message out, and
`GET /v1/audio/speech/capabilities` serves it as `env_detail`. The XTTS 501
used to say "no venv containing it was found" over a venv that was there and
populated, which sent the operator looking in the wrong place.
The general rule, which cost two bring-ups here: **a venv's health check must
exercise what the worker actually does.** An import proves the files are in
the right directory and nothing else.
exercise what the worker actually does, and say what failed.** An import
proves the files are in the right directory and nothing else; a check that
answers only true/false turns every layer below it into a mystery.
## Knowing before you ask
......
# Isolated venv for coqui XTTS (coqui/XTTS-v2).
#
# coqui-tts needs a transformers the coderai server cannot have, so it runs in
# its own venv and coderai talks to it over a pipe (codai/api/xtts_worker.py +
# tools/xtts_service.py). Do NOT merge these into requirements.txt.
#
# torch is PINNED here, which is the point of the file. `pip install coqui-tts
# soundfile requests` — what this used to do — installed coqui-tts 0.28.0 and
# its 90 dependencies and NO torch at all, so the venv built, the install
# reported success, and `import TTS` failed. Measured on zeiss, 2026-10-29.
#
# The pair has to be pinned together: parler's venv got a torchaudio pip had
# resolved against a different torch, the install succeeded, and the import
# failed on the mismatch. 2.6.0 because torchaudio has no python-3.13 wheel
# before it. An operator on a newer CUDA can raise both.
--extra-index-url https://download.pytorch.org/whl/cu124
torch==2.6.0+cu124
torchaudio==2.6.0+cu124
coqui-tts
soundfile>=0.12.0
requests>=2.28.0
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""An embedder has to be loaded the way its entry says, and refuse early.
Two faults reported from production on 2026-10-29.
**A local GGUF embedder was fetched from the Hub.** `gme-Qwen2-VL-2B-Instruct-Q4_K_M`
and `dinov2-large-Q8_0` answered
500 Failed to load embedding model: 404 Client Error …
huggingface.co/sentence-transformers/<id>/resolve/main/
for files sitting in `/AI/guffcache`. The loader dispatches on the NAME:
`.gguf` goes to llama.cpp (or, for a ViT, to the dinov2-embed server) and
anything else goes to sentence-transformers, which prepends
`sentence-transformers/` to a name with no slash and asks the Hub for it. These
entries are registered under three ids — the path, the basename, and the
basename without the extension — and the request arrived as the last of those.
The config lookup missed for the same reason, which is worse than it looks: it
carries `mmproj`, so the vision GGUF would have lost its image tower even if
the dispatch had been right.
**An impossible request paid for a model load before being refused.** Four
texts and four images to a text-only 8 GB embedder: forty seconds of loading,
then the 400 it could have had immediately. A caller cannot tell that from a
hang.
"""
import io
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from codai.api import embeddings as E
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_SRC = io.open(os.path.join(_ROOT, "codai/api/embeddings.py"), encoding="utf-8").read()
# --------------------------------------------------------------------------
# What the entry names is what gets loaded
# --------------------------------------------------------------------------
def test_the_entry_path_decides_what_is_loaded():
body = _SRC.split("async def _run_embeddings", 1)[1][:4000]
assert "_cfg_path" in body, "the entry's path is never consulted"
assert body.index("_cfg_path") < body.index("_load_embedding_locked"), (
"the name is resolved after the load, which is too late to change it")
def test_the_config_lookup_falls_back_to_the_resolver():
"""`self.config` is keyed by the registration id (a local file's full
path); a request arrives as the basename. A miss drops every per-model
setting, `mmproj` included."""
body = _SRC.split("async def _run_embeddings", 1)[1][:4000]
assert "_config_for_model(model_name)" in body
def test_a_gguf_embedder_is_not_looked_up_on_the_hub():
"""The dispatch test is on the name, so a bare id reaches
sentence-transformers and becomes a Hub request for
`sentence-transformers/<id>`."""
loader = _SRC.split("def _load_embedding_model", 1)[1]
gguf_at = loader.index("endswith('.gguf')")
st_at = loader.index("from sentence_transformers import SentenceTransformer")
assert gguf_at < st_at, (
"sentence-transformers is tried before the GGUF branch")
# --------------------------------------------------------------------------
# The pre-load refusal
# --------------------------------------------------------------------------
def test_a_gguf_without_an_mmproj_cannot_take_images():
assert E._may_embed_images("/AI/guffcache/Qwen3-Embedding-4B-Q4_K_M.gguf",
{"mmproj": None}) is False
def test_a_gguf_with_an_mmproj_can():
assert E._may_embed_images(
"/AI/guffcache/gme-Qwen2-VL-2B-Instruct-Q4_K_M.gguf",
{"mmproj": "/AI/guffcache/gme-mmproj-Q8_0.gguf"}) is True
def test_a_vit_gguf_can():
"""dinov2 GGUF goes to the dinov2-embed server, which is image-only."""
assert E._may_embed_images("/AI/guffcache/dinov2-large-Q8_0.gguf", {}) is True
def test_the_coordinate_half_of_geoclip_cannot():
assert E._may_embed_images("geoclip", {}) is True
assert E._may_embed_images("geoclip-location", {}) is False
def test_an_unknown_model_is_not_refused():
"""The check is conservative: what it cannot determine goes to the load,
and the loaded-object check decides. Refusing on a guess would break a
model that works."""
assert E._may_embed_images("definitely/not-a-real-model-anywhere", {}) is True
def test_the_refusal_happens_before_the_load():
body = _SRC.split("async def _run_embeddings", 1)[1][:6000]
assert "_may_embed_images(" in body, "nothing is checked before loading"
assert body.index("_may_embed_images(") < body.index("_load_embedding_locked"), (
"the check runs after the load, which is the forty seconds being fixed")
def test_the_loaded_object_check_is_still_there():
"""The pre-check is conservative, so the exact answer -- from the model
itself -- has to stay."""
body = _SRC.split("async def _run_embeddings", 1)[1][:9000]
assert "_supports_images(model_obj)" in body
assert body.index("_load_embedding_locked") < body.index("_supports_images(model_obj)")
def test_both_directions_are_refused_with_400():
"""Images to a text-only model, and text to an image-only one. The second
already answered properly; the first is what sent a server into a
forty-second load for nothing."""
assert "is text-only; image embedding needs a" in _SRC
assert "this embedding model is image-only" in _SRC
......@@ -218,8 +218,6 @@ def test_the_parler_venv_is_probed_on_the_gpu_not_just_imported():
src = open(os.path.join(root, "codai/api/parler_worker.py"),
encoding="utf-8").read()
assert "_gpu_ok(" in src, "the venv is trusted on an import alone"
assert "conv1d" in src, (
"the probe does not run the op that fails (the decoder is convolutional)")
boot = src.split("def _bootstrap_venv", 1)[1]
assert boot.index("_gpu_ok(py)") < boot.index("rmtree"), (
"the probe does not drive the rebuild")
......@@ -227,15 +225,31 @@ def test_the_parler_venv_is_probed_on_the_gpu_not_just_imported():
"the rebuild installs whatever pip resolves today, which is the bug")
def test_the_probe_runs_the_op_that_fails():
"""The decoder is convolutional, and a conv1d is what died with
CUDNN_STATUS_SUBLIBRARY_LOADING_FAILED. A probe that only imports torch
would have passed on that venv."""
probe = venv_env._GPU_PROBE
assert "conv1d" in probe
def test_a_cpu_only_host_passes_the_probe():
"""There is no GPU stack to be broken, so the probe must not fail the
bootstrap on a CPU-only machine."""
probe = venv_env._GPU_PROBE
assert "is_available" in probe and "SystemExit(0)" in probe
def test_the_checks_are_shared_not_copied():
"""Two workers had their own "is this venv healthy" and they did not agree
-- which is how one of them trusted an import alone."""
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src = open(os.path.join(root, "codai/api/parler_worker.py"),
encoding="utf-8").read()
probe = src.split("_GPU_PROBE = (", 1)[1].split(")\n", 1)[0]
assert "is_available" in probe and "SystemExit(0)" in probe
for name in ("parler_worker", "xtts_worker"):
src = open(os.path.join(root, "codai/api", name + ".py"),
encoding="utf-8").read()
assert "venv_env.import_ok(" in src, f"{name} has its own import check"
assert "venv_env.gpu_conv_ok(" in src, f"{name} has no GPU probe"
def test_the_parler_requirements_pin_torch():
......
......@@ -1769,18 +1769,26 @@ def test_xtts_falls_through_to_its_own_venv_when_not_importable(monkeypatch):
assert kw["speaker"] == "Ana Florence" and kw["language"] == "en"
return np.zeros(2400, dtype=np.float32)
monkeypatch.setattr(xtts_worker, "available", lambda: True)
monkeypatch.setattr(xtts_worker, "health", lambda: (True, ""))
monkeypatch.setattr(xtts_worker, "XttsSubprocess", _FakeSub)
b = tts_backends._CoquiBackend("coqui/XTTS-v2", {})
wav, sr = b.synthesize("hello", voice="", speed=1.0, lang="en")
assert sr == 24000 and len(wav) == 2400
# and with no venv either, the error says where it looked
# and with an unusable venv, the error says WHICH venv and WHY. It used to
# say only that no venv had been found, over a venv that was there and
# populated with the wrong stack.
monkeypatch.setattr(xtts_worker, "available", lambda: False)
monkeypatch.setattr(xtts_worker, "health",
lambda: (False, "ModuleNotFoundError: No module named 'torch'"))
monkeypatch.setattr(xtts_worker, "bootstrap", lambda *a, **k: False)
try:
tts_backends._CoquiBackend("coqui/XTTS-v2", {})
except tts_backends.MissingEngineError as e:
assert "/opt/coderai/venvs/TTS" in str(e)
msg = str(e)
assert str(xtts_worker.venv_dir()) in msg, msg
assert "No module named 'torch'" in msg, msg
else:
raise AssertionError("expected MissingEngineError")
......
......@@ -66,13 +66,17 @@ def test_a_missing_venv_is_created_and_coqui_installed(monkeypatch, tmp_path,
return seen["n"] > 1 and len(_no_real_subprocess) >= 1
monkeypatch.setattr(X, "available", _avail)
# bootstrap finishes on health(), which asks the venv itself; there is no
# venv here, so it is stubbed alongside available().
monkeypatch.setattr(X, "health", lambda: (_avail(), ""))
monkeypatch.setattr(X, "venv_dir", lambda: tmp_path / "xtts_venv")
monkeypatch.setattr(X, "venv_python",
lambda: tmp_path / "xtts_venv" / "bin" / "python")
assert X.bootstrap() is True
flat = " ".join(" ".join(c) for c in _no_real_subprocess)
assert "-m venv" in flat or "venv" in flat
assert "coqui-tts" in flat, "coqui-tts was never installed"
assert ("requirements-xtts.txt" in flat or "coqui-tts" in flat), (
f"coqui-tts was never installed: {flat}")
def test_a_failed_install_does_not_raise(monkeypatch, tmp_path):
......@@ -107,3 +111,61 @@ def test_the_backend_asks_for_a_bootstrap_and_can_be_opted_out():
"the coqui backend no longer builds the venv on first use")
assert "CODERAI_XTTS_AUTOINSTALL" in src, (
"a host with no network cannot opt out of the auto-install")
# --------------------------------------------------------------------------
# Installed is not usable
# --------------------------------------------------------------------------
#
# Measured on zeiss, 2026-10-29: the venv was rebuilt, `pip install coqui-tts
# soundfile requests` installed coqui-tts 0.28.0 and its ninety dependencies,
# and NO torch at all. The install reported success, `import TTS` failed, and
# the engine said "coqui-tts installed but still not importable" — naming
# neither the module nor the reason.
def test_the_install_is_pinned_not_resolved():
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src = open(os.path.join(root, "codai/api/xtts_worker.py"),
encoding="utf-8").read()
assert "pip_install_args(" in src, "the install is whatever pip resolves"
assert any(p.startswith("torch==") for p in X._PINS), (
"torch is not pinned, and a coqui-tts install brings none")
req = open(os.path.join(root, "requirements-xtts.txt"), encoding="utf-8").read()
assert "torch==" in req and "torchaudio==" in req, (
"the pair has to be pinned together")
def test_the_inline_pins_match_the_requirements_file():
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
wanted = []
for line in open(os.path.join(root, "requirements-xtts.txt"), encoding="utf-8"):
line = line.strip()
if not line or line.startswith("#"):
continue
wanted.extend(line.split(" ", 1) if line.startswith("--") else [line])
assert list(X._PINS) == wanted, (
f"the inline pins and requirements-xtts.txt disagree:\n"
f" file: {wanted}\n inline: {list(X._PINS)}")
def test_health_answers_with_a_reason():
ok, why = X.health()
assert isinstance(ok, bool)
if not ok:
assert why, "an unusable venv must say what is wrong with it"
def test_the_engine_error_carries_that_reason():
"""It used to say "no venv containing it was found" over a venv that was
there and populated, which sends the operator looking in the wrong place."""
import os
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src = open(os.path.join(root, "codai/api/tts_backends.py"),
encoding="utf-8").read()
assert "xtts_worker.health()" in src, (
"the engine does not ask the worker what is wrong")
err = src.split("raise MissingEngineError", 1)[1][:600]
assert "{_why}" in err, "the reason is not carried into the error"
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