aboutsummaryrefslogtreecommitdiff
path: root/app/backends/sglomni
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
parent391f50da7a085bec75155c0eb9b47910266058cc (diff)
downloadtts-audiobook-generator-8579517a35ef1865fc9b428899d73d52dcb27a14.tar.gz
feat: sglang backend support
Diffstat (limited to 'app/backends/sglomni')
-rw-r--r--app/backends/sglomni/__init__.py98
-rw-r--r--app/backends/sglomni/__main__.py8
-rw-r--r--app/backends/sglomni/catalog.py277
-rw-r--r--app/backends/sglomni/configs/dots_tts.yaml36
-rw-r--r--app/backends/sglomni/configs/moss_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/moss_tts_local.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_0_6b.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_1_7b.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml2
-rw-r--r--app/backends/sglomni/configs/s2pro_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/voxtral_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/zonos2_bf16.yaml28
-rw-r--r--app/backends/sglomni/constants.py37
-rw-r--r--app/backends/sglomni/gpu.py62
-rw-r--r--app/backends/sglomni/models.py283
-rw-r--r--app/backends/sglomni/pythonenv.py96
-rw-r--r--app/backends/sglomni/status.py207
-rw-r--r--app/backends/sglomni/wizard.py440
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())