unsloth/studio/install_sd_cpp_prebuilt.py
Daniel Han d36aeb54f3 Studio: classify local video pipelines, harden video preflight, and gate custom-root sd.cpp removal on ownership
- _local_model_task now tags a local diffusers pipeline that resolves to a video
  family (LTX / Wan / Hunyuan) as text-to-video, mirroring the cached-repo
  _cached_repo_task, so supported local video pipelines surface in the Video
  On-Device picker instead of being routed to the Images picker where the image
  loader rejects them. Gated on _local_is_diffusers so only a real loadable
  pipeline dir reaches the video check.
- Video validate_load_request now rejects a local pipeline pick whose directory has
  no model_index.json before the GPU handoff, mirroring the image loader, so a bad
  local pipeline can no longer evict the resident model and only then fail deep in
  from_pretrained.
- The custom/env-mode uninstall now removes a sibling stable-diffusion.cpp only when
  it carries the Studio owner marker. install_sd_cpp_prebuilt writes the canonical
  .unsloth-studio-owned marker on install; uninstall.sh and uninstall.ps1 keep any
  unowned checkout (a user's own git clone of stable-diffusion.cpp beside a custom
  Studio root is no longer deleted). A pre-marker Studio build is left behind rather
  than a user file removed.

Adds regression tests: local video pipeline tagged text-to-video (and a video-named
non-pipeline dir stays untagged so it can never trigger a doomed pipeline load), the
video local-pipeline preflight rejection, the install ownership marker, and the
uninstall keeping an unowned sibling while removing an owned one.
2026-07-07 03:15:07 +00:00

