feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines (#1083)

* feat(engines): one-click IndexTTS-2 sidecar install from Settings → Engines

IndexTTS-2 required four manual terminal steps (git clone, uv venv,
uv pip install -e ., export OMNIVOICE_INDEXTTS_DIR). This turns that into
a guided in-app install:

- backend/services/sidecar_install.py — parametrized sidecar provisioner
  (SidecarSpec/SPECS so future sidecar engines are one entry, not another
  installer). Resumable background job with step-by-step status: disk-space
  preflight (needs-X/have-Y message), source fetch (git clone --depth 1
  primary, GitHub tarball fallback when git is absent/fails), dedicated
  venv via uv (OMNIVOICE_BUNDLED_UV → PATH resolution; transformers<5
  isolation preserved — the parent env is never touched), import-probe
  verification, IndexTeam/IndexTTS-2 weights into <checkout>/checkpoints
  (where the sidecar actually loads from) via snapshot_download with the
  auto-selected/configured HF endpoint + token — no hardcoded
  huggingface.co — and persistence of OMNIVOICE_INDEXTTS_DIR (os.environ
  for immediate use, prefs.json env.* for the next launch). Idempotent:
  partial installs repair, downloads resume, healthy installs (incl. a
  user's own clone) report already_installed and are never touched.
- API: POST /engines/{id}/install starts the job, GET
  /engines/{id}/install/status polls it, DELETE /engines/{id}/install
  removes an app-managed install (loopback-gated; refuses user-managed
  clones). list_backends() gains one_click_install.
- Frontend: Settings → Engines shows an Install button on the IndexTTS2
  row with per-step progress, live log tail, weight-download %, and
  error+remediation; the manual setup snippet is demoted to a collapsed
  "Manual install" fallback. All strings via i18n (en.json).
- OMNIVOICE_INDEXTTS_DIR joins the Settings env-var allowlist
  (single-sourced from the installer SPECS).
- Docs: docs/engines/indextts.md leads with the one-click flow; manual
  steps become the fallback section. CHANGELOG Unreleased entry added.
- Tests: tests/test_sidecar_install.py (24 cases — happy path, disk-space
  fail, git-absent/git-failing tarball fallback, partial-install repair,
  already-installed/running gating, uninstall safety, spec↔bootstrap
  contract, router wiring) + 6 new EngineCompatibilityMatrix RTL cases.
  API route snapshot regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engines): harden the sidecar installer — review findings

- Route namespace: /engines/sidecar/{id}/install — a dynamic
  /engines/{id}/install would shadow the literal
  POST /engines/sonitranslate/install (engines router registers first);
  regression-guarded by test_sidecar_routes_never_shadow_literal_engine_routes.
- Weights completion marker: a killed-mid-download multi-shard weights dir
  (config.yaml + plausible shards) no longer passes for healthy; the marker
  is written only after snapshot_download returns, so re-runs resume.
- _run_logged: drain thread + proc.wait(timeout) + POSIX process-group kill
  — a grandchild holding the stdout pipe can no longer hang the step past
  its timeout.
- Job log lock: the status poll's list(deque) copy no longer races the
  worker's appends (RuntimeError under active logging).
- Self-heal: a healthy managed install whose env var was lost (prefs wiped)
  is re-pointed by start_install instead of reported already_installed
  while the engine stays unavailable; legacy bootstrap installs (Probe-2
  venv) are trusted via the engine's own probe.
- Single-sourced uv/venv-layout resolution: engines.indextts.bootstrap now
  delegates _locate_uv/_venv_python_path to services.sidecar_install.
- Frontend: stable poll interval (keyed on the running-id set, not the
  status map), reload on a job that finishes before the first poll,
  re-attach to an in-flight job on remount, i18n'd Install aria-label,
  manual-install <details> auto-opens on failure, snippet block hoisted
  out of the JSX IIFE.
- list_backends: sidecar-installable set hoisted out of the per-engine
  loop; exhaustive-shape registry test updated for one_click_install.
- Tests rebind the live services.sidecar_install module per test (other
  suites purge sys.modules["services"], which made router tests
  order-dependent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): fill in the PR ref (#1083)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): validated tarball fallback + scanner-clean installer

- The pre-filter= extractall fallback (Python < 3.11.4) now extracts
  member-by-member behind the same guards extractall(filter="data")
  enforces — regular files/dirs only, no absolute paths, no ../ escapes,
  resolved-path containment. Kills the new CodeQL py/tarslip (high) and
  Bandit B202 (error) alerts; regression-tested with a malicious tarball
  (test_safe_extract_members_blocks_tar_slip).
- snapshot_download tracks the weights repo's default branch on purpose
  (same policy as every other model download; artifacts are
  checksum-verified by hf_hub) — documented + B615 waived at the call.
- Explanatory comments on the intentional empty-except blocks
  (CodeQL py/empty-except notes).

Verified locally: bandit -ll -ii on the module reports 0 MEDIUM+ findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engines): address Greptile review — Windows tree kill, prefs write race, poll robustness

- _kill_tree: Windows now uses taskkill /F /T so a git/uv helper spawned by
  the timed-out child can't keep writing into the checkout (POSIX already
  killed the process group). Unit-tested with os.name patched to nt.
- core/prefs: mutations (set_/delete) are serialized behind a module lock —
  the installer worker persisting its env.* key concurrently with a Settings
  write could previously drop whichever key saved first (whole-class fix:
  every threaded prefs writer, not just the installer). Fail-before/
  pass-after: tests/test_prefs_thread_safety.py.
- Matrix polling: at most one in-flight status request per engine (an old
  'running' response can no longer land after a newer 'succeeded' and
  restart the poller), and four consecutive poll failures drop the stale
  snapshot instead of showing "Installing…" and hammering a dead backend
  forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-12 01:13:35 +05:30
