aboutsummaryrefslogtreecommitdiff
path: root/app/backends/sglomni/models.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-02 01:26:09 -0400
committerhistoria <historiavg@proton.me>2026-09-02 01:26:09 -0400
commit8579517a35ef1865fc9b428899d73d52dcb27a14 (patch)
treedba52f8d99cfe4014e0b787367de99f238e5a0db /app/backends/sglomni/models.py
parent391f50da7a085bec75155c0eb9b47910266058cc (diff)
downloadtts-audiobook-generator-8579517a35ef1865fc9b428899d73d52dcb27a14.tar.gz
feat: sglang backend support
Diffstat (limited to 'app/backends/sglomni/models.py')
-rw-r--r--app/backends/sglomni/models.py283
1 files changed, 283 insertions, 0 deletions
diff --git a/app/backends/sglomni/models.py b/app/backends/sglomni/models.py
new file mode 100644
index 0000000..a5211f6
--- /dev/null
+++ b/app/backends/sglomni/models.py
@@ -0,0 +1,283 @@
+"""sglang-omni model weights: install state on disk, (un)install actions.
+
+Model weights are not part of the pip install: each server fetches its
+HuggingFace repo into the standard hub cache on first start. This module
+pre-fetches ("Install") and deletes ("Uninstall") those cache directories
+per model — via the venv's hf CLI, exactly what a first server start
+would do — plus the per-model companion packages the server needs
+(``extras`` in the catalog, e.g. the qwen-tts --no-deps stack or the
+Descript DAC codec).
+
+The cache helpers mirror huggingface_hub's own directory layout and
+environment overrides (the same files ``from_pretrained`` writes), so an
+install lands exactly where a server start would look. The cache is
+shared with the other backends: a repo both host (Qwen3-TTS 1.7B Base /
+VoiceDesign exist in the qwen backend too) is downloaded once and its
+deletion affects both — the same convention every backend here accepts.
+"""
+
+import os
+import shutil
+from pathlib import Path
+from typing import List, Optional
+
+from backends import common, envs
+from backends.sglomni.catalog import ModelEntry, entry_by_key, entry_by_repo
+from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG
+from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env
+
+# The cache directory HF keeps repos in (models--<org>--<name> folders).
+# Resolution mirrors huggingface_hub.constants: HF_HUB_CACHE beats
+# HUGGINGFACE_HUB_CACHE beats HF_HOME/hub beats ~/.cache/huggingface/hub.
+
+
+def _hf_cache_dir() -> Path:
+ override = os.environ.get("HF_HUB_CACHE") or os.environ.get(
+ "HUGGINGFACE_HUB_CACHE")
+ if override:
+ return Path(override)
+ home = os.environ.get("HF_HOME")
+ if home:
+ return Path(home) / "hub"
+ return Path.home() / ".cache" / "huggingface" / "hub"
+
+
+def repo_dir(repo_id: str) -> Path:
+ """The cache directory HF keeps REPO_ID's weights in."""
+ return _hf_cache_dir() / ("models--" + repo_id.replace("/", "--"))
+
+
+def model_repo_dir(entry: ModelEntry) -> Path:
+ """The cached-weights directory for a catalog entry."""
+ return repo_dir(entry.repo)
+
+
+def _tree_has_file(path: Path) -> bool:
+ """True when any file or symlink exists under PATH (recursively)."""
+ try:
+ for item in path.iterdir():
+ # Snapshot files are symlinks into blobs/; count them even when
+ # temporarily broken (presence is what the loader checks).
+ if item.is_symlink() or item.is_file():
+ return True
+ if item.is_dir() and _tree_has_file(item):
+ return True
+ except OSError:
+ return False
+ return False
+
+
+def model_installed(entry: ModelEntry) -> bool:
+ """True when ENTRY's weights look complete in the local HF cache.
+
+ A fetched repo has refs/main plus at least one file under snapshots/;
+ anything less counts as not installed. An interrupted download simply
+ resumes — via Install, or the next server start for that model.
+ """
+ directory = model_repo_dir(entry)
+ if not (directory / "refs" / "main").is_file():
+ return False
+ return _tree_has_file(directory / "snapshots")
+
+
+def installed_entries() -> List[ModelEntry]:
+ """The catalog entries whose weights are already on disk."""
+ return [entry for entry in _all_entries() if model_installed(entry)]
+
+
+def installed_keys() -> List[str]:
+ """The installed entries' catalog keys, in catalog order."""
+ return [entry.key for entry in installed_entries()]
+
+
+def preset_voices(entry: ModelEntry) -> List[str]:
+ """The preset voice names ENTRY can speak with.
+
+ Catalog-declared speakers first (the Qwen3-TTS CustomVoice table);
+ otherwise the checkpoint's own ``voice_embedding/*.pt`` presets are
+ read from the downloaded snapshot (how Voxtral ships its named
+ voices). Empty when the model declares none or is not downloaded.
+ """
+ if entry.speakers:
+ return list(entry.speakers)
+ try:
+ snapshots = model_repo_dir(entry) / "snapshots"
+ for snapshot in sorted(snapshots.iterdir()):
+ voice_dir = snapshot / "voice_embedding"
+ if voice_dir.is_dir():
+ names = sorted(item.stem for item in voice_dir.glob("*.pt")
+ if item.is_file())
+ if names:
+ return names
+ except OSError:
+ pass
+ return []
+
+
+def _all_entries() -> List[ModelEntry]:
+ from backends.sglomni.catalog import ENTRIES
+ return list(ENTRIES)
+
+
+def system_dep_missing(entry: ModelEntry) -> Optional[str]:
+ """Remediation text when ENTRY's system binary is absent (None = ok)."""
+ if entry.system_dep and not shutil.which(entry.system_dep):
+ return (f"{entry.system_dep} (system package) was not found — "
+ f"{entry.system_hint}. The weights still download, but the "
+ "server will fail to synthesize with this model until it "
+ "is installed.")
+ return None
+
+
+def install_model(key: str, *, emit=None, cancel=None) -> int:
+ """Install a catalog model: companion packages, then its weights.
+
+ Companion ``extras`` pip-install into the sglang-omni venv exactly as
+ upstream instructs (``--no-deps`` where upstream says so — the
+ Qwen3-TTS companions must not replace the pinned Transformers 5
+ stack), and a missing system binary is a loud warning, not a stop:
+ the download is still useful and the remediation stays on screen.
+ The weights pre-download via the venv's hf CLI (resumable, streamed,
+ cancelable). Returns the exit code.
+ """
+ entry = entry_by_key(key)
+ if entry is None:
+ print(f"[ERROR] Unknown sglang-omni model: {key!r}")
+ return 1
+ rc = prepare_env(emit=emit, cancel=cancel)
+ if rc != 0:
+ return rc
+ warning = system_dep_missing(entry)
+ if warning:
+ print(f"[WARNING] {warning}")
+ # A GPU the model's default FP8 pipeline cannot run gets the bf16
+ # fallback note up front (the install itself is still useful: the
+ # weights download either way).
+ from backends.sglomni import status as sg_status
+ note = sg_status.gpu_fallback_note(entry)
+ if note:
+ print(f"[WARNING] {note}")
+ for spec, no_deps in entry.extras:
+ args = ["--no-deps"] if no_deps else None
+ rc = common.pip_install([spec], emit=emit, cancel=cancel,
+ env_dir=SGLOMNI_ENV, extra_args=args)
+ if rc != 0:
+ print(f"[WARNING] pip install {spec} failed (exit {rc}); "
+ f"install it into {SGLOMNI_ENV} manually")
+ prefix = _hf_download_prefix()
+ if prefix is None:
+ print("[ERROR] No hf CLI found in the sglang-omni venv; pip "
+ f"install {SGLOMNI_PIP_PKG} first")
+ return 1
+ print(f"[INFO] Downloading {entry.repo} into {_hf_cache_dir()}...")
+ rc = common.run_console_subprocess(
+ prefix + ["download", entry.repo], emit=emit, cancel=cancel)
+ if rc == 0:
+ print(f"[OK] {entry.label} downloaded.")
+ return rc
+
+
+def uninstall_model(key: str, *, emit=None, cancel=None) -> int:
+ """Remove a model's cached weights (the inverse of install_model).
+
+ A locally-managed server currently hosting the model is stopped first
+ (best-effort) so its weights are not deleted under a live process.
+ CANCEL is honored after that stop phase only. Returns the exit code.
+ """
+ entry = entry_by_key(key)
+ if entry is None:
+ print(f"[ERROR] Unknown sglang-omni model: {key!r}")
+ return 1
+ if _managed_running_repo() == entry.repo:
+ from backends import servers
+ servers.stop(SERVER_NAME)
+ if common.cancel_requested(cancel):
+ return 130
+ delete_model_weights([entry])
+ return 0
+
+
+def delete_model_weights(entries: Optional[List[ModelEntry]] = None) -> int:
+ """Delete the cached HF weight dirs of ENTRIES (every model by default).
+
+ Best-effort rmtree of each ``models--<org>--<name>`` directory; returns
+ how many were present and removed. Only those directories are ever
+ touched — the rest of the HF cache may be shared with unrelated tools.
+ """
+ if entries is None:
+ entries = _all_entries()
+ removed = 0
+ for entry in entries:
+ directory = model_repo_dir(entry)
+ if not directory.is_dir():
+ continue
+ print(f"[INFO] Removing cached {entry.repo} weights...")
+ shutil.rmtree(directory, ignore_errors=True)
+ if directory.exists():
+ print(f"[WARNING] Could not fully remove {directory}")
+ continue
+ removed += 1
+ if removed:
+ print(f"[OK] Deleted cached weights for {removed} "
+ f"{'model' if removed == 1 else 'models'}.")
+ return removed
+
+
+def _hf_download_prefix() -> Optional[List[str]]:
+ """The sglang-omni venv's hf CLI argv prefix (None when absent)."""
+ for name in ("hf", "huggingface-cli"):
+ candidate = envs.env_script(name, SGLOMNI_ENV)
+ if candidate.is_file():
+ return [str(candidate)]
+ return None
+
+
+def _managed_running_repo() -> Optional[str]:
+ """The repo id a locally-managed, up-and-running server hosts."""
+ from backends import probe, servers
+ from converter import config
+ if servers.pid_for(SERVER_NAME) is None:
+ return None
+ if not servers.alive(SERVER_NAME):
+ return None
+ return probe.sglomni_served_model(config.SGLOMNI_API_URL)
+
+
+def resolve_model(key: Optional[str]) -> ModelEntry:
+ """The catalog entry a run with MODEL_KEY uses.
+
+ An explicit KEY must exist in the catalog and be installed (a hosted
+ model without weights cannot boot). Without KEY the single installed
+ model is auto-selected; several installed models need an explicit pick
+ (the CLI --model flag or the Generate form's Model menu). Raises
+ RuntimeError with an actionable message otherwise.
+ """
+ if key is not None:
+ entry = entry_by_key(key)
+ if entry is None:
+ known = ", ".join(e.key for e in _all_entries())
+ raise RuntimeError(
+ f"Unknown sglang-omni model {key!r} (installed models are "
+ f"picked by catalog key; known keys: {known})")
+ if not model_installed(entry):
+ raise RuntimeError(
+ f"{entry.label} is not downloaded — install it via "
+ "Configure Backends → SGLang-Omni, or pick an installed "
+ "model.")
+ return entry
+ installed = installed_entries()
+ if not installed:
+ raise RuntimeError(
+ "No sglang-omni models are downloaded — install one via "
+ "Configure Backends → SGLang-Omni (Configure).")
+ if len(installed) > 1:
+ names = ", ".join(e.key for e in installed)
+ raise RuntimeError(
+ "Several sglang-omni models are installed; pick one with "
+ f"--model KEY (installed: {names})")
+ return installed[0]
+
+
+def entry_for_served_repo(repo: Optional[str]) -> Optional[ModelEntry]:
+ """The catalog entry a served /v1/models repo id belongs to."""
+ return entry_by_repo(repo) if repo else None