486 lines
20 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Install a prebuilt ``sd-cli`` (stable-diffusion.cpp) for the native diffusion
engine.
The chat backend ships a prebuilt llama-server; this is the diffusion analogue,
kept deliberately small. stable-diffusion.cpp publishes per-platform release
zips (macOS-arm64/Metal, Linux x86_64 CPU, plus Vulkan / ROCm / Windows
variants), so on the Phase-4 targets (Apple Silicon and CPU) there is nothing to
compile: resolve the right asset, download, extract into
``~/.unsloth/stable-diffusion.cpp``, and the engine's finder picks it up.
``resolve_release_asset`` -- the host -> asset choice -- is a pure function so the
matching matrix is unit-tested without any network. CUDA / ROCm / XPU hosts stay
on diffusers and never need this; it exists for the engines diffusers serves
poorly.
Usage:
python studio/install_sd_cpp_prebuilt.py # auto-detect host
python studio/install_sd_cpp_prebuilt.py --accelerator vulkan
python studio/install_sd_cpp_prebuilt.py --print-asset # resolve only
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import shutil
import stat
import sys
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
from typing import Optional, Sequence
# Default source: the Unsloth-built mirror, whose CPU/Apple prebuilts are compiled and
# published by unslothai/stable-diffusion.cpp (the same way unslothai/llama.cpp ships its
# prebuilts). Override with UNSLOTH_SD_CPP_REPO to point elsewhere (e.g. back to leejet).
# GPU hosts never reach here -- they run diffusers -- so only CPU/Apple assets are needed.
DEFAULT_REPO = "unslothai/stable-diffusion.cpp"
# Upstream we fall back to if the mirror can't serve this host (mirror release missing, or
# a host we don't yet build): resolve against leejet so native install still works.
UPSTREAM_FALLBACK_REPO = "leejet/stable-diffusion.cpp"
# Pinned release tag for REPRODUCIBILITY: "releases/latest" silently swaps the binary
# under users on every push. Override with UNSLOTH_SD_CPP_TAG; set it empty to track
# latest. If the pinned tag is gone, install falls back to that repo's latest.
DEFAULT_TAG = "master-741-484baa4"
# Back-compat alias (some callers/tests import REPO).
REPO = DEFAULT_REPO
def _repo() -> str:
return (os.environ.get("UNSLOTH_SD_CPP_REPO") or DEFAULT_REPO).strip() or DEFAULT_REPO
def _pinned_tag() -> Optional[str]:
"""The release tag to install: env override, else the pinned default; '' = latest."""
val = os.environ.get("UNSLOTH_SD_CPP_TAG", DEFAULT_TAG).strip()
return val or None
# accelerator -> the token that must appear in a Linux/Windows asset name.
_LINUX_ACCEL_TOKEN = {"rocm": "rocm", "vulkan": "vulkan"}
_WINDOWS_ACCEL_TOKEN = {
"cuda": "cuda12",
"vulkan": "vulkan",
"rocm": "rocm",
"cpu": "avx2",
"auto": "avx2",
}
# Tokens that mark an accelerator-specific Linux build; "auto"/"cpu" want none of them.
_LINUX_ACCEL_MARKERS = ("rocm", "vulkan", "cuda", "sycl", "musa")
_ARCH_TOKENS = {
"x86_64": ("x86_64", "x64", "amd64"),
"amd64": ("x86_64", "x64", "amd64"),
"arm64": ("arm64", "aarch64"),
"aarch64": ("arm64", "aarch64"),
}
def _arch_tokens(machine: str) -> tuple[str, ...]:
return _ARCH_TOKENS.get(machine.lower(), (machine.lower(),))
def resolve_release_asset(
asset_names: Sequence[str],
*,
system: str,
machine: str,
accelerator: str = "auto",
) -> Optional[str]:
"""Pick the best release asset for a host, or None if none matches.
``system`` / ``machine`` are ``platform.system()`` / ``platform.machine()``
values; ``accelerator`` is ``auto`` (CPU/Metal default), ``vulkan``,
``rocm``, or ``cuda`` (Windows only). Pure -- the caller passes the release's
asset name list.
"""
system = system.lower()
accel = accelerator.lower()
arch = _arch_tokens(machine)
zips = [
a for a in asset_names if a.lower().endswith(".zip") and not a.lower().startswith("cudart")
]
if system == "darwin":
pool = [
a
for a in zips
if ("darwin" in a.lower() or "macos" in a.lower()) and any(t in a.lower() for t in arch)
]
return pool[0] if pool else None
if system == "windows":
pool = [a for a in zips if "bin-win" in a.lower()]
token = _WINDOWS_ACCEL_TOKEN.get(accel, accel)
sel = [a for a in pool if token in a.lower()]
if sel:
return sel[0]
# An EXPLICIT GPU accelerator with no matching asset is a real miss, not a CPU
# request: return None so the caller can fall back to a repo that builds it,
# rather than silently installing a CPU build for a --accelerator cuda request.
if accel in ("cuda", "vulkan", "rocm"):
return None
# auto / cpu -> a plain avx2 CPU build, else any windows build.
cpu = [a for a in pool if "avx2" in a.lower()]
return cpu[0] if cpu else (pool[0] if pool else None)
# linux (and anything else unix-like)
pool = [a for a in zips if "linux" in a.lower() and any(t in a.lower() for t in arch)]
if accel in ("cuda", "vulkan", "rocm"):
# Explicit GPU accelerator: require its marker, else no match (let the caller
# fall back) -- never hand back a plain CPU build for a GPU request.
marker = _LINUX_ACCEL_TOKEN.get(accel, accel)
sel = [a for a in pool if marker in a.lower()]
else: # auto / cpu -> the plain build with no accelerator marker
sel = [a for a in pool if not any(m in a.lower() for m in _LINUX_ACCEL_MARKERS)]
return sel[0] if sel else None
def _fetch_release(
tag: Optional[str] = None,
*,
repo: Optional[str] = None,
token: Optional[str] = None,
timeout: float = 30.0,
allow_latest: bool = True,
) -> Optional[dict]:
"""GET a release JSON from GitHub. With ``tag`` set, fetch that exact release; otherwise
fetch latest. ``token`` is optional and lifts the API rate limit.
When the pinned ``tag`` is missing (404): if ``allow_latest`` fall back to that repo's
latest, else return ``None`` so the caller can try the SAME pin on another repo before
settling for any repo's unpinned latest."""
repo = repo or _repo()
token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
def _get(url: str) -> dict:
req = urllib.request.Request(url, headers = {"Accept": "application/vnd.github+json"})
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout = timeout) as resp: # noqa: S310 (fixed https host)
return json.loads(resp.read().decode("utf-8"))
base = f"https://api.github.com/repos/{repo}/releases"
if tag:
try:
return _get(f"{base}/tags/{tag}")
except urllib.error.HTTPError as exc: # pinned tag removed -> maybe latest
if exc.code != 404:
raise
if not allow_latest:
return None
print(
f"sd-cli: pinned tag {tag} not found on {repo}; falling back to latest", flush = True
)
return _get(f"{base}/latest")
# Back-compat alias: the old name fetched latest.
def _fetch_latest_release(*, token: Optional[str] = None, timeout: float = 30.0) -> dict:
return _fetch_release(None, token = token, timeout = timeout)
def _verify_sha256(path: Path, expected_digest: Optional[str]) -> None:
"""Verify ``path`` against a GitHub asset ``digest`` ('sha256:<hex>'). Integrity check
against a corrupted/tampered download before we extract + execute the binary. When the
release publishes no digest (older releases), warn and proceed rather than hard-fail."""
if not expected_digest:
print(f"sd-cli: WARNING no digest for {path.name}; cannot verify integrity", flush = True)
return
algo, _, want = expected_digest.partition(":")
if algo.lower() != "sha256" or not want:
print(
f"sd-cli: WARNING unrecognised digest {expected_digest!r}; skipping check", flush = True
)
return
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
got = h.hexdigest()
if got != want.lower():
raise RuntimeError(f"sha256 mismatch for {path.name}: expected {want.lower()}, got {got}")
def default_install_dir() -> Path:
"""``~/.unsloth/stable-diffusion.cpp`` (or under ``UNSLOTH_STUDIO_HOME`` /
``STUDIO_HOME`` if set), the sibling of the llama.cpp install the finder
probes."""
home = os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME")
base = Path(home).parent if home else Path.home() / ".unsloth"
return base / "stable-diffusion.cpp"
def _make_executable(path: Path) -> None:
mode = path.stat().st_mode
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
def _locate_sd_cli(root: Path) -> Optional[Path]:
name = "sd-cli.exe" if sys.platform == "win32" else "sd-cli"
for p in root.rglob(name):
if p.is_file():
return p
return None
def _locate_sd_server(root: Path) -> Optional[Path]:
"""The persistent ``sd-server`` binary in the extracted tree, if the archive ships
one (modern stable-diffusion.cpp releases do). Best-effort: the native backend
falls back to one-shot ``sd-cli`` when it is absent."""
name = "sd-server.exe" if sys.platform == "win32" else "sd-server"
for p in root.rglob(name):
if p.is_file():
return p
return None
def _download(
url: str,
dest: Path,
*,
timeout: float = 300.0,
) -> None:
"""Stream ``url`` to ``dest`` with an explicit timeout. ``urlretrieve`` takes no
timeout and can hang forever on a stalled socket. A User-Agent is set because the
GitHub asset CDN can reject header-less requests; the API fetch carries any token."""
import shutil
req = urllib.request.Request(url, headers = {"User-Agent": "unsloth-sd-cpp-installer"})
with urllib.request.urlopen(req, timeout = timeout) as resp, open(dest, "wb") as f: # noqa: S310
shutil.copyfileobj(resp, f)
def _safe_extractall(zf: zipfile.ZipFile, target: Path) -> None:
"""``extractall`` with a per-member containment check, so an archive carrying an
absolute path or a ``..`` entry can't write outside ``target`` (Zip-Slip)."""
base = target.resolve()
for member in zf.infolist():
dest = (base / member.filename).resolve()
if dest != base and base not in dest.parents:
raise RuntimeError(f"unsafe path in archive: {member.filename!r}")
zf.extractall(target)
def _maybe_fetch_windows_cudart(release: dict, chosen: str, target: Path) -> None:
"""On Windows + a CUDA build, also fetch the separate CUDA-runtime DLL archive.
Upstream ships the runtime as ``cudart-sd-...-win-cu12-...zip`` (which
``resolve_release_asset`` filters out); without those DLLs ``sd-cli.exe`` cannot start
on a machine that does not already have the CUDA runtime installed."""
if platform.system().lower() != "windows" or "cuda" not in chosen.lower():
return
cudart = next(
(
a
for a in release.get("assets", [])
if a["name"].lower().startswith("cudart") and "win" in a["name"].lower()
),
None,
)
if cudart is None:
return
dest = target / cudart["name"]
print(f"downloading CUDA runtime {cudart['name']} ...", flush = True)
try:
_download(cudart["browser_download_url"], dest)
# Verify integrity BEFORE extracting, like the main sd-cli archive: these DLLs are
# loaded into sd-cli.exe at runtime, so a corrupt/tampered runtime archive must be
# rejected rather than extracted next to the binary.
_verify_sha256(dest, cudart.get("digest"))
with zipfile.ZipFile(dest) as zf:
_safe_extractall(zf, target)
finally:
dest.unlink(missing_ok = True)
def _resolve_repo_asset(
repo: str,
tag: Optional[str],
accelerator: str,
token: Optional[str],
*,
allow_latest: bool = True,
) -> tuple[Optional[dict], Optional[str]]:
"""Fetch ``repo``'s release and pick the asset for this host. Returns
``(release, asset_name)`` or ``(None, None)`` when the repo has no usable release
(fetch failed, or the pinned tag is missing and ``allow_latest`` is False) or no
asset for this host, so the caller can fall back."""
try:
release = _fetch_release(tag, repo = repo, token = token, allow_latest = allow_latest)
except Exception as exc: # noqa: BLE001 - network / rate limit -> fall back
print(f"sd-cli: {repo} release fetch failed ({exc})", flush = True)
return None, None
if release is None: # pinned tag missing and the latest fallback was withheld
return None, None
names = [a["name"] for a in (release.get("assets") or [])]
chosen = resolve_release_asset(
names,
system = platform.system(),
machine = platform.machine(),
accelerator = accelerator,
)
return release, chosen
def _resolve_with_fallback(
accelerator: str, token: Optional[str]
) -> tuple[str, Optional[dict], Optional[str]]:
"""Resolve ``(used_repo, release, asset_name)`` for this host across the primary repo
and -- only when the built-in default is in use and the user did not pin a repo -- the
upstream fallback.
Ordering guarantees reproducibility: a pinned tag is tried EXACTLY on every candidate
repo before any repo's unpinned latest, so a mirror that is missing the pinned release
prefers the pinned upstream build over an unpinned mirror-latest. Returns
``(primary, None, None)`` when nothing serves this host. Shared by ``install`` and
``--print-asset`` so both honour the same fallback."""
tag = _pinned_tag()
primary = _repo()
# Fall back to upstream ONLY when the user did not pin a repo (env unset) and the
# built-in default is in use: an explicit UNSLOTH_SD_CPP_REPO (even one equal to the
# default) gets exactly that repo, with no surprise upstream substitution.
repo_pinned = bool((os.environ.get("UNSLOTH_SD_CPP_REPO") or "").strip())
allow_upstream = (
not repo_pinned and primary == DEFAULT_REPO and DEFAULT_REPO != UPSTREAM_FALLBACK_REPO
)
# (repo, tag_to_fetch, allow_latest). With a pin set, try the exact pin on every repo
# first (allow_latest = False), then each repo's latest (tag = None).
attempts: list[tuple[str, Optional[str], bool]] = []
if tag:
attempts.append((primary, tag, False))
if allow_upstream:
attempts.append((UPSTREAM_FALLBACK_REPO, tag, False))
attempts.append((primary, None, True))
if allow_upstream:
attempts.append((UPSTREAM_FALLBACK_REPO, None, True))
else:
attempts.append((primary, None, True))
if allow_upstream:
attempts.append((UPSTREAM_FALLBACK_REPO, None, True))
for repo, want_tag, allow_latest in attempts:
release, chosen = _resolve_repo_asset(
repo, want_tag, accelerator, token, allow_latest = allow_latest
)
if release is not None and chosen:
if repo != primary:
# Diagnostic goes to stderr, not stdout: --print-asset documents its
# stdout as the asset name only, so a caller parsing it as a single line
# must not see this fallback log mixed in.
print(
f"falling back to {repo} for {platform.system()}/{platform.machine()}",
file = sys.stderr,
flush = True,
)
return repo, release, chosen
return primary, None, None
def install(
*,
install_dir: Optional[Path] = None,
accelerator: str = "auto",
token: Optional[str] = None,
) -> Path:
"""Download + extract the prebuilt for this host. Returns the sd-cli path.
Resolves against the Unsloth mirror (``DEFAULT_REPO``) first; if the mirror can't
serve this host (release missing, or a host we don't build) AND the default repo is
in use, falls back to leejet upstream so native install still works. Raises
``RuntimeError`` only when neither source has an asset for the host, or the archive
has no ``sd-cli``.
"""
target = install_dir or default_install_dir()
used_repo, release, chosen = _resolve_with_fallback(accelerator, token)
if release is None or not chosen:
raise RuntimeError(
f"No prebuilt sd-cli for {platform.system()}/{platform.machine()} "
f"(accelerator={accelerator}) from {used_repo}. Build from source: "
f"https://github.com/{used_repo}"
)
print(f"sd-cli: source {used_repo} release {release.get('tag_name', '?')}", flush = True)
asset = next(a for a in release["assets"] if a["name"] == chosen)
url = asset["browser_download_url"]
target.mkdir(parents = True, exist_ok = True)
archive = target / chosen
print(f"downloading {chosen} -> {archive}", flush = True)
try:
_download(url, archive)
# Verify integrity BEFORE extracting + executing.
_verify_sha256(archive, asset.get("digest"))
print("extracting ...", flush = True)
with zipfile.ZipFile(archive) as zf:
_safe_extractall(zf, target)
# Windows CUDA builds need the separately-published cudart runtime DLLs.
_maybe_fetch_windows_cudart(release, chosen, target)
finally:
# Always drop the archive: on a sha256 mismatch / corrupt zip / network error it
# must not linger (and a stale partial would defeat a later retry).
archive.unlink(missing_ok = True)
sd_cli = _locate_sd_cli(target)
if not sd_cli:
raise RuntimeError(f"archive {chosen} contained no sd-cli binary")
if sys.platform != "win32":
_make_executable(sd_cli)
print(f"installed sd-cli -> {sd_cli}", flush = True)
# The same archive ships the persistent sd-server; make it runnable too so the
# native backend can prefer it (load once, serve many) over one-shot sd-cli.
sd_server = _locate_sd_server(target)
if sd_server is not None and sys.platform != "win32":
_make_executable(sd_server)
if sd_server is not None:
print(f"installed sd-server -> {sd_server}", flush = True)
# Ownership marker (the same one setup.sh/_is_studio_root use, and setup.ps1 writes into
# the Node sibling dir) so the uninstaller can tell a Studio-installed sd.cpp from a user's
# own stable-diffusion.cpp checkout beside a custom Studio root, and delete only ours.
try:
(target / ".unsloth-studio-owned").touch()
except OSError:
pass
return sd_cli
def main(argv: Optional[list[str]] = None) -> int:
p = argparse.ArgumentParser(description = "Install a prebuilt sd-cli (stable-diffusion.cpp).")
p.add_argument(
"--accelerator", default = "auto", choices = ["auto", "cpu", "vulkan", "rocm", "cuda"]
)
p.add_argument("--install-dir", default = None)
p.add_argument(
"--print-asset", action = "store_true", help = "resolve + print the asset, don't download"
)
args = p.parse_args(argv)
if args.print_asset:
# Route through the same primary/fallback resolution as install(), so a host the
# mirror does not build (e.g. a Linux Vulkan request) reports the upstream asset
# it would actually download instead of a false "no matching prebuilt".
_used, _release, chosen = _resolve_with_fallback(args.accelerator, None)
print(chosen or "(no matching prebuilt; build from source)")
return 0 if chosen else 2
try:
install(
install_dir = Path(args.install_dir).expanduser() if args.install_dir else None,
accelerator = args.accelerator,
)
except RuntimeError as exc:
print(f"error: {exc}", file = sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())