diff options
| author | historia <historiavg@proton.me> | 2026-09-02 01:26:09 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-02 01:26:09 -0400 |
| commit | 8579517a35ef1865fc9b428899d73d52dcb27a14 (patch) | |
| tree | dba52f8d99cfe4014e0b787367de99f238e5a0db /app/backends/sglomni | |
| parent | 391f50da7a085bec75155c0eb9b47910266058cc (diff) | |
| download | tts-audiobook-generator-8579517a35ef1865fc9b428899d73d52dcb27a14.tar.gz | |
feat: sglang backend support
Diffstat (limited to 'app/backends/sglomni')
19 files changed, 1588 insertions, 0 deletions
diff --git a/app/backends/sglomni/__init__.py b/app/backends/sglomni/__init__.py new file mode 100644 index 0000000..688e488 --- /dev/null +++ b/app/backends/sglomni/__init__.py @@ -0,0 +1,98 @@ +"""sglang-omni backend — setup wizard, server specs, model management. + +A package of focused modules behind one import surface: everything public +is re-exported here, so callers (the hub, the CLI, tests) keep using +``backends.sglomni.<name>`` regardless of which module implements it. + +Modules: + constants shared constants (package name, port, timeouts, configs) + catalog the model catalog + the install tree shape + gpu the NVIDIA GPU facts (nvidia-smi) launch decisions use + pythonenv interpreter selection/provisioning for the backend venv + models model install state, (un)install, run-model resolution + status detect() for the hub's backend menu + the server spec + wizard the TUI wizard, configure screen, uninstall/update, CLI +""" + +from .constants import ( + CONFIGS_DIR, + DEFAULT_PORT, + PYTHON_SPEC, + PYTHON_VERSIONS, + SERVER_NAME, + SERVER_START_TIMEOUT, + SGLOMNI_PIP_PKG, + UV_PIP_PKG, +) +from .gpu import ( + compute_capability, + describe, +) +from .catalog import ( + CAPABILITY_CLONE, + CAPABILITY_DESIGN, + CAPABILITY_SPEAKER, + ENTRIES, + ModelEntry, + config_path, + entries_by_keys, + entry_by_key, + entry_by_repo, + install_tree_families, +) +from .pythonenv import ( + env_compatible, + env_version, + prepare_env, +) +from .models import ( + delete_model_weights, + install_model, + installed_entries, + installed_keys, + model_installed, + preset_voices, + repo_dir, + resolve_model, + uninstall_model, +) +from .status import ( + build_spec, + detect, + gpu_fallback_note, + launch_config_path, + needs_fp8_fallback, +) +from .wizard import ( + build_parser, + main, + models_screen, + run_tui, + setup_screen, + uninstall, + update, +) + +__all__ = [ + # constants + "CONFIGS_DIR", "DEFAULT_PORT", "PYTHON_SPEC", "PYTHON_VERSIONS", + "SERVER_NAME", "SERVER_START_TIMEOUT", "SGLOMNI_PIP_PKG", "UV_PIP_PKG", + # gpu + "compute_capability", "describe", + # catalog + "CAPABILITY_CLONE", "CAPABILITY_DESIGN", "CAPABILITY_SPEAKER", + "ENTRIES", "ModelEntry", "config_path", "entries_by_keys", + "entry_by_key", "entry_by_repo", "install_tree_families", + # pythonenv + "env_compatible", "env_version", "prepare_env", + # models + "delete_model_weights", "install_model", "installed_entries", + "installed_keys", "model_installed", "preset_voices", "repo_dir", + "resolve_model", "uninstall_model", + # status + "build_spec", "detect", "gpu_fallback_note", "launch_config_path", + "needs_fp8_fallback", + # wizard + "build_parser", "main", "models_screen", "run_tui", "setup_screen", + "uninstall", "update", +] diff --git a/app/backends/sglomni/__main__.py b/app/backends/sglomni/__main__.py new file mode 100644 index 0000000..faba61e --- /dev/null +++ b/app/backends/sglomni/__main__.py @@ -0,0 +1,8 @@ +"""Direct CLI execution: ``python -m backends.sglomni`` (from app/).""" + +import sys + +from backends.sglomni import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py new file mode 100644 index 0000000..c89e865 --- /dev/null +++ b/app/backends/sglomni/catalog.py @@ -0,0 +1,277 @@ +"""The sglang-omni model catalog: what can be installed, hosted, and how. + +One server process hosts one model (`sgl-omni serve --model-path <hf-repo> +--config <yaml>`), so each entry is one launchable unit — unlike audio.cpp's +multi-entry server.json. Every entry states how its voice is supplied +(`capability`), whether a reference clip is mandatory for narration +(`requires_reference`), and which model-companion packages (`extras`) must +be pip-installed into the backend venv before its server will start. + +The catalog is static knowledge about sglang-omni's supported TTS models +(v0.1.4-era), not a live query: the server's /v1/models answers only which +model is currently hosted. Entries carry the HuggingFace repo id verbatim — +it is both the `--model-path` value and the model name /v1/models reports, +so a running server is matched back to its entry by that id. + +Voice capabilities (mirroring the audio.cpp client's vocabulary): + speaker the voice is a named preset shipped with the model (Qwen3-TTS + CustomVoice speaker table; Voxtral's preset voices) + clone the voice comes from a reference clip (per-request ref_audio + + ref_text, transcribed with Whisper when not provided) + design the voice is described by instructions (Qwen3-TTS VoiceDesign) +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +# Voice capabilities (same words the audio.cpp client uses). +CAPABILITY_SPEAKER = "speaker" +CAPABILITY_CLONE = "clone" +CAPABILITY_DESIGN = "design" + +# A model-companion pip package: (requirement spec, no_deps). The no-deps +# flag mirrors upstream install instructions that must not drag in their +# own (conflicting) transformer pins — the Qwen3-TTS companion pins +# Transformers 4 against sglang-omni's Transformers 5 stack. +Extra = Tuple[str, bool] + + +@dataclass(frozen=True) +class ModelEntry: + """One installable/hostable sglang-omni TTS model.""" + key: str # catalog id used by --model and the forms + label: str # human-readable name (menus, status lines) + repo: str # HuggingFace repo id == --model-path value + config: Optional[str] # vendored config yaml file name, None = none + capability: str # speaker / clone / design + requires_reference: bool # clone models: narration needs ref audio + extras: Tuple[Extra, ...] = field(default=()) + system_dep: Optional[str] = None # system binary the extras need + system_hint: Optional[str] = None # remediation when the binary is absent + speakers: Optional[Tuple[str, ...]] = None # preset voices (speaker) + supports_seed: bool = False # request-scoped seed accepted (Qwen3-TTS Base) + notes: str = "" # one-line description (documentation) + # The model's DEFAULT pipeline dynamically quantizes its MoE experts to + # FP8 at load time (sglang-omni's zonos2 config hardcodes it) — a Triton + # fp8e4nv kernel that only compiles on compute capability 8.9+ (RTX + # 4090/5090, Hopper). On older GPUs the server dies mid-boot; BF16_CONFIG + # is the vendored config that turns FP8 off so the model runs in bf16 + # (~2x the MoE VRAM) there instead. + fp8_moe: bool = False + fp8_min_compute_capability: Optional[Tuple[int, int]] = None + bf16_config: Optional[str] = None + + +# The Qwen3-TTS CustomVoice speaker table — the same built-in speakers the +# qwen-tts demo and audio.cpp's CustomVoice entry expose (kept as a local +# copy so this catalog stays importable without the converter package; the +# test suite asserts it matches converter.clients.speakers). +QWEN_CUSTOMVOICE_SPEAKERS: Tuple[str, ...] = ( + "Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", + "Ryan", "Aiden", "Ono_Anna", "Sohee") + +# The Qwen3-TTS companions follow the upstream TTS guide exactly: the +# qwen-tts demo package installs WITHOUT dependencies (its Transformers 4 +# pin would replace sglang-omni's pinned 5.12 stack; sglang-omni shims the +# API differences), as do sox/einops (a normal sox resolve pulls numpy past +# the ceiling numba imposes). The sox *binary* is a system package the +# wizard detects and guides (it is required at synthesis time). +_QWEN_EXTRAS: Tuple[Extra, ...] = ( + ("sox", True), ("einops", True), ("qwen-tts==0.1.1", True)) +_SOX_HINT = ("install the sox system package (e.g. sudo pacman -S sox, " + "sudo apt install sox, brew install sox)") +# The Fish Audio and ZONOS2 pipelines use the Descript DAC codec, which +# upstream installs WITH dependencies (nothing conflicts). +_DAC_EXTRAS: Tuple[Extra, ...] = ( + ("descript-audiotools==0.7.2", False), + ("descript-audio-codec==1.0.0", False)) + +ENTRIES: Tuple[ModelEntry, ...] = ( + ModelEntry( + key="qwen3_tts_0_6b_customvoice", + label="Qwen3-TTS 0.6B CustomVoice", + repo="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", + config="qwen3_tts_0_6b_customvoice.yaml", + capability=CAPABILITY_SPEAKER, + requires_reference=False, + extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, + speakers=QWEN_CUSTOMVOICE_SPEAKERS, + notes="built-in speakers, lightest model", + ), + ModelEntry( + key="qwen3_tts_0_6b_base", + label="Qwen3-TTS 0.6B Base", + repo="Qwen/Qwen3-TTS-12Hz-0.6B-Base", + config="qwen3_tts_0_6b.yaml", + capability=CAPABILITY_CLONE, + requires_reference=True, + extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, + supports_seed=True, + notes="voice cloning from a reference clip", + ), + ModelEntry( + key="qwen3_tts_1_7b_base", + label="Qwen3-TTS 1.7B Base", + repo="Qwen/Qwen3-TTS-12Hz-1.7B-Base", + config="qwen3_tts_1_7b.yaml", + capability=CAPABILITY_CLONE, + requires_reference=True, + extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, + supports_seed=True, + notes="voice cloning, higher quality", + ), + ModelEntry( + key="qwen3_tts_1_7b_voicedesign", + label="Qwen3-TTS 1.7B VoiceDesign", + repo="Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign", + config="qwen3_tts_1_7b_voicedesign.yaml", + capability=CAPABILITY_DESIGN, + requires_reference=False, + extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT, + notes="voice described by instructions", + ), + ModelEntry( + key="higgs_audio_v3_tts", + label="Higgs Audio v3 TTS", + repo="bosonai/higgs-audio-v3-tts-4b", + config=None, + capability=CAPABILITY_CLONE, + requires_reference=False, + notes="zero-shot narration, cloning from a reference clip", + ), + ModelEntry( + key="moss_tts", + label="MOSS-TTS v1.5", + repo="OpenMOSS-Team/MOSS-TTS-v1.5", + config="moss_tts.yaml", + capability=CAPABILITY_CLONE, + requires_reference=True, + notes="voice cloning from a reference clip", + ), + ModelEntry( + key="moss_tts_local", + label="MOSS-TTS Local v1.5", + repo="OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5", + config="moss_tts_local.yaml", + capability=CAPABILITY_CLONE, + requires_reference=False, + notes="48 kHz, narration without a reference or cloning", + ), + ModelEntry( + key="voxtral_tts", + label="Voxtral TTS 4B", + repo="mistralai/Voxtral-4B-TTS-2603", + config="voxtral_tts.yaml", + capability=CAPABILITY_SPEAKER, + requires_reference=False, + notes="preset voices, text-only requests", + ), + ModelEntry( + key="dots_tts_mf", + label="dots.tts (MeanFlow)", + repo="dots-studio/dots.tts-mf", + config="dots_tts.yaml", + capability=CAPABILITY_CLONE, + requires_reference=True, + notes="voice cloning, requires reference audio + transcript", + ), + ModelEntry( + key="fish_s2_pro", + label="Fish Speech S2-Pro", + repo="fishaudio/s2-pro", + config="s2pro_tts.yaml", + capability=CAPABILITY_CLONE, + requires_reference=False, + extras=_DAC_EXTRAS, + notes="zero-shot narration or cloning from a reference clip", + ), + ModelEntry( + key="zonos2", + label="ZONOS2", + repo="Zyphra/zonos2", + config=None, + capability=CAPABILITY_CLONE, + requires_reference=True, + extras=_DAC_EXTRAS, + notes="voice cloning, 44.1 kHz DAC vocoder", + fp8_moe=True, + fp8_min_compute_capability=(8, 9), + bf16_config="zonos2_bf16.yaml", + ), +) + +_BY_KEY: Dict[str, ModelEntry] = {entry.key: entry for entry in ENTRIES} +_BY_REPO: Dict[str, ModelEntry] = {entry.repo: entry for entry in ENTRIES} + + +def entry_by_key(key: str) -> Optional[ModelEntry]: + """The catalog entry for a catalog KEY, or None.""" + return _BY_KEY.get(key) + + +def entry_by_repo(repo: str) -> Optional[ModelEntry]: + """The catalog entry hosting REPO (a /v1/models id), or None.""" + return _BY_REPO.get(repo) + + +def entries_by_keys(keys) -> List[ModelEntry]: + """The ENTRIES for KEYS, in catalog order (unknown keys dropped).""" + wanted = set(keys) + return [entry for entry in ENTRIES if entry.key in wanted] + + +def config_path(entry: ModelEntry): + """The vendored config yaml path for ENTRY, or None when it runs on + --model-path alone (Higgs, ZONOS2). A declared-but-missing file means + a broken install — callers treat that like a missing entry.""" + return _config_file(entry.config) + + +def fallback_config_path(entry: ModelEntry): + """The vendored bf16 config yaml for ENTRY (None when it has none). + + The config a GPU below the entry's fp8_min_compute_capability launches + with instead of the model's default FP8-quantized pipeline.""" + return _config_file(entry.bf16_config) + + +def _config_file(name: Optional[str]): + """CONFIGS_DIR/NAME, or None when NAME is None (no config for ENTRY).""" + if name is None: + return None + from .constants import CONFIGS_DIR + return CONFIGS_DIR / name + + +def install_tree_families(available: List[ModelEntry]) -> List[dict]: + """The checkbox-tree shape for the model picker, grouped by upstream. + + One family per model origin with one option per model, so the picker + reads like the audio.cpp one (a collapsed tree of related packages). + No ``detail`` is set: the tree's status line under the buttons would + only repeat the catalog keys under the cursor. AVAILABLE filters what + is shown (already-installed models stay listed so they can be + re-checked or repaired). + """ + groups: List[Tuple[str, List[ModelEntry]]] = [ + ("Qwen3-TTS (Qwen)", [e for e in available if e.repo.startswith("Qwen/")]), + ("Boson AI", [e for e in available if e.repo.startswith("bosonai/")]), + ("OpenMOSS", [e for e in available if e.repo.startswith("OpenMOSS-Team/")]), + ("Mistral AI", [e for e in available if e.repo.startswith("mistralai/")]), + ("dots.studio", [e for e in available if e.repo.startswith("dots-studio/")]), + ("Fish Audio", [e for e in available if e.repo.startswith("fishaudio/")]), + ("Zyphra", [e for e in available if e.repo.startswith("Zyphra/")]), + ] + families = [] + for label, entries in groups: + if not entries: + continue + families.append({ + "label": label, + "options": [ + {"key": entry.key, "label": entry.label, + "recommended": False} + for entry in entries + ], + }) + return families diff --git a/app/backends/sglomni/configs/dots_tts.yaml b/app/backends/sglomni/configs/dots_tts.yaml new file mode 100644 index 0000000..d2c42f4 --- /dev/null +++ b/app/backends/sglomni/configs/dots_tts.yaml @@ -0,0 +1,36 @@ +config_cls: DotsTTSPipelineConfig +model_path: dots-studio/dots.tts-mf + +# The solver settings feed two consumers: the latent AR engine solves with +# them, and preprocessing sizes its generation schedule from them. The +# selector writes one value into both stages; an explicit per-stage entry +# under stages: would override it. +shared: + - select: + stages: [preprocessing, latent_engine] + factory: + num_steps: 4 + max_generate_length: 500 + +stages: + reference_encode: + factory: + max_concurrency: 8 + max_batch_size: 1 + max_batch_wait_ms: 4 + latent_engine: + factory: + optimize: true + engine: + mem_fraction_static: 0.20 + max_running_requests: 16 + # note (luojiaxuan): backbone decode runs through the SGLang CUDA graph + # with the model-owned feedback buffer; delete these two lines (or set + # disable_cuda_graph: true) to fall back to eager backbone decode. + disable_cuda_graph: false + cuda_graph_max_bs: 16 + vocoder: + factory: + optimize: true + max_batch_size: 4 + max_batch_wait_ms: 2 diff --git a/app/backends/sglomni/configs/moss_tts.yaml b/app/backends/sglomni/configs/moss_tts.yaml new file mode 100644 index 0000000..ef8d8fa --- /dev/null +++ b/app/backends/sglomni/configs/moss_tts.yaml @@ -0,0 +1,2 @@ +config_cls: MossTTSPipelineConfig +model_path: OpenMOSS-Team/MOSS-TTS-v1.5 diff --git a/app/backends/sglomni/configs/moss_tts_local.yaml b/app/backends/sglomni/configs/moss_tts_local.yaml new file mode 100644 index 0000000..0b37f4e --- /dev/null +++ b/app/backends/sglomni/configs/moss_tts_local.yaml @@ -0,0 +1,2 @@ +config_cls: MossTTSLocalPipelineConfig +model_path: OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5 diff --git a/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml b/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml new file mode 100644 index 0000000..a712ef9 --- /dev/null +++ b/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml @@ -0,0 +1,2 @@ +config_cls: Qwen3TTSPipelineConfig +model_path: Qwen/Qwen3-TTS-12Hz-0.6B-Base diff --git a/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml b/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml new file mode 100644 index 0000000..6b284da --- /dev/null +++ b/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml @@ -0,0 +1,2 @@ +config_cls: Qwen3TTSPipelineConfig +model_path: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice diff --git a/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml b/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml new file mode 100644 index 0000000..4f7706d --- /dev/null +++ b/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml @@ -0,0 +1,2 @@ +config_cls: Qwen3TTSPipelineConfig +model_path: Qwen/Qwen3-TTS-12Hz-1.7B-Base diff --git a/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml b/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml new file mode 100644 index 0000000..20ae0b8 --- /dev/null +++ b/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml @@ -0,0 +1,2 @@ +config_cls: Qwen3TTSPipelineConfig +model_path: Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign diff --git a/app/backends/sglomni/configs/s2pro_tts.yaml b/app/backends/sglomni/configs/s2pro_tts.yaml new file mode 100644 index 0000000..0bc3eba --- /dev/null +++ b/app/backends/sglomni/configs/s2pro_tts.yaml @@ -0,0 +1,2 @@ +config_cls: S2ProPipelineConfig +model_path: fishaudio/s2-pro diff --git a/app/backends/sglomni/configs/voxtral_tts.yaml b/app/backends/sglomni/configs/voxtral_tts.yaml new file mode 100644 index 0000000..450cbce --- /dev/null +++ b/app/backends/sglomni/configs/voxtral_tts.yaml @@ -0,0 +1,2 @@ +config_cls: VoxtralTTSPipelineConfig +model_path: mistralai/Voxtral-4B-TTS-2603 diff --git a/app/backends/sglomni/configs/zonos2_bf16.yaml b/app/backends/sglomni/configs/zonos2_bf16.yaml new file mode 100644 index 0000000..79d5d26 --- /dev/null +++ b/app/backends/sglomni/configs/zonos2_bf16.yaml @@ -0,0 +1,28 @@ +# ZONOS2 without the FP8-quantized MoE pipeline. +# +# sglang-omni's default ZONOS2 config (Zonos2PipelineConfig) hardcodes +# `fp8: True` in the tts_engine stage factory: the MoE experts are +# dynamically quantized bf16 -> fp8 at load time, and sglang's fused-MoE +# Triton kernel for fp8e4nv only compiles on compute capability 8.9+ +# (RTX 4090/5090, Hopper). On older GPUs the server dies mid-boot with +# `type fp8e4nv not supported in this architecture`. +# +# This copy turns FP8 off (`factory.fp8: false` overrides the hardcoded +# kwarg — free-form factory keys pass through to the stage factory), so +# the model runs in bf16 and works on e.g. Ampere (RTX 30xx, A100) at +# roughly twice the MoE VRAM. The managed spec selects it automatically +# on GPUs the FP8 path cannot run (backends.sglomni.status.build_spec). +# +# The bf16 weights (~11.5 GB) also need a bigger static-pool budget than +# the builder's default 0.5: on a 24 GB card 0.5 leaves no room for the +# KV cache inside 12 GB, and the server aborts with "Loaded weights +# leave no GPU memory for the KV cache" (the profiler asks for >=0.64). +# 0.70 gives ~16.8 GB static -> ~1.4 GB KV, and leaves ~7 GB of the card +# for the colocated speaker-encode/vocoder stages. +config_cls: Zonos2PipelineConfig +model_path: Zyphra/zonos2 +stages: + tts_engine: + factory: + fp8: false + mem_fraction_static: 0.70 diff --git a/app/backends/sglomni/constants.py b/app/backends/sglomni/constants.py new file mode 100644 index 0000000..97a4f3d --- /dev/null +++ b/app/backends/sglomni/constants.py @@ -0,0 +1,37 @@ +"""Shared constants for the sglang-omni backend.""" + +from pathlib import Path + +# The pip package providing the `sgl-omni` server CLI. Installed unpinned +# (like qwen-tts and faster-qwen3-tts) so the update action can move with +# upstream releases; the stack this code was verified against is 0.1.4. +SGLOMNI_PIP_PKG = "sglang-omni" +# uv is provisioned into the app env only when a Python the sglang-omni +# stack accepts (>=3.10,<3.13) is not already available. +UV_PIP_PKG = "uv" + +# The dedicated venv is backends.envs.SGLOMNI_ENV_DIR (imported from there +# by the modules that need it) — its interpreter may differ from the +# launching Python's, see pythonenv.prepare_env. + +# The vendored copies of upstream examples/configs/*.yaml (the `sgl-omni +# serve --config` argument). Two catalog models run without one (Higgs, +# ZONOS2) and are launched from --model-path alone. +CONFIGS_DIR = Path(__file__).resolve().parent / "configs" + +# The managed server's port when the configured URL names none. +DEFAULT_PORT = 8100 + +# The ServerSpec name (pid/log files: app/logs/sglomni-server.*). +SERVER_NAME = "sglomni" + +# The managed server boots a multi-stage pipeline (preprocessing, TTS +# generation, vocoder) and may pull companion weights on first start, so +# its spec overrides the shared 600 s start timeout. +SERVER_START_TIMEOUT = 1200 + +# Python interpreters the sglang-omni stack accepts (requires-python +# ">=3.10,<3.13"), newest first — the search order for both the +# compatible-interpreter scan and the uv fallback. +PYTHON_VERSIONS = ((3, 12), (3, 11), (3, 10)) +PYTHON_SPEC = "3.12" diff --git a/app/backends/sglomni/gpu.py b/app/backends/sglomni/gpu.py new file mode 100644 index 0000000..f305098 --- /dev/null +++ b/app/backends/sglomni/gpu.py @@ -0,0 +1,62 @@ +"""NVIDIA GPU facts for launch decisions (best-effort, via nvidia-smi). + +sglang-omni model pipelines carry GPU-architecture constraints the app +must respect when it builds a server spec (e.g. ZONOS2's default pipeline +quantizes its MoE experts to FP8 — a Triton kernel that only compiles on +compute capability 8.9+). The GPU's compute capability is read from +``nvidia-smi`` rather than torch so the app venv needs no CUDA stack, and +every answer here is cached: the hardware cannot change mid-process. + +Everything is best-effort: when nvidia-smi is absent, errors out, or +reports something unparsable the callers get None and keep upstream +defaults instead of second-guessing the environment. +""" + +import shutil +import subprocess +from functools import lru_cache +from typing import Optional, Tuple + + +@lru_cache(maxsize=1) +def _query() -> Optional[Tuple[str, str]]: + """(name, "M.m" compute cap) for GPU 0, or None when unanswerable.""" + if shutil.which("nvidia-smi") is None: + return None + try: + proc = subprocess.run( + ["nvidia-smi", "--query-gpu=name,compute_cap", + "--format=csv,noheader,nounits", "-i", "0"], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return None + first = proc.stdout.strip().splitlines()[:1] + if proc.returncode != 0 or not first: + return None + fields = [field.strip() for field in first[0].split(",")] + if len(fields) < 2: + return None + return fields[0], fields[1] + + +def compute_capability() -> Optional[Tuple[int, int]]: + """The first NVIDIA GPU's (major, minor) compute capability, or None. + + GPU 0 is what the managed sglang-omni pipeline stages bind to. An + unparsable capability string counts as unanswerable.""" + answer = _query() + if answer is None: + return None + try: + major, minor = (int(part) for part in answer[1].split(".")) + except ValueError: + return None + return major, minor + + +def describe() -> Optional[str]: + """A human-readable "NAME (compute capability M.m)" line, or None.""" + answer = _query() + if answer is None: + return None + return f"{answer[0]} (compute capability {answer[1]})" 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 diff --git a/app/backends/sglomni/pythonenv.py b/app/backends/sglomni/pythonenv.py new file mode 100644 index 0000000..11bbaa6 --- /dev/null +++ b/app/backends/sglomni/pythonenv.py @@ -0,0 +1,96 @@ +"""The sglang-omni venv: interpreter selection and provisioning. + +sglang-omni requires Python >=3.10,<3.13 while the app itself is +version-agnostic — a host whose only ``python`` is 3.13+ must still be +able to install and run this backend. ``prepare_env`` resolves a usable +interpreter for the dedicated venv (``app/envs/sglomni``), in order: + +1. the venv already exists with an acceptable interpreter (no-op); +2. the app env's interpreter or a ``python3.10/3.11/3.12`` on PATH fits + (the venv is created from it — no download); +3. uv (pip-installed into the app env) provisions a managed standalone + CPython 3.12 into ``app/envs/pythons`` and creates the venv from it — + the zero-prerequisites path for hosts like stock Arch, where only the + latest Python exists and no root/package-manager knowledge is needed. + +An existing venv built with an incompatible interpreter (an older tool +version, a since-upgraded system Python) is transparently recreated: a +venv holds nothing user-owned, and every package in it is reinstalled by +the setup that follows anyway. +""" + +import shutil +import sys +from pathlib import Path +from typing import Optional, Tuple + +from backends import common, envs +from backends.sglomni.constants import PYTHON_SPEC, PYTHON_VERSIONS + +# The dedicated venv (its interpreter may differ from the launching one). +SGLOMNI_ENV = envs.SGLOMNI_ENV_DIR + + +def env_version() -> Optional[Tuple[int, int]]: + """The (major, minor) Python version of the venv's interpreter.""" + if not envs.env_exists(SGLOMNI_ENV): + return None + return envs.python_version(envs.env_python(SGLOMNI_ENV)) + + +def env_compatible() -> bool: + """True when the venv exists with a Python the sglang-omni stack accepts.""" + version = env_version() + return version is not None and tuple(version) in {tuple(v) for v in PYTHON_VERSIONS} + + +def prepare_env(*, emit=None, cancel=None) -> int: + """Make SGLOMNI_ENV exist with a Python in PYTHON_VERSIONS. Returns 0/1. + + The three resolution paths are described in the module docstring; every + path ends with a pip-equipped venv so the regular ``python -m pip`` + helpers (requirements installs, model-companion extras) keep working. + Prints what it does so the task view shows why a download happens (or, + usually, does not). + """ + if env_compatible(): + return 0 + if envs.env_exists(SGLOMNI_ENV): + version = env_version() + found = f"{version[0]}.{version[1]}" if version else "unknown" + print(f"[WARNING] {SGLOMNI_ENV} was built with Python {found}, " + "which the sglang-omni stack does not support (needs " + "3.10-3.12); recreating it with a compatible interpreter.") + # Nothing user-owned lives in the tool-managed venv, and every + # package is reinstalled by the steps that follow this one. + shutil.rmtree(SGLOMNI_ENV, ignore_errors=True) + if SGLOMNI_ENV.exists(): + print("[ERROR] Could not remove the incompatible venv; " + f"delete {SGLOMNI_ENV} manually and re-run setup.") + return 1 + + interpreter = envs.compatible_interpreter(PYTHON_VERSIONS) + if interpreter is not None: + if interpreter == envs.env_python(envs.ENV_DIR): + print(f"[INFO] using the app environment's interpreter " + f"({sys.version_info.major}.{sys.version_info.minor}) " + "for the sglang-omni venv.") + else: + print(f"[INFO] using {interpreter} for the sglang-omni venv.") + return envs.create_env(SGLOMNI_ENV, interpreter) + + # No compatible interpreter anywhere: provision one with uv. + print("[INFO] no Python 3.10-3.12 found on this system — provisioning " + f"a managed CPython {PYTHON_SPEC} with uv (no root required).") + rc = envs.ensure_uv(emit=emit, cancel=cancel) + if rc != 0: + print("[WARNING] pip install uv failed (exit " + f"{rc}); install uv manually and re-run setup") + return rc + rc = envs.provision_env_with_uv(SGLOMNI_ENV, PYTHON_SPEC, + emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] uv venv failed (exit {rc}); create the venv " + f"manually, e.g.: uv venv --seed --python {PYTHON_SPEC} " + f"{SGLOMNI_ENV}") + return rc diff --git a/app/backends/sglomni/status.py b/app/backends/sglomni/status.py new file mode 100644 index 0000000..099817e --- /dev/null +++ b/app/backends/sglomni/status.py @@ -0,0 +1,207 @@ +"""detect() for the hub's backend menu: how far sglang-omni is set up.""" + +from pathlib import Path +from typing import List, Optional + +from backends import BackendStatus, ServerSpec, envs, format_launch_hint, \ + probe, servers +from backends.sglomni import gpu +from backends.sglomni.catalog import ModelEntry, entry_by_repo, \ + fallback_config_path, config_path +from backends.sglomni.constants import DEFAULT_PORT, SERVER_NAME, \ + SERVER_START_TIMEOUT, SGLOMNI_PIP_PKG +from backends.sglomni.models import installed_entries +from backends.sglomni.pythonenv import SGLOMNI_ENV, env_version +from converter import config + + +def _is_installed() -> bool: + if envs.env_script("sgl-omni", SGLOMNI_ENV).is_file(): + return True + return envs.module_available("sglang_omni", SGLOMNI_ENV) + + +def build_spec(entry: ModelEntry) -> ServerSpec: + """The managed ServerSpec hosting ENTRY on the configured port. + + Public because the run preparation (hub) and the CLI's managed-server + bootstrap need to boot exactly the model their run selected, which can + differ from detect()'s default. The config yaml is the vendored copy + from the catalog; models without one (Higgs, ZONOS2) run from + --model-path alone — except that a GPU the FP8 kernels cannot run + launches ZONOS2's vendored bf16 config instead (launch_config_path). + """ + url = config.SGLOMNI_API_URL + argv: List[str] = [ + str(envs.env_script("sgl-omni", SGLOMNI_ENV)), + "serve", "--model-path", entry.repo, + ] + yaml_path = launch_config_path(entry) + if yaml_path is not None: + argv += ["--config", str(yaml_path)] + argv += ["--port", str(_port())] + return ServerSpec(SERVER_NAME, url, argv, + identity=probe.IDENTITY_SGLOMNI, + start_timeout=SERVER_START_TIMEOUT) + + +def needs_fp8_fallback(entry: ModelEntry) -> bool: + """True when ENTRY's default FP8-quantized pipeline cannot run here. + + Only models with a declared fp8_min_compute_capability are candidates, + and only when an NVIDIA GPU actually answers: a GPU this tool cannot + read keeps upstream defaults rather than second-guessing the host.""" + if not (entry.fp8_moe and entry.fp8_min_compute_capability): + return False + capability = gpu.compute_capability() + if capability is None: + return False + return capability < entry.fp8_min_compute_capability + + +def launch_config_path(entry: ModelEntry) -> Optional[Path]: + """The vendored config yaml ENTRY's server should launch with (None = + --model-path alone, the upstream default). + + A model whose default pipeline quantizes its MoE experts to FP8 + launches its vendored bf16 config instead on GPUs the FP8 Triton + kernels cannot compile on (needs_fp8_fallback) — the server then runs + the model in bf16 at about twice the MoE VRAM. A missing fallback + file degrades to the model's normal config (the server reports the + FP8 failure itself; the boot-failure hint names it).""" + if needs_fp8_fallback(entry) and entry.bf16_config: + path = fallback_config_path(entry) + if path is not None and path.is_file(): + return path + return config_path(entry) + + +def gpu_fallback_note(entry: ModelEntry) -> Optional[str]: + """A human-readable note when ENTRY launches its bf16 fallback here. + + Printed by the install and boot flows so the (small) VRAM cost and + the reason are on the record before the server starts. None when the + model runs its default (FP8) pipeline, or when no GPU answered.""" + if not needs_fp8_fallback(entry): + return None + minimum = (".".join(str(part) + for part in entry.fp8_min_compute_capability)) + where = gpu.describe() or "unknown GPU" + path = fallback_config_path(entry) + if path is None or not path.is_file(): + return (f"{entry.label}'s default pipeline quantizes its MoE " + f"experts to FP8, which needs compute capability " + f"{minimum}+; this GPU ({where}) cannot run it, and the " + "vendored bf16 fallback config is missing — the server " + "will fail to start this model.") + return (f"{entry.label}'s default pipeline quantizes its MoE experts " + f"to FP8, which needs compute capability {minimum}+; this GPU " + f"({where}) cannot run it — launching the vendored bf16 " + "config instead (about twice the MoE VRAM).") + + +def _port() -> int: + return _port_of(config.SGLOMNI_API_URL) + + +def _port_of(url: str) -> int: + import urllib.parse + try: + return urllib.parse.urlsplit(url).port or DEFAULT_PORT + except ValueError: + return DEFAULT_PORT + + +def _managed_running_entry() -> Optional[ModelEntry]: + """The catalog model a locally-managed, running server hosts.""" + if servers.pid_for(SERVER_NAME) is None: + return None + if not servers.alive(SERVER_NAME): + return None + return entry_by_repo( + probe.sglomni_served_model(config.SGLOMNI_API_URL)) + + +def detect() -> BackendStatus: + """Detect whether sglang-omni is installed, plus the launch command. + + One managed spec exists per detection, hosting the first *installed* + catalog model (catalog order) on the single configured port — runs + needing another model boot it via their own spec (the Generate form's + Model menu / CLI --model), restarting a managed server that hosts + something else. Which model currently answers is read via the probe + (local pid alive → our URL; otherwise the remote URL) so the status + names the *running* model even when it differs from the default one. + """ + installed = _is_installed() + present = installed_entries() + configured = installed and bool(present) + details: List[str] = [] + details.append("pip: installed" if installed else + f"not installed — run setup to pip install " + f"{SGLOMNI_PIP_PKG}") + if installed: + version = env_version() + if version is not None: + details.append(f"python: {version[0]}.{version[1]}") + else: + details.append("python: unknown version") + if present: + # A model launching its bf16 fallback on this GPU says so, so the + # status line explains why its server boots with a config file. + def _model_tag(model_entry: ModelEntry) -> str: + if needs_fp8_fallback(model_entry): + return f"{model_entry.key} (bf16 fallback)" + return model_entry.key + details.append(f"models: {', '.join(_model_tag(e) for e in present)}") + else: + details.append("no models downloaded — run setup (or Configure) to " + "install one") + details.append(f"port: {_port()}") + specs: List[ServerSpec] = [build_spec(present[0])] if configured else [] + managed = servers.manages(specs) + local_models: List[str] = [] + if managed and specs and servers.alive(specs[0].name): + entry = _managed_running_entry() + if entry is not None: + local_models.append(entry.label) + remote_models, remote_urls = _detect_remote(managed) + running_models = list(dict.fromkeys(local_models + remote_models)) + return BackendStatus( + SERVER_NAME, "SGLang-Omni", + installed=installed, configured=configured, + running=managed or bool(remote_urls), + details=details, + launch_hint=format_launch_hint(specs), + servers=specs, + managed=managed, + remote=bool(remote_urls), + remote_urls=remote_urls, + remote_models=remote_models, + running_models=running_models, + partial="installed (no models)" if installed and not configured + else "") + + +def _detect_remote(managed: bool = False): + """Detect an externally-run sglang-omni server at the remote URL. + + Returns ``([model_label, ...], {spec_name: url})``. The remote URL must + answer as sglang-omni (probe identity); when it equals the local URL + and this tool started that server, it is ignored (already reported as + "[local]"). + """ + remote_models: List[str] = [] + remote_urls: dict = {} + url = (config.SGLOMNI_REMOTE_URL or "").strip() + if not url: + return remote_models, remote_urls + if managed and probe.same_endpoint(url, config.SGLOMNI_API_URL): + return remote_models, remote_urls + if probe.identify_server(url) != probe.IDENTITY_SGLOMNI: + return remote_models, remote_urls + remote_urls[SERVER_NAME] = url + entry = entry_by_repo(probe.sglomni_served_model(url)) + remote_models.append(entry.label if entry + else probe.sglomni_served_model(url) or "unknown") + return remote_models, remote_urls diff --git a/app/backends/sglomni/wizard.py b/app/backends/sglomni/wizard.py new file mode 100644 index 0000000..2083ec8 --- /dev/null +++ b/app/backends/sglomni/wizard.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +"""Set up the SGLang-Omni backend for the audiobook generator. + +sglang-omni is a pip package (``sglang-omni``, providing the ``sgl-omni`` +server CLI) that serves one TTS model per server process from the +OpenAI-compatible ``/v1/audio/speech`` endpoint. This module sets the +backend up end-to-end: provision a Python 3.10-3.12 venv (``app/envs/ +sglomni`` — the stack does not support 3.13+, and the interpreter is +provisioned with uv when the host has none), pip-install the package, +and install the models picked in the wizard (companion packages per the +upstream recipes + HuggingFace weight pre-download). + +It is driven by ``audiobook.py``'s hub but can also be run directly: + +Usage: + python -m backends.sglomni [--models KEY[,KEY...]] [--all] + [--skip-install] [--skip-python] + +The Configure screen (``models_screen``) manages models after the fact — +the same checkbox tree the setup wizard uses (mirroring the audio.cpp +modify flow): the installed models start checked, checking installs a +model, and unchecking one removes its cached weights after a confirm. +""" + +import argparse +import shutil +import sys +from pathlib import Path +from typing import List, Optional, Tuple + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from backends import common, envs, servers, setup +from backends.sglomni import catalog as sg_catalog +from backends.sglomni import models as sg_models +from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG, \ + UV_PIP_PKG +from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env +from backends.sglomni.status import _is_installed +from ui import taskview, tui + +_GO_BACK = object() + + +def _nvidia_gpu_present() -> bool: + """True when an NVIDIA driver answers nvidia-smi (best effort).""" + proc = common.run_console_subprocess_quiet( + ["nvidia-smi", "-L"], timeout=10) + return proc is not None and proc.returncode == 0 + + +def _preflight() -> List[str]: + """Blocking-problem messages (empty = fine); warnings print inline.""" + problems: List[str] = [] + if sys.platform == "win32": + problems.append( + "SGLang-Omni does not support Windows: its CUDA serving stack " + "(sglang, flash-attn, NVIDIA-only wheels) has no Windows " + "builds. Use the audio.cpp, qwen or faster backend instead.") + return problems + + +def _gpu_warning() -> Optional[str]: + if _nvidia_gpu_present(): + return None + return ("No NVIDIA GPU was detected (nvidia-smi did not answer). " + "SGLang-Omni serves CUDA-only: the install will succeed but " + "the server will not start without one.") + + +def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: + """Pick the models to install (the wizard's only question). + + A checkbox tree grouped by upstream org, pre-checked with the already + installed models (a modify flow — the same tree the Configure screen + uses): checking a model installs it, unchecking one removes its + cached weights after a confirm, and confirming an empty tree installs + the package only. The confirm's diff runs as ordered task-view steps + (venv, pip package, then removals before downloads). Esc returns None + (aborted); declining the uninstall confirm re-opens the tree. + """ + problems = _preflight() + for problem in problems: + if stdscr is not None: + tui.flash(stdscr, problem, "err") + else: + print(f"[ERROR] {problem}") + return None + + warning = _gpu_warning() + if warning is not None: + if stdscr is not None: + if not tui.confirm(stdscr, f"{warning} Install anyway?", + default=False): + return None + else: + print(f"[WARNING] {warning}") + + while True: + families = sg_catalog.install_tree_families(list(sg_catalog.ENTRIES)) + installed = set(sg_models.installed_keys()) + picked = tui.checkbox_tree( + stdscr, "Select SGLang-Omni Models to Install", families, + expand_all=True, back_value=_GO_BACK, + checked={(index, option["key"]) + for index, family in enumerate(families) + for option in family["options"] + if option["key"] in installed}, + start_on_buttons=bool(installed), allow_empty=True) + if picked is _GO_BACK: + return None + to_uninstall, to_install = _model_diff( + {key for _index, key in picked}) + if to_uninstall and not _confirm_uninstall(stdscr, to_uninstall): + continue + return { + "keys": [entry.key for entry in to_install], + "uninstall_keys": [entry.key for entry in to_uninstall], + "do_python": not args.skip_python, + "do_install": (not _is_installed()) and not args.skip_install, + } + + +def _execute_steps(settings: dict) -> List[taskview.TaskStep]: + """The ordered setup steps: Python venv, pip package, models.""" + steps: List[taskview.TaskStep] = [] + + if settings.get("do_python"): + def python(emit, cancel): + rc = prepare_env(emit=emit, cancel=cancel) + if rc != 0: + print("[WARNING] could not prepare a Python 3.10-3.12 " + "venv; see the messages above") + return rc + steps.append(taskview.TaskStep("Prepare Python 3.10-3.12", python)) + + if settings.get("do_install"): + def install(emit, cancel): + rc = common.pip_install([SGLOMNI_PIP_PKG], emit=emit, + cancel=cancel, env_dir=SGLOMNI_ENV, + extra_args=["--pre"]) + if rc != 0: + print(f"[WARNING] pip install failed (exit {rc}); install " + f"{SGLOMNI_PIP_PKG} manually") + else: + print(f"[OK] {SGLOMNI_PIP_PKG} installed") + return rc + steps.append(taskview.TaskStep(f"Install {SGLOMNI_PIP_PKG}", + install)) + + steps += _reconcile_steps( + sg_catalog.entries_by_keys(settings.get("uninstall_keys", [])), + sg_catalog.entries_by_keys(settings.get("keys", []))) + return steps + + +def _model_diff(picked_keys: set) -> Tuple[List[sg_catalog.ModelEntry], + List[sg_catalog.ModelEntry]]: + """The (to_uninstall, to_install) diff a tree selection implies. + + Both lists are in catalog order: to_uninstall holds the currently + installed models the tree left unchecked, to_install the checked ones + whose weights are not on disk yet. + """ + installed = set(sg_models.installed_keys()) + to_uninstall = [entry for entry in sg_catalog.ENTRIES + if entry.key in installed + and entry.key not in picked_keys] + to_install = [entry for entry in sg_catalog.ENTRIES + if entry.key in picked_keys + and entry.key not in installed] + return to_uninstall, to_install + + +def _confirm_uninstall(stdscr, + entries: List[sg_catalog.ModelEntry]) -> bool: + """Confirm deleting cached weights; Esc counts as a decline.""" + question = (f"Remove cached weights for {len(entries)} " + f"{'model' if len(entries) == 1 else 'models'}?") + body = ["Their cached weights are deleted — a managed server hosting", + "one of them is stopped first, and removed weights", + "re-download on the next install or server start.", ""] + body += [entry.label for entry in entries] + return tui.confirm(stdscr, question, body=body, default=False, + cancel_value=False) is True + + +def _reconcile_steps(to_uninstall: List[sg_catalog.ModelEntry], + to_install: List[sg_catalog.ModelEntry] + ) -> List[taskview.TaskStep]: + """One task-view step per model: removals first (they free disk). + + install_model and uninstall_model stream through EMIT and honor + CANCEL (the hf download and the server stop both run inside); each + closure binds its model's key. + """ + steps: List[taskview.TaskStep] = [] + for entry in to_uninstall: + steps.append(taskview.TaskStep( + f"Delete {entry.label} weights", + lambda emit, cancel, target=entry.key: sg_models.uninstall_model( + target, emit=emit, cancel=cancel))) + for entry in to_install: + steps.append(taskview.TaskStep( + f"Install {entry.label}", + lambda emit, cancel, target=entry.key: sg_models.install_model( + target, emit=emit, cancel=cancel))) + return steps + + +def _execute(settings: dict) -> int: + """Console tail: python venv, pip install, model work.""" + return taskview.run_steps_inline(_execute_steps(settings)) + + +def setup_screen(stdscr) -> int: + """Run the setup on an existing curses screen (the hub's). + + Returns 0 on completion, 1 when the user aborted (Esc in the tree, a + blocking preflight problem, or a declined GPU warning). + """ + args = build_parser().parse_args([]) + settings = _wizard(stdscr, args) + if settings is None: + return 1 + return taskview.run_steps(stdscr, "Setting up SGLang-Omni", _execute_steps(settings)) + + +def run_tui(args: Optional[argparse.Namespace] = None) -> int: + """Run the sglang-omni setup end-to-end. + + The wizard's screens need a curses session of their own (the hub runs + them on its own screen); the model work is the console tail after the + terminal is restored. + """ + if args is None: + args = build_parser().parse_args([]) + import curses + try: + settings = curses.wrapper(lambda scr: _wizard(scr, args)) + except tui.WizardCancelled: + return 1 + if settings is None: + return 1 + return _execute(settings) + + +def _collect_from_flags(args: argparse.Namespace, + parser: argparse.ArgumentParser) -> Optional[dict]: + """Build the settings dict from flags for a non-interactive run.""" + problems = _preflight() + for problem in problems: + print(f"[ERROR] {problem}") + if problems: + return None + warning = _gpu_warning() + if warning is not None: + print(f"[WARNING] {warning}") + if args.all: + keys = [entry.key for entry in sg_catalog.ENTRIES] + elif args.models: + keys = [] + for part in args.models.split(","): + key = part.strip() + if not key: + continue + if sg_catalog.entry_by_key(key) is None: + known = ", ".join(e.key for e in sg_catalog.ENTRIES) + parser.error(f"unknown model key {key!r} (known: {known})") + keys.append(key) + else: + keys = [] + print("[INFO] No --models given: installing the package only " + "(use --models KEY[,KEY...] or --all to add models, or the " + "TUI's Configure screen).") + return { + "keys": keys, + "do_python": not args.skip_python, + "do_install": (not _is_installed()) and not args.skip_install, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Set up the SGLang-Omni backend: provision a Python " + "3.10-3.12 venv (app/envs/sglomni), pip install " + "sglang-omni, and install the selected models " + "(companion packages + HuggingFace weights).") + parser.add_argument("models_pos", nargs="*", metavar="KEY", + help="Model catalog keys to install (same as " + "--models, space separated)") + parser.add_argument("--models", type=str, default=None, metavar="KEYS", + help="Comma-separated model catalog keys to " + "install (e.g. higgs_audio_v3_tts,moss_tts)") + parser.add_argument("--all", action="store_true", + help="Install every catalog model") + parser.add_argument("--skip-install", action="store_true", + help="Do not pip install sglang-omni") + parser.add_argument("--skip-python", action="store_true", + help="Do not create/provision the venv") + return parser + + +def models_screen(stdscr) -> int: + """Per-model (un)install screen: the hub's Configure-SGLang-Omni leaf. + + The same checkbox tree the audio.cpp modify flow uses: one entry per + catalog model grouped by upstream org, with the installed models + pre-checked (Confirm accepts the tree as it stands). Checking a model + installs it — companion packages plus a pre-download of its weights + via the hf CLI — and unchecking one removes its cached weights after + a confirm (a managed server hosting the model is stopped first); the + whole diff runs as one streamed, resumable, cancelable task-view run + (removals before downloads). Install requires the pip package; with + none installed a guidance flash replaces the run. The tree re-opens + after every action, re-detecting disk state; Esc pops back to + Configure Backends. Always returns 0. + """ + while True: + families = sg_catalog.install_tree_families(list(sg_catalog.ENTRIES)) + installed = set(sg_models.installed_keys()) + picked = tui.checkbox_tree( + stdscr, "Select SGLang-Omni Models", families, + back_value=_GO_BACK, + checked={(index, option["key"]) + for index, family in enumerate(families) + for option in family["options"] + if option["key"] in installed}, + start_on_buttons=True, allow_empty=True) + if picked is _GO_BACK: + return 0 + to_uninstall, to_install = _model_diff( + {key for _index, key in picked}) + if not to_install and not to_uninstall: + continue + if to_install and not _is_installed(): + tui.flash(stdscr, "Install the SGLang-Omni backend first " + "(Configure Backends > Install Backend).", "warn") + continue + if to_uninstall and not _confirm_uninstall(stdscr, to_uninstall): + continue + steps = _reconcile_steps(to_uninstall, to_install) + rc = taskview.run_steps(stdscr, "Configure SGLang-Omni", steps, + wait_on_finish=False) + if rc == 0: + parts = [] + if to_install: + parts.append(f"{len(to_install)} installed") + if to_uninstall: + parts.append(f"{len(to_uninstall)} removed") + tui.flash(stdscr, "SGLang-Omni models updated: " + + ", ".join(parts) + ".", "ok") + else: + tui.flash(stdscr, "Could not update SGLang-Omni models.", "err") + + +def uninstall(*, emit=None, cancel=None) -> int: + """Remove the SGLang-Omni backend entirely. + + Phases: stop the managed server, pip-uninstall sglang-omni and every + catalog model's companion packages, delete every cached weight + snapshot, then remove the tool-owned venv (app/envs/sglomni — the + heavyweight CUDA stack is the install, so unlike the lighter backends + the whole environment goes) and the uv-managed interpreters under + app/envs/pythons. CANCEL is honored between phases only. Returns the + exit code (130 when cancelled before a remaining phase). + """ + if servers.pid_for(SERVER_NAME) is not None: + servers.stop(SERVER_NAME) + if common.cancel_requested(cancel): + return 130 + packages = [SGLOMNI_PIP_PKG] + for entry in sg_catalog.ENTRIES: + for spec, _no_deps in entry.extras: + name = spec.split("=")[0].split("<")[0].split(">")[0].strip() + if name and name not in packages: + packages.append(name) + if envs.env_exists(SGLOMNI_ENV): + rc = common.pip_uninstall(packages, emit=emit, env_dir=SGLOMNI_ENV) + if rc != 0: + print(f"[WARNING] pip uninstall failed (exit {rc}); the venv " + "is removed below anyway") + else: + rc = 0 + if common.cancel_requested(cancel): + return 130 + sg_models.delete_model_weights() + if common.cancel_requested(cancel): + return 130 + for directory in (SGLOMNI_ENV, envs.PYTHON_INSTALL_DIR): + if directory.is_dir(): + print(f"[INFO] Removing {directory}...") + shutil.rmtree(directory, ignore_errors=True) + if directory.exists(): + print(f"[WARNING] Could not fully remove {directory}") + else: + print(f"[OK] {directory} removed.") + return rc + + +def update(*, emit=None, cancel=None) -> int: + """Update the sglang-omni backend: pip install -U in its venv. + + A managed server that is running is stopped first (best-effort): it + imports the very package being upgraded. CANCEL is honored between + phases only. Model weights are untouched (they live in the shared + HuggingFace cache and survive package upgrades). When the venv does + not exist there is nothing to update. Returns the exit code. + """ + if servers.pid_for(SERVER_NAME) is not None: + servers.stop(SERVER_NAME) + if common.cancel_requested(cancel): + return 130 + if not envs.env_exists(SGLOMNI_ENV): + print("[INFO] SGLang-Omni is not installed; nothing to update.") + return 0 + rc = common.pip_install([SGLOMNI_PIP_PKG], emit=emit, cancel=cancel, + env_dir=SGLOMNI_ENV, upgrade=True, + extra_args=["--pre"]) + if rc != 0: + print(f"[WARNING] pip install -U failed (exit {rc}); update " + f"{SGLOMNI_PIP_PKG} manually") + else: + print(f"[OK] {SGLOMNI_PIP_PKG} is up to date (or just upgraded).") + return rc + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + if setup.interactive(): + return run_tui(args) + settings = _collect_from_flags(args, parser) + if settings is None: + return 1 + return _execute(settings) + + +if __name__ == "__main__": + sys.exit(main()) |