committed by GitHub
co-authored by Claude Fable 5 mergetest
parent 9c81e3389d
commit ff56865cf7
18 changed files with 2390 additions and 64 deletions
+2
View File
@@ -10,6 +10,8 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
### Added
- **IndexTTS-2 installs itself now — one click in Settings → Engines.** The emotion-controlled cloning engine used to demand four terminal steps (clone the repo, create a venv, `uv pip install`, set an environment variable); the row now has an Install button that does all of it — source fetch (git, with a no-git tarball fallback), an isolated venv that keeps its `transformers<5` away from the app, the ~6 GB model weights (via your configured/auto-selected Hugging Face endpoint), and configuration — with step-by-step progress, a disk-space check before anything is written, and resumable repair if anything is interrupted. The engine is usable the moment the job finishes, no restart; existing manual installs are detected and left untouched, and the manual steps remain as a collapsible fallback. The provisioner is parametrized so future sidecar engines (MOSS-v1.5, dots.tts, Confucius4) can reuse it. (#1083)
- **Model downloads now find a reachable Hugging Face endpoint on their own.** On networks where huggingface.co is blocked or slow (the class of first-run dead-ends behind #984), the app quietly probes the official endpoint and the hf-mirror.com community mirror, picks whichever actually works, remembers the choice, and re-checks only when a download fails or the pick goes stale — so a restricted-network first run reaches a working voice instead of a wall of connection errors. Anyone who already set a mirror (env var, pref, or Settings) stays exactly where they pointed: explicit choices are never auto-switched, and Settings → Models → Hugging Face mirror now shows the automatic pick with its measured latency plus a "Test again" button. Probes only touch the two download hosts — no geo-IP, no telemetry — and every download stays checksum-verified by `huggingface_hub` regardless of endpoint.
## [0.3.17] — 2026-07-11
+91
View File
@@ -156,6 +156,97 @@ async def uninstall_translation_engine(engine_id: str):
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
# ── One-click sidecar-engine install (IndexTTS-2 & friends) ────────────────
#
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
# the parent's transformers>=5.3) used to require four manual terminal steps.
# These routes drive services.sidecar_install: POST starts a resumable
# background job, GET polls its step-by-step status (the Settings → Engines
# Install button polls this), DELETE removes an app-managed install.
#
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
# — because a dynamic segment there would shadow pre-existing literal routes
# (this router registers before sonitranslate's, so a dynamic
# POST /engines/{engine_id}/install would swallow
# POST /engines/sonitranslate/install). Mirrors the
# /engines/translation/{engine_id}/install namespace pattern.
#
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
# data directory — only the local desktop frontend may trigger it. The job
# runs fine in packaged builds: the venv lives under the user data dir, not
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
@router.post(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_loopback)],
)
def install_sidecar_engine(engine_id: str):
"""Start (or report) the one-click install for a sidecar engine.
Returns ``{status: "started"|"already_running"|"already_installed"}``.
404 for engines that have no sidecar installer the response names the
translation-engine route so a mis-aimed client can self-correct.
"""
from services import sidecar_install
try:
return sidecar_install.start_install(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=(
f"No one-click installer for engine {engine_id!r}. Sidecar "
f"installers exist for: {sorted(sidecar_install.SPECS)}. "
"(Translation engines install via POST "
"/engines/translation/{id}/install.)"
),
)
@router.get(
"/engines/sidecar/{engine_id}/install/status",
dependencies=[Depends(require_loopback)],
)
def sidecar_install_status(engine_id: str):
"""Step-by-step status of the sidecar install job (poll while running).
Shape: ``{engine_id, installed, managed, install_dir, job}`` where job is
null before the first run, else ``{state, steps[], log[], error,
remediation, weights_progress, started_at, finished_at}``.
"""
from services import sidecar_install
try:
return sidecar_install.get_status(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=f"No one-click installer for engine {engine_id!r}.",
)
@router.delete(
"/engines/sidecar/{engine_id}/install",
dependencies=[Depends(require_loopback)],
)
def uninstall_sidecar_engine(engine_id: str):
"""Remove an app-managed sidecar install (checkout + venv + weights) and
clear the persisted path. Refuses user-managed installs (a clone the user
made themselves) and installs with a job still running."""
from services import sidecar_install
try:
res = sidecar_install.uninstall(engine_id)
except KeyError:
raise HTTPException(
status_code=404,
detail=f"No one-click installer for engine {engine_id!r}.",
)
if res["status"] == "install_in_progress":
raise HTTPException(status_code=409, detail="Install is still running — wait for it to finish.")
if res["status"] == "not_managed":
raise HTTPException(status_code=400, detail=res["detail"])
return res
# ── Engine health-check (Plan 02-04 / ENGINE-06) ───────────────────────────
#
# The Compat Matrix UI's "Test engine" button calls into this endpoint so
+11
View File
@@ -754,6 +754,17 @@ PERSISTENT_KEYS = {
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
}
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
# installer persists these via prefs.json `env.*` (restored at startup in
# main.py); merging them here lets users inspect/clear them from the same
# Settings env panel as every other persisted var. Single-sourced from the
# installer's SPECS so a future sidecar engine can't forget to register.
try:
from services.sidecar_install import persistent_env_vars as _sidecar_env_vars
PERSISTENT_KEYS |= _sidecar_env_vars()
except Exception: # pragma: no cover — defensive: env panel > installer wiring
pass
# Keys whose value must be a valid TCP port (102465535). Validated before
# being set so a bad value never reaches uvicorn / the share listener.
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
+17 -6
View File
@@ -15,6 +15,7 @@ import json
import logging
import os
import tempfile
import threading
from typing import Any, Optional
from core.config import DATA_DIR
@@ -23,6 +24,14 @@ logger = logging.getLogger("omnivoice.prefs")
_PREFS_PATH = os.path.join(DATA_DIR, "prefs.json")
# Serializes the load-modify-save cycle of every mutation. Writers run on
# many threads (FastAPI's request threadpool, background workers like the
# sidecar-engine installer); without the lock two concurrent set_/delete
# calls interleave their read-modify-write and the later save silently
# drops the other's key. Reads stay lock-free — the atomic os.replace in
# _save guarantees they never see a torn file.
_MUTATE_LOCK = threading.RLock()
def _load() -> dict:
try:
@@ -60,16 +69,18 @@ def get(key: str, default: Any = None) -> Any:
def set_(key: str, value: Any) -> None:
data = _load()
data[key] = value
_save(data)
with _MUTATE_LOCK:
data = _load()
data[key] = value
_save(data)
def delete(key: str) -> None:
"""Remove *key* from prefs.json if present."""
data = _load()
data.pop(key, None)
_save(data)
with _MUTATE_LOCK:
data = _load()
data.pop(key, None)
_save(data)
def resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any:
+13 -14
View File
@@ -41,9 +41,7 @@ from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
@@ -145,11 +143,12 @@ def _venv_python_path(venv_dir: Path) -> Path:
"""Return the python executable path inside a venv directory.
Handles the Unix (``bin/python``) vs Windows (``Scripts/python.exe``)
layout. No filesystem access caller checks .is_file().
layout. No filesystem access caller checks .is_file(). Delegates to
the canonical implementation in :mod:`services.sidecar_install` so the
cross-platform venv-layout rule lives in exactly one place.
"""
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
from services.sidecar_install import _venv_python
return _venv_python(venv_dir)
def _probe_paths() -> list[Path]:
@@ -189,14 +188,14 @@ def _venv_can_import_indextts(python_path: Path) -> bool:
def _locate_uv() -> Optional[str]:
"""Find the uv binary — bundled first (Tauri-set env var), else PATH."""
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
sys_uv = shutil.which("uv")
if sys_uv:
return sys_uv
return None
"""Find the uv binary — bundled first (Tauri-set env var), else PATH.
Delegates to :mod:`services.sidecar_install`'s canonical resolver so the
bundled-uv contract (env var name, precedence) can't drift between this
lazy bootstrap and the one-click installer.
"""
from services.sidecar_install import _locate_uv as _canonical_locate_uv
return _canonical_locate_uv()
def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
+921
View File
@@ -0,0 +1,921 @@
"""One-click sidecar-engine provisioner (issue: IndexTTS-2 in-app install).
Some engines (IndexTTS-2 today; MOSS-v1.5 / dots.tts / Confucius4 are the
same shape) can't live in the app venv because they pin a ``transformers``
version that conflicts with the parent's ``>=5.3``. They run as sidecars:
a source checkout + a dedicated venv + (for IndexTTS-2) model weights in
``<checkout>/checkpoints/``. Until now provisioning that trio was four
manual terminal steps; this module turns it into a resumable background
job the Settings Engines UI can start and poll.
Design notes (single source of truth for the choices):
* **Fetch: git primary, tarball fallback.** ``git clone --depth 1`` is the
primary path (fast, matches the documented manual flow, and leaves a
repo the user can update). When git is absent common on Windows we
fall back to downloading the GitHub source tarball over HTTPS (httpx,
honours proxy env vars) and extracting it with :mod:`tarfile`. A
``pip install git+https://`` path was rejected because the engine
*directory* must exist on disk anyway: the sidecar resolves its venv
and model weights relative to it.
* **Managed install root:** ``DATA_DIR/engines/<engine_id>/`` always
user-writable (works in frozen/packaged builds where ``backend/`` is
read-only), survives app updates, and never collides with a user's own
clone. A user-managed install (env var already pointing at their clone)
is left completely alone.
* **Weights ARE part of the install** for engines whose sidecar loads
from ``<checkout>/<weights_subdir>/`` (IndexTTS-2's ``main.py`` reads
``$OMNIVOICE_INDEXTTS_DIR/checkpoints/config.yaml`` verified). The
download goes through ``huggingface_hub.snapshot_download`` with the
endpoint from :mod:`services.endpoint_race` (HF endpoint auto-select;
**no hardcoded huggingface.co**) and the token from
:mod:`services.token_resolver`.
* **Idempotent + resumable:** every step no-ops when its output is
already healthy and repairs it when it is half-there (a checkout
without ``pyproject.toml`` is re-fetched; a venv that can't import the
probe module is re-installed; ``snapshot_download`` resumes weights).
* **Persistence:** on success the checkout path is written to
``os.environ[<env_var>]`` (the engine's bootstrap reads the env var, so
it works immediately no restart) and to ``prefs.json`` under
``env.<env_var>`` (restored into the environment at startup by
``main.py``), the same mechanism Settings' env panel uses.
Cross-platform: no symlinks, no shell strings (argv lists only), venv
layout resolved per-OS (``Scripts/python.exe`` vs ``bin/python``).
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.sidecar_install")
_GIB = 1024 ** 3
# Headroom kept free on the target volume on top of the estimated install
# size. Same needs-X/have-Y message shape as the model-install guard
# (api.routers.setup.models.disk_space_error), but a deliberately SMALLER
# floor than its MIN_FREE_GB=10: required_bytes here is already a
# conservative over-estimate, so stacking the full model-cache headroom on
# top would block legitimate installs on ~15 GB-free machines.
MIN_FREE_GB = 5
# Bounded in-memory log per job (last N lines survive; enough for the UI's
# log tail and for the failure remediation to quote real output).
_LOG_MAX_LINES = 200
_GIT_CLONE_TIMEOUT_S = 600
_TARBALL_TIMEOUT_S = 600
_UV_VENV_TIMEOUT_S = 300
_UV_PIP_INSTALL_TIMEOUT_S = 3600
_IMPORT_PROBE_TIMEOUT_S = 120
# ── Spec ───────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SidecarSpec:
"""Everything the provisioner needs to install one sidecar engine.
Parametrized so future sidecar engines (MOSS-v1.5, dots.tts,
Confucius4) become one SPECS entry, not another installer.
"""
engine_id: str
display_name: str
repo_url: str # git clone URL (primary fetch path)
tarball_url: str # source tarball (fallback when git is absent)
checkout_dirname: str # directory name of the checkout under the managed root
env_var: str # env var the engine's bootstrap reads (install dir)
probe_module: str # python -c "import <probe_module>" proves the venv works
weights_repo_id: Optional[str] = None # HF repo downloaded into <checkout>/<weights_subdir>
weights_subdir: str = "checkpoints"
docs_path: str = "docs/engines" # where the manual-install fallback lives
required_bytes: int = 12 * _GIB # conservative source+venv+weights estimate for preflight
# Called after a successful install/uninstall so the engine's memoised
# venv resolution re-probes (import inside the lambda — never at module load).
invalidate: Callable[[], None] = field(default=lambda: None)
# Cheap "is a healthy install already present?" probe (file existence only).
installed_probe: Callable[[], bool] = field(default=lambda: False)
def _indextts_invalidate() -> None:
from engines.indextts import bootstrap
bootstrap.invalidate()
def _indextts_installed() -> bool:
from engines.indextts.bootstrap import is_indextts_installed
return is_indextts_installed()
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
display_name="IndexTTS-2",
repo_url="https://github.com/index-tts/index-tts.git",
tarball_url="https://github.com/index-tts/index-tts/archive/refs/heads/main.tar.gz",
checkout_dirname="index-tts",
env_var="OMNIVOICE_INDEXTTS_DIR",
probe_module="indextts.infer_v2",
weights_repo_id="IndexTeam/IndexTTS-2",
weights_subdir="checkpoints",
docs_path="docs/engines/indextts.md",
# ~0.1 GB source + up to ~6 GB venv (torch + transformers<5) +
# ~6 GB weights. Deliberately conservative; the preflight subtracts
# whatever a partial install already put on disk.
required_bytes=12 * _GIB,
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
),
}
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
return SPECS.get(engine_id)
def persistent_env_vars() -> set[str]:
"""Env vars the provisioner persists — merged into the Settings env-var
allowlist (api.routers.system.PERSISTENT_KEYS) so users can inspect or
clear them from the same panel as every other persisted var."""
return {s.env_var for s in SPECS.values()}
# ── Paths ──────────────────────────────────────────────────────────────────
def managed_root(spec: SidecarSpec) -> Path:
"""Per-engine managed install root (checkout lives inside it)."""
return Path(DATA_DIR) / "engines" / spec.engine_id
def managed_checkout(spec: SidecarSpec) -> Path:
return managed_root(spec) / spec.checkout_dirname
def _venv_python(venv_dir: Path) -> Path:
"""Venv python path, per-OS layout (Windows: Scripts/, POSIX: bin/)."""
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _locate_uv() -> Optional[str]:
"""Find uv: bundled (Tauri-set OMNIVOICE_BUNDLED_UV) first, then PATH.
Same resolution order as engines.indextts.bootstrap._locate_uv the
canonical uv-resolution pattern for sidecar venvs.
"""
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
return shutil.which("uv")
# ── Disk preflight ─────────────────────────────────────────────────────────
def _dir_size_bytes(path: Path) -> int:
"""Best-effort recursive size (bytes already spent by a partial install)."""
total = 0
try:
for root, _dirs, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
continue
except OSError:
pass # unreadable dir — treat as zero bytes spent
return total
def disk_free_bytes(path: Path) -> int:
"""Free bytes on the volume backing *path* (nearest existing ancestor).
Never raises; 0 when the volume can't be probed."""
try:
p = path.resolve()
while not p.exists():
parent = p.parent
if parent == p:
break
p = parent
return int(shutil.disk_usage(str(p)).free)
except Exception:
return 0
def disk_space_error(spec: SidecarSpec) -> Optional[str]:
"""Actionable message when the estimated remaining install won't fit
(needs X + headroom Y, have Z same shape as the model-install guard);
``None`` when it fits or the volume can't be probed."""
root = managed_root(spec)
already = _dir_size_bytes(root)
remaining = max(0, spec.required_bytes - already)
free = disk_free_bytes(root)
if free <= 0:
return None # can't probe → never block on missing information
required = remaining + MIN_FREE_GB * _GIB
if free >= required:
return None
def _gb(n: int) -> str:
return f"{n / _GIB:.1f} GB"
return (
f"Not enough disk space to install {spec.display_name}: it needs about "
f"{_gb(remaining)} plus {MIN_FREE_GB} GB free headroom ({_gb(required)} total), "
f"but only {_gb(free)} is free at {root}. Free up space and retry."
)
# ── Job state ──────────────────────────────────────────────────────────────
STEP_IDS = (
"preflight",
"fetch_source",
"create_venv",
"install_deps",
"verify",
"fetch_weights",
"persist",
)
_jobs: dict[str, dict] = {}
_jobs_lock = threading.Lock()
# Guards each job's log deque: the worker thread appends while the status
# poll copies it, and list() over a deque raises RuntimeError if it mutates
# mid-iteration. One module-level lock is plenty — appends are tiny and at
# most one job runs per engine.
_log_lock = threading.Lock()
class _StepError(Exception):
"""Install-step failure carrying user-facing remediation text."""
def __init__(self, message: str, remediation: str):
super().__init__(message)
self.remediation = remediation
def _new_job(engine_id: str) -> dict:
return {
"engine_id": engine_id,
"state": "running",
"steps": [{"id": s, "state": "pending", "detail": None} for s in STEP_IDS],
"log": deque(maxlen=_LOG_MAX_LINES),
"error": None,
"remediation": None,
"weights_progress": None,
"started_at": time.time(),
"finished_at": None,
}
def _job_step(job: dict, step_id: str) -> dict:
return next(s for s in job["steps"] if s["id"] == step_id)
def _log(job: dict, line: str) -> None:
line = line.rstrip()
if line:
with _log_lock:
job["log"].append(line)
logger.info("[%s install] %s", job["engine_id"], line)
def _serialize_job(job: Optional[dict]) -> Optional[dict]:
if job is None:
return None
out = dict(job)
with _log_lock:
out["log"] = list(job["log"])
out["steps"] = [dict(s) for s in job["steps"]]
return out
def get_status(engine_id: str) -> dict:
"""Install state + last/current job for one engine. Cheap (file probes)."""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
job = _serialize_job(_jobs.get(engine_id))
installed = _healthy(spec)
checkout = managed_checkout(spec)
env_dir = os.environ.get(spec.env_var)
return {
"engine_id": engine_id,
"installed": installed,
# True when the on-disk install is the app-managed one (uninstallable
# from the app). A user's own clone is never "managed".
"managed": bool(
checkout.is_dir()
and (not env_dir or Path(env_dir) == checkout)
),
"install_dir": env_dir or (str(checkout) if checkout.is_dir() else None),
"job": job,
}
def _safe_installed(spec: SidecarSpec) -> bool:
try:
return bool(spec.installed_probe())
except Exception:
return False
def _user_managed_dir(spec: SidecarSpec) -> Optional[Path]:
"""The user's own install dir when the env var points anywhere but the
app-managed checkout; None for managed/unset (ours to provision)."""
env_dir = os.environ.get(spec.env_var)
if env_dir and Path(env_dir) != managed_checkout(spec):
return Path(env_dir)
return None
def _healthy(spec: SidecarSpec) -> bool:
"""A COMPLETE install: for a user-managed dir, trust the engine's own
probe (their clone, their layout); for the app-managed install require
the venv AND the fully-downloaded weights, so a partial install repairs
instead of reporting already_installed."""
if _user_managed_dir(spec) is not None:
return _safe_installed(spec)
checkout = managed_checkout(spec)
if not checkout.is_dir():
# No managed install at all. A legacy install may still exist (e.g.
# IndexTTS's old lazy-bootstrap venv under backend/engines/) — trust
# the engine's own probe so we never re-provision over a working one.
return _safe_installed(spec)
if not _venv_python(checkout / ".venv").is_file():
return False
if spec.weights_repo_id and not _weights_present(spec):
return False
return True
def _persist(spec: SidecarSpec) -> None:
"""Point the engine at the managed checkout: process env for immediate
use, prefs.json ``env.*`` for the next launch, and invalidate the
engine's memoised venv resolution so it re-probes without a restart."""
checkout = managed_checkout(spec)
os.environ[spec.env_var] = str(checkout)
from core import prefs
prefs.set_(f"env.{spec.env_var}", str(checkout))
try:
spec.invalidate()
except Exception:
pass # best-effort cache invalidation — the env var is already set
def start_install(engine_id: str) -> dict:
"""Start (or report) the install job for *engine_id*.
Returns ``{"status": "started"|"already_running"|"already_installed", ...}``.
Raises KeyError for an engine with no sidecar spec.
"""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
existing = _jobs.get(engine_id)
if existing and existing["state"] == "running":
return {"status": "already_running", "engine": engine_id}
# A healthy install (user-managed or app-managed) never reinstalls;
# a PARTIAL managed install falls through so the job repairs it.
if _healthy(spec):
# Self-heal: a healthy MANAGED install whose env var was lost
# (e.g. prefs.json wiped) just needs re-pointing, not a reinstall.
if (
_user_managed_dir(spec) is None
and _venv_python(managed_checkout(spec) / ".venv").is_file()
and not _safe_installed(spec)
):
_persist(spec)
return {"status": "already_installed", "engine": engine_id}
job = _new_job(engine_id)
_jobs[engine_id] = job
th = threading.Thread(
target=_run_install, args=(spec, job),
name=f"sidecar-install-{engine_id}", daemon=True,
)
th.start()
return {"status": "started", "engine": engine_id}
def uninstall(engine_id: str) -> dict:
"""Remove the app-managed install and clear the persisted path.
Refuses to touch a user-managed install (env var pointing anywhere but
the managed checkout) those were never ours to delete.
"""
spec = get_spec(engine_id)
if spec is None:
raise KeyError(engine_id)
with _jobs_lock:
job = _jobs.get(engine_id)
if job and job["state"] == "running":
return {"status": "install_in_progress", "engine": engine_id}
env_dir = os.environ.get(spec.env_var)
checkout = managed_checkout(spec)
if env_dir and Path(env_dir) != checkout:
return {
"status": "not_managed",
"engine": engine_id,
"detail": (
f"{spec.display_name} points at {env_dir}, which OmniVoice did not "
f"install. Remove that directory yourself if you want it gone, or "
f"clear {spec.env_var} in Settings."
),
}
root = managed_root(spec)
removed = root.is_dir()
shutil.rmtree(root, ignore_errors=True)
if env_dir: # only ever the managed checkout at this point
os.environ.pop(spec.env_var, None)
from core import prefs
if prefs.get(f"env.{spec.env_var}") == str(checkout):
prefs.delete(f"env.{spec.env_var}")
try:
spec.invalidate()
except Exception:
pass # best-effort cache invalidation — uninstall already succeeded
with _jobs_lock:
_jobs.pop(engine_id, None)
return {"status": "uninstalled" if removed else "not_installed", "engine": engine_id}
# ── Worker ─────────────────────────────────────────────────────────────────
def _run_install(spec: SidecarSpec, job: dict) -> None:
step_fns: list[tuple[str, Callable[[SidecarSpec, dict], None]]] = [
("preflight", _step_preflight),
("fetch_source", _step_fetch_source),
("create_venv", _step_create_venv),
("install_deps", _step_install_deps),
("verify", _step_verify),
("fetch_weights", _step_fetch_weights),
("persist", _step_persist),
]
try:
for step_id, fn in step_fns:
step = _job_step(job, step_id)
step["state"] = "running"
try:
fn(spec, job)
except _StepError:
step["state"] = "error"
raise
except Exception as exc: # noqa: BLE001 — surfaced into the job
step["state"] = "error"
raise _StepError(
f"{type(exc).__name__}: {exc}",
"Re-run the install — it resumes from where it stopped. If it "
f"keeps failing, see {spec.docs_path} for the manual steps.",
) from exc
if step["state"] == "running":
step["state"] = "done"
job["state"] = "succeeded"
_log(job, f"{spec.display_name} installed successfully.")
except _StepError as exc:
job["state"] = "failed"
job["error"] = str(exc)
job["remediation"] = exc.remediation
_log(job, f"FAILED: {exc}")
finally:
job["finished_at"] = time.time()
def _step_preflight(spec: SidecarSpec, job: dict) -> None:
if _locate_uv() is None:
raise _StepError(
"uv was not found (checked the bundled path via OMNIVOICE_BUNDLED_UV, "
"then PATH).",
"Install uv from https://docs.astral.sh/uv/ and relaunch OmniVoice, or "
"set OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary.",
)
err = disk_space_error(spec)
if err:
raise _StepError(err, "Free up disk space (or move OmniVoice's data directory "
"to a larger volume) and retry.")
managed_root(spec).mkdir(parents=True, exist_ok=True)
_job_step(job, "preflight")["detail"] = "uv found, disk space OK"
_log(job, "Preflight OK — uv resolved and enough free disk space.")
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if (checkout / "pyproject.toml").is_file():
step["state"] = "done"
step["detail"] = "source already present"
_log(job, f"Source already present at {checkout} — skipping fetch.")
return
if checkout.exists():
# Half-fetched checkout (no pyproject.toml) — repair by refetching.
_log(job, f"Removing incomplete checkout at {checkout}")
shutil.rmtree(checkout, ignore_errors=True)
git = shutil.which("git")
if git:
_log(job, f"Cloning {spec.repo_url} (git, depth 1) …")
rc = _run_logged(job, [git, "clone", "--depth", "1", spec.repo_url, str(checkout)],
timeout=_GIT_CLONE_TIMEOUT_S)
if rc == 0 and (checkout / "pyproject.toml").is_file():
step["detail"] = "git clone"
return
_log(job, f"git clone failed (exit {rc}) — falling back to source tarball.")
shutil.rmtree(checkout, ignore_errors=True)
else:
_log(job, "git not found — using the source-tarball fallback.")
_fetch_tarball(spec, job, checkout)
if not (checkout / "pyproject.toml").is_file():
raise _StepError(
f"Fetched source at {checkout} has no pyproject.toml — the download "
"appears incomplete or the upstream layout changed.",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} to the clone (see the engine docs).",
)
step["detail"] = "source tarball"
def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
"""Download + extract the GitHub source tarball (no git required).
Extraction is member-validated (no absolute paths / parent escapes) and
never uses symlinks, so it behaves identically on Windows.
"""
import httpx
root = managed_root(spec)
root.mkdir(parents=True, exist_ok=True)
_log(job, f"Downloading {spec.tarball_url}")
fd, tmp_tar = tempfile.mkstemp(suffix=".tar.gz", dir=str(root))
try:
with os.fdopen(fd, "wb") as out:
with httpx.stream(
"GET", spec.tarball_url, follow_redirects=True,
timeout=_TARBALL_TIMEOUT_S,
) as resp:
resp.raise_for_status()
for chunk in resp.iter_bytes():
out.write(chunk)
_log(job, "Extracting source tarball …")
with tempfile.TemporaryDirectory(dir=str(root)) as tmp_dir:
with tarfile.open(tmp_tar, "r:gz") as tf:
try:
tf.extractall(tmp_dir, filter="data") # stdlib safe-extract (3.11.4+)
except TypeError: # pragma: no cover — pre-filter= interpreters
_safe_extract_members(tf, tmp_dir)
entries = [p for p in Path(tmp_dir).iterdir() if p.is_dir()]
if len(entries) != 1:
raise _StepError(
f"Unexpected tarball layout ({len(entries)} top-level dirs).",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} (see the engine docs).",
)
# os.replace-style move keeps this atomic-ish on the same volume.
shutil.move(str(entries[0]), str(checkout))
finally:
try:
os.unlink(tmp_tar)
except OSError:
pass # temp tarball already gone / locked — harmless leftover
def _safe_extract_members(tf: "tarfile.TarFile", dest: str) -> None:
"""Tar-slip-guarded extraction for interpreters without
``extractall(filter="data")`` (Python < 3.11.4).
Mirrors what the "data" filter enforces: only regular files and
directories (no symlinks/hardlinks/devices also keeps Windows
behaviour identical), no absolute paths, and every resolved target must
stay inside *dest*.
"""
dest_abs = os.path.abspath(dest)
for member in tf.getmembers():
if not (member.isreg() or member.isdir()):
continue # drop symlinks/hardlinks/devices/fifos
name = member.name
if name.startswith(("/", "\\")) or ".." in name.replace("\\", "/").split("/"):
continue # absolute path or parent-dir escape
target = os.path.abspath(os.path.join(dest, name))
if os.path.commonpath([dest_abs, target]) != dest_abs:
continue # resolved outside the extraction dir
tf.extract(member, dest)
def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "create_venv")
checkout = managed_checkout(spec)
venv_dir = checkout / ".venv"
py = _venv_python(venv_dir)
if py.is_file():
step["state"] = "done"
step["detail"] = "venv already present"
_log(job, f"Venv already present at {venv_dir} — skipping.")
return
uv = _locate_uv()
_log(job, f"Creating venv at {venv_dir}")
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S)
if rc != 0 or not py.is_file():
raise _StepError(
f"uv venv failed (exit {rc}) at {venv_dir}.",
"Check the log above for the uv error; free disk space or fix "
"permissions on the data directory, then re-run the install.",
)
step["detail"] = "venv created"
def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
"""`uv pip install -e <checkout>` into the dedicated venv.
Deliberately NOT `uv sync` sync would apply the sidecar's lockfile
semantics; `uv pip install -e` resolves the sidecar's own pins
(e.g. transformers<5) inside ITS venv, never touching the parent app.
Idempotent: re-running repairs a partial dependency set.
"""
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
uv = _locate_uv()
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
rc = _run_logged(
job,
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
)
if rc != 0:
raise _StepError(
f"uv pip install -e failed (exit {rc}).",
"Usually a network hiccup — re-run the install to resume. Behind a "
"proxy, set HTTPS_PROXY in Settings → Environment first.",
)
_job_step(job, "install_deps")["detail"] = "dependencies installed"
def _step_verify(spec: SidecarSpec, job: dict) -> None:
checkout = managed_checkout(spec)
py = _venv_python(checkout / ".venv")
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
try:
proc = subprocess.run(
[str(py), "-c", f"import {spec.probe_module}"],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
raise _StepError(
f"Import probe failed to run: {exc}",
"Re-run the install; if it keeps failing, delete the engine in "
"Settings → Engines and install again.",
) from exc
if proc.returncode != 0:
tail = proc.stderr.decode("utf-8", errors="replace")[-500:]
raise _StepError(
f"`import {spec.probe_module}` failed in the new venv: {tail}",
"Re-run the install — dependency resolution resumes and repairs "
"partial installs. If it keeps failing, use the manual install in "
"the engine docs.",
)
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
_log(job, "Venv verified.")
# Written into the weights dir after snapshot_download COMPLETES. A partial
# multi-shard download can leave config.yaml + several plausible shards on
# disk, so file heuristics alone would declare a killed-mid-download install
# healthy and never resume it (the sidecar edition of #352). Only this
# installer writes the marker; user-managed clones never hit this path.
_WEIGHTS_COMPLETE_MARKER = ".omnivoice_weights_complete"
def _weights_present(spec: SidecarSpec) -> bool:
"""True only for a COMPLETED weights download: the completion marker
plus a sanity floor (config.yaml + one 5 MB weight file the same
truncated-download floor the model store uses)."""
wdir = managed_checkout(spec) / spec.weights_subdir
if not (wdir / _WEIGHTS_COMPLETE_MARKER).is_file():
return False
return _weights_floor_ok(wdir)
def _weights_floor_ok(wdir: Path) -> bool:
if not (wdir / "config.yaml").is_file():
return False
floor = 5 * 1024 * 1024
try:
for root, _dirs, files in os.walk(wdir):
for f in files:
try:
if os.path.getsize(os.path.join(root, f)) >= floor:
return True
except OSError:
continue
except OSError:
pass # unreadable weights dir — treat as not present
return False
def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_weights")
if not spec.weights_repo_id:
step["state"] = "skipped"
step["detail"] = "engine has no bundled-weights requirement"
return
if _weights_present(spec):
step["state"] = "done"
step["detail"] = "weights already present"
_log(job, "Model weights already present — skipping download.")
return
wdir = managed_checkout(spec) / spec.weights_subdir
wdir.mkdir(parents=True, exist_ok=True)
_log(job, f"Downloading {spec.weights_repo_id}{wdir} (several GB — resumable) …")
from huggingface_hub import snapshot_download
from services import endpoint_race
from services.token_resolver import resolve as resolve_token
from utils import hf_progress
# Mirror per-file byte progress into the job so the polling UI can show
# it — same tqdm hook the model store's SSE feed uses.
def _listener(ev: dict) -> None:
try:
# Only mirror events for OUR repo — a concurrent model-store
# download must not scribble its progress into this job.
if ev.get("repo_id") not in (None, spec.weights_repo_id):
return
job["weights_progress"] = {
"filename": ev.get("filename"),
"downloaded": ev.get("downloaded"),
"total": ev.get("total"),
"pct": ev.get("pct"),
}
except Exception:
pass # progress mirroring is advisory — never break the download
listener_id = hf_progress.register_listener(_listener)
repo_token = hf_progress.current_repo_id.set(spec.weights_repo_id)
try:
# Tracks the repo's default branch on purpose (same policy as every
# other model download in the app — see setup/download.py): the
# source checkout is unpinned upstream `main` anyway, and hf_hub
# checksum-verifies each artifact. Hence the B615 waiver below.
kwargs: dict = {
"repo_id": spec.weights_repo_id,
"local_dir": str(wdir),
"token": resolve_token(),
}
endpoint = endpoint_race.effective_endpoint()
if endpoint:
kwargs["endpoint"] = endpoint
tqdm_cls = hf_progress.tracked_tqdm_class()
if tqdm_cls is not None:
kwargs["tqdm_class"] = tqdm_cls
try:
snapshot_download(**kwargs) # nosec B615 — deliberate default-branch policy, see above
except Exception as exc:
raise _StepError(
f"Model weight download failed: {exc}",
"Re-run the install — the download resumes where it stopped. "
"Check Settings → Network (HF endpoint / proxy) if it keeps failing.",
) from exc
finally:
hf_progress.unregister_listener(listener_id)
hf_progress.current_repo_id.reset(repo_token)
if not _weights_floor_ok(wdir):
raise _StepError(
"Weight download finished but no plausible weight files were found — "
"the download was likely interrupted.",
"Re-run the install to resume the download.",
)
# snapshot_download returned AND the sanity floor holds → mark complete,
# so _weights_present/_healthy stop treating this dir as a partial.
(wdir / _WEIGHTS_COMPLETE_MARKER).write_text(
f"{spec.weights_repo_id}\n{time.time():.0f}\n", encoding="utf-8",
)
step["detail"] = "weights downloaded"
_log(job, "Model weights downloaded.")
def _step_persist(spec: SidecarSpec, job: dict) -> None:
_persist(spec)
_job_step(job, "persist")["detail"] = f"{spec.env_var}={managed_checkout(spec)}"
_log(job, f"Saved {spec.env_var} — the engine is ready to use, no restart needed.")
# ── Subprocess runner with live log capture ────────────────────────────────
def _run_logged(job: dict, argv: list[str], *, timeout: float) -> int:
"""Run *argv*, streaming combined stdout+stderr lines into the job log.
Returns the exit code; -1 on timeout (process tree killed) or spawn
failure. argv-list only never a shell string so paths with spaces
are safe on every platform.
The stdout drain runs on its own daemon thread and the main flow blocks
on ``proc.wait(timeout=)``. That bounds the step even when a grandchild
(uv resolver worker, git helper) inherits the pipe and outlives the
killed child a blocking ``for line in proc.stdout`` on this thread
would hang past the timeout waiting for pipe EOF.
"""
popen_kwargs: dict = {}
if os.name == "posix":
# New session → we can kill the whole process group on timeout
# instead of only the direct child.
popen_kwargs["start_new_session"] = True
try:
proc = subprocess.Popen(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
**popen_kwargs,
)
except OSError as exc:
_log(job, f"failed to spawn {argv[0]}: {exc}")
return -1
def _drain() -> None:
try:
assert proc.stdout is not None
for line in proc.stdout:
_log(job, line)
except (OSError, ValueError):
pass # pipe closed by the timeout kill — nothing left to read
drain = threading.Thread(target=_drain, daemon=True)
drain.start()
try:
rc = proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
_kill_tree(proc)
_log(job, f"process timed out after {timeout:.0f}s — killed")
return -1
drain.join(5.0) # give the drain a moment to flush the tail
return rc if rc is not None else -1
def _kill_tree(proc: "subprocess.Popen") -> None:
"""Kill the child and its whole process tree, on every platform.
POSIX: the child was started in its own session, so SIGKILL the group.
Windows: ``proc.kill()`` only terminates the direct child a git/uv
helper it spawned would keep running (and writing into the checkout)
past our timeout so use ``taskkill /T`` to fell the tree.
"""
if os.name == "posix":
import signal
try:
os.killpg(proc.pid, signal.SIGKILL)
return
except (ProcessLookupError, PermissionError, OSError):
pass # group already gone / not ours — fall through to plain kill
else: # Windows
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=15,
)
return
except (OSError, subprocess.SubprocessError):
pass # taskkill unavailable/failed — fall through to plain kill
try:
proc.kill()
except OSError:
pass # process already exited
__all__ = [
"SPECS",
"SidecarSpec",
"disk_space_error",
"get_spec",
"get_status",
"managed_checkout",
"managed_root",
"persistent_env_vars",
"start_install",
"uninstall",
]
+23
View File
@@ -1698,6 +1698,23 @@ _MLX_AUDIO_MODEL_LABELS: dict[str, str] = {
}
def _sidecar_installable_ids() -> frozenset[str]:
"""Engine ids with a one-click sidecar installer. Deferred import — the
installer module is tiny, but keeping the import inside the function
means a broken/absent installer can never take the engine picker down.
All current sidecar SPECS are TTS engines, so only this registry carries
``one_click_install``; the first non-TTS sidecar engine will need the same
field plumbed into asr_backend/llm_backend.list_backends and the Install
button into their matrix rows.
"""
try:
from services.sidecar_install import SPECS
return frozenset(SPECS)
except Exception: # pragma: no cover — defensive only
return frozenset()
def list_backends() -> list[dict]:
"""Enumerate every registered backend with its availability state.
@@ -1713,6 +1730,7 @@ def list_backends() -> list[dict]:
# e.g. VoxCPM2's >=2.0.3 upgrade hint)
"install_hint": Optional[str],
"setup_snippet": Optional[str], # exact `export VAR=...` for path-gated opt-in engines
"one_click_install": bool, # services.sidecar_install can provision it in-app
"last_error": Optional[str], # cached most-recent failure
"isolation_mode": "in-process" | "subprocess",
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
@@ -1745,6 +1763,7 @@ def list_backends() -> list[dict]:
from core.device_caps import detect_host_caps
from services.engine_routing import routing_fields
caps = detect_host_caps()
installable = _sidecar_installable_ids()
out: list[dict] = []
for bid, cls in _REGISTRY.items():
@@ -1790,6 +1809,10 @@ def list_backends() -> list[dict]:
"install_hint": _INSTALL_HINTS.get(bid),
# Exact `export VAR=...` line for path-gated opt-in engines, or None.
"setup_snippet": _SETUP_SNIPPETS.get(bid),
# True when services.sidecar_install can provision this engine
# in-app (Settings renders an Install button instead of leading
# with the manual setup snippet).
"one_click_install": bid in installable,
"last_error": _LAST_ERRORS.get(bid),
"isolation_mode": isolation,
"gpu_compat": list(gpu_compat),
+72 -18
View File
@@ -8,12 +8,57 @@ pins `transformers>=5.3`. This isolation is the resolution of
canonical `OffloadedCache` ImportError that resulted from loading
both libraries inside one Python interpreter.
## Install
## Install (one-click, recommended)
IndexTTS-2 is **not** bundled with OmniVoice — the model weights are
~6 GB and the package itself pins a conflicting transformers
version. OmniVoice ships with a sidecar runner that loads IndexTTS
into an isolated venv on demand.
into an isolated venv on demand, plus a guided installer that
provisions everything for you:
1. Open **Settings → Engines**, expand the IndexTTS2 row
("Why unavailable?"), and click **Install**.
2. Watch the step-by-step progress: preflight (uv + disk space),
source fetch, isolated venv creation, dependency install,
verification, model-weight download (~6 GB, resumable), and
configuration save.
3. Done — the engine flips to `available: true` immediately, **no
restart needed**.
What the installer does under the hood (all cross-platform —
macOS / Windows / Linux):
* Fetches the IndexTTS source with `git clone --depth 1` (or, when
git isn't installed, downloads the GitHub source tarball over
HTTPS) into OmniVoice's data directory
(`<data-dir>/engines/indextts2/index-tts`).
* Creates a dedicated venv inside the checkout with `uv venv` and
runs `uv pip install -e .` against it — the `transformers<5`
isolation is preserved; the parent app's environment is never
touched. uv is resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`.
* Downloads the `IndexTeam/IndexTTS-2` weights into
`checkpoints/` (where the sidecar loads them from), honouring your
configured/auto-selected Hugging Face endpoint and HF token.
* Persists `OMNIVOICE_INDEXTTS_DIR` for you (in-process for
immediate use + `prefs.json` for the next launch).
Preflight requires roughly **12 GB free disk space** (source + venv +
weights, checked before anything is written); the install fails early
with an actionable message otherwise. Re-running the installer is
always safe: it repairs partial installs and resumes interrupted
downloads instead of starting over. An app-managed install can be
removed again with `DELETE /engines/sidecar/indextts2/install` (a
user-managed clone is never touched).
If you already installed IndexTTS manually (any OmniVoice version),
the installer detects it via `OMNIVOICE_INDEXTTS_DIR` and reports
`already_installed` — nothing is re-downloaded or moved.
## Manual install (fallback)
The manual flow still works and is what the installer automates. Use
it if you want the clone somewhere specific, share one clone across
tools, or can't use the in-app installer:
1. Clone the IndexTTS repo on disk:
@@ -32,16 +77,12 @@ into an isolated venv on demand.
uv pip install -e .
```
3. Download the model weights (~6 GB). Either:
3. Download the model weights (~6 GB):
```bash
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
```
or let HuggingFace cache them on first synthesize call (the parent
forwards `HF_HOME` / `HF_HUB_CACHE` to the sidecar so the cache is
shared with the rest of OmniVoice's downloads).
4. Set the `OMNIVOICE_INDEXTTS_DIR` environment variable to the repo
root (the directory that contains `checkpoints/` and
`pyproject.toml`):
@@ -65,12 +106,14 @@ into an isolated venv on demand.
OmniVoice probes for a usable IndexTTS Python interpreter in this
priority order (see `backend/engines/indextts/bootstrap.py`):
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — your existing clone's
venv. Highest priority, so v0.2.7 users who already ran
`uv pip install -e .` get zero migration cost on the upgrade to
v0.3.x.
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — the install dir's own
venv. This is what BOTH the one-click installer (which sets
`OMNIVOICE_INDEXTTS_DIR` to its managed checkout) and a manual
clone resolve to. Highest priority, so v0.2.7 users who already
ran `uv pip install -e .` get zero migration cost on the upgrade
to v0.3.x.
2. **`backend/engines/indextts/.venv/`** — OmniVoice's own venv,
created on demand by step 3.
created on demand by the lazy bootstrap below.
3. **Lazy bootstrap** — if neither venv exists, OmniVoice runs
`uv venv backend/engines/indextts/.venv` and
`uv pip install --python <python> -e ${OMNIVOICE_INDEXTTS_DIR}`
@@ -87,14 +130,25 @@ weights survive the upgrade byte-for-byte.
### `IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR ...`
You haven't pointed OmniVoice at an IndexTTS clone yet. Follow the
**Install** steps above.
You haven't installed IndexTTS yet. Click **Install** on the
IndexTTS2 row in **Settings → Engines** (recommended), or follow the
**Manual install** steps above.
### `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
### `uv was not found` / `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
The bootstrap path needs a working `uv` binary. Either install `uv`
into your `PATH` (https://docs.astral.sh/uv/) or pre-create the venv
manually with `uv venv` and `uv pip install -e` as in step 2.
Both the one-click installer and the bootstrap path need a working
`uv` binary (resolved from `OMNIVOICE_BUNDLED_UV`, then `PATH`).
Either install `uv` into your `PATH` (https://docs.astral.sh/uv/) or
pre-create the venv manually with `uv venv` and `uv pip install -e`
as in the manual steps.
### `Not enough disk space to install IndexTTS-2 ...`
The installer's preflight found less free space than the estimated
source + venv + weights footprint (plus headroom). The message names
the exact numbers; free up space (or move OmniVoice's data directory
to a larger volume) and click Install again — it resumes where it
stopped.
### `IndexTTS bootstrap completed but `import indextts.infer_v2` still fails`
+58
View File
@@ -84,6 +84,64 @@ export async function selfTestEngine(engineId: string): Promise<EngineSelfTestRe
return apiPost<EngineSelfTestResponse>(`/engines/${encodeURIComponent(engineId)}/selftest`, {});
}
// ── One-click sidecar-engine install (IndexTTS-2 & friends) ─────────────
export type SidecarStepState = 'pending' | 'running' | 'done' | 'skipped' | 'error';
export interface SidecarInstallStep {
id: string;
state: SidecarStepState;
detail: string | null;
}
export interface SidecarInstallJob {
engine_id: string;
state: 'running' | 'succeeded' | 'failed';
steps: SidecarInstallStep[];
log: string[];
error: string | null;
remediation: string | null;
weights_progress: {
filename: string | null;
downloaded: number | null;
total: number | null;
pct: number | null;
} | null;
started_at: number;
finished_at: number | null;
}
export interface SidecarInstallStatus {
engine_id: string;
installed: boolean;
managed: boolean;
install_dir: string | null;
job: SidecarInstallJob | null;
}
export interface SidecarInstallStartResponse {
status: 'started' | 'already_running' | 'already_installed';
engine: string;
}
/** Start the resumable one-click install for a sidecar engine (IndexTTS-2).
* Idempotent: re-POSTing while a job runs returns `already_running`; a
* healthy install returns `already_installed`; a partial install repairs. */
export async function installSidecarEngine(engineId: string): Promise<SidecarInstallStartResponse> {
return apiPost<SidecarInstallStartResponse>(
`/engines/sidecar/${encodeURIComponent(engineId)}/install`,
{},
);
}
/** Poll the sidecar install job step-by-step states + log tail + error
* with remediation. Cheap (file probes only), safe to poll every ~1.5 s. */
export async function getSidecarInstallStatus(engineId: string): Promise<SidecarInstallStatus> {
return apiJson<SidecarInstallStatus>(
`/engines/sidecar/${encodeURIComponent(engineId)}/install/status`,
);
}
export async function listTranslationEngines(): Promise<TranslationEnginesResponse> {
return apiJson<TranslationEnginesResponse>('/engines/translation');
}
+4
View File
@@ -41,6 +41,10 @@ interface EngineBackend {
// Copy-paste-ready `export VAR=...` line for a path-gated opt-in engine
// (IndexTTS / MOSS-v1.5 / dots.tts / Confucius4), else null/absent.
setup_snippet?: string | null;
// True when the backend's sidecar provisioner can install this engine
// in-app (Settings renders an Install button; the manual snippet is
// demoted to a collapsible fallback). Absent on legacy payloads.
one_click_install?: boolean;
last_error?: string | null;
isolation_mode?: 'in-process' | 'subprocess';
gpu_compat?: GPUTarget[];
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Cpu,
Mic,
@@ -11,10 +11,17 @@ import {
Volume2,
Copy,
Check,
Download,
} from 'lucide-react';
import { toastErrorWithReport } from '../utils/errorToast';
import { useTranslation } from 'react-i18next';
import { listEngines, getEngineHealth, selfTestEngine } from '../api/engines';
import {
listEngines,
getEngineHealth,
selfTestEngine,
installSidecarEngine,
getSidecarInstallStatus,
} from '../api/engines';
import { listLoadedModels, unloadLoadedModel } from '../api/system';
import { copyText } from '../utils/copyText';
import { ChevronRight } from 'lucide-react';
@@ -175,6 +182,9 @@ function normalizeEntry(entry) {
Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0 ? entry.gpu_compat : ['cpu'],
// Copy-paste `export VAR=...` line for a path-gated opt-in engine, or null.
setup_snippet: entry.setup_snippet || null,
// The backend's sidecar provisioner can install this engine in-app
// renders an Install button; the manual snippet demotes to a fallback.
one_click_install: entry.one_click_install === true,
// Routing (#21) may be absent on a legacy/older backend payload, in
// which case the matrix renders exactly as before (no routing badge).
effective_device: entry.effective_device || null,
@@ -210,6 +220,9 @@ export default function EngineCompatibilityMatrix({
// even where /model/loaded isn't reachable (errors are swallowed).
apiListLoadedModels = listLoadedModels,
apiUnloadModel = unloadLoadedModel,
// One-click sidecar install layer same injection story as the rest.
apiInstallEngine = installSidecarEngine,
apiInstallStatus = getSidecarInstallStatus,
}) {
const { t } = useTranslation();
const [data, setData] = useState(null);
@@ -379,6 +392,97 @@ export default function EngineCompatibilityMatrix({
[apiSelfTestEngine, selfTestByEngine],
);
// One-click sidecar install (IndexTTS-2 & friends): POST starts a
// resumable background job; this map holds the latest polled status per
// engine id ({installed, managed, install_dir, job}).
const [installByEngine, setInstallByEngine] = useState({});
// At most ONE in-flight status request per engine otherwise a slow
// backend lets responses land out of order (an old 'running' snapshot
// overwriting a newer 'succeeded' would restart the poller forever).
const installInflightRef = useRef(new Set());
// Consecutive poll failures per engine after a few in a row the backend
// is gone, so drop the stale snapshot instead of showing "Installing"
// (and hammering the endpoint) indefinitely.
const installPollFailuresRef = useRef({});
const refreshInstall = useCallback(
async (id) => {
if (installInflightRef.current.has(id)) return null; // serialize per engine
installInflightRef.current.add(id);
try {
const st = await apiInstallStatus(id);
installPollFailuresRef.current[id] = 0;
setInstallByEngine((prev) => ({ ...prev, [id]: st }));
return st;
} catch {
const n = (installPollFailuresRef.current[id] || 0) + 1;
installPollFailuresRef.current[id] = n;
if (n >= 4) {
installPollFailuresRef.current[id] = 0;
setInstallByEngine((prev) => {
const { [id]: _stale, ...rest } = prev;
return rest; // stops the poller; a later reload/click re-attaches
});
}
return null; // advisory polling errors never break the matrix
} finally {
installInflightRef.current.delete(id);
}
},
[apiInstallStatus],
);
const startInstall = useCallback(
async (id) => {
setExpandedId(id); // the panel is where progress renders
try {
const res = await apiInstallEngine(id);
if (res.status === 'already_installed') {
reload();
return;
}
const st = await refreshInstall(id);
// A repair-only rerun can finish before this first status snapshot
// the poller below only watches 'running' jobs, so reload here too.
if (st?.job?.state === 'succeeded') reload();
} catch (e) {
toastErrorWithReport(t('engines.installFailed', { message: e?.message || String(e) }), e);
}
},
[apiInstallEngine, refreshInstall, reload, t],
);
// Poll running install jobs every 1.5 s; on success reload the matrix so
// the row flips to available without a manual refresh. Keyed on the SET of
// running ids (not the status map itself): every poll replaces the map, so
// depending on it directly would tear down + recreate the interval each
// tick, resetting the 1.5 s clock and dropping in-flight responses.
const runningInstallKey = Object.entries(installByEngine)
.filter(([, st]) => st?.job?.state === 'running')
.map(([id]) => id)
.sort()
.join(',');
useEffect(() => {
if (!runningInstallKey) return undefined;
const ids = runningInstallKey.split(',');
const iv = setInterval(async () => {
for (const id of ids) {
const st = await refreshInstall(id);
if (st?.job?.state === 'succeeded') reload();
}
}, 1500);
return () => clearInterval(iv);
}, [runningInstallKey, refreshInstall, reload]);
// Re-attach to an in-flight install after a remount (Settings closed and
// reopened while the backend job kept running): one cheap status probe per
// installable-but-unavailable row restores the progress panel + poller.
useEffect(() => {
for (const b of data?.tts?.backends || []) {
if (b.one_click_install === true && !b.available) refreshInstall(b.id);
}
}, [data, refreshInstall]);
const copySetup = useCallback(async (id, snippet) => {
const ok = await copyText(snippet);
if (!ok) return;
@@ -527,10 +631,48 @@ export default function EngineCompatibilityMatrix({
const canSelfTest =
activeFamily === 'tts' && b.available && b.isolation_mode !== 'subprocess';
// Unavailable-row detail material for the expansion panel.
// One-click-installable rows always have a panel it hosts the
// install progress and the demoted manual-install fallback.
const hasDetails =
!b.available && !!(b.reason || b.install_hint || b.last_error || b.setup_snippet);
!b.available &&
!!(
b.reason ||
b.install_hint ||
b.last_error ||
b.setup_snippet ||
b.one_click_install
);
const install = installByEngine[b.id] || null;
const installJob = install?.job || null;
const installRunning = installJob?.state === 'running';
const expanded = hasDetails && expandedId === b.id;
const panelId = `engine-detail-${b.id}`;
// Manual setup line (Copy button) top-level on plain path-gated
// rows; demoted to a collapsed "Manual install" fallback on
// one-click-installable rows (auto-opened when the install fails,
// since the snippet IS the recovery path then).
const setupSnippetBlock = b.setup_snippet ? (
<div
className="engine-matrix__setup mt-[2px] flex flex-col gap-[3px]"
data-testid={`setup-snippet-${b.id}`}
>
<span className={cn('text-[11px]', MUTED)}>{t('engines.setupSnippetLabel')}</span>
<div className="flex flex-wrap items-center gap-[6px]">
<code className="engine-matrix__setup-code break-all rounded px-[6px] py-[2px] font-mono text-[11px] [background:var(--chrome-bg-inset,rgba(255,255,255,0.05))] text-[color:var(--chrome-fg,currentColor)]">
{b.setup_snippet}
</code>
<Button
size="sm"
variant="subtle"
onClick={() => copySetup(b.id, b.setup_snippet)}
leading={copiedId === b.id ? <Check size={11} /> : <Copy size={11} />}
aria-label={t('engines.copySetup', { engine: b.display_name })}
>
{copiedId === b.id ? t('engines.copied') : t('engines.copy')}
</Button>
</div>
</div>
) : null;
return (
<React.Fragment key={b.id}>
<div
@@ -833,6 +975,27 @@ export default function EngineCompatibilityMatrix({
{health?.inflight ? t('engines.testing') : t('engines.testEngine')}
</Button>
)}
{/* One-click sidecar install the guided replacement for
the four manual terminal steps. Progress renders in
the expansion panel (auto-opened on click). */}
{!b.available && b.one_click_install && (
<Button
size="sm"
variant="subtle"
onClick={() => startInstall(b.id)}
disabled={installRunning}
loading={installRunning}
leading={!installRunning && <Download size={11} />}
data-testid={`install-${b.id}`}
aria-label={t('engines.installAria', { engine: b.display_name })}
>
{installRunning
? t('engines.installing')
: installJob?.state === 'failed'
? t('engines.retryInstall')
: t('engines.install')}
</Button>
)}
{!b.available && (
<Button
size="sm"
@@ -982,33 +1145,90 @@ export default function EngineCompatibilityMatrix({
{t('engines.lastError', { error: b.last_error })}
</span>
)}
{/* Copy-paste-ready setup line for a path-gated opt-in
engine (IndexTTS/MOSS-v1.5/dots/Confucius4) the
exact `export VAR=…` so users don't hunt the docs. */}
{b.setup_snippet && (
{/* One-click install progress: per-step states + the
live log tail while the provisioner job runs, error
+ remediation on failure. Poll-driven (1.5 s). */}
{b.one_click_install && installJob && (
<div
className="engine-matrix__setup mt-[2px] flex flex-col gap-[3px]"
data-testid={`setup-snippet-${b.id}`}
className="engine-matrix__install mt-[2px] flex flex-col gap-[3px]"
data-testid={`install-progress-${b.id}`}
>
<span className={cn('text-[11px]', MUTED)}>
{t('engines.setupSnippetLabel')}
</span>
<div className="flex flex-wrap items-center gap-[6px]">
<code className="engine-matrix__setup-code break-all rounded px-[6px] py-[2px] font-mono text-[11px] [background:var(--chrome-bg-inset,rgba(255,255,255,0.05))] text-[color:var(--chrome-fg,currentColor)]">
{b.setup_snippet}
</code>
<Button
size="sm"
variant="subtle"
onClick={() => copySetup(b.id, b.setup_snippet)}
leading={copiedId === b.id ? <Check size={11} /> : <Copy size={11} />}
aria-label={t('engines.copySetup', { engine: b.display_name })}
<ul className="m-0 flex list-none flex-col gap-[1px] p-0 font-mono text-[11px]">
{installJob.steps.map((s) => (
<li
key={s.id}
className={cn(
s.state === 'error' &&
'text-[color:var(--chrome-severity-err,#cc241d)]',
s.state === 'done' &&
'text-[color:var(--chrome-severity-ok,#98971a)]',
(s.state === 'pending' || s.state === 'skipped') && MUTED,
)}
data-install-step={s.id}
data-step-state={s.state}
>
{s.state === 'done'
? '[x]'
: s.state === 'running'
? '[>]'
: s.state === 'error'
? '[!]'
: '[ ]'}{' '}
{t(`engines.installStep_${s.id}`, { defaultValue: s.id })}
{s.id === 'fetch_weights' &&
s.state === 'running' &&
installJob.weights_progress?.pct != null &&
`${Math.round(installJob.weights_progress.pct * 100)}%`}
</li>
))}
</ul>
{installRunning && installJob.log.length > 0 && (
<code
className={cn(
'block max-w-full truncate font-mono text-[10px]',
MUTED,
)}
title={installJob.log.slice(-12).join('\n')}
>
{copiedId === b.id ? t('engines.copied') : t('engines.copy')}
</Button>
</div>
{installJob.log[installJob.log.length - 1]}
</code>
)}
{installJob.state === 'failed' && (
<span className="block text-[11px] text-[color:var(--chrome-severity-err,#cc241d)]">
{installJob.error}
{installJob.remediation ? `${installJob.remediation}` : ''}
</span>
)}
{installJob.state === 'succeeded' && (
<span className="block text-[11px] text-[color:var(--chrome-severity-ok,#98971a)]">
{t('engines.installDone')}
</span>
)}
</div>
)}
{/* Copy-paste-ready setup line for a path-gated opt-in
engine (IndexTTS/MOSS-v1.5/dots/Confucius4) the
exact `export VAR=…` so users don't hunt the docs.
On one-click-installable rows it demotes to a
collapsed "Manual install" fallback (forced open when
the install failed it's the recovery path then). */}
{setupSnippetBlock &&
(b.one_click_install ? (
<details
className="engine-matrix__manual mt-[2px]"
data-testid={`manual-install-${b.id}`}
{...(installJob?.state === 'failed' ? { open: true } : {})}
>
<summary
className={cn('cursor-pointer select-none text-[11px]', MUTED)}
>
{t('engines.manualInstall')}
</summary>
{setupSnippetBlock}
</details>
) : (
setupSnippetBlock
))}
</div>
</div>
)}
@@ -13,6 +13,8 @@ vi.mock('../../api/engines', () => ({
selectEngine: vi.fn(),
getEngineHealth: vi.fn(),
selfTestEngine: vi.fn(),
installSidecarEngine: vi.fn(),
getSidecarInstallStatus: vi.fn(),
}));
// Residency layer (/model/loaded) mocked so the matrix never hits the
+15 -1
View File
@@ -1739,7 +1739,21 @@
"unloadFailed": "Could not unload: {{message}}",
"familyDesc_tts": "Turns your script into speech. The engine marked active is the one Studio, Dubbing and Batch use.",
"familyDesc_asr": "Turns audio into text — transcription for dubbing, captions and dictation.",
"familyDesc_llm": "Optional text helper for translation and rewrites. \"Off\" simply skips those steps."
"familyDesc_llm": "Optional text helper for translation and rewrites. \"Off\" simply skips those steps.",
"install": "Install",
"installing": "Installing…",
"retryInstall": "Retry install",
"installDone": "Installed — the engine is ready to use, no restart needed.",
"installFailed": "Install failed: {{message}}",
"manualInstall": "Manual install (advanced)",
"installStep_preflight": "Checking uv and disk space",
"installStep_fetch_source": "Fetching engine source",
"installStep_create_venv": "Creating isolated environment",
"installStep_install_deps": "Installing dependencies",
"installStep_verify": "Verifying the environment",
"installStep_fetch_weights": "Downloading model weights",
"installStep_persist": "Saving configuration",
"installAria": "Install {{engine}}"
},
"errors": {
"title": "This tab hit a snag.",
@@ -1207,4 +1207,212 @@ describe('EngineCompatibilityMatrix', () => {
expect(screen.queryByTestId('why-toggle-indextts2')).not.toBeInTheDocument();
expect(screen.getByTestId('why-toggle-kittentts')).toBeInTheDocument();
});
// One-click sidecar install (IndexTTS-2 & friends)
/** An unavailable, one-click-installable IndexTTS2 row. */
function makeInstallableResponse() {
const res = makeEnginesResponse();
res.tts.backends[2] = {
...res.tts.backends[2],
available: false,
reason: 'IndexTTS-2 venv not found.',
setup_snippet: 'export OMNIVOICE_INDEXTTS_DIR=/path/to/index-tts',
one_click_install: true,
};
return res;
}
/** Pre-install status: no job yet (what the on-mount re-attach probe sees). */
function makeIdleStatus() {
return {
engine_id: 'indextts2',
installed: false,
managed: false,
install_dir: null,
job: null,
};
}
function makeInstallStatus(jobState, overrides = {}) {
return {
engine_id: 'indextts2',
installed: false,
managed: false,
install_dir: null,
job: {
engine_id: 'indextts2',
state: jobState,
steps: [
{ id: 'preflight', state: 'done', detail: null },
{ id: 'fetch_source', state: 'running', detail: null },
{ id: 'create_venv', state: 'pending', detail: null },
{ id: 'install_deps', state: 'pending', detail: null },
{ id: 'verify', state: 'pending', detail: null },
{ id: 'fetch_weights', state: 'pending', detail: null },
{ id: 'persist', state: 'pending', detail: null },
],
log: ['Cloning https://github.com/index-tts/index-tts.git (git, depth 1) …'],
error: null,
remediation: null,
weights_progress: null,
started_at: 1,
finished_at: null,
...overrides,
},
};
}
it('installable unavailable rows get an Install button; clicking it starts the job and shows step progress', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeInstallableResponse());
const apiInstallEngine = vi.fn().mockResolvedValue({ status: 'started', engine: 'indextts2' });
// First call = the on-mount re-attach probe (no job yet); later calls =
// the post-click status refresh with the running job.
const apiInstallStatus = vi
.fn()
.mockResolvedValueOnce(makeIdleStatus())
.mockResolvedValue(makeInstallStatus('running'));
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiInstallEngine={apiInstallEngine}
apiInstallStatus={apiInstallStatus}
/>,
);
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
const installBtn = screen.getByTestId('install-indextts2');
expect(installBtn).toHaveTextContent('Install');
fireEvent.click(installBtn);
await waitFor(() => expect(apiInstallEngine).toHaveBeenCalledWith('indextts2'));
expect(apiInstallStatus).toHaveBeenCalledWith('indextts2');
// Clicking auto-opens the detail panel where the progress renders.
const progress = await screen.findByTestId('install-progress-indextts2');
expect(within(progress).getByText(/Checking uv and disk space/)).toBeInTheDocument();
const running = progress.querySelector('[data-install-step="fetch_source"]');
expect(running).toHaveAttribute('data-step-state', 'running');
// The live log tail is visible while the job runs.
expect(within(progress).getByText(/Cloning https:\/\//)).toBeInTheDocument();
// The button reflects the in-flight job.
expect(screen.getByTestId('install-indextts2')).toHaveTextContent('Installing…');
});
it('a failed job renders the error with its remediation and offers Retry', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeInstallableResponse());
const apiInstallEngine = vi.fn().mockResolvedValue({ status: 'started', engine: 'indextts2' });
const apiInstallStatus = vi
.fn()
.mockResolvedValueOnce(makeIdleStatus())
.mockResolvedValue(
makeInstallStatus('failed', {
error: 'Not enough disk space to install IndexTTS-2',
remediation: 'Free up disk space and retry.',
finished_at: 2,
}),
);
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiInstallEngine={apiInstallEngine}
apiInstallStatus={apiInstallStatus}
/>,
);
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
fireEvent.click(screen.getByTestId('install-indextts2'));
const progress = await screen.findByTestId('install-progress-indextts2');
expect(
within(progress).getByText(/Not enough disk space .* Free up disk space and retry\./),
).toBeInTheDocument();
expect(screen.getByTestId('install-indextts2')).toHaveTextContent('Retry install');
});
it('already_installed responses skip the job and just reload the matrix', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeInstallableResponse());
const apiInstallEngine = vi
.fn()
.mockResolvedValue({ status: 'already_installed', engine: 'indextts2' });
const apiInstallStatus = vi.fn().mockResolvedValue(makeIdleStatus());
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiInstallEngine={apiInstallEngine}
apiInstallStatus={apiInstallStatus}
/>,
);
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
fireEvent.click(screen.getByTestId('install-indextts2'));
await waitFor(() => expect(apiListEngines).toHaveBeenCalledTimes(2)); // reload()
// No job progress ever rendered nothing to poll beyond the mount probe.
expect(screen.queryByTestId('install-progress-indextts2')).not.toBeInTheDocument();
});
it('demotes the manual setup snippet to a collapsed fallback on installable rows', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeInstallableResponse());
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
// Installable-but-unavailable rows probe install status on mount
// (re-attach to an in-flight job) stub it so no network happens.
apiInstallStatus={vi.fn().mockResolvedValue(makeIdleStatus())}
/>,
);
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
fireEvent.click(screen.getByTestId('why-toggle-indextts2'));
// Installable row: snippet lives INSIDE a collapsed <details> fallback.
const manual = screen.getByTestId('manual-install-indextts2');
expect(manual.tagName).toBe('DETAILS');
expect(manual).not.toHaveAttribute('open');
expect(within(manual).getByTestId('setup-snippet-indextts2')).toBeInTheDocument();
});
it('re-attaches to an in-flight install job on mount (no click needed)', async () => {
const apiListEngines = vi.fn().mockResolvedValue(makeInstallableResponse());
const apiInstallStatus = vi.fn().mockResolvedValue(makeInstallStatus('running'));
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
apiInstallStatus={apiInstallStatus}
/>,
);
await waitFor(() => screen.getByText('IndexTTS2 (test)'));
// The mount probe found a running job the button reflects it without
// any user interaction (Settings was closed and reopened mid-install).
await waitFor(() =>
expect(screen.getByTestId('install-indextts2')).toHaveTextContent('Installing…'),
);
expect(apiInstallStatus).toHaveBeenCalledWith('indextts2');
});
it('keeps the setup snippet top-level on rows without a one-click installer', async () => {
const res = makeEnginesResponse();
res.tts.backends[1].setup_snippet = 'export OMNIVOICE_SHERPA_MODEL=/m';
const apiListEngines = vi.fn().mockResolvedValue(res);
render(
<EngineCompatibilityMatrix
family="tts"
apiListEngines={apiListEngines}
apiGetEngineHealth={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('KittenTTS (test)'));
fireEvent.click(screen.getByTestId('why-toggle-kittentts'));
expect(screen.getByTestId('setup-snippet-kittentts')).toBeInTheDocument();
expect(screen.queryByTestId('manual-install-kittentts')).not.toBeInTheDocument();
expect(screen.queryByTestId('install-kittentts')).not.toBeInTheDocument();
});
});
@@ -162,6 +162,9 @@ def test_list_backends_shape(registry_sandbox):
# Cloning capability: bool from the class attr, None when
# model-dependent (a property, e.g. mlx-audio).
"supports_cloning",
# True when services.sidecar_install can provision the engine in-app
# (the Settings Install button keys off this).
"one_click_install",
}
mlx_audio_extra = {"curated_models", "active_model_id"}
for entry in out:
+3
View File
@@ -5,6 +5,7 @@ DELETE /api/settings/hf-token
DELETE /batch/jobs/{job_id}
DELETE /dub/history
DELETE /dub/history/{history_id}
DELETE /engines/sidecar/{engine_id}/install
DELETE /engines/translation/{engine_id}
DELETE /gallery/voices/{voice_id}
DELETE /glossary/{project_id}
@@ -73,6 +74,7 @@ GET /engines
GET /engines/asr
GET /engines/effects/presets
GET /engines/llm
GET /engines/sidecar/{engine_id}/install/status
GET /engines/sonitranslate/status
GET /engines/translation
GET /engines/tts
@@ -159,6 +161,7 @@ POST /dub/transcribe/{job_id}
POST /dub/translate
POST /dub/upload
POST /engines/select
POST /engines/sidecar/{engine_id}/install
POST /engines/sonitranslate/dub
POST /engines/sonitranslate/install
POST /engines/sonitranslate/start
+62
View File
@@ -0,0 +1,62 @@
"""prefs.json mutation thread-safety.
Writers run on many threads FastAPI's request threadpool plus background
workers (the sidecar-engine installer persists its install dir from a worker
thread). Each ``set_``/``delete`` is a load-modify-save of the whole JSON
file; before the module-level mutation lock, two concurrent writers could
interleave (both load, both save) and the later save silently dropped the
other's key. Fail-before/pass-after: this test loses keys reliably on the
unlocked implementation.
"""
import os
import threading
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
from core import prefs
def test_concurrent_set_never_drops_keys(tmp_path, monkeypatch):
monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
n_threads, n_keys = 8, 25
barrier = threading.Barrier(n_threads)
def writer(tid: int) -> None:
barrier.wait() # maximize interleaving
for i in range(n_keys):
prefs.set_(f"t{tid}.k{i}", tid * 1000 + i)
threads = [threading.Thread(target=writer, args=(tid,)) for tid in range(n_threads)]
for th in threads:
th.start()
for th in threads:
th.join()
data = prefs._load()
expected = {f"t{tid}.k{i}" for tid in range(n_threads) for i in range(n_keys)}
missing = expected - set(data)
assert not missing, f"concurrent writers dropped {len(missing)} keys: {sorted(missing)[:5]}"
def test_concurrent_set_and_delete_serialize(tmp_path, monkeypatch):
monkeypatch.setattr(prefs, "_PREFS_PATH", str(tmp_path / "prefs.json"))
prefs.set_("keep", 1)
barrier = threading.Barrier(2)
def setter():
barrier.wait()
for i in range(50):
prefs.set_(f"s{i}", i)
def deleter():
barrier.wait()
for i in range(50):
prefs.delete(f"absent{i}") # churns load-save alongside the setter
t1, t2 = threading.Thread(target=setter), threading.Thread(target=deleter)
t1.start(); t2.start(); t1.join(); t2.join()
data = prefs._load()
assert data.get("keep") == 1
assert all(data.get(f"s{i}") == i for i in range(50))
+640
View File
@@ -0,0 +1,640 @@
"""One-click sidecar-engine provisioner (services.sidecar_install).
The provisioner replaces IndexTTS-2's four manual terminal steps (clone,
venv, `uv pip install -e .`, set OMNIVOICE_INDEXTTS_DIR) with a resumable
background job. These tests run the job with git/uv/httpx/HF mocked and
cover: the happy path, the disk-space preflight, the git-absent tarball
fallback, partial-install repair, already-installed detection, uninstall
safety (never delete a user's own clone), and the router wiring.
"""
import io
import os
import tarfile
import threading
from pathlib import Path
from types import SimpleNamespace
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from services import sidecar_install as si
_GIB = 1024 ** 3
# ── fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _clean_state(monkeypatch, tmp_path):
"""Hermetic per-test state: managed root under tmp, no leaked jobs/env.
Rebinds the module-level ``si`` to the LIVE ``services.sidecar_install``
module: other suites purge ``sys.modules["services"]`` for DB isolation,
so the object imported at collection time can differ from the one the
engines router imports at call time patching the stale copy would make
the router tests order-dependent.
"""
import importlib
global si
si = importlib.import_module("services.sidecar_install")
monkeypatch.setattr(si, "DATA_DIR", str(tmp_path / "data"))
monkeypatch.setattr(si, "_jobs", {})
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
yield
def _mk_spec(**over) -> si.SidecarSpec:
calls = over.pop("_calls", {})
defaults = dict(
engine_id="fake-side",
display_name="Fake Sidecar",
repo_url="https://github.com/example/fake-side.git",
tarball_url="https://github.com/example/fake-side/archive/refs/heads/main.tar.gz",
checkout_dirname="fake-side",
env_var="OMNIVOICE_FAKE_SIDE_DIR",
probe_module="fake_side.infer",
weights_repo_id=None,
required_bytes=1 * _GIB,
invalidate=lambda: calls.setdefault("invalidated", 0) or calls.update(
invalidated=calls.get("invalidated", 0) + 1
),
installed_probe=lambda: False,
)
defaults.update(over)
return si.SidecarSpec(**defaults)
def _fake_run_logged(created: list):
"""A _run_logged stand-in that fabricates git/uv side effects on disk."""
def run(job, argv, *, timeout):
created.append(argv)
prog = os.path.basename(argv[0])
if prog.startswith("git"):
checkout = Path(argv[-1])
checkout.mkdir(parents=True, exist_ok=True)
(checkout / "pyproject.toml").write_text("[project]\nname='fake'\n")
elif prog.startswith("uv") and argv[1] == "venv":
venv = Path(argv[2])
py = si._venv_python(venv)
py.parent.mkdir(parents=True, exist_ok=True)
py.write_text("#!fake python\n")
# uv pip install: nothing to fabricate
return 0
return run
def _stub_verify_ok(monkeypatch):
monkeypatch.setattr(
si.subprocess, "run",
lambda *a, **k: SimpleNamespace(returncode=0, stderr=b"", stdout=b""),
)
def _run(spec):
job = si._new_job(spec.engine_id)
si._run_install(spec, job)
return job
def _step_states(job):
return {s["id"]: s["state"] for s in job["steps"]}
# ── happy path ─────────────────────────────────────────────────────────────
def test_happy_path_installs_and_persists(monkeypatch):
calls = {}
prefs_written = {}
spec = _mk_spec(_calls=calls)
argvs = []
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", _fake_run_logged(argvs))
_stub_verify_ok(monkeypatch)
monkeypatch.setattr("core.prefs.set_", lambda k, v: prefs_written.update({k: v}))
job = _run(spec)
assert job["state"] == "succeeded", (job["error"], list(job["log"]))
states = _step_states(job)
assert states["preflight"] == "done"
assert states["fetch_source"] == "done"
assert states["create_venv"] == "done"
assert states["install_deps"] == "done"
assert states["verify"] == "done"
assert states["fetch_weights"] == "skipped" # no weights_repo_id
assert states["persist"] == "done"
checkout = si.managed_checkout(spec)
# Engine usable immediately: env var set in THIS process…
assert os.environ["OMNIVOICE_FAKE_SIDE_DIR"] == str(checkout)
# …and persisted for the next launch via the env.* prefs mechanism.
assert prefs_written == {"env.OMNIVOICE_FAKE_SIDE_DIR": str(checkout)}
# Memoised venv resolution invalidated so it re-probes without restart.
assert calls.get("invalidated") == 1
# uv pip install targeted the sidecar venv's own python (isolation
# preserved — the parent app's env is never touched).
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
assert pip[pip.index("--python") + 1] == str(si._venv_python(checkout / ".venv"))
assert pip[-1] == str(checkout)
def test_rerun_after_success_skips_completed_steps(monkeypatch):
spec = _mk_spec()
argvs = []
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", _fake_run_logged(argvs))
_stub_verify_ok(monkeypatch)
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
assert _run(spec)["state"] == "succeeded"
argvs.clear()
job2 = _run(spec)
assert job2["state"] == "succeeded"
# No re-clone, no re-venv; only the idempotent pip repair pass runs.
assert all(a[1:3] == ["pip", "install"] for a in argvs), argvs
states = _step_states(job2)
assert states["fetch_source"] == "done" and states["create_venv"] == "done"
# ── disk-space preflight ───────────────────────────────────────────────────
def test_disk_space_preflight_fails_early_with_numbers(monkeypatch):
spec = _mk_spec(required_bytes=10 * _GIB)
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 2 * _GIB)
job = _run(spec)
assert job["state"] == "failed"
assert _step_states(job)["preflight"] == "error"
# Nothing after preflight ran.
assert _step_states(job)["fetch_source"] == "pending"
# The error names what's needed and what's free, so the user can act.
assert "10.0 GB" in job["error"] and "2.0 GB" in job["error"]
assert "disk space" in job["remediation"].lower() or "disk space" in job["error"].lower()
def test_disk_preflight_subtracts_partial_install(monkeypatch, tmp_path):
# 1 GiB required, 0.9 GiB already on disk from a prior partial run →
# only the remainder (+headroom) must fit, so a resume isn't blocked.
spec = _mk_spec(required_bytes=1 * _GIB)
root = si.managed_root(spec)
root.mkdir(parents=True)
monkeypatch.setattr(si, "_dir_size_bytes", lambda p: int(0.9 * _GIB))
monkeypatch.setattr(si, "disk_free_bytes", lambda p: (si.MIN_FREE_GB + 1) * _GIB)
assert si.disk_space_error(spec) is None
def test_missing_uv_is_actionable(monkeypatch):
spec = _mk_spec()
monkeypatch.setattr(si, "_locate_uv", lambda: None)
job = _run(spec)
assert job["state"] == "failed"
assert "uv" in job["error"]
assert "docs.astral.sh/uv" in job["remediation"]
# ── git-absent tarball fallback ────────────────────────────────────────────
def _tarball_bytes(root_name: str, with_pyproject: bool = True) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
if with_pyproject:
data = b"[project]\nname='fake'\n"
info = tarfile.TarInfo(f"{root_name}/pyproject.toml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
data2 = b"print('hi')\n"
info2 = tarfile.TarInfo(f"{root_name}/fake_side/__init__.py")
info2.size = len(data2)
tf.addfile(info2, io.BytesIO(data2))
return buf.getvalue()
class _FakeStream:
def __init__(self, payload: bytes):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def raise_for_status(self):
return None
def iter_bytes(self):
yield self._payload
def test_git_absent_falls_back_to_tarball(monkeypatch):
spec = _mk_spec()
urls = []
monkeypatch.setattr(si.shutil, "which", lambda n: None) # no git anywhere
import httpx
def fake_stream(method, url, **kw):
urls.append((method, url))
return _FakeStream(_tarball_bytes("fake-side-main"))
monkeypatch.setattr(httpx, "stream", fake_stream)
job = si._new_job(spec.engine_id)
step = si._job_step(job, "fetch_source")
step["state"] = "running"
si._step_fetch_source(spec, job)
assert urls == [("GET", spec.tarball_url)]
checkout = si.managed_checkout(spec)
assert (checkout / "pyproject.toml").is_file()
assert (checkout / "fake_side" / "__init__.py").is_file()
assert step["detail"] == "source tarball"
def test_git_failure_falls_back_to_tarball(monkeypatch):
"""A present-but-failing git (proxy block, DNS, …) must not dead-end."""
spec = _mk_spec()
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", lambda job, argv, timeout: 128) # git exits 128
import httpx
monkeypatch.setattr(
httpx, "stream",
lambda m, u, **k: _FakeStream(_tarball_bytes("fake-side-main")),
)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_source")["state"] = "running"
si._step_fetch_source(spec, job)
assert (si.managed_checkout(spec) / "pyproject.toml").is_file()
def test_kill_tree_uses_taskkill_on_windows(monkeypatch):
"""On Windows proc.kill() fells only the direct child — a spawned git/uv
helper would keep writing into the checkout past the timeout. The tree
kill must go through taskkill /T there (POSIX uses killpg)."""
calls = {}
monkeypatch.setattr(si.os, "name", "nt")
monkeypatch.setattr(
si.subprocess, "run",
lambda argv, **kw: calls.setdefault("argv", argv) or SimpleNamespace(returncode=0),
)
proc = SimpleNamespace(pid=4242, kill=lambda: calls.setdefault("plain_kill", True))
si._kill_tree(proc)
assert calls["argv"][:4] == ["taskkill", "/F", "/T", "/PID"]
assert calls["argv"][4] == "4242"
assert "plain_kill" not in calls # taskkill succeeded — no fallback
def test_safe_extract_members_blocks_tar_slip(tmp_path):
"""The pre-filter= fallback extractor must drop parent-dir escapes,
absolute paths, and symlinks mirroring extractall(filter='data')."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
good = tarfile.TarInfo("pkg/ok.txt")
good.size = 2
tf.addfile(good, io.BytesIO(b"ok"))
evil = tarfile.TarInfo("../evil.txt")
evil.size = 4
tf.addfile(evil, io.BytesIO(b"pwnd"))
absolute = tarfile.TarInfo("/abs.txt")
absolute.size = 3
tf.addfile(absolute, io.BytesIO(b"abs"))
link = tarfile.TarInfo("pkg/link")
link.type = tarfile.SYMTYPE
link.linkname = "/etc/passwd"
tf.addfile(link)
buf.seek(0)
dest = tmp_path / "sandbox" / "out"
dest.mkdir(parents=True)
with tarfile.open(fileobj=buf, mode="r:gz") as tf:
si._safe_extract_members(tf, str(dest))
assert (dest / "pkg" / "ok.txt").read_text() == "ok"
assert not (tmp_path / "sandbox" / "evil.txt").exists()
assert not (dest / "pkg" / "link").exists()
def test_tarball_without_pyproject_fails_with_remediation(monkeypatch):
spec = _mk_spec()
monkeypatch.setattr(si.shutil, "which", lambda n: None)
import httpx
monkeypatch.setattr(
httpx, "stream",
lambda m, u, **k: _FakeStream(_tarball_bytes("fake-side-main", with_pyproject=False)),
)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_source")["state"] = "running"
with pytest.raises(si._StepError) as ei:
si._step_fetch_source(spec, job)
assert "pyproject.toml" in str(ei.value)
assert spec.env_var in ei.value.remediation
# ── partial-install repair ─────────────────────────────────────────────────
def test_half_fetched_checkout_is_refetched(monkeypatch):
"""A checkout without pyproject.toml (killed mid-clone) is wiped and
re-fetched instead of being trusted or corrupting the install."""
spec = _mk_spec()
checkout = si.managed_checkout(spec)
(checkout / "leftover").mkdir(parents=True)
(checkout / "leftover" / "junk.txt").write_text("stale")
argvs = []
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", _fake_run_logged(argvs))
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_source")["state"] = "running"
si._step_fetch_source(spec, job)
assert (checkout / "pyproject.toml").is_file()
assert not (checkout / "leftover").exists()
assert any(a[1] == "clone" for a in argvs)
def _write_weights(wdir: Path, *, complete: bool) -> None:
"""Fabricate a weights dir; ``complete=True`` adds the completion marker
the installer writes after snapshot_download returns."""
wdir.mkdir(parents=True, exist_ok=True)
(wdir / "config.yaml").write_text("model: fake\n")
(wdir / "weights.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
if complete:
(wdir / si._WEIGHTS_COMPLETE_MARKER).write_text("Example/Weights\n")
def test_partial_install_is_not_already_installed(monkeypatch):
"""venv present but weights missing → NOT healthy → a re-run repairs it
instead of short-circuiting with already_installed."""
spec = _mk_spec(weights_repo_id="Example/Weights")
checkout = si.managed_checkout(spec)
py = si._venv_python(checkout / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
assert si._healthy(spec) is False
# Complete the weights (incl. the completion marker) → healthy flips true.
_write_weights(checkout / spec.weights_subdir, complete=True)
assert si._healthy(spec) is True
def test_interrupted_multishard_weights_are_not_healthy(monkeypatch):
"""Regression: a killed-mid-download weights dir can hold config.yaml +
plausible shards, but WITHOUT the completion marker it must stay
unhealthy so a re-run resumes the download instead of reporting
already_installed and failing later at model-load time."""
spec = _mk_spec(weights_repo_id="Example/Weights")
checkout = si.managed_checkout(spec)
py = si._venv_python(checkout / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
_write_weights(checkout / spec.weights_subdir, complete=False)
assert si._weights_present(spec) is False
assert si._healthy(spec) is False
monkeypatch.setitem(si.SPECS, "fake-side", spec)
monkeypatch.setattr(si, "_run_install", lambda s, j: None)
assert si.start_install("fake-side")["status"] == "started" # repairs, not skips
def test_weights_step_downloads_via_endpoint_autoselect(monkeypatch):
"""The weights download must ride snapshot_download with the endpoint
from services.endpoint_race never a hardcoded huggingface.co URL."""
spec = _mk_spec(weights_repo_id="Example/Weights")
wdir = si.managed_checkout(spec) / spec.weights_subdir
seen = {}
def fake_snapshot_download(**kwargs):
seen.update(kwargs)
Path(kwargs["local_dir"]).mkdir(parents=True, exist_ok=True)
(Path(kwargs["local_dir"]) / "config.yaml").write_text("ok\n")
(Path(kwargs["local_dir"]) / "w.safetensors").write_bytes(b"\0" * (6 * 1024 * 1024))
import huggingface_hub
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
from services import endpoint_race
monkeypatch.setattr(endpoint_race, "effective_endpoint", lambda: "https://hf-mirror.example")
monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_weights")["state"] = "running"
si._step_fetch_weights(spec, job)
assert seen["repo_id"] == "Example/Weights"
assert seen["endpoint"] == "https://hf-mirror.example"
assert seen["local_dir"] == str(wdir)
assert si._weights_present(spec)
def test_weights_step_skips_when_already_present(monkeypatch):
spec = _mk_spec(weights_repo_id="Example/Weights")
wdir = si.managed_checkout(spec) / spec.weights_subdir
_write_weights(wdir, complete=True)
import huggingface_hub
def boom(**kw): # pragma: no cover — must not be reached
raise AssertionError("snapshot_download must not run when weights exist")
monkeypatch.setattr(huggingface_hub, "snapshot_download", boom)
job = si._new_job(spec.engine_id)
si._job_step(job, "fetch_weights")["state"] = "running"
si._step_fetch_weights(spec, job)
assert si._job_step(job, "fetch_weights")["state"] == "done"
# ── already-installed / already-running gating ────────────────────────────
def test_healthy_managed_install_reheals_lost_env_var(monkeypatch):
"""A complete managed install whose env var vanished (prefs.json wiped)
is re-pointed by start_install instead of being reinstalled and instead
of returning already_installed while the engine stays unavailable."""
spec = _mk_spec(weights_repo_id="Example/Weights")
monkeypatch.setitem(si.SPECS, "fake-side", spec)
checkout = si.managed_checkout(spec)
py = si._venv_python(checkout / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
_write_weights(checkout / spec.weights_subdir, complete=True)
prefs_written = {}
monkeypatch.setattr("core.prefs.set_", lambda k, v: prefs_written.update({k: v}))
assert "OMNIVOICE_FAKE_SIDE_DIR" not in os.environ
res = si.start_install("fake-side")
assert res["status"] == "already_installed"
assert os.environ["OMNIVOICE_FAKE_SIDE_DIR"] == str(checkout)
assert prefs_written == {"env.OMNIVOICE_FAKE_SIDE_DIR": str(checkout)}
def test_start_install_reports_already_installed_for_user_clone(monkeypatch):
spec = _mk_spec(installed_probe=lambda: True)
monkeypatch.setitem(si.SPECS, "fake-side", spec)
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", "/home/user/own-clone")
res = si.start_install("fake-side")
assert res["status"] == "already_installed"
def test_start_install_reports_already_running(monkeypatch):
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
ran = threading.Event()
monkeypatch.setattr(si, "_run_install", lambda s, j: ran.set())
first = si.start_install("fake-side")
assert first["status"] == "started"
# Freeze the job as running to simulate the in-flight window.
si._jobs["fake-side"]["state"] = "running"
assert si.start_install("fake-side")["status"] == "already_running"
assert ran.wait(5)
def test_start_install_unknown_engine_raises_keyerror():
with pytest.raises(KeyError):
si.start_install("definitely-not-an-engine")
def test_get_status_synthesizes_state_without_job(monkeypatch):
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
st = si.get_status("fake-side")
assert st == {
"engine_id": "fake-side",
"installed": False,
"managed": False,
"install_dir": None,
"job": None,
}
# ── uninstall ──────────────────────────────────────────────────────────────
def test_uninstall_removes_managed_install_and_prefs(monkeypatch):
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
checkout = si.managed_checkout(spec)
py = si._venv_python(checkout / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", str(checkout))
deleted = []
monkeypatch.setattr("core.prefs.get", lambda k, d=None: str(checkout))
monkeypatch.setattr("core.prefs.delete", lambda k: deleted.append(k))
res = si.uninstall("fake-side")
assert res["status"] == "uninstalled"
assert not si.managed_root(spec).exists()
assert "OMNIVOICE_FAKE_SIDE_DIR" not in os.environ
assert deleted == ["env.OMNIVOICE_FAKE_SIDE_DIR"]
def test_uninstall_refuses_user_managed_clone(monkeypatch, tmp_path):
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
user_clone = tmp_path / "my-own-clone"
user_clone.mkdir()
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", str(user_clone))
res = si.uninstall("fake-side")
assert res["status"] == "not_managed"
assert user_clone.exists() # never deleted
assert os.environ["OMNIVOICE_FAKE_SIDE_DIR"] == str(user_clone) # never cleared
def test_uninstall_refuses_while_job_running(monkeypatch):
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
si._jobs["fake-side"] = si._new_job("fake-side")
assert si.uninstall("fake-side")["status"] == "install_in_progress"
# ── indextts2 spec wiring (the engine this ships for) ─────────────────────
def test_indextts2_spec_matches_bootstrap_contract():
spec = si.get_spec("indextts2")
assert spec is not None
# The env var must be the one engines/indextts/bootstrap.py actually
# reads — anything else would install into a dir the engine never finds.
assert spec.env_var == "OMNIVOICE_INDEXTTS_DIR"
assert spec.probe_module == "indextts.infer_v2"
# main.py loads from <dir>/checkpoints/config.yaml (verified) — the
# installer must put the weights exactly there.
assert spec.weights_repo_id == "IndexTeam/IndexTTS-2"
assert spec.weights_subdir == "checkpoints"
assert spec.repo_url.endswith("index-tts.git")
def test_indextts2_env_var_in_settings_allowlist():
from api.routers.system import PERSISTENT_KEYS
assert "OMNIVOICE_INDEXTTS_DIR" in PERSISTENT_KEYS
def test_list_backends_flags_indextts2_one_click():
"""Fail-before/pass-after: the Settings UI keys the Install button off
this field without it the engine stays a manual setup_snippet."""
from services import tts_backend
row = next(r for r in tts_backend.list_backends() if r["id"] == "indextts2")
assert row["one_click_install"] is True
other = next(r for r in tts_backend.list_backends() if r["id"] == "omnivoice")
assert other["one_click_install"] is False
# ── router wiring ──────────────────────────────────────────────────────────
def test_sidecar_routes_never_shadow_literal_engine_routes():
"""Regression: the engines router registers BEFORE literal-path routers
(e.g. sonitranslate), so a dynamic ``/engines/{engine_id}/install`` here
would swallow ``POST /engines/sonitranslate/install``. The sidecar
installer must keep its own literal namespace (/engines/sidecar/)."""
from api.routers import engines as engines_router
install_paths = [
r.path for r in engines_router.router.routes if "install" in r.path
]
assert install_paths, "sidecar install routes missing"
for p in install_paths:
assert not p.startswith("/engines/{"), (
f"{p} would shadow literal /engines/<x>/install routes registered later"
)
def test_router_404s_engines_without_installer():
from fastapi import HTTPException
from api.routers import engines as engines_router
with pytest.raises(HTTPException) as ei:
engines_router.install_sidecar_engine("omnivoice")
assert ei.value.status_code == 404
with pytest.raises(HTTPException) as ei:
engines_router.sidecar_install_status("omnivoice")
assert ei.value.status_code == 404
with pytest.raises(HTTPException) as ei:
engines_router.uninstall_sidecar_engine("omnivoice")
assert ei.value.status_code == 404
def test_router_uninstall_maps_refusals_to_http_errors(monkeypatch):
from fastapi import HTTPException
from api.routers import engines as engines_router
spec = _mk_spec()
monkeypatch.setitem(si.SPECS, "fake-side", spec)
si._jobs["fake-side"] = si._new_job("fake-side")
with pytest.raises(HTTPException) as ei:
engines_router.uninstall_sidecar_engine("fake-side")
assert ei.value.status_code == 409
si._jobs.pop("fake-side")
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", "/somewhere/else")
with pytest.raises(HTTPException) as ei:
engines_router.uninstall_sidecar_engine("fake-side")
assert ei.value.status_code == 400