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:
co-authored by
Claude Fable 5
mergetest
parent
9c81e3389d
commit
ff56865cf7
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (1024–65535). 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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+3
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user