Merge branch 'diffusion-train-precision' into diffusion-train-tab-2

This commit is contained in:
Daniel Han 2026-07-04 01:04:40 +00:00
commit 9b8132ec9e
23 changed files with 3942 additions and 213 deletions

View file

@ -55,6 +55,7 @@ def main(argv = None) -> int:
# Reuse the runtime quant factory + filter so offline == runtime (the LPIPS-0 invariant).
from core.inference.diffusion_transformer_quant import (
FP8_GRANULARITY,
TQ_FP8,
TQ_SCHEMES,
_make_quant_config,
@ -100,25 +101,30 @@ def main(argv = None) -> int:
k: (v.detach().to("cpu") if hasattr(v, "detach") else v)
for k, v in transformer.state_dict().items()
}
metadata = {
"base_model_id": args.base,
"family": fam.name,
"scheme": scheme,
"min_features": args.min_features,
# The layers skipped for this scheme (int8's M=1 modulation projections; () for
# the scaled_mm schemes) and, for fp8, the baked accumulate mode. Both let the
# loader reject a checkpoint that would not match the runtime path.
"exclude_name_tokens": list(exclude_name_tokens),
"fast_accum": fast_accum,
"torch_dtype": args.dtype,
"quant_backend": "torchao",
"transformer_class": fam.transformer_class,
"torch_version": torch.__version__,
"torchao_version": getattr(torchao, "__version__", "?"),
"diffusers_version": diffusers.__version__,
}
# Record the fp8 granularity so the loader can reject a stale per-tensor checkpoint
# (the runtime now requires per-row; see FP8_GRANULARITY).
if scheme == TQ_FP8:
metadata["fp8_granularity"] = FP8_GRANULARITY
ckpt = {
"format": PREQUANT_FORMAT,
"metadata": {
"base_model_id": args.base,
"family": fam.name,
"scheme": scheme,
"min_features": args.min_features,
# The layers skipped for this scheme (int8's M=1 modulation projections; () for
# the scaled_mm schemes) and, for fp8, the baked accumulate mode. Both let the
# loader reject a checkpoint that would not match the runtime path.
"exclude_name_tokens": list(exclude_name_tokens),
"fast_accum": fast_accum,
"torch_dtype": args.dtype,
"quant_backend": "torchao",
"transformer_class": fam.transformer_class,
"torch_version": torch.__version__,
"torchao_version": getattr(torchao, "__version__", "?"),
"diffusers_version": diffusers.__version__,
},
"metadata": metadata,
"state_dict": state_dict,
}

View file

@ -29,7 +29,12 @@ from typing import Any, Optional
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_families import DiffusionFamily, family_sd_cpp_supported
from core.inference.sd_cpp_backend import _install_allowed, ensure_sd_cpp_binary
from core.inference.sd_cpp_backend import (
_install_allowed,
_server_binary_runnable,
ensure_sd_cpp_binary,
ensure_sd_server_binary,
)
from core.inference.sd_cpp_engine import (
ENGINE_DIFFUSERS,
ENGINE_SD_CPP,
@ -148,20 +153,37 @@ def select_and_activate_engine(
fam_ok = family_sd_cpp_supported(fam)
binary = None
server_binary = None
if policy_eligible and fam_ok:
binary = ensure_sd_cpp_binary(
# Probe the resident sd-server FIRST: the backend PREFERS it, and an sd-server-only
# install (no sd-cli) must still route to native rather than silently falling back to
# diffusers. Checking it before the sd-cli install also means a server-only host does
# not pay an avoidable sd-cli download. Install the accelerator-matched build (ROCm /
# Vulkan / CUDA) so a forced-native GPU load gets the GPU server, not the CPU one.
server_binary = ensure_sd_server_binary(
allow_install = _install_allowed(),
accelerator = _install_accelerator_for(backend),
)
# Probe runnability here, before committing the route to native: a present but
# non-runnable binary (wrong arch, missing shared libs, no execute bit) would
# otherwise pass as available and only fail inside the background load, instead
# of falling back to diffusers now.
if server_binary and not _server_binary_runnable(server_binary):
logger.warning(
"sd-server at %s is present but not runnable; not using it", server_binary
)
server_binary = None
# sd-cli is the one-shot fallback. Always LOCATE an existing binary, but only
# auto-INSTALL it when there is no usable server, so a server-only install is not
# forced to also download a CLI it will never use. Probe runnability before
# committing native: a present but non-runnable binary (wrong arch, missing shared
# libs, no execute bit) would otherwise pass as available and only fail inside the
# background load, instead of falling back to diffusers now.
binary = ensure_sd_cpp_binary(
allow_install = _install_allowed() and server_binary is None,
accelerator = _install_accelerator_for(backend),
)
if binary and SdCppEngine(binary = binary).version() is None:
logger.warning("sd-cli at %s is present but not runnable; using diffusers", binary)
logger.warning("sd-cli at %s is present but not runnable; not using it", binary)
binary = None
native_available = bool(binary) and policy_eligible and fam_ok
native_available = bool(binary or server_binary) and policy_eligible and fam_ok
choice = select_diffusion_engine(
backend, native_available = native_available, prefer_native = prefer_native
)
@ -173,8 +195,8 @@ def select_and_activate_engine(
reason = f"GPU backend '{backend}' uses diffusers"
elif not fam_ok:
reason = f"family '{fam.name}' has no native sd.cpp asset mapping"
elif not binary:
reason = "sd-cli binary unavailable"
elif not (binary or server_binary):
reason = "native sd.cpp binary unavailable"
else:
reason = "diffusers selected"
return _activate(ENGINE_DIFFUSERS, reason)

View file

@ -421,6 +421,38 @@ def resolve_base_repo(fam: DiffusionFamily, base_repo: Optional[str]) -> str:
return base or fam.base_repo
# Default (steps, guidance) per model for callers that can't pass them — namely
# the OpenAI /v1/images/generations endpoint, whose spec has no step/guidance
# knobs. Distilled "turbo/schnell" models want few steps and no CFG; the full
# "dev" models want more steps and real CFG. Matched by substring, most specific
# first — the same scheme and values as the UI's MODEL_DEFAULTS table
# (studio/frontend/src/features/images/images-page.tsx); keep the two in sync.
_GENERATION_DEFAULTS: tuple[tuple[str, int, float], ...] = (
("z-image-turbo", 9, 0.0),
("flux.1-schnell", 4, 0.0),
("flux.1", 28, 3.5),
("flux.2-klein", 4, 0.0),
("qwen-image", 20, 4.0),
("z-image", 20, 4.0),
)
# Unrecognised model: distilled few-step / no-CFG shape, matching the UI fallback.
_GENERATION_DEFAULT_FALLBACK = (9, 0.0)
def default_generation_params(*identifiers: Optional[str]) -> tuple[int, float]:
"""Default ``(steps, guidance)`` for a loaded model. The first identifier that
names a known model wins (the repo id, then the resolved base repo), so a
local-path load whose repo id is just a filesystem path that may not name
the model still resolves via its base repo. Within an identifier, keys are
matched as substrings, most specific first (the same scheme as the UI)."""
for identifier in identifiers:
needle = (identifier or "").lower()
for key, steps, guidance in _GENERATION_DEFAULTS:
if key in needle:
return steps, guidance
return _GENERATION_DEFAULT_FALLBACK
def family_prequant_repo(fam: DiffusionFamily, scheme: str) -> Optional[str]:
"""The hosted pre-quantized transformer repo for ``scheme`` in this family, or None."""
for entry_scheme, repo_id in fam.prequant_repos:

View file

@ -268,6 +268,22 @@ def _validate_checkpoint(
if meta.get("scheme") != scheme:
_warn(logger, scheme, ValueError(f"checkpoint scheme {meta.get('scheme')!r} != {scheme!r}"))
return False
# fp8 REQUIRES per-row granularity (per-tensor collapses outlier-heavy DiTs to noise). A
# checkpoint built before that fix carries the old per-tensor layout and either omits
# ``fp8_granularity`` or records something other than per-row; reject it so the loader
# falls back to rebuilding/re-quantising instead of installing a broken fp8 transformer.
from .diffusion_transformer_quant import FP8_GRANULARITY, TQ_FP8
if scheme == TQ_FP8 and meta.get("fp8_granularity") != FP8_GRANULARITY:
_warn(
logger,
scheme,
ValueError(
f"fp8 checkpoint granularity {meta.get('fp8_granularity')!r} != "
f"{FP8_GRANULARITY!r} (stale per-tensor artifact); rebuild it"
),
)
return False
ckpt_base = meta.get("base_model_id")
if base:
# A checkpoint whose keys happen to match a different base can load strict=True and

View file

@ -37,6 +37,12 @@ TQ_AUTO = "auto"
TQ_SCHEMES = (TQ_INT8, TQ_FP8, TQ_NVFP4, TQ_MXFP8)
TQ_MODES = (TQ_AUTO,) + TQ_SCHEMES
# The fp8 weight/activation granularity the runtime config uses (see _make_quant_config):
# per-ROW is REQUIRED for correctness on outlier-heavy DiTs. Stamped into a pre-quantized
# fp8 checkpoint's metadata at build time and required by the loader, so a stale checkpoint
# baked with the old per-TENSOR layout is rejected and rebuilt rather than reproducing noise.
FP8_GRANULARITY = "per_row"
# Skip linears whose in/out features are below this. A small share of the FLOPs, so leaving
# them bf16 costs ~nothing and keeps quality a touch higher.
DEFAULT_MIN_LINEAR_FEATURES = 512
@ -298,19 +304,29 @@ def _make_quant_config(scheme: str, fast_accum: Optional[bool] = None) -> Any:
if scheme == TQ_INT8:
return Int8DynamicActivationInt8WeightConfig()
if scheme == TQ_FP8:
# Choose fp8 accumulate by GPU class (unless forced). On consumer / workstation
# cards (GDDR) the fp8 tensor cores run ~2x faster with FP16 (fast) accumulate
# than FP32 (e.g. ~838 vs ~419 TFLOPS on RTX 50xx), so fast accumulate is a real
# win there. Data-center HBM parts default to the higher-precision accumulate.
# fast accumulate is a precision (not overflow) tradeoff and stays below the fp8
# quant noise floor (measured 0 non-finite even on Z-Image's ~1e6 activations).
# Per-ROW granularity (per-token activation scale + per-output-channel weight scale)
# is REQUIRED for correctness, not just quality. torchao's default fp8 granularity is
# per-TENSOR: one scale for the whole activation tensor. A DiT with extreme activation
# outliers breaks under that -- z-image's MLP activations peak near 6.6e4, and a single
# such outlier forces a tensor-wide scale that pushes every normal value (~1-30) below
# the fp8 resolution, so they quantise to ~0 and the denoise collapses to pure noise
# (measured on B200: per-tensor fp8 = noise, per-row fp8 = matches bf16). Per-row
# confines each outlier to its own token/channel. This is also why int8 dynamic was
# always fine here: it is per-token by default. The per-row scaled_mm is probed by
# _smoke_probe, so an arch/build without it falls through the ladder to int8.
#
# fast accumulate (fp8 only) is chosen by GPU class unless forced: consumer / workstation
# cards (GDDR) run the fp8 tensor cores ~2x faster with FP16 (fast) accumulate than FP32
# (e.g. ~838 vs ~419 TFLOPS on RTX 50xx); data-center HBM parts keep precise accumulate.
from torchao.quantization import PerRow
try:
from torchao.float8 import Float8MMConfig
return Float8DynamicActivationFloat8WeightConfig(
mm_config = Float8MMConfig(use_fast_accum = _resolve_fast_accum(fast_accum))
granularity = PerRow(),
mm_config = Float8MMConfig(use_fast_accum = _resolve_fast_accum(fast_accum)),
)
except Exception: # noqa: BLE001 — older torchao without the explicit knob
return Float8DynamicActivationFloat8WeightConfig()
except Exception: # noqa: BLE001 — older torchao without the explicit mm knob
return Float8DynamicActivationFloat8WeightConfig(granularity = PerRow())
if scheme == TQ_NVFP4:
from torchao.prototype.mx_formats import NVFP4DynamicActivationNVFP4WeightConfig

View file

@ -326,6 +326,142 @@ def build_sd_cpp_upscale_command(
return cmd
def build_sd_cpp_server_command(
binary: str,
files: SdCppModelFiles,
*,
host: str,
port: int,
vae_format: Optional[str] = None,
offload: Optional[list[str]] = None,
native_speed: Optional[str] = None,
threads: Optional[int] = None,
scratch_dir: Optional[str] = None,
verbose: bool = False,
extra_args: Optional[list[str]] = None,
) -> list[str]:
"""Build the ``sd-server`` argv: model + hardware/server flags only.
``sd-server`` (stable-diffusion.cpp ``examples/server``) loads the model once at
spawn from the SAME flags ``sd-cli`` takes (``--diffusion-model`` / ``--vae`` /
the text encoders / ``--vae-format`` / offload + speed), and adds ``--listen-ip``
/ ``--listen-port``. Per-generation parameters (prompt, size, steps, seed, cfg,
sampler, batch) are NOT here -- they go in each ``/sdcpp/v1/img_gen`` request, so
one resident process serves many generations without reloading the weights.
``offload`` / ``native_speed`` map to the exact same sd.cpp flags as the one-shot
engine (``--offload-to-cpu`` / ``--diffusion-fa`` / ...), verified to be accepted
by ``sd-server --help``. ``scratch_dir`` (if given) is pointed at by the LoRA /
hires-upscaler / embeddings directory flags: sd-server's img_gen handler recursively
iterates those dirs, and an unset / missing dir makes it fail the request, so we give
it a real (empty) directory. ``extra_args`` is appended last so a power user can
override anything (sd.cpp's parser is last-wins).
"""
if not files.diffusion_model:
raise ValueError("diffusion_model path is required")
cmd: list[str] = [binary, "--diffusion-model", files.diffusion_model]
for flag, value in (
("--vae", files.vae),
("--clip_l", files.clip_l),
("--clip_g", files.clip_g),
("--t5xxl", files.t5xxl),
("--llm", files.llm),
("--qwen2vl", files.qwen2vl),
):
if value:
cmd += [flag, value]
if vae_format:
cmd += ["--vae-format", vae_format]
cmd += ["--listen-ip", str(host), "--listen-port", str(int(port))]
if scratch_dir:
cmd += [
"--lora-model-dir",
scratch_dir,
"--hires-upscalers-dir",
scratch_dir,
"--embd-dir",
scratch_dir,
]
if threads is not None:
cmd += ["--threads", str(int(threads))]
offload = list(offload or [])
if offload:
cmd += offload
# De-dup speed flags against offload (offload may already include --diffusion-fa).
cmd += [f for f in native_speed_flags(native_speed) if f not in offload]
if verbose:
cmd += ["-v"]
if extra_args:
cmd += list(extra_args)
return cmd
def build_img_gen_request(
*,
prompt: str,
negative_prompt: Optional[str] = None,
width: int = 1024,
height: int = 1024,
steps: Optional[int] = None,
seed: Optional[int] = None,
batch_count: int = 1,
sample_method: Optional[str] = None,
flow_shift: Optional[float] = None,
cfg_scale: Optional[float] = None,
distilled_guidance: Optional[float] = None,
output_format: str = "png",
lora: Optional[list[dict]] = None,
) -> dict:
"""Build the ``POST /sdcpp/v1/img_gen`` JSON body for one text-to-image request.
The native ``sdcpp`` API takes the whole batch in one request (``batch_count``),
so a batch reuses the resident model with no reload. Sampling lives under
``sample_params``; guidance is split exactly like the one-shot engine's
``_map_guidance``: a FLUX distilled value goes to ``guidance.distilled_guidance``,
a real classifier-free scale goes to ``guidance.txt_cfg``. Only set keys are
emitted so the server applies its own defaults for the rest.
"""
if not str(prompt).strip():
raise ValueError("prompt is required")
guidance: dict = {}
if cfg_scale is not None:
guidance["txt_cfg"] = float(cfg_scale)
if distilled_guidance is not None:
guidance["distilled_guidance"] = float(distilled_guidance)
sample_params: dict = {}
if steps is not None:
sample_params["sample_steps"] = int(steps)
if sample_method:
sample_params["sample_method"] = str(sample_method)
if flow_shift is not None:
sample_params["flow_shift"] = float(flow_shift)
if guidance:
sample_params["guidance"] = guidance
req: dict = {
"prompt": prompt,
"negative_prompt": negative_prompt or "",
"width": int(width),
"height": int(height),
"batch_count": max(1, int(batch_count)),
"output_format": output_format,
}
if seed is not None:
req["seed"] = int(seed)
if sample_params:
req["sample_params"] = sample_params
# Structured LoRA list: the sdcpp API resolves each ``path`` against the server's
# scanned ``--lora-model-dir`` (prompt-embedded ``<lora:>`` tags are intentionally
# unsupported server-side), so LoRAs must be staged there and named here.
if lora:
req["lora"] = lora
return req
def _fmt_float(value: float) -> str:
"""Compact float -> str: drop a trailing ``.0`` so ``1.0`` -> ``1`` (sd-cli
accepts both, but the tidy form keeps logged commands readable)."""

View file

@ -50,13 +50,22 @@ from core.inference.diffusion_memory import (
OFFLOAD_NONE,
OFFLOAD_SEQUENTIAL,
)
from core.inference.sd_cpp_args import SdCppGenParams, SdCppModelFiles, offload_flags
from core.inference.sd_cpp_args import (
SdCppGenParams,
SdCppModelFiles,
build_img_gen_request,
offload_flags,
)
from core.inference.sd_cpp_engine import (
SdCppCancelled,
SdCppEngine,
find_sd_cpp_binary,
find_sd_server_binary,
runtime_env,
)
from core.inference.sd_cpp_server import SdCppServer
from loggers import get_logger
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
logger = get_logger(__name__)
@ -69,6 +78,45 @@ _STEP_RE = re.compile(r"(\d+)\s*/\s*(\d+)")
# download / extract / chmod.
_install_lock = threading.Lock()
# sd-server accepts at most this many images per img_gen job; larger Studio batches
# (the request model allows up to 32) are split into chunks of this size, the way the
# one-shot path did them one image at a time.
_MAX_SERVER_BATCH = 8
# Per-image wall-clock budget for a server job, so a batch gets a timeout proportional to
# its image count (matching the one-shot path, where each image had its own budget) rather
# than one fixed deadline the whole batch has to finish within.
_SERVER_PER_IMAGE_TIMEOUT_S = 1800.0
def _server_binary_runnable(binary: str) -> bool:
"""Best-effort probe that ``binary`` can actually execute (not just exist).
Runs ``<binary> --help`` with the same runtime env the server will use, so a present
but unrunnable build (wrong arch, missing shared libs, no execute bit) is caught before
a multi-GB asset download. Conservative: only a clear "cannot launch" signal (OSError,
or the dynamic-loader exit codes 126/127) returns False; anything else is treated as
runnable so a quirky ``--help`` exit code never blocks a working binary."""
import subprocess
try:
proc = subprocess.run(
[binary, "--help"],
capture_output = True,
timeout = 20,
env = runtime_env(binary),
**windows_hidden_subprocess_kwargs(),
)
except OSError:
return False # cannot exec at all (wrong arch / no execute bit / missing loader)
except Exception: # noqa: BLE001 -- timeout or anything odd: don't block on a flaky probe
return True
# A negative return code is a signal death (e.g. -4 SIGILL from an incompatible
# prebuilt on an older CPU): the binary launches but immediately crashes, so treat it
# as unavailable and let the load fall back to diffusers instead of routing to a
# server that will die on startup.
return proc.returncode >= 0 and proc.returncode not in (126, 127)
def ensure_sd_cpp_binary(*, allow_install: bool = True, accelerator: str = "cpu") -> Optional[str]:
"""Path to a usable ``sd-cli`` binary, installing the prebuilt once if needed.
@ -106,9 +154,51 @@ def ensure_sd_cpp_binary(*, allow_install: bool = True, accelerator: str = "cpu"
return None
def ensure_sd_server_binary(
*, allow_install: bool = True, accelerator: str = "cpu"
) -> Optional[str]:
"""Path to a usable ``sd-server`` binary, installing the prebuilt once if needed.
Unlike ``ensure_sd_cpp_binary``, this installs when *sd-server specifically* is
missing -- even if an ``sd-cli`` from an older install is already present -- so an
existing one-shot install is upgraded to the persistent server (the prebuilt archive
ships both). Returns None when it is absent and cannot be installed; the backend then
uses the one-shot fallback. Never raises.
"""
found = find_sd_server_binary()
if found:
return found
if not allow_install:
return None
with _install_lock:
found = find_sd_server_binary()
if found:
return found
try:
import sys
studio_dir = Path(__file__).resolve().parents[3] # .../studio
if str(studio_dir) not in sys.path:
sys.path.insert(0, str(studio_dir))
from install_sd_cpp_prebuilt import install as _install
except Exception as exc: # noqa: BLE001 -- import path / module issues are non-fatal
logger.warning("sd-server installer import failed: %s", exc)
return None
try:
_install(accelerator = accelerator) # extracts sd-cli AND sd-server
except Exception as exc: # noqa: BLE001 -- download/extract failure -> fall back
logger.warning("sd-server auto-install failed: %s", exc)
return None
return find_sd_server_binary()
@dataclass(frozen = True)
class _SdState:
"""The loaded native checkpoint: resolved asset paths + run settings."""
"""The loaded native checkpoint: resolved asset paths + run settings.
``server`` is the resident ``sd-server`` process (the model is loaded once, inside
it) when ``mode == "server"``; in the ``"oneshot"`` fallback it is ``None`` and each
generation re-runs ``sd-cli``."""
repo_id: str
base_repo: str
@ -121,6 +211,8 @@ class _SdState:
threads: Optional[int] = None
sampling_method: Optional[str] = None
flow_shift: Optional[float] = None
server: Optional[SdCppServer] = None
mode: str = "server"
# Token kept so LoRA adapters selected at generate time can be fetched from the Hub.
hf_token: Optional[str] = None
@ -194,11 +286,19 @@ class SdCppDiffusionBackend:
self._lock = threading.Lock()
self._generate_lock = threading.Lock()
self._engine = engine # resolved lazily on first load so import stays cheap
# An engine passed in is an EXPLICIT injection (the test seam / escape hatch) and
# pins one-shot mode; an engine cached later by a runtime fallback must NOT, so a
# now-available server can still be used on the next load.
self._engine_injected = engine is not None
self._state: Optional[_SdState] = None
self._loading: Optional[_SdLoading] = None
self._load_token = 0
self._cancel_event = threading.Event()
self._active_generate_cancel: Optional[threading.Event] = None
# The sd-server being started for an in-flight load, before it is committed to
# _state. Tracked so an unload / superseding load can stop it mid-startup instead
# of leaving it loading (and holding the generate lock) for the whole timeout.
self._pending_server: Optional[SdCppServer] = None
self._gen: Optional[_SdGen] = None
@property
@ -215,6 +315,38 @@ class SdCppDiffusionBackend:
self._engine = SdCppEngine(binary = binary)
return self._engine
def _resolve_backend(self) -> tuple[str, Optional[str], Optional[SdCppEngine]]:
"""Pick the native execution mode: ("server", binary, None) or ("oneshot", None, engine).
The persistent ``sd-server`` is preferred (load once, serve many). The one-shot
``sd-cli`` is the fallback for older / custom builds that lack the server target.
An explicitly injected engine forces one-shot (the unit-test seam and an escape
hatch), so a test never spawns a real server or triggers an install. A lazily
cached fallback engine does NOT force one-shot: once a resident server becomes
available (installed, or a per-model start that previously failed now works), the
next load can use it, instead of being pinned to one-shot for the whole session.
"""
if self._engine_injected and self._engine is not None:
return "oneshot", None, self._resolve_engine()
# Install the sd-server build matching the resolved device backend (ROCm / Vulkan /
# CUDA), not the default CPU build: a forced/enabled native load on a GPU host must
# not silently fetch the plain-CPU server. Lazy import avoids an import cycle with
# the router, which imports this backend during engine selection.
from core.inference.diffusion_engine_router import _install_accelerator_for
accelerator = _install_accelerator_for(
getattr(resolve_diffusion_device_target(), "backend", "cpu")
)
server_binary = ensure_sd_server_binary(
allow_install = _install_allowed(), accelerator = accelerator
)
if server_binary is not None:
return "server", server_binary, None
logger.warning(
"sd-server not found; falling back to one-shot sd-cli (reloads the model per image)."
)
return "oneshot", None, self._resolve_engine()
# ── Background load + progress ─────────────────────────────────────────
def begin_load(
@ -308,10 +440,35 @@ class SdCppDiffusionBackend:
_load_token: int,
) -> None:
try:
# Ensure the binary up front so an install failure surfaces before the
# multi-GB asset pull (the router also pre-checks, but a forced reload here
# must not silently download then fail at generate).
engine = self._resolve_engine()
# Resolve the backend mode (persistent sd-server preferred, one-shot sd-cli
# fallback) and binary up front so an install / missing-binary failure
# surfaces before the multi-GB asset pull.
mode, server_binary, engine = self._resolve_backend()
if mode == "server":
# Probe the server binary before the multi-GB asset pull: a present but
# unrunnable build (wrong arch / missing libs) would otherwise download
# everything and only then fail to start. If it cannot run, fall back to
# the one-shot engine now (when it is usable), else surface the failure.
assert server_binary is not None
if not _server_binary_runnable(server_binary):
logger.warning(
"sd-server at %s is present but not runnable; trying one-shot sd-cli.",
server_binary,
)
try:
usable = self._resolve_engine().version() is not None
except Exception: # noqa: BLE001
usable = False
if not usable:
raise RuntimeError("sd-server binary is present but not runnable.")
mode, server_binary, engine = "oneshot", None, self._resolve_engine()
if mode == "oneshot":
# Probe the binary: version() returns None when the present binary cannot
# run (bad perms / missing libs), so fail now rather than commit a "ready"
# state that crashes on the first generation.
assert engine is not None
if engine.version() is None:
raise RuntimeError("sd-cli binary is present but not runnable.")
assets = self._asset_specs(repo_id, gguf_filename, fam)
self._set_expected_bytes(assets, hf_token)
@ -328,38 +485,21 @@ class SdCppDiffusionBackend:
)
device = resolve_diffusion_device_target().device
# Honor the requested speed everywhere; offload only off-CPU (forced
# sd_cpp / MPS), since on CPU sd-cli is resident in RAM and the offload
# flags are no-ops.
# sd_cpp / MPS), since on CPU the weights are resident in RAM and the
# offload flags are no-ops.
offload: tuple[str, ...] = ()
if device != "cpu":
offload = tuple(offload_flags(_memory_policy(memory_mode, cpu_offload)))
state = _SdState(
repo_id = repo_id,
base_repo = base,
family = fam,
device = device,
files = files,
vae_format = fam.sd_cpp_vae_format,
native_speed = _native_speed_for(speed_mode),
offload_flags = offload,
threads = None,
sampling_method = fam.sd_cpp_sampling_method,
flow_shift = fam.sd_cpp_flow_shift,
hf_token = hf_token,
)
# Probe the binary: version() returns None when the present binary cannot
# run (bad permissions / missing shared libs), so fail the load now rather
# than commit a "ready" state that crashes on the first generation.
if engine.version() is None:
raise RuntimeError("sd-cli binary is present but not runnable.")
# A generation that started during the (slow) asset download is still running
# against the OLD model. Abort it, then WAIT on _generate_lock for it to exit
# before publishing the new state -- otherwise that stale sd-cli run can finish
# afterward and persist an image from the previous model once this load reports
# ready (mirrors the diffusers load_pipeline commit). _generate_lock is taken
# only here, not during the download, so the long fetch never serialises against
# generation; the inner token re-check guards an unload/newer load arriving while
# we waited.
native_speed = _native_speed_for(speed_mode)
# Tear down any previously-loaded model, then commit the new one. A generation
# that started during the (slow) asset download is still running against the OLD
# model: abort it and WAIT on _generate_lock for it to exit before swapping, or
# a stale run could finish afterward and persist an image from the previous
# model. For server mode we stop the old server and start (load) the new one
# HERE, under _generate_lock, so generation never races a half-loaded server and
# two resident models never coexist. _generate_lock is taken only now, not during
# the download, so the long fetch never serialises against generation.
with self._lock:
if self._load_token != _load_token:
return # superseded / cancelled
@ -369,6 +509,79 @@ class SdCppDiffusionBackend:
with self._lock:
if self._load_token != _load_token:
return # superseded / cancelled while waiting
old_state = self._state
self._state = None # the old model is being torn down
if old_state is not None and old_state.server is not None:
old_state.server.stop()
server: Optional[SdCppServer] = None
if mode == "server":
assert server_binary is not None
server = SdCppServer(server_binary)
# Publish the not-yet-committed server so unload() / a superseding load
# can stop it mid-startup (SdCppServer.stop aborts the readiness wait
# without waiting on the lifecycle lock), instead of it loading for the
# full startup timeout while holding the generate lock.
with self._lock:
self._pending_server = server
try:
# Blocks until the server has loaded the model and is answering
# (its readiness check); raises with the log tail on a failed load.
server.start(
files,
vae_format = fam.sd_cpp_vae_format,
offload = list(offload),
native_speed = native_speed,
threads = None,
)
except SdCppCancelled:
# Startup was aborted by an unload / superseding load: stop the
# half-started server and bail (the outer handler returns cleanly).
server.stop()
raise
except Exception as start_exc: # noqa: BLE001
# A present-but-unusable sd-server must be no worse than the
# one-shot engine: fall back to sd-cli when it is usable, else
# surface the server error.
logger.warning(
"sd-server failed to start (%s); falling back to one-shot sd-cli.",
start_exc,
)
server.stop()
server = None
try:
usable = self._resolve_engine().version() is not None
except Exception: # noqa: BLE001
usable = False
if not usable:
raise start_exc
mode = "oneshot"
finally:
with self._lock:
if self._pending_server is server:
self._pending_server = None
state = _SdState(
repo_id = repo_id,
base_repo = base,
family = fam,
device = device,
files = files,
vae_format = fam.sd_cpp_vae_format,
native_speed = native_speed,
offload_flags = offload,
threads = None,
sampling_method = fam.sd_cpp_sampling_method,
flow_shift = fam.sd_cpp_flow_shift,
server = server,
mode = mode,
hf_token = hf_token,
)
with self._lock:
if self._load_token != _load_token:
# Superseded / unloaded while we were loading: discard the server
# we just started so it doesn't leak (and keep _state unloaded).
if server is not None:
server.stop()
return
self._state = state
self._loading = None
except SdCppCancelled:
@ -496,11 +709,13 @@ class SdCppDiffusionBackend:
upscale: Optional[float] = None,
# Reference workflow is GPU/diffusers-only (FLUX.2); accepted for interface parity.
reference_images: Optional[list[str]] = None,
# LoRA adapters as (id, weight) pairs; resolved + materialized into a managed dir
# and injected as <lora:ALIAS:w> prompt tags per sd-cli run. None/empty = no LoRA.
# LoRA adapters as (id, weight) pairs; resolved up front, then applied per engine
# path: <lora:ALIAS:w> prompt tags for one-shot sd-cli, structured `lora` entries
# for the resident sd-server. None/empty = no LoRA.
loras: Optional[list[tuple[str, float]]] = None,
# ControlNet: accepted for interface parity with the diffusers backend. Native sd.cpp
# ControlNet (sd-cli --control-net) is a follow-up; a request is rejected clearly.
# Accepted for the uniform engine interface; the guard below rejects it on the native
# engine (ControlNet is diffusers-only) like img2img/inpaint, so a direct API call with
# ControlNet set fails clearly instead of TypeError'ing on an unexpected kwarg.
controlnet: Optional[tuple[str, str, str, float, float, float]] = None,
) -> dict[str, Any]:
import tempfile
@ -534,8 +749,17 @@ class SdCppDiffusionBackend:
state = self._state
if state is None:
raise RuntimeError(DIFFUSION_NOT_LOADED_MSG)
# A resident server can exit while idle; if a client generates without first
# polling status, drop the stale loaded state and report not-loaded so it gets
# the recoverable reload path instead of a 500 from img_gen (not running).
if (
state.mode == "server"
and state.server is not None
and not state.server.is_alive()
):
self._state = None
raise RuntimeError(DIFFUSION_NOT_LOADED_MSG)
self._active_generate_cancel = cancel
engine = self._resolve_engine()
try:
if seed is None:
seed = int.from_bytes(os.urandom(6), "big") & ((1 << 53) - 1)
@ -543,11 +767,10 @@ class SdCppDiffusionBackend:
seed = int(seed)
cfg_scale, flux_guidance = _map_guidance(state.family, guidance)
# Resolve any selected LoRA adapters up front (downloads land in the HF
# cache; a bad id fails here as a clear 400 before we spawn sd-cli).
# Drop weight-0 rows BEFORE the support gate: LoraSpec documents weight 0
# as disabling the adapter (the diffusers path treats it as empty), so a
# request carrying only disabled rows must stay a no-op even on a family
# where native LoRA is unsupported, rather than 400 on a dead selection.
# cache; a bad id fails here as a clear 400 before we generate). Drop
# weight-0 rows BEFORE the support gate: weight 0 disables an adapter, so a
# request carrying only disabled rows stays a no-op even on a family where
# native LoRA is unsupported, rather than 400 on a dead selection.
lora_resolved: list = []
active_loras = [(i, w) for (i, w) in (loras or []) if w != 0]
if active_loras:
@ -564,71 +787,41 @@ class SdCppDiffusionBackend:
lora_resolved = diffusion_lora.resolve_specs(
active_loras, hf_token = state.hf_token, cancel_event = cancel
)
extra_args: list[str] = []
if state.vae_format:
extra_args += ["--vae-format", state.vae_format]
if state.flow_shift is not None:
extra_args += ["--flow-shift", repr(float(state.flow_shift))]
self._gen = _SdGen(total_steps = int(steps))
images = []
seeds: list[int] = []
with tempfile.TemporaryDirectory(prefix = "sdcpp_gen_") as tmpdir:
# Materialize selected LoRAs into a managed dir sd-cli can scan, and
# inject matching <lora:ALIAS:w> tags into the prompt (deduped against
# any the user typed). Empty -> prompt/dir unchanged.
eff_prompt = prompt
lora_dir: Optional[str] = None
if lora_resolved:
materialized = diffusion_lora.materialize_native_dir(
lora_resolved, Path(tmpdir) / "loras"
)
eff_prompt = diffusion_lora.inject_prompt_tags(prompt, materialized)
lora_dir = str(Path(tmpdir) / "loras")
for index in range(max(1, int(batch_size))):
if cancel.is_set():
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# Distinct seed per batch image (sd-cli is one image/run here),
# so a batch is reproducible image-by-image from the base seed.
# Mask to sd-cli's int64 range, NOT 53 bits: the request model and
# the diffusers backend both accept large explicit seeds, so a tight
# 2**53 mask would silently truncate them (2**53 -> 0) and collide
# distinct requested seeds onto the same image. Randomly-drawn seeds
# above are already 53-bit (JS-safe); explicit seeds pass through.
seed_i = (seed + index) & ((1 << 63) - 1)
out_path = str(Path(tmpdir) / f"img_{index}.png")
params = SdCppGenParams(
prompt = eff_prompt,
negative_prompt = negative_prompt or None,
width = int(width),
height = int(height),
steps = int(steps),
cfg_scale = cfg_scale,
guidance = flux_guidance,
seed = seed_i,
sampling_method = state.sampling_method,
batch_count = 1,
lora_dir = lora_dir,
lora_apply_mode = "auto" if lora_dir else None,
)
engine.generate(
state.files,
params,
output_path = out_path,
offload = list(state.offload_flags) or None,
native_speed = state.native_speed,
threads = state.threads,
extra_args = extra_args or None,
on_log = self._on_log,
cancel_event = cancel,
)
with Image.open(out_path) as im:
images.append(im.copy())
seeds.append(seed_i)
if state.mode == "server" and state.server is not None:
images, seeds = self._generate_server(
state,
prompt = prompt,
negative_prompt = negative_prompt,
width = width,
height = height,
steps = steps,
seed = seed,
batch_size = batch_size,
cfg_scale = cfg_scale,
flux_guidance = flux_guidance,
lora_resolved = lora_resolved,
cancel = cancel,
)
else:
images, seeds = self._generate_oneshot(
state,
prompt = prompt,
negative_prompt = negative_prompt,
width = width,
height = height,
steps = steps,
seed = seed,
batch_size = batch_size,
cfg_scale = cfg_scale,
flux_guidance = flux_guidance,
lora_resolved = lora_resolved,
cancel = cancel,
)
if cancel.is_set():
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# ``seeds`` is the per-image seed (each sd-cli run used seed+index), so
# the route can persist the real seed for every image in the batch.
# ``seeds`` is the per-image seed (image i used seed+i), so the route can
# persist the real seed for every image in the batch.
return {
"images": images,
"seed": int(seed),
@ -643,6 +836,195 @@ class SdCppDiffusionBackend:
if self._active_generate_cancel is cancel:
self._active_generate_cancel = None
def _generate_server(
self,
state: _SdState,
*,
prompt: str,
negative_prompt: Optional[str],
width: int,
height: int,
steps: int,
seed: int,
batch_size: int,
cfg_scale: Optional[float],
flux_guidance: Optional[float],
lora_resolved: list,
cancel: threading.Event,
) -> tuple[list, list[int]]:
"""Generate via the resident sd-server (no model reload).
A batch larger than the server's per-job limit is split into chunks: the server
rejects a batch_count above _MAX_SERVER_BATCH, and the one-shot path served large
batches image-by-image, so preserve that. The base seed is masked to sd.cpp's
signed-int64 range (the request model / diffusers accept larger seeds), and each
chunk is submitted at base+offset so the per-image seeds stay reproducible. Each
chunk gets a timeout proportional to its image count so a slow CPU batch is not
cancelled partway through on one fixed deadline.
LoRA on the server goes through the structured ``lora`` request field, NOT prompt
tags (the sdcpp API intentionally ignores ``<lora:>`` in the prompt). Selected
adapters are staged into the server's ``--lora-model-dir`` scratch dir, which the
server rescans per request, and referenced by their staged filename."""
import io
import os
import shutil
from PIL import Image
from core.inference import diffusion_lora
assert state.server is not None
total = max(1, int(batch_size))
# sd.cpp's image seed is signed int64; mask the base (and every derived seed) so a
# large explicit seed is not rejected / wrapped inconsistently by the server.
base_seed = int(seed) & ((1 << 63) - 1)
images: list = []
seeds: list[int] = []
# Stage selected LoRAs into a per-request subdir of the server's lora-model-dir so a
# previous request's adapters can't leak into this one; reference them by the path
# relative to that dir (what the server's recursive scan resolves against). The
# subdir is removed after the batch. supports_lora already gated the family upstream.
lora_payload: Optional[list[dict]] = None
lora_stage: Optional[Path] = None
if lora_resolved:
server_lora_dir = state.server.lora_dir
if server_lora_dir:
lora_stage = Path(server_lora_dir) / f"gen_{os.urandom(6).hex()}"
materialized = diffusion_lora.materialize_native_dir(lora_resolved, lora_stage)
lora_payload = [
{"path": f"{lora_stage.name}/{Path(m.path).name}", "multiplier": float(m.weight)}
for m in materialized
]
try:
for offset in range(0, total, _MAX_SERVER_BATCH):
if cancel.is_set():
raise SdCppCancelled("sd-server generation was cancelled.")
count = min(_MAX_SERVER_BATCH, total - offset)
chunk_seed = (base_seed + offset) & ((1 << 63) - 1)
payload = build_img_gen_request(
prompt = prompt,
negative_prompt = negative_prompt or None,
width = int(width),
height = int(height),
steps = int(steps),
seed = chunk_seed,
batch_count = count,
sample_method = state.sampling_method,
flow_shift = state.flow_shift,
cfg_scale = cfg_scale,
distilled_guidance = flux_guidance,
lora = lora_payload,
)
blobs = state.server.img_gen(
payload,
on_step = self._on_log,
cancel_event = cancel,
total_timeout = _SERVER_PER_IMAGE_TIMEOUT_S * count,
)
# All-or-nothing per chunk, like the one-shot path: if the server returns fewer
# blobs than requested (e.g. one image in the batch failed to encode), fail
# rather than silently dropping images from the user's requested batch.
if not cancel.is_set() and len(blobs) != count:
raise RuntimeError(
f"sd-server returned {len(blobs)} of {count} requested images in the batch."
)
images.extend(Image.open(io.BytesIO(b)).convert("RGB") for b in blobs)
# sd.cpp advances the seed per image within a job, so report chunk_seed+i.
seeds.extend((chunk_seed + i) & ((1 << 63) - 1) for i in range(len(blobs)))
finally:
if lora_stage is not None:
shutil.rmtree(lora_stage, ignore_errors = True)
return images, seeds
def _generate_oneshot(
self,
state: _SdState,
*,
prompt: str,
negative_prompt: Optional[str],
width: int,
height: int,
steps: int,
seed: int,
batch_size: int,
cfg_scale: Optional[float],
flux_guidance: Optional[float],
lora_resolved: list,
cancel: threading.Event,
) -> tuple[list, list[int]]:
"""Fallback path: re-run one-shot sd-cli per image (reloads the model each time).
LoRA on the one-shot path uses sd-cli's own mechanism: materialize the selected
adapters into a ``--lora-model-dir`` and inject matching ``<lora:ALIAS:w>`` tags
into the prompt (sd-cli parses and strips them). supports_lora already gated the
family upstream, so a non-empty ``lora_resolved`` is safe to apply here."""
import tempfile
from PIL import Image
from core.inference import diffusion_lora
engine = self._resolve_engine()
extra_args: list[str] = []
if state.vae_format:
extra_args += ["--vae-format", state.vae_format]
if state.flow_shift is not None:
extra_args += ["--flow-shift", repr(float(state.flow_shift))]
images = []
seeds: list[int] = []
with tempfile.TemporaryDirectory(prefix = "sdcpp_gen_") as tmpdir:
# Materialize selected LoRAs into a managed dir sd-cli can scan, and inject
# matching <lora:ALIAS:w> tags into the prompt (deduped against any the user
# typed). Empty -> prompt/dir unchanged.
eff_prompt = prompt
lora_dir: Optional[str] = None
if lora_resolved:
materialized = diffusion_lora.materialize_native_dir(
lora_resolved, Path(tmpdir) / "loras"
)
eff_prompt = diffusion_lora.inject_prompt_tags(prompt, materialized)
lora_dir = str(Path(tmpdir) / "loras")
for index in range(max(1, int(batch_size))):
if cancel.is_set():
raise RuntimeError(DIFFUSION_CANCELLED_MSG)
# Distinct seed per batch image, reproducible image-by-image from the base
# seed. Mask to int64, NOT 53 bits: the request model and the diffusers
# backend both accept large explicit seeds, so a tight 2**53 mask would
# truncate them and collide distinct requested seeds onto the same image.
seed_i = (seed + index) & ((1 << 63) - 1)
out_path = str(Path(tmpdir) / f"img_{index}.png")
params = SdCppGenParams(
prompt = eff_prompt,
negative_prompt = negative_prompt or None,
width = int(width),
height = int(height),
steps = int(steps),
cfg_scale = cfg_scale,
guidance = flux_guidance,
seed = seed_i,
sampling_method = state.sampling_method,
batch_count = 1,
lora_dir = lora_dir,
lora_apply_mode = "auto" if lora_dir else None,
)
engine.generate(
state.files,
params,
output_path = out_path,
offload = list(state.offload_flags) or None,
native_speed = state.native_speed,
threads = state.threads,
extra_args = extra_args or None,
on_log = self._on_log,
cancel_event = cancel,
)
with Image.open(out_path) as im:
images.append(im.copy())
seeds.append(seed_i)
return images, seeds
def _on_log(self, line: str) -> None:
gen = self._gen
if gen is None or gen.total_steps <= 0:
@ -680,13 +1062,40 @@ class SdCppDiffusionBackend:
with self._lock:
if self._active_generate_cancel is not None:
self._active_generate_cancel.set()
state = self._state
self._state = None
self._load_token += 1
self._loading = None
# A load may be mid server.start() with the server not yet committed to _state;
# grab it too so we can stop it (its startup is abortable) instead of leaving it
# loading for the full startup timeout.
pending = self._pending_server
self._pending_server = None
# Stop the resident server outside the lock (terminate can take a few seconds). A
# mid-flight generation had its cancel event set above, so its poll loop unwinds
# as the process goes away.
if state is not None and state.server is not None:
state.server.stop()
if pending is not None and pending is not (state.server if state else None):
pending.stop()
return self.status()
def status(self) -> dict[str, Any]:
state = self._state
# A resident sd-server can exit after load (OOM-killed / crashed while idle). If so,
# drop the stale loaded state so status reports not-loaded and clients reload,
# instead of every generation failing with a 500 against a dead process.
if (
state is not None
and state.mode == "server"
and state.server is not None
and not state.server.is_alive()
):
logger.warning("sd-server exited after load; clearing loaded state")
with self._lock:
if self._state is state:
self._state = None
state = None
if state is None:
return {
"loaded": False,
@ -706,6 +1115,7 @@ class SdCppDiffusionBackend:
"attention_backend": None,
"transformer_cache": None,
"engine": "sd_cpp",
"native_mode": None,
"supports_lora": False,
"supports_controlnet": False,
"workflows": [],
@ -740,8 +1150,10 @@ class SdCppDiffusionBackend:
model_kind = "gguf",
transformer_quant = None,
),
# Native ControlNet (sd-cli --control-net) is a follow-up; off for now.
# ControlNet is diffusers-only; the native engine's generate() rejects it.
"supports_controlnet": False,
# "server" = resident sd-server (load once); "oneshot" = legacy per-image sd-cli.
"native_mode": state.mode,
# The native engine supports plain text-to-image only (generate() rejects
# img2img / inpaint / reference / upscale), so advertise just txt2img. Without
# this the status omits workflows, the UI reads [], and it disables the Create

View file

@ -51,6 +51,9 @@ logger = logging.getLogger(__name__)
# target is ``sd-cli``; older builds shipped ``sd`` -- both are probed on PATH.
_BINARY_STEM = "sd-cli"
_LEGACY_STEM = "sd"
# The persistent HTTP server target (stable-diffusion.cpp ``examples/server``). It
# ships next to ``sd-cli`` in both the prebuilt archives and the cmake build tree.
_SERVER_STEM = "sd-server"
class SdCppCancelled(RuntimeError):
@ -119,11 +122,11 @@ def runtime_env(binary: str, base_env: Optional[dict[str, str]] = None) -> dict[
return env
def _layout_candidates(root: Path) -> list[Path]:
"""sd-cli locations under a stable-diffusion.cpp checkout/install ``root``,
def _layout_candidates(root: Path, stem: str = _BINARY_STEM) -> list[Path]:
"""``stem`` locations under a stable-diffusion.cpp checkout/install ``root``,
highest priority first: the cmake ``build/bin`` tree, then a Windows Release
subdir, then the root itself."""
name = _binary_name(_BINARY_STEM)
name = _binary_name(stem)
cands = [
root / "build" / "bin" / name,
root / "build" / "bin" / "Release" / name,
@ -133,37 +136,39 @@ def _layout_candidates(root: Path) -> list[Path]:
return cands
def find_sd_cpp_binary() -> Optional[str]:
"""Locate the ``sd-cli`` binary, or None.
def _first_file(paths: list[Path]) -> Optional[str]:
for p in paths:
try:
if p.is_file():
return str(p)
except OSError:
continue
return None
Search order (mirrors the llama.cpp finder so a Studio install lands where
both engines look):
1. ``SD_CLI_PATH`` env -- a direct path to the binary.
def _find_binary(
*, direct_env: str, path_stems: tuple[str, ...], layout_stem: str
) -> Optional[str]:
"""Shared finder for the stable-diffusion.cpp binaries.
Search order (mirrors the llama.cpp finder so a Studio install lands where every
binary is looked for):
1. ``direct_env`` -- a direct path to the binary.
2. ``UNSLOTH_SD_CPP_PATH`` env -- a stable-diffusion.cpp install dir.
3. The default install root build layouts (the installer target); honors
``UNSLOTH_STUDIO_HOME`` / ``STUDIO_HOME``, else ``~/.unsloth/stable-diffusion.cpp``.
4. ``./stable-diffusion.cpp`` in-tree build (developer checkout).
5. ``sd-cli`` (then legacy ``sd``) on PATH.
5. ``path_stems`` on PATH (in order).
"""
def _first_file(paths: list[Path]) -> Optional[str]:
for p in paths:
try:
if p.is_file():
return str(p)
except OSError:
continue
return None
# 1. Direct binary path.
env_bin = os.environ.get("SD_CLI_PATH")
env_bin = os.environ.get(direct_env)
if env_bin and Path(env_bin).is_file():
return env_bin
# 2. Custom install dir.
custom = os.environ.get("UNSLOTH_SD_CPP_PATH")
if custom:
hit = _first_file(_layout_candidates(Path(custom)))
hit = _first_file(_layout_candidates(Path(custom), layout_stem))
if hit:
return hit
@ -177,27 +182,55 @@ def find_sd_cpp_binary() -> Optional[str]:
if studio_home
else Path.home() / ".unsloth" / "stable-diffusion.cpp"
)
hit = _first_file(_layout_candidates(default_root))
hit = _first_file(_layout_candidates(default_root, layout_stem))
if hit:
return hit
# 4. In-tree developer build: <repo_root>/stable-diffusion.cpp.
try:
project_root = Path(__file__).resolve().parents[4]
hit = _first_file(_layout_candidates(project_root / "stable-diffusion.cpp"))
hit = _first_file(_layout_candidates(project_root / "stable-diffusion.cpp", layout_stem))
if hit:
return hit
except (OSError, IndexError):
pass
# 5. PATH.
for stem in (_BINARY_STEM, _LEGACY_STEM):
for stem in path_stems:
on_path = shutil.which(stem)
if on_path:
return on_path
return None
def find_sd_cpp_binary() -> Optional[str]:
"""Locate the one-shot ``sd-cli`` binary (env ``SD_CLI_PATH``), or None.
Probes ``sd-cli`` then legacy ``sd`` on PATH. This is the fallback engine once the
persistent ``sd-server`` exists; it also still backs the ESRGAN upscale mode.
"""
return _find_binary(
direct_env = "SD_CLI_PATH",
path_stems = (_BINARY_STEM, _LEGACY_STEM),
layout_stem = _BINARY_STEM,
)
def find_sd_server_binary() -> Optional[str]:
"""Locate the persistent ``sd-server`` binary (env ``SD_SERVER_PATH``), or None.
Same precedence as ``find_sd_cpp_binary`` but keyed to the ``sd-server`` stem, so
a Studio install (prebuilt archive or cmake build, both of which ship ``sd-server``
next to ``sd-cli``) is found in the same places. Preferred over the one-shot CLI:
it loads the model once and serves many generations without reloading from disk.
"""
return _find_binary(
direct_env = "SD_SERVER_PATH",
path_stems = (_SERVER_STEM,),
layout_stem = _SERVER_STEM,
)
class SdCppEngine:
"""A thin handle over a located ``sd-cli`` binary.

View file

@ -0,0 +1,510 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Persistent ``sd-server`` (stable-diffusion.cpp) process manager.
The native diffusion tier used to shell out to one-shot ``sd-cli`` per image, which
reloaded the multi-GB GGUF from disk every generation. ``sd-server`` (the upstream
``examples/server`` target) loads the model once at spawn and serves many generations
over HTTP, exactly like the chat backend's persistent ``llama-server``. This manager
owns ONLY the process + HTTP lifecycle; the backend (``sd_cpp_backend.py``) still owns
asset resolution, request validation, and the public Studio surface.
Shape mirrors ``core/rag/embed_llama_server.py``:
* ``start`` -- pick a free loopback port, spawn the server (model loads here),
drain stdout on a daemon thread, poll until ready.
* ``img_gen`` -- POST ``/sdcpp/v1/img_gen`` (the whole batch in one request),
poll the async job to a terminal state, return image bytes.
* ``stop`` -- SIGTERM -> wait -> SIGKILL, join the drain thread (idempotent).
Readiness is real: upstream ``main.cpp`` loads the model BEFORE it binds the port and
prints ``listening on:``, so a 200 from ``GET /v1/models`` (a trivial handler) means the
model is loaded; a load failure exits the process before listening and is surfaced with
the captured log tail. (The richer ``/sdcpp/v1/capabilities`` handler can block in some
builds, so it is not used for readiness.) The job JSON has no per-step field, so step progress is recovered by
parsing the server's stdout (the same ``N/M`` lines ``sd-cli`` emits), routed to the
active generation's callback.
Import-light on purpose (no torch / diffusers / PIL), so selecting the native tier on
a CPU box never drags the GPU stack into the process.
"""
from __future__ import annotations
import atexit
import base64
import logging
import shutil
import socket
import subprocess
import tempfile
import threading
import time
from collections import deque
from typing import Any, Callable, Optional
import httpx
from core.inference.sd_cpp_args import SdCppModelFiles, build_sd_cpp_server_command
from core.inference.sd_cpp_engine import SdCppCancelled, runtime_env
from utils.native_path_leases import child_env_without_native_path_secret
from utils.process_lifetime import adopt_pid, child_popen_kwargs, forget_pid
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
logger = logging.getLogger(__name__)
# httpx transport errors meaning "the server is gone / connection refused" -- treated
# as "not ready yet" while polling readiness, and as a fatal "server died" mid-request.
_TRANSPORT_ERRORS = (
httpx.ConnectError,
httpx.ReadError,
httpx.RemoteProtocolError,
httpx.WriteError,
)
# Readiness probe. Upstream binds the port only AFTER the model is loaded, so any 200
# means ready. We use /v1/models (a trivial, always-fast handler) rather than
# /sdcpp/v1/capabilities: the capabilities handler can block in some builds (it enumerates
# model metadata), which would stall readiness even though the server is up.
_READY_PATH = "/v1/models"
# Native async sdcpp API.
_IMG_GEN_PATH = "/sdcpp/v1/img_gen"
_JOBS_PATH = "/sdcpp/v1/jobs"
_TERMINAL_OK = "completed"
_TERMINAL_FAIL = "failed"
_TERMINAL_CANCELLED = "cancelled"
# After a cancel is requested, how long to let the server reflect it in job status before
# abandoning the poll. The native cancel is best-effort, so without this cap a server that
# ignores/loses the cancel would keep this call (and the backend's generate lock) alive
# until the job finishes naturally, blocking a superseding load from swapping the model.
_CANCEL_GRACE_S = 5.0
class SdCppServer:
"""A resident ``sd-server`` subprocess plus the HTTP client that drives it."""
def __init__(
self,
binary: str,
*,
host: str = "127.0.0.1",
) -> None:
self.binary = binary
self.host = host
self.port: Optional[int] = None
self._process: Optional[subprocess.Popen] = None
# Fixed-size, thread-safe tail buffer: the drain thread appends while lifecycle /
# request threads read it for diagnostics, so a deque(maxlen) is safer and cheaper
# than a list with manual slicing.
self._tail: deque[str] = deque(maxlen = 200)
self._stdout_thread: Optional[threading.Thread] = None
self._lifecycle_lock = threading.Lock()
# Set (lock-free) by stop() so a blocking start()/readiness wait can be aborted
# promptly without waiting on the lifecycle lock start() holds.
self._abort = threading.Event()
# Set for the duration of a generation so the continuous stdout drain can feed
# the active request's step-progress callback; cleared in img_gen's finally.
self._step_listener: Optional[Callable[[str], None]] = None
# trust_env=False: this client only ever talks to the loopback sd-server, so it must
# not route through HTTP_PROXY/HTTPS_PROXY (a proxy without 127.0.0.1 in NO_PROXY
# would break readiness/generation). Matches the local llama-server clients.
self._client = httpx.Client(timeout = 30.0, trust_env = False)
self._scratch_dir: Optional[str] = None
self._stopped = False
atexit.register(self.stop)
# ── lifecycle ────────────────────────────────────────────────────────────
@property
def base_url(self) -> str:
return f"http://{self.host}:{self.port}"
@property
def lora_dir(self) -> Optional[str]:
"""The server's ``--lora-model-dir`` scratch dir. Adapters staged here are picked
up per request (the sdcpp ``img_gen`` route refreshes its LoRA scan each call), so
server-mode LoRA works by materializing here and referencing the files via the
structured ``lora`` request field (the server ignores ``<lora:>`` prompt tags)."""
return self._scratch_dir
def is_alive(self) -> bool:
return self._process is not None and self._process.poll() is None
@staticmethod
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def start(
self,
files: SdCppModelFiles,
*,
vae_format: Optional[str] = None,
offload: Optional[list[str]] = None,
native_speed: Optional[str] = None,
threads: Optional[int] = None,
env: Optional[dict[str, str]] = None,
startup_timeout: float = 600.0,
) -> None:
"""Spawn the server (which loads the model) and block until it is ready.
Raises ``RuntimeError`` (with the captured log tail) if the process exits during
startup or never answers within ``startup_timeout``. Holds the lifecycle lock so
a concurrent start/stop can't interleave.
"""
with self._lifecycle_lock:
# A stop()/unload that raced in AFTER the backend published this server as
# _pending_server but BEFORE start() took the lock has already set _abort and
# closed the httpx client. Honor that delivered stop instead of clearing the
# abort and spawning a model process the cancelled load would then leak.
if self._stopped or self._abort.is_set():
raise SdCppCancelled("sd-server start was cancelled before launch.")
self._abort.clear()
port = self._find_free_port()
# An empty scratch dir for sd-server's LoRA / upscaler / embeddings scans
# (it recursively iterates them per request and errors on a missing dir).
self._scratch_dir = tempfile.mkdtemp(prefix = "sdcpp_dirs_")
cmd = build_sd_cpp_server_command(
self.binary,
files,
host = self.host,
port = port,
vae_format = vae_format,
offload = list(offload or []),
native_speed = native_speed,
threads = threads,
scratch_dir = self._scratch_dir,
verbose = True, # sd-server prints the per-step sampling lines we parse
)
run_env = runtime_env(self.binary, child_env_without_native_path_secret())
if env:
run_env.update(env)
logger.info("starting sd-server: %s", " ".join(cmd))
# Clear in place: reassigning to [] drops the deque(maxlen=200) bound, so the
# continuous stdout drain would then grow the tail without limit for the whole
# resident-server lifetime.
self._tail.clear()
self._spawn_error: Optional[Exception] = None
spawned = threading.Event()
# Spawn INSIDE the drain thread, which then reads stdout for the process's whole
# lifetime. child_popen_kwargs() sets PR_SET_PDEATHSIG, which on Linux is bound to
# the CREATING THREAD -- so the child must be created by a thread that outlives it,
# or a transient spawner thread ending would kill the server. The drain thread is
# exactly that long-lived owner; it dies only when the process exits or the
# interpreter goes away (the case we DO want to reap the GPU-resident server).
def _own_process() -> None:
try:
proc = subprocess.Popen(
cmd,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
errors = "replace",
env = run_env,
**windows_hidden_subprocess_kwargs(),
**child_popen_kwargs(),
)
except Exception as exc: # noqa: BLE001 -- surface the spawn failure to start()
self._spawn_error = exc
spawned.set()
return
self._process = proc
self.port = port
adopt_pid(proc.pid) # so a global shutdown sweep also reaps it
spawned.set()
self._drain_stdout(proc)
# stdout closed == the process exited; reap it so it is not left a zombie
# until the next stop()/reload.
try:
proc.wait(timeout = 5)
except Exception: # noqa: BLE001
pass
self._stdout_thread = threading.Thread(
target = _own_process, daemon = True, name = "sd-server-owner"
)
self._stdout_thread.start()
spawned.wait()
if self._spawn_error is not None:
self._dispose()
raise RuntimeError(f"failed to spawn sd-server: {self._spawn_error}")
if not self._wait_ready(startup_timeout):
tail = "\n".join(list(self._tail)[-30:])
aborted = self._abort.is_set()
self._kill_locked()
self._dispose()
if aborted:
raise SdCppCancelled("sd-server startup was cancelled.")
raise RuntimeError("sd-server failed to become ready. Last output:\n" + tail[:2000])
def _wait_ready(
self,
timeout: float,
interval: float = 0.5,
) -> bool:
"""Poll ``/v1/models`` until 200; bail early if the process exits.
Upstream binds the port only AFTER the model is loaded, so a 200 here is a true
ready signal (no half-loaded race)."""
deadline = time.monotonic() + timeout
url = f"{self.base_url}{_READY_PATH}"
while time.monotonic() < deadline:
# A concurrent stop() (unload / superseding load) sets _abort so this wait can
# bail without holding the model-load hostage for the full startup_timeout.
if self._abort.is_set():
logger.info("sd-server startup aborted before ready")
return False
if not self.is_alive():
code = None if self._process is None else self._process.returncode
logger.error("sd-server exited early during load (code %s)", code)
return False
try:
if self._client.get(url, timeout = 2.0).status_code == 200:
return True
except (*_TRANSPORT_ERRORS, httpx.TimeoutException):
pass
time.sleep(interval)
logger.error("sd-server readiness timed out after %ss", timeout)
return False
def _drain_stdout(self, proc: subprocess.Popen) -> None:
"""Drain stdout so the pipe never deadlocks; keep a tail for diagnostics and
feed each line to the active generation's step callback."""
try:
assert proc.stdout is not None
for raw in proc.stdout:
line = raw.rstrip()
if not line:
continue
self._tail.append(line) # deque(maxlen) discards the oldest automatically
logger.debug("[sd-server] %s", line)
cb = self._step_listener
if cb is not None:
try:
cb(line)
except Exception: # noqa: BLE001 -- a progress callback must never break drain
pass
except Exception: # noqa: BLE001 -- drain thread must never raise (pipe closed at teardown)
pass
def stop(self) -> None:
"""Terminate the server (SIGTERM -> SIGKILL), join the drain, and release the HTTP
client + atexit handler. Idempotent."""
# Signal abort BEFORE contending for the lifecycle lock: a concurrent start() holds
# that lock for the whole (up to startup_timeout) readiness wait, so setting the
# event lets that wait bail immediately instead of stop() blocking behind it.
self._abort.set()
self._stopped = True
with self._lifecycle_lock:
self._kill_locked()
self._dispose()
def _dispose(self) -> None:
"""Release per-instance resources (on stop / failed start). The backend never
reuses a disposed server, so this closes the pooled httpx client and drops the
atexit handler that would otherwise pin every reloaded instance for the session."""
try:
atexit.unregister(self.stop)
except Exception: # noqa: BLE001
pass
try:
self._client.close()
except Exception: # noqa: BLE001
pass
if self._scratch_dir:
shutil.rmtree(self._scratch_dir, ignore_errors = True)
self._scratch_dir = None
def _kill_locked(self) -> None:
proc = self._process
if proc is None:
return
pid = proc.pid
try:
proc.terminate()
proc.wait(timeout = 5)
except subprocess.TimeoutExpired:
logger.warning("sd-server did not exit on SIGTERM; killing")
try:
proc.kill()
proc.wait(timeout = 5)
except Exception: # noqa: BLE001 -- best-effort teardown
pass
except Exception as exc: # noqa: BLE001
logger.warning("error terminating sd-server: %s", exc)
finally:
forget_pid(pid)
self._process = None
self.port = None
if self._stdout_thread is not None:
self._stdout_thread.join(timeout = 2)
self._stdout_thread = None
# ── generation ───────────────────────────────────────────────────────────
def img_gen(
self,
payload: dict[str, Any],
*,
on_step: Optional[Callable[[str], None]] = None,
cancel_event: Optional[threading.Event] = None,
poll_interval: float = 0.4,
submit_timeout: float = 60.0,
total_timeout: float = 1800.0,
) -> list[bytes]:
"""Submit one async ``img_gen`` job, poll it to completion, return image bytes.
``on_step`` receives each server stdout line (for the step bar). ``cancel_event``,
when set, cancels the job via the native endpoint and raises ``SdCppCancelled``.
Raises ``RuntimeError`` on submit/poll failures (including the server dying), with
the log tail attached.
"""
# If the server was already stopped for a cancel/unload/superseding load that set
# the cancel event before this submit began, report it as a cancellation (which the
# route maps to a client-state 409) rather than a generic "server died" 500.
if self._stopped or not self.is_alive():
if cancel_event is not None and cancel_event.is_set():
raise SdCppCancelled("sd-server generation was cancelled.")
raise RuntimeError("sd-server is not running.")
self._step_listener = on_step
job_id: Optional[str] = None
try:
# Submit -> 202 Accepted + job id.
try:
resp = self._client.post(
f"{self.base_url}{_IMG_GEN_PATH}", json = payload, timeout = submit_timeout
)
except (*_TRANSPORT_ERRORS, httpx.TimeoutException) as exc:
raise RuntimeError(self._died_message("img_gen submit", exc)) from exc
if resp.status_code == 429:
raise RuntimeError("sd-server job queue is full (HTTP 429).")
if resp.status_code not in (200, 202):
raise RuntimeError(
f"sd-server img_gen submit -> {resp.status_code}: {resp.text[:500]}"
)
try:
job = resp.json()
except ValueError as exc:
raise RuntimeError(
f"sd-server img_gen returned a non-JSON submit response: {exc}"
) from exc
if not isinstance(job, dict):
raise RuntimeError(
f"sd-server img_gen returned an unexpected submit response type: {type(job)}"
)
job_id = job.get("id")
if not job_id:
raise RuntimeError(f"sd-server img_gen returned no job id: {job}")
# Poll the job to a terminal state.
deadline = time.monotonic() + total_timeout
cancel_sent_at: Optional[float] = None
while True:
if cancel_event is not None and cancel_event.is_set():
if cancel_sent_at is None:
self.cancel(job_id)
cancel_sent_at = time.monotonic()
elif time.monotonic() - cancel_sent_at > _CANCEL_GRACE_S:
# The best-effort cancel was not reflected in job status within the
# grace window; abandon the poll so the caller can stop the server
# instead of holding the generate lock until the job finishes.
raise SdCppCancelled("sd-server generation was cancelled.")
if not self.is_alive():
# If we're unwinding a cancel (e.g. unload killed the server), surface a
# clean cancellation rather than a generic "server died" error.
if cancel_event is not None and cancel_event.is_set():
raise SdCppCancelled("sd-server generation was cancelled.")
raise RuntimeError(self._died_message("img_gen poll", None))
if time.monotonic() > deadline:
# Best-effort cancel, then tear the server down: current sd-server does
# not interrupt an already-generating job (cancel_generating=false / 409),
# so leaving it up would keep denoising the abandoned job and block later
# generations/reloads behind it. Stopping frees the slot; the backend sees
# the dead server on the next generate and takes the recoverable reload path.
self.cancel(job_id)
self.stop()
raise RuntimeError(f"sd-server generation timed out after {total_timeout}s")
try:
jr = self._client.get(f"{self.base_url}{_JOBS_PATH}/{job_id}", timeout = 10.0)
except (*_TRANSPORT_ERRORS, httpx.TimeoutException):
time.sleep(poll_interval)
continue
except RuntimeError as exc:
# A concurrent stop()/unload closes the shared httpx client; httpx then
# raises a plain RuntimeError ("client has been closed") that is NOT a
# transport error. When we are being cancelled, report it as a clean
# cancellation (route -> 409) instead of a generic 500 generation failure.
if cancel_event is not None and cancel_event.is_set():
raise SdCppCancelled("sd-server generation was cancelled.") from exc
raise
if jr.status_code in (404, 410):
raise RuntimeError(f"sd-server job {job_id} is gone (HTTP {jr.status_code}).")
if jr.status_code != 200:
time.sleep(poll_interval)
continue
try:
jd = jr.json()
except ValueError as exc:
raise RuntimeError(f"sd-server job status was not JSON: {exc}") from exc
if not isinstance(jd, dict):
raise RuntimeError(
f"sd-server job status returned an unexpected response type: {type(jd)}"
)
status = jd.get("status")
if status == _TERMINAL_OK:
return self._decode_images(jd)
if status == _TERMINAL_FAIL:
err = jd.get("error") or {}
raise RuntimeError(
"sd-server generation failed: "
f"{err.get('code', 'error')}: {err.get('message', '')}".strip()
)
if status == _TERMINAL_CANCELLED:
raise SdCppCancelled("sd-server generation was cancelled.")
time.sleep(poll_interval)
finally:
self._step_listener = None
def cancel(self, job_id: str) -> None:
"""Best-effort native cancel of an in-flight job."""
try:
self._client.post(f"{self.base_url}{_JOBS_PATH}/{job_id}/cancel", timeout = 5.0)
except Exception: # noqa: BLE001 -- cancel is best-effort
pass
@staticmethod
def _decode_images(job: dict[str, Any]) -> list[bytes]:
# Defensive against an unexpected response shape (a misbehaving/older server):
# verify each level is the type we index before calling dict/list methods.
result = job.get("result") if isinstance(job, dict) else None
images = result.get("images") if isinstance(result, dict) else None
items = [it for it in images if isinstance(it, dict)] if isinstance(images, list) else []
out: list[bytes] = []
for item in sorted(items, key = lambda d: d.get("index", 0)):
b64 = item.get("b64_json")
if not b64:
continue
try:
out.append(base64.b64decode(b64))
except Exception as exc: # noqa: BLE001
raise RuntimeError(f"sd-server returned an undecodable image: {exc}") from exc
if not out:
raise RuntimeError("sd-server completed the job but returned no images.")
return out
def _died_message(self, where: str, exc: Optional[Exception]) -> str:
tail = "\n".join(list(self._tail)[-20:])
base = f"sd-server connection lost during {where}"
if not self.is_alive():
code = None if self._process is None else self._process.returncode
base += f" (process exited, code {code})"
if exc is not None:
base += f": {exc}"
if tail:
base += "\nLast output:\n" + tail[:1500]
return base

View file

@ -2089,6 +2089,11 @@ class DiffusionStatusResponse(BaseModel):
"txt2img, img2img, inpaint. Empty when nothing is loaded or on the native engine.",
)
engine: Optional[str] = Field(None, description = "Active diffusion engine: diffusers | sd_cpp")
native_mode: Optional[str] = Field(
None,
description = "Native sd.cpp execution mode: server (resident sd-server) | oneshot "
"(per-image sd-cli) | null (diffusers engine)",
)
fallback_reason: Optional[str] = Field(
None,
description = "Why diffusers was chosen over the native sd.cpp engine (null when none)",
@ -2105,3 +2110,68 @@ class DiffusionStatusResponse(BaseModel):
"picker's enabled state). Diffusers only, for families with a ControlNet pipeline; False "
"for the native engine, GGUF-via-diffusers, and torchao fp8/int8 dense.",
)
# ── OpenAI-compatible images API (POST /v1/images/generations) ──
#
# Shapes mirror OpenAI's CreateImageRequest / ImagesResponse so off-the-shelf
# OpenAI clients work unchanged. The loaded image GGUF stands in for the model;
# GPT-image-only knobs (quality, style, background, output_format, ...) are
# accepted and ignored, exactly as dall-e-2 ignores them. The size string is
# parsed and `stream` is rejected in the route, where the diffusion backend is
# in reach; everything Pydantic can check declaratively lives here.
class ImageGenerationRequest(BaseModel):
"""OpenAI ``CreateImageRequest`` for ``POST /v1/images/generations``.
``prompt`` is the only required field, per the spec. Unlisted OpenAI fields
are ignored (Pydantic's default), matching dall-e-2's treatment of the
GPT-image-only parameters."""
prompt: str = Field(..., min_length = 1, description = "Text description of the image(s).")
model: Optional[str] = Field(
None, description = "Model id (informational; the loaded image model is used)."
)
n: int = Field(1, ge = 1, le = 10, description = "Number of images to generate (1-10).")
size: str = Field(
"auto", description = "'auto' or '<width>x<height>' (256-2048, each a multiple of 16)."
)
response_format: Literal["url", "b64_json"] = Field(
"url", description = "Return each image as a URL or a base64-encoded PNG."
)
user: Optional[str] = Field(None, description = "End-user identifier (accepted, unused).")
# gpt-image-only; declared so we can reject it with a clear error instead of
# silently returning JSON to a client that asked for an SSE stream.
stream: Optional[bool] = Field(
None, description = "Streaming image generation is not supported; omit or set false."
)
@field_validator("n", "size", "response_format", mode = "before")
@classmethod
def _null_means_default(cls, value, info):
# OpenAI marks these nullable WITH a default, so an explicit null means
# "use the default" — coalesce it instead of 400-ing a spec-valid body.
if value is None:
return cls.model_fields[info.field_name].default
return value
class ImageGenerationData(BaseModel):
"""One image in an ``ImagesResponse`` (OpenAI ``Image``). Exactly one of
``url`` / ``b64_json`` is set, per the request's ``response_format``; the
route serializes with ``exclude_none`` so the unused key is omitted."""
b64_json: Optional[str] = Field(
None, description = "Base64-encoded PNG (response_format=b64_json)."
)
url: Optional[str] = Field(None, description = "URL to the PNG bytes (response_format=url).")
class ImageGenerationResponse(BaseModel):
"""OpenAI ``ImagesResponse``. dall-e-shaped: the GPT-image-only top-level
fields (background/output_format/size/quality/usage) are omitted, since our
sizes wouldn't satisfy their fixed enums and we report no token usage."""
created: int = Field(..., description = "Unix timestamp (seconds) the images were created.")
data: list[ImageGenerationData] = Field(..., description = "The generated images.")

View file

@ -1062,6 +1062,9 @@ from models.inference import (
DiffusionLoadProgressResponse,
GalleryImage,
GalleryListResponse,
ImageGenerationRequest,
ImageGenerationData,
ImageGenerationResponse,
LoadResponse,
LoadProgressResponse,
UnloadResponse,
@ -11375,3 +11378,167 @@ async def diffusion_load_progress(current_subject: str = Depends(get_current_sub
async def diffusion_generate_progress(current_subject: str = Depends(get_current_subject)):
from core.inference.diffusion_engine_router import get_active_diffusion_engine
return DiffusionGenerateProgressResponse(**get_active_diffusion_engine().generate_progress())
# ──────────────────────────────────────────────────────────────────────────
# OpenAI-compatible images API (POST /v1/images/generations)
#
# The inference router is mounted at both /api/inference and /v1, so this also
# answers /v1/images/generations for off-the-shelf OpenAI clients. It maps
# OpenAI's CreateImageRequest onto the in-process diffusion backend and returns
# an ImagesResponse. Studio's own Image tab uses the richer /images/generate
# route above; this is the spec-shaped surface, and the single error boundary
# mapping backend exceptions to OpenAI error envelopes (the global /v1 handler
# wraps HTTPException detail into the envelope).
# ──────────────────────────────────────────────────────────────────────────
# Diffusion dims must land in [256, 2048] on a multiple of 16 (8x VAE downsample
# x 2x patch); the named OpenAI sizes (1024x1024, 1536x1024, 256x256, ...) all
# satisfy this. Mirrors DiffusionGenerateRequest's width/height bounds so both
# generate paths accept the same geometry.
_IMAGE_SIZE_RE = _re.compile(r"^(\d{1,5})\s*x\s*(\d{1,5})$")
_IMAGE_DIM_MIN, _IMAGE_DIM_MAX = 256, 2048
# Sanitized 503 detail shared by the pre-check and the unload-race branch, so both
# "no image model" responses stay identical.
_NO_IMAGE_MODEL_MSG = "No image model loaded. Load an image model first."
def _parse_openai_image_size(size: str) -> tuple[int, int]:
"""OpenAI ``size`` -> (width, height). ``auto``/empty -> 1024x1024 (~1MP, what
these models target). Raises ValueError with a client-facing message."""
text = (size or "").strip().lower()
if text in ("", "auto"):
return 1024, 1024
match = _IMAGE_SIZE_RE.match(text)
if not match:
raise ValueError("size must be 'auto' or '<width>x<height>', e.g. '1024x1024'.")
width, height = int(match.group(1)), int(match.group(2))
for label, value in (("width", width), ("height", height)):
if not _IMAGE_DIM_MIN <= value <= _IMAGE_DIM_MAX:
raise ValueError(f"size {label} must be between {_IMAGE_DIM_MIN} and {_IMAGE_DIM_MAX}.")
if value % 16 != 0:
raise ValueError(f"size {label} must be a multiple of 16.")
return width, height
def _absolute_image_url(request: Request, relative: str) -> str:
"""Join a relative gallery path onto the request's own scheme+host, for the
response_format=url links. Like every Studio route, the target needs the
bearer token; b64_json avoids that for clients that can't carry it."""
return str(request.base_url).rstrip("/") + relative
@router.post(
"/images/generations",
response_model = ImageGenerationResponse,
response_model_exclude_none = True,
)
async def openai_image_generations(
body: ImageGenerationRequest,
request: Request,
current_subject: str = Depends(get_current_subject),
):
"""OpenAI-compatible text-to-image (POST /v1/images/generations).
Generates ``n`` images from ``prompt`` on the loaded diffusion model and
returns them as URLs (default) or base64 PNGs per ``response_format``. Steps
and guidance have no OpenAI knob, so they default per loaded model."""
from core.inference import image_gallery
from core.inference.diffusion_engine_router import get_active_diffusion_engine
from core.inference.diffusion_families import default_generation_params
if body.stream:
raise HTTPException(
status_code = 400,
detail = openai_error_body(
"Streaming image generation is not supported.", status = 400, param = "stream"
),
)
try:
width, height = _parse_openai_image_size(body.size)
except ValueError as exc:
raise HTTPException(
status_code = 400, detail = openai_error_body(str(exc), status = 400, param = "size")
)
# Use the active engine (diffusers OR native sd.cpp on a no-GPU host), the same
# accessor /images/generate uses, so a model loaded on the native engine isn't
# wrongly reported unloaded here.
backend = get_active_diffusion_engine()
status = backend.status()
if not status.get("loaded"):
# Mirror /v1/completions and /v1/embeddings, which 503 when their backend
# isn't loaded; the global handler turns this into the OpenAI envelope.
raise HTTPException(status_code = 503, detail = _NO_IMAGE_MODEL_MSG)
# Fall back to the resolved base repo so a local-path load (whose repo_id is a
# filesystem path) still gets the right per-model steps/guidance.
steps, guidance = default_generation_params(status.get("repo_id"), status.get("base_repo"))
try:
result = await asyncio.to_thread(
backend.generate,
prompt = body.prompt,
width = width,
height = height,
steps = steps,
guidance = guidance,
batch_size = body.n,
)
except Exception as exc: # noqa: BLE001 (single boundary, sanitized envelope)
# A RuntimeError with the model now unloaded means it was evicted/unloaded
# between the readiness check above and the call (a transient race): 503.
# Every other failure (CUDA OOM, a diffusers shape/device error, both also
# RuntimeError) is a real 500, and its raw message must not reach the client.
if isinstance(exc, RuntimeError) and not backend.is_loaded:
raise HTTPException(status_code = 503, detail = _NO_IMAGE_MODEL_MSG)
logger.error("openai_images.generate_failed: %s", exc)
raise HTTPException(status_code = 500, detail = "Image generation failed.")
created = int(time.time())
want_b64 = body.response_format == "b64_json"
# Persist each image with its full recipe embedded, like /images/generate, so
# the response_format=url links resolve and the images show up in the gallery.
recipe = {
"prompt": body.prompt,
"negative_prompt": None,
"width": width,
"height": height,
"steps": steps,
"guidance": guidance,
# The batch shares one base seed, so restoring a batch_index>0 sibling needs the
# original batch_size to replay it (same as /images/generate); persist it.
"batch_size": body.n,
"model": result.get("repo_id"),
"created_at": float(created),
}
# The diffusers batch shares one seed; the native sd.cpp batch uses a distinct seed
# per image (returned in ``seeds``), so record each image's own seed, like
# /images/generate, or a native batch_index>0 image shows the wrong seed.
per_image_seeds = result.get("seeds")
def _persist() -> list[ImageGenerationData]:
items: list[ImageGenerationData] = []
for index, image in enumerate(result["images"]):
seed = (
per_image_seeds[index]
if per_image_seeds and index < len(per_image_seeds)
else result["seed"]
)
record = image_gallery.save(image, {**recipe, "batch_index": index, "seed": seed})
if want_b64:
encoded = image_gallery.image_b64(record["id"])
if encoded is None: # vanished between write and read — fail the call
raise RuntimeError("generated image could not be read back for encoding")
items.append(ImageGenerationData(b64_json = encoded))
else:
items.append(ImageGenerationData(url = _absolute_image_url(request, record["url"])))
return items
try:
data = await asyncio.to_thread(_persist)
except Exception as exc: # noqa: BLE001
logger.error("openai_images.persist_failed: %s", exc)
raise HTTPException(status_code = 500, detail = "Failed to save the generated image.")
return ImageGenerationResponse(created = created, data = data)

View file

@ -32,6 +32,10 @@ def _clean_env_and_state(monkeypatch):
"get_active_diffusion_engine",
lambda: SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}),
)
# Default: no resident sd-server (so existing tests exercise the sd-cli path only) and
# a stubbed runnability probe, so neither reaches the real install/exec path.
monkeypatch.setattr(r, "ensure_sd_server_binary", lambda **_: None)
monkeypatch.setattr(r, "_server_binary_runnable", lambda *_a, **_k: True)
yield
@ -70,6 +74,16 @@ def test_cpu_with_binary_and_supported_family_picks_sd_cpp(monkeypatch):
assert r.active_engine_name() == ENGINE_SD_CPP
def test_cpu_with_only_sd_server_picks_sd_cpp(monkeypatch):
# An sd-server-only install (no runnable sd-cli) must still route to native: the
# backend prefers the resident server, so a runnable sd-server is native availability.
_set_device(monkeypatch, "cpu")
_set_binary(monkeypatch, None) # no sd-cli
monkeypatch.setattr(r, "SdCppEngine", lambda **_: SimpleNamespace(version = lambda: None))
monkeypatch.setattr(r, "ensure_sd_server_binary", lambda **_: "/usr/bin/sd-server")
assert _select() == ENGINE_SD_CPP
def test_present_but_not_runnable_binary_falls_back(monkeypatch):
# A binary that exists but cannot run (version() -> None) must fall back to
# diffusers at selection, not commit native and fail inside the load.

View file

@ -133,9 +133,13 @@ def _stub_torch_accelerate(
def _good_ckpt(scheme = "fp8", base = "Tongyi-MAI/Z-Image-Turbo"):
meta = {"scheme": scheme, "base_model_id": base}
# fp8 checkpoints must record per-row granularity or the loader rejects them as stale.
if scheme == "fp8":
meta["fp8_granularity"] = "per_row"
return {
"format": PREQUANT_FORMAT,
"metadata": {"scheme": scheme, "base_model_id": base},
"metadata": meta,
"state_dict": {"weight": object()},
}
@ -220,6 +224,23 @@ def test_load_base_mismatch_is_none(monkeypatch, tmp_path):
assert _load(monkeypatch, tmp_path, _good_ckpt(base = "other/model")) is None
def test_load_fp8_stale_per_tensor_is_rejected(monkeypatch, tmp_path):
# A pre-fix fp8 checkpoint has no fp8_granularity (old per-tensor layout); it must be
# rejected so the loader rebuilds instead of reproducing the noise failure.
stale = _good_ckpt(scheme = "fp8")
del stale["metadata"]["fp8_granularity"]
assert _load(monkeypatch, tmp_path, stale, scheme = "fp8") is None
# An explicit per-tensor granularity is likewise rejected.
per_tensor = _good_ckpt(scheme = "fp8")
per_tensor["metadata"]["fp8_granularity"] = "per_tensor"
assert _load(monkeypatch, tmp_path, per_tensor, scheme = "fp8") is None
def test_load_int8_ignores_fp8_granularity(monkeypatch, tmp_path):
# The granularity gate is fp8-only: an int8 checkpoint without it still loads.
assert _load(monkeypatch, tmp_path, _good_ckpt(scheme = "int8"), scheme = "int8") is not None
def test_load_missing_base_metadata_is_none(monkeypatch, tmp_path):
# A checkpoint whose keys happen to match a different base can load strict=True and then
# render from the wrong weights, so a base was requested but none recorded must be refused.

View file

@ -415,6 +415,23 @@ def test_resolve_fast_accum(monkeypatch):
assert tq._resolve_fast_accum(False) is False # forced off (e.g. on a consumer card)
def test_fp8_config_uses_per_row_granularity():
"""FP8 must use PerRow (per-token activation + per-channel weight) scaling. torchao's
default is per-TENSOR: on a DiT with extreme activation outliers (z-image's ~6.6e4) one
outlier forces a tensor-wide scale that pushes normal values below fp8 resolution and the
denoise collapses to noise. This is the regression guard for that fix (validated on B200:
per-tensor fp8 = noise, per-row fp8 = matches bf16)."""
torchao_quant = pytest.importorskip("torchao.quantization")
per_row = getattr(torchao_quant, "PerRow", None)
if per_row is None:
pytest.skip("torchao build without PerRow granularity")
cfg = tq._make_quant_config(TQ_FP8)
gran = getattr(cfg, "granularity", None)
assert gran is not None, "fp8 config must set an explicit granularity, not torchao's default"
grans = gran if isinstance(gran, (list, tuple)) else [gran]
assert grans and all(isinstance(g, per_row) for g in grans), f"expected all PerRow, got {gran}"
def test_quantize_transformer_applies_and_marks(monkeypatch):
monkeypatch.setattr(tq, "select_transformer_quant_scheme", lambda target, mode: TQ_FP8)
seen: dict = {}

View file

@ -0,0 +1,386 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""FastAPI round-trip tests for the OpenAI-compatible POST /v1/images/generations.
The diffusion backend and image gallery are replaced with light fakes, so these
exercise the route wiring, OpenAI param mapping, validation, error envelopes, and
response shape without torch, diffusers, weights, or a GPU. The pure helpers
(`_parse_openai_image_size`, `default_generation_params`) are unit-tested directly.
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import core.inference.diffusion as diffusion_module
import core.inference.diffusion_engine_router as engine_router
import core.inference.image_gallery as gallery_module
from auth.authentication import get_current_subject
from core.inference.diffusion_families import default_generation_params
from routes.inference import router, _parse_openai_image_size
from utils.api_errors import install_api_error_handlers
# ── pure helpers ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"repo_id, expected",
[
("unsloth/Z-Image-Turbo-GGUF", (9, 0.0)), # turbo entry, before the z-image fallback
("unsloth/Z-Image-GGUF", (20, 4.0)),
("unsloth/FLUX.1-schnell-GGUF", (4, 0.0)), # schnell entry, before the flux.1 entry
("black-forest-labs/FLUX.1-dev", (28, 3.5)),
("unsloth/FLUX.2-klein-4B-GGUF", (4, 0.0)),
("unsloth/Qwen-Image-2512-GGUF", (20, 4.0)),
("some/unknown-model", (9, 0.0)), # fallback
("", (9, 0.0)),
],
)
def test_default_generation_params(repo_id, expected):
assert default_generation_params(repo_id) == expected
def test_default_generation_params_specificity_ordering():
# The "-turbo" / "-schnell" entries must win over their broader siblings; a
# reorder that broke this would silently mis-default.
assert default_generation_params("x/Z-Image-Turbo") != default_generation_params("x/Z-Image")
assert default_generation_params("x/FLUX.1-schnell") != default_generation_params(
"x/FLUX.1-dev"
)
def test_default_generation_params_falls_back_to_base_repo():
# A local-path load: repo_id is a filesystem path that names no model, so the
# resolved base repo is what identifies it (and distinguishes dev from schnell).
assert default_generation_params("/models/my-ckpt", "black-forest-labs/FLUX.1-dev") == (28, 3.5)
assert default_generation_params("/models/my-ckpt", "black-forest-labs/FLUX.1-schnell") == (
4,
0.0,
)
assert default_generation_params("/models/my-ckpt", "Qwen/Qwen-Image") == (20, 4.0)
# repo_id wins when it already names the model; base repo is only a fallback.
assert default_generation_params("unsloth/Z-Image-Turbo-GGUF", "Tongyi-MAI/Z-Image") == (9, 0.0)
# Nothing identifiable -> fallback; None identifiers are skipped.
assert default_generation_params(None, None) == (9, 0.0)
assert default_generation_params("/models/x", None) == (9, 0.0)
@pytest.mark.parametrize(
"size, expected",
[
("auto", (1024, 1024)),
("", (1024, 1024)),
("AUTO", (1024, 1024)),
("512x512", (512, 512)),
("512x256", (512, 256)),
("1792x1024", (1792, 1024)), # dall-e-3 named size: must pass the bounds
("1024x1792", (1024, 1792)),
(" 256 x 256 ", (256, 256)),
],
)
def test_parse_image_size_ok(size, expected):
assert _parse_openai_image_size(size) == expected
@pytest.mark.parametrize("size", ["abc", "100x100", "4096x4096", "300x300", "512", "x512", "0x0"])
def test_parse_image_size_rejects(size):
with pytest.raises(ValueError):
_parse_openai_image_size(size)
# ── route round-trip ────────────────────────────────────────────────────
class _FakeBackend:
def __init__(
self,
loaded = True,
repo_id = "unsloth/Z-Image-Turbo-GGUF",
base_repo = None,
generate_error = None,
unload_on_generate = False,
native_seeds = False,
) -> None:
self._loaded = loaded
self._repo_id = repo_id
self._base_repo = base_repo
# Model the native sd.cpp engine, which returns a distinct seed per image.
self._native_seeds = native_seeds
# When set, generate() raises this; unload_on_generate flips is_loaded off
# first, to model the eviction/unload race vs an in-pipeline failure (OOM).
self._generate_error = generate_error
self._unload_on_generate = unload_on_generate
self.calls = []
@property
def is_loaded(self):
return self._loaded
def status(self):
return {
"loaded": self._loaded,
"repo_id": self._repo_id if self._loaded else None,
"family": "z-image" if self._loaded else None,
"base_repo": self._base_repo if self._loaded else None,
"device": "cpu",
"dtype": "float32",
"cpu_offload": False,
}
def generate(
self,
*,
prompt,
width,
height,
steps,
guidance,
batch_size = 1,
):
if not self._loaded:
raise RuntimeError("No diffusion model is loaded.")
if self._generate_error is not None:
if self._unload_on_generate:
self._loaded = False
raise self._generate_error
self.calls.append(
dict(
prompt = prompt,
width = width,
height = height,
steps = steps,
guidance = guidance,
batch_size = batch_size,
)
)
out = {
"images": [object() for _ in range(batch_size)],
"seed": 4242,
"repo_id": self._repo_id,
}
if self._native_seeds:
out["seeds"] = [4242 + i for i in range(batch_size)]
return out
def _make_client(backend):
store = {}
def _save(image, meta):
image_id = f"img{len(store)}"
record = {**meta, "id": image_id, "url": f"/api/inference/images/gallery/{image_id}/file"}
store[image_id] = record
return record
app = FastAPI()
install_api_error_handlers(app)
app.include_router(router, prefix = "/v1")
app.dependency_overrides[get_current_subject] = lambda: "test-user"
return TestClient(app), store, _save
@pytest.fixture
def client(monkeypatch):
backend = _FakeBackend()
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
monkeypatch.setattr(gallery_module, "image_b64", lambda i: "QUJD" if i in store else None)
cli.backend = backend # type: ignore[attr-defined]
return cli
def _post(client, body):
return client.post("/v1/images/generations", json = body)
def test_url_response_shape(client):
resp = _post(client, {"prompt": "a sloth", "size": "256x256"})
assert resp.status_code == 200
body = resp.json()
assert set(body.keys()) == {"created", "data"}
assert isinstance(body["created"], int) and body["created"] > 0
assert len(body["data"]) == 1
item = body["data"][0]
assert "url" in item and "b64_json" not in item # exclude_none drops the unused key
assert item["url"].endswith("/file")
# Z-Image-Turbo defaults (9 steps, 0 guidance) flow into the backend call.
assert client.backend.calls[0] == dict(
prompt = "a sloth", width = 256, height = 256, steps = 9, guidance = 0.0, batch_size = 1
)
def test_b64_response_shape(client):
resp = _post(client, {"prompt": "a sloth", "size": "256x256", "response_format": "b64_json"})
assert resp.status_code == 200
item = resp.json()["data"][0]
assert "b64_json" in item and "url" not in item
assert item["b64_json"] == "QUJD"
def test_local_load_uses_base_repo_for_defaults(monkeypatch):
# repo_id is a local path that names no model; base_repo identifies FLUX.1-dev,
# so the route must pick 28 steps / 3.5 guidance, not the 9/0 fallback.
backend = _FakeBackend(repo_id = "/models/my-flux", base_repo = "black-forest-labs/FLUX.1-dev")
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256"})
assert resp.status_code == 200
assert backend.calls[0]["steps"] == 28 and backend.calls[0]["guidance"] == 3.5
def test_pipeline_runtime_error_is_sanitized_500(monkeypatch):
# A RuntimeError raised inside the pipeline while the model stays loaded (e.g.
# CUDA OOM, a RuntimeError subclass) must be a sanitized 500, not a 503 that
# echoes the raw exception text.
oom = RuntimeError("CUDA out of memory. Tried to allocate 20.00 GiB (GPU 0; 47.5 GiB total)")
backend = _FakeBackend(generate_error = oom)
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256"})
assert resp.status_code == 500
assert resp.json()["error"]["message"] == "Image generation failed."
assert "CUDA" not in resp.text # raw exception text must not leak
def test_unload_race_returns_503(monkeypatch):
# The model is evicted/unloaded between the readiness check and the call: the
# RuntimeError with is_loaded now False is the one case that maps to 503.
backend = _FakeBackend(
generate_error = RuntimeError("No diffusion model is loaded."),
unload_on_generate = True,
)
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256"})
assert resp.status_code == 503
err = resp.json()["error"]
assert err["type"] == "api_error"
# The 503 carries the fixed sanitized message, not the raw exception text.
assert err["message"] == "No image model loaded. Load an image model first."
def test_non_runtime_pipeline_error_is_500(monkeypatch):
# A non-RuntimeError from the pipeline (the model stays loaded) must not route
# to the 503 branch (which is gated on isinstance RuntimeError) -> sanitized 500.
backend = _FakeBackend(generate_error = ValueError("bad tensor shape"))
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256"})
assert resp.status_code == 500
assert "shape" not in resp.text
def test_n_maps_to_batch(client):
resp = _post(client, {"prompt": "p", "size": "256x256", "n": 3})
assert resp.status_code == 200
assert len(resp.json()["data"]) == 3
assert client.backend.calls[0]["batch_size"] == 3
def test_batch_persists_batch_size(monkeypatch):
# n>1 must persist batch_size in each gallery record so the Studio restore path
# can replay a batch_index>0 sibling (which shares the batch's single seed).
backend = _FakeBackend()
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256", "n": 3})
assert resp.status_code == 200
records = sorted(store.values(), key = lambda r: r["batch_index"])
assert [r["batch_index"] for r in records] == [0, 1, 2]
assert all(r["batch_size"] == 3 for r in records)
def test_uses_active_engine_not_diffusers_singleton(monkeypatch):
# On a no-GPU host the loaded model lives behind the native sd_cpp engine, not the
# diffusers singleton. The route must query get_active_diffusion_engine (like
# /images/generate) or it 503s a model that is loaded and usable.
active = _FakeBackend(loaded = True) # the active (e.g. sd_cpp) engine, loaded
idle_diffusers = _FakeBackend(loaded = False) # diffusers singleton, empty
monkeypatch.setattr(engine_router, "get_active_diffusion_engine", lambda: active)
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: idle_diffusers)
cli, store, _save = _make_client(active)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256"})
assert resp.status_code == 200
assert len(active.calls) == 1 # the active engine did the work, not the idle singleton
def test_native_batch_persists_per_image_seed(monkeypatch):
# The native sd.cpp engine returns a distinct seed per image (base+index) in
# "seeds"; each gallery record must store its own seed (like /images/generate),
# not the shared base, or a restored batch_index>0 image shows the wrong seed.
backend = _FakeBackend(native_seeds = True)
monkeypatch.setattr(engine_router, "get_active_diffusion_engine", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p", "size": "256x256", "n": 3})
assert resp.status_code == 200
records = sorted(store.values(), key = lambda r: r["batch_index"])
assert [r["seed"] for r in records] == [4242, 4243, 4244]
def test_null_fields_coalesce_to_defaults(client):
# OpenAI marks n/size/response_format nullable-with-default: null -> default.
resp = _post(client, {"prompt": "p", "n": None, "size": None, "response_format": None})
assert resp.status_code == 200
assert len(resp.json()["data"]) == 1
assert "url" in resp.json()["data"][0]
assert client.backend.calls[0]["width"] == 1024 # size null -> auto -> 1024
@pytest.mark.parametrize(
"body, param",
[
({"size": "256x256"}, "prompt"), # missing prompt
({"prompt": "", "size": "256x256"}, "prompt"), # empty prompt
({"prompt": "p", "size": "300x300"}, "size"), # not multiple of 16
({"prompt": "p", "size": "abc"}, "size"), # unparseable
({"prompt": "p", "stream": True}, "stream"), # streaming unsupported
],
)
def test_validation_400_with_param(client, body, param):
resp = _post(client, body)
assert resp.status_code == 400
err = resp.json()["error"]
assert err["type"] == "invalid_request_error"
assert err["param"] == param
for k in ("message", "code"):
assert k in err
@pytest.mark.parametrize("n", [0, 11, -1])
def test_n_out_of_range_400(client, n):
resp = _post(client, {"prompt": "p", "size": "256x256", "n": n})
assert resp.status_code == 400
assert resp.json()["error"]["type"] == "invalid_request_error"
def test_no_model_loaded_503(monkeypatch):
backend = _FakeBackend(loaded = False)
monkeypatch.setattr(diffusion_module, "get_diffusion_backend", lambda: backend)
cli, store, _save = _make_client(backend)
monkeypatch.setattr(gallery_module, "save", _save)
resp = cli.post("/v1/images/generations", json = {"prompt": "p"})
assert resp.status_code == 503
# 503 still wears the OpenAI envelope (api_error) on the /v1 surface.
assert resp.json()["error"]["type"] == "api_error"
def test_auth_required():
backend = _FakeBackend()
app = FastAPI()
install_api_error_handlers(app)
app.include_router(router, prefix = "/v1")
# No dependency override: the real auth dependency runs and rejects.
resp = TestClient(app).post("/v1/images/generations", json = {"prompt": "p"})
assert resp.status_code in (401, 403)

View file

@ -21,7 +21,9 @@ from core.inference.sd_cpp_args import (
SdCppGenParams,
SdCppModelFiles,
SdCppUpscaleParams,
build_img_gen_request,
build_sd_cpp_command,
build_sd_cpp_server_command,
build_sd_cpp_upscale_command,
native_speed_flags,
offload_flags,
@ -364,3 +366,104 @@ def test_build_upscale_requires_input_and_model():
SdCppUpscaleParams(input_image = "/i.png", upscale_model = ""),
output_path = "/o.png",
)
# ── sd-server spawn command ──────────────────────────────────────────────────
def test_server_command_has_model_and_listen_but_no_request_params():
files = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/ae.sft", llm = "/m/q.gguf")
cmd = build_sd_cpp_server_command(
"/bin/sd-server", files, host = "127.0.0.1", port = 5678, vae_format = "flux2"
)
assert _pair(cmd, "--diffusion-model") == "/m/z.gguf"
assert _pair(cmd, "--vae") == "/m/ae.sft"
assert _pair(cmd, "--llm") == "/m/q.gguf"
assert _pair(cmd, "--vae-format") == "flux2"
assert _pair(cmd, "--listen-ip") == "127.0.0.1"
assert _pair(cmd, "--listen-port") == "5678"
# Per-request parameters must NOT be baked into the spawn command.
for flag in (
"--prompt",
"--seed",
"--steps",
"--cfg-scale",
"--guidance",
"--width",
"--height",
"--batch-count",
):
assert flag not in cmd
def test_server_command_maps_offload_and_speed_and_dedupes():
files = SdCppModelFiles(diffusion_model = "/m/z.gguf")
cmd = build_sd_cpp_server_command(
"/bin/sd-server",
files,
host = "127.0.0.1",
port = 1,
offload = ["--offload-to-cpu", "--diffusion-fa"],
native_speed = "default", # would add --diffusion-fa again
threads = 8,
)
assert _pair(cmd, "--threads") == "8"
assert cmd.count("--diffusion-fa") == 1 # de-duped against offload
assert "--offload-to-cpu" in cmd
def test_server_command_scratch_dir_expands_to_lora_upscaler_embd():
files = SdCppModelFiles(diffusion_model = "/m/z.gguf")
cmd = build_sd_cpp_server_command(
"/bin/sd-server", files, host = "127.0.0.1", port = 1, scratch_dir = "/tmp/scratch"
)
assert _pair(cmd, "--lora-model-dir") == "/tmp/scratch"
assert _pair(cmd, "--hires-upscalers-dir") == "/tmp/scratch"
assert _pair(cmd, "--embd-dir") == "/tmp/scratch"
# Absent when not requested.
bare = build_sd_cpp_server_command("/bin/sd-server", files, host = "127.0.0.1", port = 1)
assert "--lora-model-dir" not in bare and "--hires-upscalers-dir" not in bare
def test_server_command_requires_diffusion_model():
with pytest.raises(ValueError):
build_sd_cpp_server_command(
"/bin/sd-server", SdCppModelFiles(diffusion_model = ""), host = "127.0.0.1", port = 1
)
# ── img_gen request body ─────────────────────────────────────────────────────
def test_img_gen_request_maps_core_fields():
req = build_img_gen_request(
prompt = "a fox",
negative_prompt = "blurry",
width = 512,
height = 768,
steps = 8,
seed = 42,
batch_count = 3,
sample_method = "euler",
cfg_scale = 4.0,
)
assert req["prompt"] == "a fox" and req["negative_prompt"] == "blurry"
assert req["width"] == 512 and req["height"] == 768
assert req["seed"] == 42 and req["batch_count"] == 3
assert req["sample_params"]["sample_steps"] == 8
assert req["sample_params"]["sample_method"] == "euler"
assert req["sample_params"]["guidance"]["txt_cfg"] == 4.0
assert req["output_format"] == "png"
def test_img_gen_request_flux_uses_distilled_guidance():
req = build_img_gen_request(prompt = "x", steps = 4, distilled_guidance = 3.5, flow_shift = 3.0)
g = req["sample_params"]["guidance"]
assert g["distilled_guidance"] == 3.5
assert "txt_cfg" not in g
assert req["sample_params"]["flow_shift"] == 3.0
def test_img_gen_request_requires_prompt():
with pytest.raises(ValueError):
build_img_gen_request(prompt = " ", steps = 4)

View file

@ -77,10 +77,70 @@ def _loaded_backend(fam_name = "z-image", engine = None):
vae_format = fam.sd_cpp_vae_format,
sampling_method = fam.sd_cpp_sampling_method,
flow_shift = fam.sd_cpp_flow_shift,
mode = "oneshot", # this fixture injects an engine, so it exercises the one-shot path
)
return b
class _FakeServer:
"""Stands in for SdCppServer: records the spawn + one img_gen per whole batch."""
def __init__(self, binary):
self.binary = binary
self.started = None
self.stopped = False
self.payloads = []
self.timeouts = []
self.alive = True
self.lora_dir = None # set by a test to the server's --lora-model-dir scratch dir
def is_alive(self):
return self.alive and not self.stopped
def start(
self,
files,
*,
vae_format = None,
offload = None,
native_speed = None,
threads = None,
):
self.started = dict(
files = files,
vae_format = vae_format,
offload = offload,
native_speed = native_speed,
threads = threads,
)
def img_gen(
self,
payload,
*,
on_step = None,
cancel_event = None,
total_timeout = None,
):
import io as _io
self.payloads.append(payload)
self.timeouts.append(total_timeout)
if on_step is not None:
steps = payload.get("sample_params", {}).get("sample_steps", 0)
on_step(f" {steps}/{steps}")
n = int(payload.get("batch_count", 1))
blobs = []
for i in range(n):
buf = _io.BytesIO()
Image.new("RGB", (1, 1), (i, i, i)).save(buf, format = "PNG")
blobs.append(buf.getvalue())
return blobs
def stop(self):
self.stopped = True
# ── asset resolution ──────────────────────────────────────────────────────────
@ -321,6 +381,260 @@ def test_run_load_cancels_and_waits_for_inflight_generation(monkeypatch):
assert b._state is not None and b._state.repo_id == "unsloth/Z-Image-Turbo-GGUF"
# ── persistent sd-server mode ──────────────────────────────────────────────────
def test_resolve_backend_prefers_server(monkeypatch):
b = SdCppDiffusionBackend() # no injected engine
monkeypatch.setattr(bk, "find_sd_server_binary", lambda: "/x/sd-server")
mode, binary, engine = b._resolve_backend()
assert mode == "server" and binary == "/x/sd-server" and engine is None
def test_resolve_backend_injected_engine_forces_oneshot():
b = SdCppDiffusionBackend(engine = _FakeEngine())
mode, binary, engine = b._resolve_backend()
assert mode == "oneshot" and binary is None and engine is not None
def test_resolve_backend_falls_back_to_oneshot_without_server(monkeypatch):
b = SdCppDiffusionBackend()
monkeypatch.setattr(bk, "find_sd_server_binary", lambda: None)
monkeypatch.setattr(bk, "_install_allowed", lambda: False) # don't attempt a real install
monkeypatch.setattr(bk, "find_sd_cpp_binary", lambda: "/usr/bin/sd-cli")
mode, binary, engine = b._resolve_backend()
assert mode == "oneshot" and engine is not None
def test_resolve_backend_cached_fallback_engine_does_not_pin_oneshot(monkeypatch):
# A lazily cached fallback engine (NOT an explicit injection) must not force one-shot:
# once a server is available again, the next load can use it.
b = SdCppDiffusionBackend() # no injected engine
b._engine = _FakeEngine() # simulate a prior lazy one-shot fallback caching the engine
monkeypatch.setattr(bk, "find_sd_server_binary", lambda: "/x/sd-server")
mode, binary, engine = b._resolve_backend()
assert mode == "server" and binary == "/x/sd-server" and engine is None
def _run_server_load(
monkeypatch,
b,
servers,
fam_name = "z-image",
):
fam = detect_family(fam_name)
monkeypatch.setattr(bk, "find_sd_server_binary", lambda: "/x/sd-server")
# The fake binary path is not a real executable; skip the up-front runnability probe.
monkeypatch.setattr(bk, "_server_binary_runnable", lambda *_a, **_k: True)
def _factory(binary):
s = _FakeServer(binary)
servers.append(s)
return s
monkeypatch.setattr(bk, "SdCppServer", _factory)
monkeypatch.setattr(b, "_asset_specs", lambda *a, **k: [])
monkeypatch.setattr(b, "_set_expected_bytes", lambda *a, **k: None)
monkeypatch.setattr(
b,
"_fetch_assets",
lambda *a, **k: {"diffusion_model": "/m/z.gguf", "vae": "/m/vae.sft", "llm": "/m/llm.sft"},
)
monkeypatch.setattr(
bk, "resolve_diffusion_device_target", lambda: types.SimpleNamespace(device = "cpu")
)
b._load_token = 1
b._run_load(
repo_id = "unsloth/Z-Image-Turbo-GGUF",
gguf_filename = "z.gguf",
base = fam.base_repo,
fam = fam,
hf_token = None,
_load_token = 1,
)
def test_server_load_spawns_once_and_status_reports_mode(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
assert len(servers) == 1
assert servers[0].started is not None # the model is loaded once, at spawn
assert b._state is not None and b._state.mode == "server" and b._state.server is servers[0]
assert b.status()["native_mode"] == "server"
def test_server_generate_uses_one_request_for_whole_batch(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
out = b.generate(prompt = "a fox", width = 64, height = 64, steps = 8, seed = 7, batch_size = 3)
assert len(out["images"]) == 3
assert all(isinstance(im, Image.Image) for im in out["images"])
# ONE job for the whole batch (no per-image model reload), unlike the one-shot path.
assert len(servers[0].payloads) == 1
assert servers[0].payloads[0]["batch_count"] == 3
assert out["seed"] == 7 and out["seeds"] == [7, 8, 9]
# step progress was driven from the server's stdout line.
assert b._gen is None # cleared after generate
def test_server_generate_splits_batches_above_server_limit(monkeypatch):
# A batch above the server's per-job limit is chunked (the one-shot path did these
# image-by-image); each chunk gets a timeout proportional to its image count.
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
out = b.generate(prompt = "x", width = 64, height = 64, steps = 4, seed = 100, batch_size = 10)
assert len(out["images"]) == 10
counts = [p["batch_count"] for p in servers[0].payloads]
assert counts == [bk._MAX_SERVER_BATCH, 10 - bk._MAX_SERVER_BATCH] # [8, 2]
# Each chunk's timeout scales with its image count, not one fixed batch deadline.
assert servers[0].timeouts == [
bk._SERVER_PER_IMAGE_TIMEOUT_S * 8,
bk._SERVER_PER_IMAGE_TIMEOUT_S * 2,
]
# Seeds run contiguously across chunks (chunk 2 submitted at base + 8).
assert out["seeds"] == list(range(100, 110))
assert servers[0].payloads[1]["seed"] == 108
def test_server_generate_masks_large_seed(monkeypatch):
# sd.cpp's image seed is signed int64; a larger explicit seed must be masked before it
# reaches the server (the request model / diffusers accept up to 2**64 - 1).
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
out = b.generate(prompt = "x", width = 64, height = 64, steps = 4, seed = 2**64 - 1, batch_size = 1)
assert servers[0].payloads[0]["seed"] <= (1 << 63) - 1
assert all(s <= (1 << 63) - 1 for s in out["seeds"])
def test_status_clears_when_server_died(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
assert b.status()["loaded"] is True
servers[0].alive = False # the resident server crashed / was OOM-killed
st = b.status()
assert st["loaded"] is False
assert b._state is None # stale state was dropped so clients reload
def test_server_generate_progress_from_stdout(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
seen = {}
class _WatchServer(_FakeServer):
def img_gen(
self,
payload,
*,
on_step = None,
cancel_event = None,
total_timeout = None,
):
on_step(" 4/8")
seen["mid"] = b.generate_progress()
return super().img_gen(
payload, on_step = on_step, cancel_event = cancel_event, total_timeout = total_timeout
)
b._state = bk._SdState(
repo_id = b._state.repo_id,
base_repo = b._state.base_repo,
family = b._state.family,
device = b._state.device,
files = b._state.files,
vae_format = b._state.vae_format,
sampling_method = b._state.sampling_method,
flow_shift = b._state.flow_shift,
server = _WatchServer("/x/sd-server"),
mode = "server",
)
b.generate(prompt = "x", steps = 8, seed = 1)
assert seen["mid"]["step"] == 4 and seen["mid"]["total_steps"] == 8
def test_server_unload_stops_server(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
st = b.unload()
assert st["loaded"] is False
assert servers[0].stopped is True
assert b._state is None
def test_server_reload_stops_old_server_before_new(monkeypatch):
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
# A second load must tear down the first server and start a fresh one.
b._load_token = 2
fam = detect_family("z-image")
b._run_load(
repo_id = "unsloth/Z-Image-Turbo-GGUF",
gguf_filename = "z.gguf",
base = fam.base_repo,
fam = fam,
hf_token = None,
_load_token = 2,
)
assert len(servers) == 2
assert servers[0].stopped is True # old server stopped
assert b._state.server is servers[1] and servers[1].stopped is False
def test_server_start_failure_falls_back_to_oneshot(monkeypatch):
# A present-but-broken sd-server must not fail the load when sd-cli works.
b = SdCppDiffusionBackend()
monkeypatch.setattr(bk, "find_sd_server_binary", lambda: "/x/sd-server")
# Probe passes; the failure we exercise here is in start(), not the up-front probe.
monkeypatch.setattr(bk, "_server_binary_runnable", lambda *_a, **_k: True)
class _BadServer:
def __init__(self, binary):
self.stopped = False
def start(self, *a, **k):
raise RuntimeError("sd-server broken")
def stop(self):
self.stopped = True
monkeypatch.setattr(bk, "SdCppServer", _BadServer)
fake = _FakeEngine()
monkeypatch.setattr(b, "_resolve_engine", lambda: fake)
monkeypatch.setattr(b, "_asset_specs", lambda *a, **k: [])
monkeypatch.setattr(b, "_set_expected_bytes", lambda *a, **k: None)
monkeypatch.setattr(
b,
"_fetch_assets",
lambda *a, **k: {"diffusion_model": "/m/z.gguf", "vae": "/m/vae.sft", "llm": "/m/llm.sft"},
)
monkeypatch.setattr(
bk, "resolve_diffusion_device_target", lambda: types.SimpleNamespace(device = "cpu")
)
fam = detect_family("z-image")
b._load_token = 1
b._run_load(
repo_id = "unsloth/Z-Image-Turbo-GGUF",
gguf_filename = "z.gguf",
base = fam.base_repo,
fam = fam,
hf_token = None,
_load_token = 1,
)
assert b._state is not None and b._state.mode == "oneshot" and b._state.server is None
# and it can still generate via the one-shot engine
out = b.generate(prompt = "x", steps = 4, seed = 1)
assert len(out["images"]) == 1 and len(fake.calls) == 1
def test_run_load_redacts_paths_in_progress_error(monkeypatch):
# A load failure surfaced via load_progress() must run through redact_native_paths, the
# same scrub the diffusers load path applies, so a registered native path can't leak.
@ -355,3 +669,108 @@ def test_run_load_redacts_paths_in_progress_error(monkeypatch):
with npl._REDACTION_LOCK:
if secret_root in npl._NATIVE_PATH_REDACTIONS:
npl._NATIVE_PATH_REDACTIONS.remove(secret_root)
# ── LoRA (native engine) ────────────────────────────────────────────────────────
def _fake_materialize(resolved, dest):
"""Stand-in for diffusion_lora.materialize_native_dir: write a stub file per adapter
into ``dest`` and return the resolved list pointing at the written paths (mirroring the
real helper's contract without touching the Hub / real weights)."""
from pathlib import Path as _P
from core.inference import diffusion_lora as dl
dest.mkdir(parents = True, exist_ok = True)
out = []
for r in resolved:
p = _P(dest) / f"{r.alias}.safetensors"
p.write_bytes(b"stub")
out.append(dl.ResolvedLora(r.id, r.alias, str(p), r.fmt, r.weight))
return out
def _patch_lora(monkeypatch, resolved, supported = True):
from core.inference import diffusion_lora as dl
monkeypatch.setattr(dl, "supports_lora", lambda **k: supported)
monkeypatch.setattr(dl, "resolve_specs", lambda specs, **k: list(resolved))
monkeypatch.setattr(dl, "materialize_native_dir", _fake_materialize)
def test_generate_oneshot_applies_loras_via_prompt_tags(monkeypatch):
# One-shot sd-cli LoRA: adapters materialized into a --lora-model-dir and selected with
# <lora:ALIAS:w> tags injected into the prompt (the real inject_prompt_tags runs here).
from core.inference import diffusion_lora as dl
eng = _FakeEngine()
b = _loaded_backend(engine = eng) # mode = "oneshot"
_patch_lora(monkeypatch, [dl.ResolvedLora("id1", "myalias", "/x/a.safetensors", "safetensors", 0.8)])
b.generate(prompt = "a fox", steps = 4, seed = 1, loras = [("id1", 0.8)])
_, params, _, _ = eng.calls[0]
assert params.lora_dir is not None and params.lora_apply_mode == "auto"
assert "<lora:myalias:0.8>" in params.prompt
def test_generate_server_stages_loras_and_sends_structured_field(monkeypatch, tmp_path):
# Server-mode LoRA rides the structured `lora` request field (the sdcpp API ignores
# <lora:> prompt tags): adapters staged into the server's --lora-model-dir, referenced
# by their path relative to it + the validated multiplier.
from pathlib import Path as _P
from core.inference import diffusion_lora as dl
b = SdCppDiffusionBackend()
servers: list = []
_run_server_load(monkeypatch, b, servers)
servers[0].lora_dir = str(tmp_path)
_patch_lora(monkeypatch, [dl.ResolvedLora("id1", "myalias", "/x/a.safetensors", "safetensors", 0.7)])
b.generate(prompt = "x", steps = 4, seed = 1, batch_size = 1, loras = [("id1", 0.7)])
payload = servers[0].payloads[0]
assert "lora" in payload and len(payload["lora"]) == 1
assert payload["lora"][0]["multiplier"] == 0.7
assert payload["lora"][0]["path"].endswith("myalias.safetensors")
assert "<lora:" not in payload["prompt"] # no prompt-tag mechanism on the server
# The per-request stage subdir under the server's lora dir is removed after the batch.
assert not list(_P(tmp_path).glob("gen_*"))
def test_generate_rejects_loras_on_unsupported_family(monkeypatch):
b = _loaded_backend(engine = _FakeEngine())
_patch_lora(monkeypatch, [], supported = False)
with pytest.raises(ValueError, match = "LoRA is not supported"):
b.generate(prompt = "x", steps = 4, seed = 1, loras = [("id1", 1.0)])
def test_generate_zero_weight_loras_are_noop(monkeypatch):
# weight-0 rows are dropped BEFORE the support gate, so a request carrying only disabled
# adapters stays a no-op even on a family where native LoRA is unsupported.
eng = _FakeEngine()
b = _loaded_backend(engine = eng)
_patch_lora(monkeypatch, [], supported = False) # would raise if the gate were reached
b.generate(prompt = "x", steps = 4, seed = 1, loras = [("id1", 0.0)])
_, params, _, _ = eng.calls[0]
assert params.lora_dir is None # nothing applied
def test_generate_rejects_controlnet_on_native_engine():
# ControlNet is diffusers-only. The route passes `controlnet` to whichever engine is
# active, so the native backend must reject it with a clean ValueError (-> 400) rather
# than TypeError on an unexpected kwarg (-> opaque 500).
b = _loaded_backend(engine = _FakeEngine())
with pytest.raises(ValueError, match = "ControlNet is not yet supported on the native"):
b.generate(prompt = "x", steps = 4, seed = 1, controlnet = ("id", "img", "canny", 1.0, 0.0, 1.0))
def test_generate_rejects_image_conditioned_on_native_engine():
# img2img / inpaint / reference / upscale are likewise diffusers-only; a direct API call
# with an init image on the native engine gets a clean ValueError, not a silent txt2img.
b = _loaded_backend(engine = _FakeEngine())
with pytest.raises(ValueError, match = "not yet supported on the native"):
b.generate(prompt = "x", steps = 4, seed = 1, init_image = "data:image/png;base64,AAAA")
def test_status_native_reports_supports_controlnet_false():
b = _loaded_backend()
assert b.status()["supports_controlnet"] is False

View file

@ -24,6 +24,7 @@ from core.inference.sd_cpp_engine import (
ENGINE_SD_CPP,
SdCppEngine,
find_sd_cpp_binary,
find_sd_server_binary,
runtime_env,
select_diffusion_engine,
)
@ -75,6 +76,58 @@ def test_find_returns_none_when_absent(tmp_path, monkeypatch):
assert find_sd_cpp_binary() is None
# ── sd-server discovery ──────────────────────────────────────────────────────
def _clear_server_env(monkeypatch):
monkeypatch.delenv("SD_SERVER_PATH", raising = False)
monkeypatch.delenv("SD_CLI_PATH", raising = False)
monkeypatch.delenv("UNSLOTH_SD_CPP_PATH", raising = False)
def test_find_server_prefers_sd_server_path_env(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
binary = tmp_path / "sd-server"
binary.write_text("x")
monkeypatch.setenv("SD_SERVER_PATH", str(binary))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() == str(binary)
def test_find_server_build_layout(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
root = tmp_path / "sdcpp"
built = root / "build" / "bin" / "sd-server"
built.parent.mkdir(parents = True)
built.write_text("x")
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() == str(built)
def test_find_server_path_fallback(tmp_path, monkeypatch):
_clear_server_env(monkeypatch)
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(
eng.shutil, "which", lambda stem: "/usr/bin/sd-server" if stem == "sd-server" else None
)
assert find_sd_server_binary() == "/usr/bin/sd-server"
def test_find_server_not_confused_with_sd_cli(tmp_path, monkeypatch):
# A tree that has only sd-cli must NOT be reported as an sd-server (and vice versa),
# so the backend correctly falls back to one-shot when only the CLI is present.
_clear_server_env(monkeypatch)
root = tmp_path / "sdcpp"
(root / "build" / "bin").mkdir(parents = True)
(root / "build" / "bin" / "sd-cli").write_text("x")
monkeypatch.setenv("UNSLOTH_SD_CPP_PATH", str(root))
monkeypatch.setattr(eng.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
monkeypatch.setattr(eng.shutil, "which", lambda *_a: None)
assert find_sd_server_binary() is None
assert find_sd_cpp_binary() == str(root / "build" / "bin" / "sd-cli")
# ── availability / version ──────────────────────────────────────────────────

View file

@ -148,11 +148,12 @@ def test_pinned_tag_default_and_override(monkeypatch):
assert _pinned_tag() is None
def test_repo_default_and_mirror_override(monkeypatch):
def test_repo_default_and_override(monkeypatch):
monkeypatch.delenv("UNSLOTH_SD_CPP_REPO", raising = False)
assert _repo() == DEFAULT_REPO == "leejet/stable-diffusion.cpp"
monkeypatch.setenv("UNSLOTH_SD_CPP_REPO", "unslothai/stable-diffusion.cpp")
assert _repo() == "unslothai/stable-diffusion.cpp"
# Default is the Unsloth mirror; the env override can point back to leejet upstream.
assert _repo() == DEFAULT_REPO == "unslothai/stable-diffusion.cpp"
monkeypatch.setenv("UNSLOTH_SD_CPP_REPO", "leejet/stable-diffusion.cpp")
assert _repo() == "leejet/stable-diffusion.cpp"
# ── sha256 integrity check ──────────────────────────────────────────────────
@ -306,3 +307,257 @@ def test_find_sd_cpp_binary_honors_studio_home(tmp_path, monkeypatch):
binary.parent.mkdir(parents = True)
binary.write_bytes(b"x")
assert eng.find_sd_cpp_binary() == str(binary)
# ── Unsloth mirror: default source + the CPU/Apple asset set it publishes ─────
_TAG = "master-741-484baa4"
# Exactly what unslothai/stable-diffusion.cpp's CI publishes (CPU + Apple only; GPU
# hosts run diffusers and never reach the native installer).
_MIRROR_ASSETS = [
f"sd-{_TAG}-bin-Darwin-macOS-arm64.zip",
f"sd-{_TAG}-bin-Darwin-macOS-x86_64.zip",
f"sd-{_TAG}-bin-Linux-Ubuntu-22.04-x86_64.zip",
f"sd-{_TAG}-bin-Linux-Ubuntu-24.04-aarch64.zip",
f"sd-{_TAG}-bin-win-cpu-x64.zip",
]
def _mresolve(
system,
machine,
accelerator = "auto",
):
return resolve_release_asset(
_MIRROR_ASSETS, system = system, machine = machine, accelerator = accelerator
)
def test_default_repo_is_the_unsloth_mirror():
assert sdmod.DEFAULT_REPO == "unslothai/stable-diffusion.cpp"
assert sdmod.UPSTREAM_FALLBACK_REPO == "leejet/stable-diffusion.cpp"
def test_mirror_matrix_resolves_every_cpu_apple_host():
assert _mresolve("Darwin", "arm64") == f"sd-{_TAG}-bin-Darwin-macOS-arm64.zip"
assert _mresolve("Darwin", "x86_64") == f"sd-{_TAG}-bin-Darwin-macOS-x86_64.zip"
# WSL reports as Linux x86_64, so this also covers WSL.
assert _mresolve("Linux", "x86_64") == f"sd-{_TAG}-bin-Linux-Ubuntu-22.04-x86_64.zip"
assert _mresolve("Linux", "aarch64") == f"sd-{_TAG}-bin-Linux-Ubuntu-24.04-aarch64.zip"
assert _mresolve("Windows", "AMD64") == f"sd-{_TAG}-bin-win-cpu-x64.zip"
assert _mresolve("Windows", "AMD64", "cpu") == f"sd-{_TAG}-bin-win-cpu-x64.zip"
# ── mirror -> upstream fallback in install() ─────────────────────────────────
def _stub_two_repos(monkeypatch, *, mirror_serves, upstream_serves, zip_bytes, digest):
"""Stub _fetch_release to serve (or 404) per repo, so install()'s mirror->upstream
fallback can be exercised without network. Host pinned to Linux x86_64."""
monkeypatch.delenv("UNSLOTH_SD_CPP_REPO", raising = False)
monkeypatch.delenv("UNSLOTH_SD_CPP_TAG", raising = False)
mirror_name = f"sd-{_TAG}-bin-Linux-Ubuntu-22.04-x86_64.zip"
upstream_name = "sd-master-741-484baa4-bin-Linux-Ubuntu-24.04-x86_64.zip"
def _rel(name):
return {
"tag_name": _TAG,
"assets": [
{
"name": name,
"browser_download_url": f"https://example.invalid/{name}",
"digest": digest,
}
],
}
def fake_fetch(
tag = None,
*,
repo = None,
token = None,
timeout = 30.0,
allow_latest = True,
):
r = repo or sdmod.DEFAULT_REPO
if r == sdmod.DEFAULT_REPO:
if mirror_serves:
return _rel(mirror_name)
raise urllib.error.HTTPError(f"https://api/{r}", 404, "not found", None, None)
if upstream_serves:
return _rel(upstream_name)
raise urllib.error.HTTPError(f"https://api/{r}", 404, "not found", None, None)
monkeypatch.setattr(sdmod, "_fetch_release", fake_fetch)
monkeypatch.setattr(sdmod, "_download", lambda url, dest, **k: dest.write_bytes(zip_bytes))
monkeypatch.setattr(sdmod.platform, "system", lambda: "Linux")
monkeypatch.setattr(sdmod.platform, "machine", lambda: "x86_64")
return mirror_name, upstream_name
def test_install_uses_mirror_when_available(tmp_path, monkeypatch, capsys):
zb = _zip_with_sd_cli()
_stub_two_repos(
monkeypatch,
mirror_serves = True,
upstream_serves = True,
zip_bytes = zb,
digest = "sha256:" + hashlib.sha256(zb).hexdigest(),
)
sd_cli = install(install_dir = tmp_path)
assert sd_cli.name == "sd-cli" and sd_cli.is_file()
assert "unslothai/stable-diffusion.cpp" in capsys.readouterr().out
def test_install_falls_back_to_upstream_when_mirror_missing(tmp_path, monkeypatch, capsys):
zb = _zip_with_sd_cli()
_stub_two_repos(
monkeypatch,
mirror_serves = False,
upstream_serves = True,
zip_bytes = zb,
digest = "sha256:" + hashlib.sha256(zb).hexdigest(),
)
sd_cli = install(install_dir = tmp_path)
assert sd_cli.name == "sd-cli" and sd_cli.is_file()
captured = capsys.readouterr()
# The repo-fallback diagnostic goes to stderr so --print-asset's stdout stays a single
# asset line; the "source ..." install note still prints to stdout.
assert "falling back to leejet/stable-diffusion.cpp" in captured.err
assert "source leejet/stable-diffusion.cpp" in captured.out
def test_install_errors_when_neither_source_serves(tmp_path, monkeypatch):
_stub_two_repos(
monkeypatch, mirror_serves = False, upstream_serves = False, zip_bytes = b"", digest = ""
)
with pytest.raises(RuntimeError, match = "No prebuilt sd-cli"):
install(install_dir = tmp_path)
# ── explicit GPU accelerator on a CPU-only mirror -> no CPU substitution ──────
def test_mirror_windows_gpu_accel_is_no_match_not_cpu():
# The mirror ships only a CPU win zip. An explicit --accelerator cuda/vulkan/rocm must
# NOT silently resolve to that CPU build; it returns None so install() falls back to
# upstream, which does build the accelerated asset.
for accel in ("cuda", "vulkan", "rocm"):
assert _mresolve("Windows", "AMD64", accel) is None
# auto / cpu still resolve to the CPU build.
assert _mresolve("Windows", "AMD64", "cpu") == f"sd-{_TAG}-bin-win-cpu-x64.zip"
def test_mirror_linux_gpu_accel_is_no_match_not_cpu():
for accel in ("cuda", "vulkan", "rocm"):
assert _mresolve("Linux", "x86_64", accel) is None
assert _mresolve("Linux", "x86_64", "cpu") == f"sd-{_TAG}-bin-Linux-Ubuntu-22.04-x86_64.zip"
def test_upstream_full_matrix_still_resolves_gpu_accel():
# Regression guard: the "explicit GPU accel -> None on miss" change must not break
# the upstream matrix, which does publish the accelerated builds.
assert _resolve("Windows", "AMD64", "cuda") == "sd-master-8caa3f9-bin-win-cuda12-x64.zip"
assert _resolve("Linux", "x86_64", "vulkan").endswith("x86_64-vulkan.zip")
assert "rocm" in _resolve("Linux", "x86_64", "rocm")
# ── explicit repo override suppresses the upstream fallback ───────────────────
def test_explicit_repo_override_equal_to_default_suppresses_fallback(tmp_path, monkeypatch):
# A user who pins UNSLOTH_SD_CPP_REPO (even to the default value) must get exactly that
# repo -- no surprise leejet substitution -- so a missing release errors instead.
_stub_two_repos(
monkeypatch, mirror_serves = False, upstream_serves = True, zip_bytes = b"", digest = ""
)
monkeypatch.setenv("UNSLOTH_SD_CPP_REPO", sdmod.DEFAULT_REPO)
with pytest.raises(RuntimeError, match = "No prebuilt sd-cli"):
install(install_dir = tmp_path)
# ── pinned tag missing on mirror -> pinned upstream before mirror latest ──────
def test_pinned_tag_prefers_upstream_pin_over_mirror_latest(tmp_path, monkeypatch, capsys):
# The mirror lacks the pinned tag but has a newer latest; upstream has the pin. The
# pinned upstream build must win over the unpinned mirror-latest (reproducibility).
monkeypatch.delenv("UNSLOTH_SD_CPP_REPO", raising = False)
monkeypatch.setenv("UNSLOTH_SD_CPP_TAG", "master-999-pinned")
zb = _zip_with_sd_cli()
digest = "sha256:" + hashlib.sha256(zb).hexdigest()
def _rel(name, tag):
return {
"tag_name": tag,
"assets": [
{
"name": name,
"browser_download_url": f"https://example.invalid/{name}",
"digest": digest,
}
],
}
mirror_latest = "sd-master-000-latest-bin-Linux-Ubuntu-22.04-x86_64.zip"
upstream_pinned = "sd-master-999-pinned-bin-Linux-Ubuntu-24.04-x86_64.zip"
def fake_fetch(
tag = None,
*,
repo = None,
token = None,
timeout = 30.0,
allow_latest = True,
):
r = repo or sdmod.DEFAULT_REPO
if r == sdmod.DEFAULT_REPO:
if tag == "master-999-pinned": # mirror lacks the pin
if not allow_latest:
return None
return _rel(mirror_latest, "master-000-latest")
return _rel(mirror_latest, "master-000-latest")
# upstream HAS the pin
if tag == "master-999-pinned":
return _rel(upstream_pinned, "master-999-pinned")
return _rel(upstream_pinned, "master-999-pinned")
monkeypatch.setattr(sdmod, "_fetch_release", fake_fetch)
monkeypatch.setattr(sdmod, "_download", lambda url, dest, **k: dest.write_bytes(zb))
monkeypatch.setattr(sdmod.platform, "system", lambda: "Linux")
monkeypatch.setattr(sdmod.platform, "machine", lambda: "x86_64")
install(install_dir = tmp_path)
out = capsys.readouterr().out
assert "source leejet/stable-diffusion.cpp release master-999-pinned" in out
# ── --print-asset routes through the primary/upstream fallback ────────────────
def test_print_asset_uses_upstream_fallback(monkeypatch, capsys):
# A host the mirror does not build (Linux Vulkan) must print the upstream asset that a
# real install would fetch, not "no matching prebuilt".
monkeypatch.delenv("UNSLOTH_SD_CPP_REPO", raising = False)
monkeypatch.delenv("UNSLOTH_SD_CPP_TAG", raising = False)
def fake_fetch(
tag = None,
*,
repo = None,
token = None,
timeout = 30.0,
allow_latest = True,
):
r = repo or sdmod.DEFAULT_REPO
if r == sdmod.DEFAULT_REPO:
return {"tag_name": _TAG, "assets": [{"name": n} for n in _MIRROR_ASSETS]}
return {"tag_name": "master-8caa3f9", "assets": [{"name": n} for n in _ASSETS]}
monkeypatch.setattr(sdmod, "_fetch_release", fake_fetch)
monkeypatch.setattr(sdmod.platform, "system", lambda: "Linux")
monkeypatch.setattr(sdmod.platform, "machine", lambda: "x86_64")
rc = sdmod.main(["--print-asset", "--accelerator", "vulkan"])
out = capsys.readouterr().out
assert rc == 0
assert "vulkan" in out and "no matching prebuilt" not in out

View file

@ -0,0 +1,417 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the persistent sd-server process manager (SdCppServer).
Hermetic: subprocess.Popen and the httpx client are faked, so nothing spawns a real
binary or opens a socket beyond the free-port probe."""
from __future__ import annotations
import base64
import io
import threading
import pytest
from PIL import Image
from core.inference import sd_cpp_server as srv
from core.inference.sd_cpp_args import SdCppModelFiles
from core.inference.sd_cpp_engine import SdCppCancelled
from core.inference.sd_cpp_server import SdCppServer
_FILES = SdCppModelFiles(diffusion_model = "/m/z.gguf", vae = "/m/vae.sft", llm = "/m/llm.sft")
def _png_b64(shade: int) -> str:
buf = io.BytesIO()
Image.new("RGB", (1, 1), (shade, shade, shade)).save(buf, format = "PNG")
return base64.b64encode(buf.getvalue()).decode()
class _FakePopen:
"""Minimal Popen stand-in. stdout yields the scripted lines then BLOCKS until the
process is terminated/killed/exited -- mirroring a real child that holds its pipe
open for its lifetime (so the owner/drain thread stays alive, as in production)."""
def __init__(
self,
lines = (),
exit_code = None,
):
self.pid = 4242
self._lines = list(lines)
self._exit = exit_code # None == alive
self.returncode = exit_code
self.terminated = False
self.killed = False
self._done = threading.Event()
if exit_code is not None:
self._done.set()
@property
def stdout(self):
def _gen():
for ln in self._lines:
yield ln
self._done.wait() # hold the pipe open until the process ends
return _gen()
def poll(self):
return self._exit
def terminate(self):
self.terminated = True
self._exit = 0
self.returncode = 0
self._done.set()
def wait(self, timeout = None):
self._done.wait(timeout)
if self._exit is None:
self._exit = 0
self.returncode = 0
return self.returncode
def kill(self):
self.killed = True
self._exit = -9
self.returncode = -9
self._done.set()
class _Resp:
def __init__(
self,
status_code,
payload = None,
text = "",
bad_json = False,
):
self.status_code = status_code
self._payload = payload if payload is not None else {}
self.text = text
self._bad_json = bad_json
def json(self):
if self._bad_json:
raise ValueError("not json")
return self._payload
class _FakeClient:
def __init__(
self,
*,
get = None,
post = None,
):
self._get = get or (lambda url: _Resp(200, {}))
self._post = post or (lambda url, json: _Resp(202, {"id": "job1"}))
self.get_urls = []
self.post_calls = []
self.closed = False
def get(
self,
url,
timeout = None,
):
self.get_urls.append(url)
return self._get(url)
def post(
self,
url,
json = None,
timeout = None,
):
self.post_calls.append((url, json))
return self._post(url, json)
def close(self):
self.closed = True
@pytest.fixture
def patched(monkeypatch):
"""Neutralise process-lifetime side effects for the manager under test."""
monkeypatch.setattr(srv, "adopt_pid", lambda pid: None)
monkeypatch.setattr(srv, "forget_pid", lambda pid: None)
monkeypatch.setattr(srv, "child_popen_kwargs", lambda: {})
monkeypatch.setattr(srv, "windows_hidden_subprocess_kwargs", lambda: {})
return monkeypatch
def _server_with(popen, client):
s = SdCppServer("/x/sd-server")
s._client = client
# Attach the fake process + port so generation tests can run without start().
s._process = popen
s.port = 1234
return s
# ── start / readiness ──────────────────────────────────────────────────────────
def test_start_becomes_ready_when_capabilities_200(patched):
popen = _FakePopen(lines = ["loading model", "listening on: http://127.0.0.1:1"])
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
s = _server_with(
popen, _FakeClient(get = lambda url: _Resp(200, {"model": {"path": "/m/z.gguf"}}))
)
s.start(_FILES, startup_timeout = 5.0)
assert s.is_alive() is True
assert s.port is not None
def test_start_fails_fast_when_process_exits(patched):
# Model load failed -> process exits before listening; start must raise with the tail.
popen = _FakePopen(lines = ["error: bad model"], exit_code = 1)
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
# Capabilities never answers (connection refused) -> readiness relies on exit detection.
s = _server_with(
popen, _FakeClient(get = lambda url: (_ for _ in ()).throw(srv.httpx.ConnectError("refused")))
)
with pytest.raises(RuntimeError, match = "failed to become ready"):
s.start(_FILES, startup_timeout = 2.0)
# ── generation ───────────────────────────────────────────────────────────────
def _completed_job(images_b64):
return _Resp(
200,
{
"status": "completed",
"result": {"images": [{"index": i, "b64_json": b} for i, b in enumerate(images_b64)]},
},
)
def test_img_gen_returns_image_bytes_in_index_order(patched):
popen = _FakePopen()
s = _server_with(
popen,
_FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobA"}),
# result images deliberately out of order -> manager must sort by index.
get = lambda url: _Resp(
200,
{
"status": "completed",
"result": {
"images": [
{"index": 1, "b64_json": _png_b64(200)},
{"index": 0, "b64_json": _png_b64(50)},
]
},
},
),
),
)
blobs = s.img_gen({"prompt": "x", "batch_count": 2, "sample_params": {"sample_steps": 4}})
assert len(blobs) == 2
first = Image.open(io.BytesIO(blobs[0])).convert("RGB").getpixel((0, 0))
assert first == (50, 50, 50) # index 0 first
def test_img_gen_failed_job_raises(patched):
popen = _FakePopen()
s = _server_with(
popen,
_FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobF"}),
get = lambda url: _Resp(
200, {"status": "failed", "error": {"code": "x", "message": "boom"}}
),
),
)
with pytest.raises(RuntimeError, match = "generation failed.*boom"):
s.img_gen({"prompt": "x"})
def test_img_gen_queue_full_raises(patched):
popen = _FakePopen()
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(429, text = "busy")))
with pytest.raises(RuntimeError, match = "queue is full"):
s.img_gen({"prompt": "x"})
def test_img_gen_cancel_posts_cancel_and_raises(patched):
popen = _FakePopen()
cancel = threading.Event()
cancel.set() # already cancelled before the first poll
client = _FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobC"}),
get = lambda url: _Resp(
200, {"status": "cancelled", "error": {"code": "cancelled", "message": "c"}}
),
)
s = _server_with(popen, client)
with pytest.raises(SdCppCancelled):
s.img_gen({"prompt": "x"}, cancel_event = cancel)
assert any(url.endswith("/cancel") for url, _ in client.post_calls)
def test_img_gen_detects_server_death(patched):
popen = _FakePopen()
def _die_get(url):
popen._exit = 137 # the process died between submit and poll
return _Resp(200, {"status": "generating"})
s = _server_with(
popen, _FakeClient(post = lambda url, json: _Resp(202, {"id": "jobD"}), get = _die_get)
)
with pytest.raises(RuntimeError, match = "connection lost|process exited"):
s.img_gen({"prompt": "x"})
# ── stdout routing + stop ──────────────────────────────────────────────────────
def test_drain_routes_lines_to_step_listener_and_tail(patched):
s = SdCppServer("/x/sd-server")
seen = []
s._step_listener = seen.append
# exit_code set so stdout ends after the scripted lines (a live fake would block).
s._drain_stdout(_FakePopen(lines = ["sampling 1/8", "", "sampling 8/8", "done"], exit_code = 0))
assert "sampling 1/8" in seen and "sampling 8/8" in seen
assert "" not in seen # blank lines skipped
assert s._tail[-1] == "done"
def test_stop_is_idempotent_and_terminates(patched):
popen = _FakePopen()
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
client = _FakeClient(get = lambda url: _Resp(200, {}))
s = _server_with(popen, client)
s.start(_FILES, startup_timeout = 5.0)
s.stop()
assert popen.terminated is True
assert s.is_alive() is False
assert client.closed is True # stop() releases the pooled HTTP client
s.stop() # second call must not raise
def test_img_gen_submit_error_raises(patched):
popen = _FakePopen()
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(400, text = "bad params")))
with pytest.raises(RuntimeError, match = "submit -> 400"):
s.img_gen({"prompt": "x"})
def test_img_gen_malformed_submit_json_raises(patched):
popen = _FakePopen()
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(202, bad_json = True)))
with pytest.raises(RuntimeError, match = "non-JSON submit"):
s.img_gen({"prompt": "x"})
def test_img_gen_empty_result_raises(patched):
popen = _FakePopen()
s = _server_with(
popen,
_FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobE"}),
get = lambda url: _Resp(200, {"status": "completed", "result": {"images": []}}),
),
)
with pytest.raises(RuntimeError, match = "no images"):
s.img_gen({"prompt": "x"})
def test_img_gen_rejected_after_stop(patched):
popen = _FakePopen()
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
s.start(_FILES, startup_timeout = 5.0)
s.stop()
with pytest.raises(RuntimeError, match = "not running"):
s.img_gen({"prompt": "x"})
# ── cancellation + defensive parsing (review follow-ups) ───────────────────────
def test_img_gen_cancelled_before_submit_reports_cancellation(patched):
# The server was stopped for a cancel/unload before submit; with the cancel event set
# this must surface as a cancellation (route -> 409), not a generic "not running" 500.
popen = _FakePopen()
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
s = _server_with(popen, _FakeClient(get = lambda url: _Resp(200, {})))
s.start(_FILES, startup_timeout = 5.0)
s.stop()
cancel = threading.Event()
cancel.set()
with pytest.raises(SdCppCancelled):
s.img_gen({"prompt": "x"}, cancel_event = cancel)
def test_img_gen_abandons_when_cancel_not_honored(patched):
# A best-effort cancel the server ignores must not pin this call (and the generate
# lock) until natural completion: after the grace window it raises cancellation.
patched.setattr(srv, "_CANCEL_GRACE_S", 0.0)
popen = _FakePopen()
cancel = threading.Event()
cancel.set()
client = _FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobG"}),
get = lambda url: _Resp(200, {"status": "generating"}), # never terminal
)
s = _server_with(popen, client)
with pytest.raises(SdCppCancelled):
s.img_gen({"prompt": "x"}, cancel_event = cancel, poll_interval = 0.01)
def test_img_gen_non_dict_submit_json_raises(patched):
popen = _FakePopen()
s = _server_with(popen, _FakeClient(post = lambda url, json: _Resp(202, ["not", "a", "dict"])))
with pytest.raises(RuntimeError, match = "unexpected submit response"):
s.img_gen({"prompt": "x"})
def test_img_gen_non_dict_status_json_raises(patched):
popen = _FakePopen()
s = _server_with(
popen,
_FakeClient(
post = lambda url, json: _Resp(202, {"id": "jobH"}),
get = lambda url: _Resp(200, ["unexpected"]),
),
)
with pytest.raises(RuntimeError, match = "unexpected response type"):
s.img_gen({"prompt": "x"}, poll_interval = 0.01)
def test_decode_images_tolerates_unexpected_shapes():
# A misbehaving/older server can return non-dict result/images/items; _decode_images
# must raise a clean "no images" rather than an AttributeError on .get().
for job in ({"result": ["x"]}, {"result": {"images": "nope"}}, {"result": {"images": [1, 2]}}):
with pytest.raises(RuntimeError, match = "no images"):
SdCppServer._decode_images(job)
def test_start_aborted_by_concurrent_stop(patched):
# A stop() during the readiness wait must abort start() promptly (without waiting out
# the startup timeout) and surface as a cancellation.
popen = _FakePopen(lines = ["loading model"])
patched.setattr(srv.subprocess, "Popen", lambda *a, **k: popen)
def _never_ready(url):
raise srv.httpx.ConnectError("refused")
s = _server_with(popen, _FakeClient(get = _never_ready))
def _stop_soon():
import time as _t
_t.sleep(0.2)
s.stop()
threading.Thread(target = _stop_soon, daemon = True).start()
with pytest.raises(SdCppCancelled):
s.start(_FILES, startup_timeout = 30.0)

View file

@ -0,0 +1,510 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getHfToken, hfApiToken } from "@/features/hub/stores/hf-token-store";
import { toast } from "@/lib/toast";
import {
type DiffusionTrainingInfo,
type DiffusionTrainingStatus,
getDiffusionTrainingInfo,
getDiffusionTrainingStatus,
startDiffusionTraining,
stopDiffusionTraining,
uploadDiffusionDataset,
} from "./api";
// The two official SDXL bases the backend allowlists for non-GGUF loads. Everything the
// dropdown offers is trainable; "custom" is the escape hatch for local SDXL checkpoints.
const SDXL_BASES: Array<{ id: string; label: string }> = [
{ id: "stabilityai/stable-diffusion-xl-base-1.0", label: "SDXL Base 1.0 (best quality)" },
{ id: "stabilityai/sdxl-turbo", label: "SDXL Turbo (fast, good for quick tests)" },
];
const CUSTOM_BASE = "__custom__";
const UPLOAD_DATASET = "__upload__";
const DATASET_FILE_ACCEPT = ".png,.jpg,.jpeg,.webp,.bmp,.txt,.caption,.jsonl";
const selectClass =
"h-8 w-full rounded-md border border-input bg-background px-2 text-xs";
// A self-contained "Train an SDXL LoRA" dialog. It posts to /api/train/diffusion/start
// and polls /status while open, so it never blocks the page and works whether or not a
// model is loaded for generation. Only SDXL is trainable today; the backend refuses
// known non-SDXL picks instantly, and the base-model dropdown keeps users on safe picks.
export function DiffusionTrainDialog({
open,
onOpenChange,
defaultBaseModel,
onTrainingComplete,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultBaseModel?: string;
// Called once when a run finishes so the page can rescan the LoRA picker.
onTrainingComplete?: () => void;
}) {
const [baseChoice, setBaseChoice] = useState(SDXL_BASES[0].id);
const [customBase, setCustomBase] = useState("");
const [info, setInfo] = useState<DiffusionTrainingInfo | null>(null);
const [dataset, setDataset] = useState<string>(UPLOAD_DATASET);
const [uploadName, setUploadName] = useState("my-images");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [outputDir, setOutputDir] = useState("");
const [instancePrompt, setInstancePrompt] = useState("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [steps, setSteps] = useState(500);
const [learningRate, setLearningRate] = useState(0.0001);
const [rank, setRank] = useState(16);
const [resolution, setResolution] = useState(1024);
const [batchSize, setBatchSize] = useState(1);
const [precision, setPrecision] = useState<"bf16" | "fp16" | "no">("bf16");
const [starting, setStarting] = useState(false);
const [status, setStatus] = useState<DiffusionTrainingStatus | null>(null);
// The dialog stays mounted (ImagesPage is keep-alive), so seed per-open state here:
// the base-model choice from the currently loaded SDXL pipeline (when there is one),
// and the dataset list from the backend.
const refreshInfo = useCallback(async (): Promise<DiffusionTrainingInfo | null> => {
try {
const i = await getDiffusionTrainingInfo();
setInfo(i);
return i;
} catch {
return null; // older backends: keep the upload-only flow usable
}
}, []);
useEffect(() => {
if (!open) return;
if (defaultBaseModel) {
setBaseChoice(defaultBaseModel);
}
void refreshInfo().then((i) => {
// Preselect the only dataset, or the freshest-looking state: with no datasets
// yet, the picker sits on "Upload new images".
setDataset((cur) => {
if (cur !== UPLOAD_DATASET && i?.datasets.some((d) => d.name === cur)) return cur;
return i && i.datasets.length > 0 ? i.datasets[0].name : UPLOAD_DATASET;
});
});
}, [open, defaultBaseModel, refreshInfo]);
const poll = useCallback(async () => {
try {
setStatus(await getDiffusionTrainingStatus());
} catch {
// Best-effort; a failed poll should not surface an error while the dialog is open.
}
}, []);
// Poll status only while the dialog is open.
useEffect(() => {
if (!open) return;
void poll();
const id = window.setInterval(() => void poll(), 1500);
return () => window.clearInterval(id);
}, [open, poll]);
const active = Boolean(status?.active) || status?.status === "running";
const completed = status?.status === "completed";
const pct =
status && status.total_steps > 0
? Math.min(100, Math.round((status.step / status.total_steps) * 100))
: 0;
// Notify the parent exactly once when a run reaches "completed", so it can rescan the
// LoRA picker (a LoRA trained while a model is loaded is otherwise invisible until a
// model swap re-runs the discovery effect).
const [notifiedComplete, setNotifiedComplete] = useState(false);
useEffect(() => {
if (status?.status === "completed" && !notifiedComplete) {
setNotifiedComplete(true);
onTrainingComplete?.();
} else if (status?.status === "running" && notifiedComplete) {
setNotifiedComplete(false); // arm again for the next run
}
}, [status?.status, notifiedComplete, onTrainingComplete]);
const selectedDataset =
dataset !== UPLOAD_DATASET ? info?.datasets.find((d) => d.name === dataset) : undefined;
const onUpload = useCallback(async () => {
const files = Array.from(fileInputRef.current?.files ?? []);
if (files.length === 0) {
toast.error("Choose the images to upload first.");
return;
}
const name = uploadName.trim();
if (!name) {
toast.error("Give the dataset a folder name, e.g. my-style-photos.");
return;
}
setUploading(true);
try {
const res = await uploadDiffusionDataset(name, files);
toast.success(
`Uploaded ${res.uploaded} file${res.uploaded === 1 ? "" : "s"} - ` +
`"${res.name}" now has ${res.image_count} images`,
);
if (fileInputRef.current) fileInputRef.current.value = "";
await refreshInfo();
setDataset(res.name);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}, [uploadName, refreshInfo]);
const onStart = useCallback(async () => {
const baseModel = (baseChoice === CUSTOM_BASE ? customBase : baseChoice).trim();
if (!baseModel) {
toast.error("Pick a base model (or fill in the custom repo/path).");
return;
}
if (dataset === UPLOAD_DATASET) {
toast.error("Upload your training images first (or pick an existing dataset).");
return;
}
if (!outputDir.trim()) {
toast.error("Name the adapter (this becomes its folder under Studio outputs).");
return;
}
if (selectedDataset && selectedDataset.caption_count === 0 && !instancePrompt.trim()) {
toast.error(
"These images have no captions - add a trigger prompt so the trainer knows " +
"what to learn (it becomes the caption for every image).",
);
return;
}
// Mirror the backend's numeric validation so obvious mistakes are caught before the
// request (the backend returns 400 for these; catching here gives a clearer message).
if (steps < 1) return toast.error("Steps must be at least 1.");
if (rank < 1) return toast.error("LoRA rank must be at least 1.");
if (resolution < 64 || resolution % 8 !== 0) {
return toast.error("Resolution must be a multiple of 8 and at least 64.");
}
if (batchSize < 1) return toast.error("Batch size must be at least 1.");
if (learningRate <= 0) return toast.error("Learning rate must be greater than 0.");
setStarting(true);
try {
await startDiffusionTraining({
base_model: baseModel,
data_dir: dataset,
output_dir: outputDir.trim(),
instance_prompt: instancePrompt.trim() || undefined,
resolution,
train_steps: steps,
learning_rate: learningRate,
train_batch_size: batchSize,
lora_rank: rank,
mixed_precision: precision,
// Forward the saved Hub token so a gated/private SDXL base can be trained (the
// image load flow already sends it, so a model you can load, you can also train).
hf_token: hfApiToken(getHfToken()) || undefined,
});
toast.success("Training started");
void poll();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to start training");
} finally {
setStarting(false);
}
}, [
baseChoice,
customBase,
dataset,
selectedDataset,
outputDir,
instancePrompt,
resolution,
steps,
learningRate,
batchSize,
rank,
precision,
poll,
]);
const onStop = useCallback(async () => {
try {
await stopDiffusionTraining();
toast.success("Stop requested; finishing the current step.");
void poll();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to stop training");
}
}, [poll]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-lg flex-col">
<DialogHeader>
<DialogTitle>Train an SDXL LoRA</DialogTitle>
<DialogDescription>
Teach SDXL a style, character, or subject from your own images. The finished
adapter shows up in this page&apos;s LoRA picker. Only SDXL can be trained for
now - FLUX, Qwen-Image and Z-Image load LoRAs but can&apos;t train them yet.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 overflow-y-auto py-2 pr-1">
{/* 1. Base model: a constrained dropdown instead of free text, so the "SDXL
only" rule is embodied by the control. Custom stays available for local
SDXL checkpoints; a known non-SDXL pick is refused instantly by the API. */}
<div className="grid gap-1.5">
<Label className="text-xs">Base model to train on</Label>
<select
value={baseChoice}
onChange={(e) => setBaseChoice(e.target.value)}
className={selectClass}
>
{SDXL_BASES.map((b) => (
<option key={b.id} value={b.id}>
{b.label}
</option>
))}
{defaultBaseModel && !SDXL_BASES.some((b) => b.id === defaultBaseModel) && (
<option value={defaultBaseModel}>Loaded model: {defaultBaseModel}</option>
)}
<option value={CUSTOM_BASE}>Custom SDXL repo or local path...</option>
</select>
{baseChoice === CUSTOM_BASE && (
<Input
value={customBase}
placeholder="my-org/my-sdxl-finetune or /path/to/sdxl-pipeline"
spellCheck={false}
onChange={(e) => setCustomBase(e.target.value)}
className="h-8 text-xs"
/>
)}
</div>
{/* 2. Training images: pick an existing dataset folder or upload straight from
the browser - no shell access or Studio-home knowledge needed. */}
<div className="grid gap-1.5">
<Label className="text-xs">Training images</Label>
<select
value={dataset}
onChange={(e) => setDataset(e.target.value)}
className={selectClass}
>
{(info?.datasets ?? []).map((d) => (
<option key={d.name} value={d.name}>
{d.name} ({d.image_count} image{d.image_count === 1 ? "" : "s"}
{d.caption_count > 0 ? `, ${d.caption_count} captions` : ""})
</option>
))}
<option value={UPLOAD_DATASET}>Upload new images...</option>
</select>
{dataset === UPLOAD_DATASET && (
<div className="grid gap-1.5 rounded-md border border-dashed border-border p-2">
<Input
value={uploadName}
placeholder="my-style-photos"
spellCheck={false}
onChange={(e) => setUploadName(e.target.value)}
className="h-8 text-xs"
aria-label="New dataset name"
/>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={DATASET_FILE_ACCEPT}
className="min-w-0 flex-1 text-xs file:mr-2 file:rounded-md file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
aria-label="Training images"
/>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 shrink-0"
onClick={onUpload}
disabled={uploading}
>
{uploading ? "Uploading..." : "Upload"}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
10-50 images work well. Optional captions: a .txt per image (same
filename) or a metadata.jsonl; without them the trigger prompt below
captions every image. You can upload more into the same name later.
</p>
</div>
)}
{selectedDataset && selectedDataset.caption_count === 0 && (
<p className="text-[11px] text-muted-foreground">
No caption files in this dataset - the trigger prompt below will be used
as the caption for every image.
</p>
)}
</div>
{/* 3. What to call the result + how to trigger it. */}
<div className="grid gap-1.5">
<Label className="text-xs">Adapter name</Label>
<Input
value={outputDir}
placeholder="my-style-lora"
spellCheck={false}
onChange={(e) => setOutputDir(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Trigger prompt (how you&apos;ll invoke the style later)</Label>
<Input
value={instancePrompt}
placeholder="a photo in SKS style"
onChange={(e) => setInstancePrompt(e.target.value)}
className="h-8 text-xs"
/>
</div>
{/* 4. Hyperparameters, collapsed: the defaults suit a first run, and hiding
them keeps the primary flow at three decisions. */}
<button
type="button"
className="w-fit text-xs text-muted-foreground underline-offset-2 hover:underline"
onClick={() => setShowAdvanced((s) => !s)}
aria-expanded={showAdvanced}
>
{showAdvanced ? "Hide training settings" : "Training settings (defaults suit a first run)"}
</button>
{showAdvanced && (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid gap-1.5">
<Label className="text-xs">Steps</Label>
<Input
type="number"
min={1}
value={steps}
onChange={(e) => setSteps(Number(e.target.value) || 1)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">LoRA rank</Label>
<Input
type="number"
min={1}
value={rank}
onChange={(e) => setRank(Number(e.target.value) || 1)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Resolution</Label>
<Input
type="number"
min={64}
step={64}
value={resolution}
onChange={(e) => setResolution(Number(e.target.value) || 1024)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Batch</Label>
<Input
type="number"
min={1}
value={batchSize}
onChange={(e) => setBatchSize(Number(e.target.value) || 1)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">Learning rate</Label>
<Input
type="number"
step={0.00001}
min={0}
value={learningRate}
onChange={(e) => setLearningRate(Number(e.target.value) || 0.0001)}
className="h-8 text-xs"
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Precision</Label>
<select
value={precision}
onChange={(e) => setPrecision(e.target.value as "bf16" | "fp16" | "no")}
className={selectClass}
>
<option value="bf16">bf16 (default)</option>
<option value="fp16">fp16 (older GPUs)</option>
<option value="no">fp32 (no mixed)</option>
</select>
</div>
</div>
</>
)}
{status && status.status !== "idle" && (
<div className="rounded-lg border border-border bg-muted/30 p-3 text-xs">
<div className="mb-1 flex items-center justify-between">
<span className="font-medium capitalize">{status.status}</span>
<span className="text-muted-foreground">
{status.total_steps > 0 ? `${status.step}/${status.total_steps}` : ""}
</span>
</div>
<div className="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-border">
<div className="h-full bg-primary transition-all" style={{ width: `${pct}%` }} />
</div>
<div className="text-muted-foreground">
{completed
? "Adapter ready - find it in the LoRA picker on this page."
: status.message}
{status.loss != null && !completed && <> · loss {status.loss.toFixed(4)}</>}
{status.lora_path && (
<div className="mt-1 break-all text-[11px]">Saved: {status.lora_path}</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
{active ? (
<Button type="button" variant="destructive" onClick={onStop}>
Stop
</Button>
) : completed ? (
<>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Done - open the LoRA picker
</Button>
<Button type="button" onClick={onStart} disabled={starting || uploading}>
{starting ? "Starting..." : "Train another"}
</Button>
</>
) : (
<Button type="button" onClick={onStart} disabled={starting || uploading}>
{starting ? "Starting..." : "Start training"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -2006,7 +2006,6 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
)}
</div>
</div>
{/* Train mode: the full-page training workspace. Kept unmounted in Create mode so its
polling stops; Create's own state (gallery, model, workflow) is untouched. */}
{pageMode === "train" ? (

View file

@ -38,14 +38,18 @@ import zipfile
from pathlib import Path
from typing import Optional, Sequence
# Default upstream source. Overridable with UNSLOTH_SD_CPP_REPO so a pinned unslothai
# mirror (built the same way as unslothai/llama.cpp's prebuilts) can be used without a
# code change once it exists; otherwise this falls back to leejet upstream.
DEFAULT_REPO = "leejet/stable-diffusion.cpp"
# 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 upstream push. Override with UNSLOTH_SD_CPP_TAG; set it empty to
# track latest. If the pinned tag is gone upstream, install falls back to latest.
DEFAULT_TAG = "master-737-3b6c9ca"
# 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
@ -118,14 +122,24 @@ def resolve_release_asset(
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 not sel: # fall back to a plain avx2 CPU build
sel = [a for a in pool if "avx2" in a.lower()]
return sel[0] if sel else (pool[0] if pool else None)
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 _LINUX_ACCEL_TOKEN:
sel = [a for a in pool if _LINUX_ACCEL_TOKEN[accel] in a.lower()]
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
@ -137,10 +151,14 @@ def _fetch_release(
repo: Optional[str] = None,
token: Optional[str] = None,
timeout: float = 30.0,
) -> dict:
"""GET a release JSON from GitHub. With ``tag`` set, fetch that exact release (and fall
back to latest if the tag is gone upstream); otherwise fetch latest. ``token`` is
optional and lifts the API rate limit."""
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")
@ -155,9 +173,11 @@ def _fetch_release(
if tag:
try:
return _get(f"{base}/tags/{tag}")
except urllib.error.HTTPError as exc: # pinned tag removed upstream -> latest
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
)
@ -213,6 +233,17 @@ def _locate_sd_cli(root: Path) -> Optional[Path]:
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,
@ -272,6 +303,90 @@ def _maybe_fetch_windows_cudart(release: dict, chosen: str, target: Path) -> Non
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,
@ -280,25 +395,22 @@ def install(
) -> Path:
"""Download + extract the prebuilt for this host. Returns the sd-cli path.
Raises ``RuntimeError`` if no asset matches the host (the caller should then
build from source) or the archive has no ``sd-cli``.
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()
release = _fetch_release(_pinned_tag(), token = token)
print(f"sd-cli: source {_repo()} release {release.get('tag_name', '?')}", flush = True)
names = [a["name"] for a in release.get("assets", [])]
chosen = resolve_release_asset(
names,
system = platform.system(),
machine = platform.machine(),
accelerator = accelerator,
)
if not chosen:
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}). Build from source: "
f"https://github.com/{_repo()}"
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)
@ -323,6 +435,13 @@ def install(
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)
return sd_cli
@ -338,14 +457,10 @@ def main(argv: Optional[list[str]] = None) -> int:
args = p.parse_args(argv)
if args.print_asset:
release = _fetch_release(_pinned_tag())
names = [a["name"] for a in release.get("assets", [])]
chosen = resolve_release_asset(
names,
system = platform.system(),
machine = platform.machine(),
accelerator = args.accelerator,
)
# 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